dispatch.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671
  1. """The dispatch bundle (moved verbatim from the pre-split web_stack_test.py;
  2. see test/harness.py for the shared machinery and the runner)."""
  3. from harness import * # noqa: F401,F403 — the shared wire helpers
  4. # =============================================================================
  5. # Bundle: dispatch — the task pipeline end to end: registration tokens,
  6. # rules, project notes, pushed chunks/results ingestion, capability
  7. # routing, the create form, and retry/manual resume.
  8. # =============================================================================
  9. def run(ctx):
  10. auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
  11. app = ctx.app
  12. print("== registration: token minting and delivery ==")
  13. registered = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  14. if s["name"] == "fake-saddle"]
  15. check(len(registered) == 1 and registered[0]["token"].startswith("saddle-"),
  16. "fake saddle registered with an API token")
  17. # Plan 10 phase 1: registration delivers the per-saddle token to
  18. # the daemon via POST /register {saddle_token, web_base_url}.
  19. check(wait_for(lambda: len(fake.register_log) > 0 or False,
  20. timeout=15, what="/register delivery") is not None,
  21. "registration POSTs /register to the daemon")
  22. register_body = fake.register_log[0]
  23. check(register_body.get("saddle_token") == fake_token,
  24. "/register carries the minted per-saddle token")
  25. check(register_body.get("web_base_url") == WEB,
  26. "/register carries the web base URL")
  27. check(fake.token == fake_token,
  28. "daemon persisted the token (its asks use it)")
  29. token_conn = db()
  30. row_token = token_conn.execute("select token from saddles where id = ?",
  31. (fake_id,)).fetchone()[0]
  32. token_conn.close()
  33. check(row_token == fake_token,
  34. "per-saddle token lives on the saddle row")
  35. # The register-time ask (idle-awake daemon): 204, nothing
  36. # offerable yet — the saddle is asleep and wakeable.
  37. check(wait_for(lambda: any(a["status"] == 204 for a in fake.asks) or False,
  38. timeout=15, what="register-time ask") is not None,
  39. "the daemon's idle ask returns 204 (nothing offerable)")
  40. print("== feature 5: rules ==")
  41. rules_state = slot_public(auth, "/rules", "rules")
  42. for name, body in [
  43. ("Use Invercargill", "Never use libgee; use Invercargill enumerables."),
  44. ("Write Vala docs", "Every public class carries a doc comment."),
  45. ("Prefer meson", "Builds go through meson; no makefiles."),
  46. ("Co-dev rule", "Keep the wire contract exactly."),
  47. ("Reference rule", "Must never ride along."),
  48. ]:
  49. act(auth, rules_state["create"], {"name": name, "body": body})
  50. listed = {r["name"]: r for r in slot_public(auth, "/rules", "rules")["rules"]}
  51. check(len(listed) == 5, "five rules created and listed on /rules")
  52. def rule_id(name):
  53. rows = [r for r in slot_public(auth, "/rules", "rules")["rules"] if r["name"] == name]
  54. return int(rows[0]["id"])
  55. state = slot_public(auth, "/projects", "projects")
  56. act(auth, state["create"], {"name": "co", "git_url": "https://example.com/co.git"})
  57. act(auth, state["create"], {"name": "ref", "git_url": "https://example.com/ref.git"})
  58. co_id = int([p["id"] for p in slot_public(auth, "/projects", "projects")["projects"]
  59. if p["name"] == "co"][0])
  60. ref_id = int([p["id"] for p in slot_public(auth, "/projects", "projects")["projects"]
  61. if p["name"] == "ref"][0])
  62. project = slot_public(auth, "/projects/1", "project")
  63. act(auth, project["add_link"], {"linked_project_id": str(co_id),
  64. "relationship": "co-developed"})
  65. act(auth, project["add_link"], {"linked_project_id": str(ref_id),
  66. "relationship": "reference"})
  67. act(auth, project["attach_rule"], {"rule_id": str(rule_id("Use Invercargill"))})
  68. act(auth, project["attach_rule"], {"rule_id": str(rule_id("Prefer meson"))})
  69. project = slot_public(auth, "/projects/1", "project")
  70. check(sorted(r["name"] for r in project["rules"]) == ["Prefer meson", "Use Invercargill"],
  71. "project page lists its attached rules")
  72. co_project = slot_public(auth, "/projects/%d" % co_id, "project")
  73. act(auth, co_project["attach_rule"], {"rule_id": str(rule_id("Co-dev rule"))})
  74. ref_project = slot_public(auth, "/projects/%d" % ref_id, "project")
  75. act(auth, ref_project["attach_rule"], {"rule_id": str(rule_id("Reference rule"))})
  76. tasks_state = slot_public(auth, "/tasks", "tasks")
  77. # Draft-first (deterministic rule attachment): the dispatcher claims
  78. # a queued task on its 1s tick, which under load could beat the
  79. # attach_rule calls below and ship an assignment without the task's
  80. # own rules — attach while draft, enqueue last.
  81. act(auth, tasks_state["create"], {
  82. "project_id": "1",
  83. "title": "Rules dispatch",
  84. "description": "Effective rule set rides the /start payload.",
  85. "priority": "2",
  86. })
  87. rule_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  88. if t["title"] == "Rules dispatch"][0])
  89. detail = task_state(auth, rule_task)
  90. act(auth, detail["attach_rule"], {"rule_id": str(rule_id("Write Vala docs"))})
  91. act(auth, detail["attach_rule"], {"rule_id": str(rule_id("Use Invercargill"))})
  92. enqueue_task(auth, rule_task)
  93. detail = task_state(auth, rule_task)
  94. check(sorted(r["name"] for r in detail["rules"]) == ["Use Invercargill", "Write Vala docs"],
  95. "task page lists its attached rules")
  96. check(wait_for(lambda: len(fake.start_payloads) > 0 or False,
  97. timeout=30, what="/start push") is not None,
  98. "dispatcher pushes the task to the fake saddle")
  99. payload = fake.start_payloads[-1]
  100. rule_names = [r["name"] for r in payload.get("rules", [])]
  101. check(rule_names == ["Write Vala docs", "Use Invercargill", "Prefer meson", "Co-dev rule"],
  102. "effective rules in /start: task, primary, co-developed order (%s)" % rule_names)
  103. check(all(r.get("body") for r in payload.get("rules", [])),
  104. "rules carry their bodies")
  105. check("Reference rule" not in rule_names,
  106. "reference project rules excluded")
  107. detail = task_state(auth, rule_task)
  108. act(auth, detail["rules"][0]["detach"], {})
  109. check([r["name"] for r in task_state(auth, rule_task)["rules"]] == ["Use Invercargill"],
  110. "detached task rule disappears")
  111. rules_rows = slot_public(auth, "/rules", "rules")["rules"]
  112. docs_row = [r for r in rules_rows if r["name"] == "Write Vala docs"][0]
  113. act(auth, docs_row["update"], {"name": "Write Vala docs", "body": "Doc comments or bust."})
  114. check(any(r["body"] == "Doc comments or bust."
  115. for r in slot_public(auth, "/rules", "rules")["rules"]),
  116. "rule edit saves the body")
  117. check(wait_task_done(auth, rule_task, timeout=60) is not None,
  118. "dispatched task completes against the fake daemon")
  119. print("== feature 6: project notes + callback ==")
  120. # Plan 10 phase 1: the shared saddle-callback token is gone —
  121. # the per-saddle token authorises the callback endpoints.
  122. callback_token = fake_token
  123. check(callback_token.startswith("saddle-") and "saddle-callback-" not in callback_token,
  124. "note callback authorised by the per-saddle token")
  125. status, _ = saddle_api("POST", "/api/saddle/note", None,
  126. {"project": "demo", "body": "x"})
  127. check(status == 401, "note endpoint rejects a missing token")
  128. status, _ = saddle_api("POST", "/api/saddle/note", "saddle-callback-not-real",
  129. {"project": "demo", "body": "x"})
  130. check(status == 401, "note endpoint rejects a wrong (legacy-style) token")
  131. status, body = saddle_api("POST", "/api/saddle/note", callback_token,
  132. {"task_id": rule_task, "project": "demo",
  133. "body": "style.css tokens live in :root"})
  134. check(status == 200 and body.get("ok") is True, "note accepted with the callback token")
  135. status, _ = saddle_api("POST", "/api/saddle/note", callback_token,
  136. {"project": "no-such-project", "body": "x"})
  137. check(status == 400, "unknown project rejected")
  138. status, _ = saddle_api("POST", "/api/saddle/note", callback_token,
  139. {"project": "demo", "body": " "})
  140. check(status == 400, "empty note body rejected")
  141. project = slot_public(auth, "/projects/1", "project")
  142. check(any("style.css tokens" in n["body"] and int(n["source_task_id"]) == rule_task
  143. for n in project["notes"]),
  144. "project page lists the note with its source task")
  145. act(auth, [n for n in project["notes"] if "style.css tokens" in n["body"]][0]["delete"], {})
  146. check(all("style.css tokens" not in n["body"]
  147. for n in slot_public(auth, "/projects/1", "project")["notes"]),
  148. "note deleted from the project page")
  149. # /start payload: notes of primary + co-developed (not reference),
  150. # oldest first; callback carries the configured base URL + token.
  151. saddle_api("POST", "/api/saddle/note", callback_token,
  152. {"project": "demo", "body": "note-one"})
  153. saddle_api("POST", "/api/saddle/note", callback_token,
  154. {"project": "demo", "body": "note-two"})
  155. saddle_api("POST", "/api/saddle/note", callback_token,
  156. {"project": "co", "body": "co-note"})
  157. conn = db()
  158. conn.execute("insert into project_notes (project_id, body, created_at)"
  159. " values (?, 'ref-note-must-not-ship', strftime('%s','now'))", (ref_id,))
  160. conn.commit()
  161. conn.close()
  162. task_id_tmp = create_queued(auth, {
  163. "project_id": "1",
  164. "title": "Notes dispatch",
  165. "description": "Project notes ride the /start payload.",
  166. "priority": "2",
  167. })
  168. check(wait_for(lambda: len(fake.start_payloads) >= 2 or False,
  169. timeout=30, what="second /start push") is not None,
  170. "dispatcher pushes the notes task")
  171. payload = fake.start_payloads[-1]
  172. notes = payload.get("project_notes", [])
  173. check([n["body"] for n in notes] == ["note-one", "note-two", "co-note"],
  174. "project_notes: primary + co-developed, oldest first (%s)" % [n["body"] for n in notes])
  175. check(all(n.get("project") for n in notes)
  176. and notes[2]["project"] == "co",
  177. "project_notes carry the project name")
  178. check("ref-note-must-not-ship" not in [n["body"] for n in notes],
  179. "reference project notes excluded")
  180. callback = payload.get("callback", {})
  181. check(callback.get("base_url") == WEB and callback.get("token") == callback_token,
  182. "callback carries the configured base URL and the minted token")
  183. # entry cap: 30 of 33 candidates
  184. conn = db()
  185. for i in range(33):
  186. conn.execute("insert into project_notes (project_id, body, created_at)"
  187. " values (?, ?, strftime('%s','now'))", (1, "bulk-%02d" % i))
  188. conn.commit()
  189. conn.close()
  190. create_queued(auth, {
  191. "project_id": "1",
  192. "title": "Entry cap dispatch",
  193. "priority": "2",
  194. })
  195. check(wait_for(lambda: len(fake.start_payloads) >= 3 or False,
  196. timeout=30, what="third /start push") is not None,
  197. "dispatcher pushes the entry-cap task")
  198. notes = fake.start_payloads[-1].get("project_notes", [])
  199. check(len(notes) == 30 and notes[0]["body"] == "note-one" and notes[1]["body"] == "note-two",
  200. "project_notes capped at 30 entries, oldest kept (%d)" % len(notes))
  201. # char cap: the giant note is truncated so the total stays <= 20000
  202. conn = db()
  203. conn.execute("delete from project_notes where body like 'bulk-%' or body = 'co-note'")
  204. conn.execute("insert into project_notes (project_id, body, created_at)"
  205. " values (?, ?, strftime('%s','now'))", (1, "giant!" + "x" * 25000,))
  206. conn.commit()
  207. conn.close()
  208. create_queued(auth, {
  209. "project_id": "1",
  210. "title": "Char cap dispatch",
  211. "priority": "2",
  212. })
  213. check(wait_for(lambda: len(fake.start_payloads) >= 4 or False,
  214. timeout=30, what="fourth /start push") is not None,
  215. "dispatcher pushes the char-cap task")
  216. notes = fake.start_payloads[-1].get("project_notes", [])
  217. total = sum(len(n["body"]) for n in notes)
  218. check(len(notes) == 3 and total == 20000 and notes[2]["body"].startswith("giant!"),
  219. "project_notes char cap: head kept, giant truncated (entries=%d, chars=%d)"
  220. % (len(notes), total))
  221. print("== phase 2: pushed events -> the unified ledger ==")
  222. # Wait for the last dispatch to finish so its pushes landed.
  223. check(wait_task_done(auth, task_id_of("Char cap dispatch"), timeout=60) is not None,
  224. "char-cap task completes")
  225. conn = db()
  226. raw_session = conn.execute(
  227. "select s.id from sessions s join tasks t on t.id = s.task_id"
  228. " where t.title = 'Char cap dispatch' order by s.id desc limit 1").fetchone()[0]
  229. rows = conn.execute(
  230. "select seq, kind from events where session_id = ? order by seq",
  231. (raw_session,)).fetchall()
  232. conn.close()
  233. check(len(rows) >= 5,
  234. "the ledger captured the round's event batch (%d)" % len(rows))
  235. kinds = [r[1] for r in rows]
  236. check(all(k in kinds for k in
  237. ("lifecycle", "turn_text", "tool_call", "tool_result", "usage")),
  238. "the opening batch carries the §7.2 kinds (%s)" % sorted(set(kinds)))
  239. seqs = [r[0] for r in rows]
  240. check(seqs == sorted(seqs) and len(set(seqs)) == len(seqs),
  241. "stored events keep one strictly increasing seq per session")
  242. # The plan-17 event log wire: one newest-first JSON page per task.
  243. cap_task = task_id_of("Char cap dispatch")
  244. status, page = saddle_api(
  245. "GET", "/tasks/%d/events?limit=30" % cap_task, None)
  246. check(status == 200 and page.get("ok") is True
  247. and len(page.get("events", [])) >= 5,
  248. "GET /tasks/<id>/events serves the newest-first page")
  249. page_events = page["events"]
  250. ids = [e["id"] for e in page_events]
  251. check(ids == sorted(ids, reverse=True),
  252. "the JSON page lists events newest first")
  253. check(page_events[0]["kind"] == "state_change"
  254. and page_events[0]["payload"].get("to") == "done",
  255. "the newest event is the terminal state change")
  256. # The SSE stream seeds the most-recent window then stays open.
  257. request = urllib.request.Request(WEB + "/tasks/%d/events/stream" % cap_task)
  258. stream = urllib.request.urlopen(request, timeout=15)
  259. stream_events = []
  260. deadline = time.monotonic() + 6
  261. while time.monotonic() < deadline and len(stream_events) < 30:
  262. line = stream.readline().decode().strip()
  263. if not line.startswith("data: ") or line == "data: ping":
  264. continue
  265. event = json.loads(line[6:])
  266. stream_events.append(event)
  267. stream.close()
  268. check(len(stream_events) >= 5,
  269. "the event stream seeds the most-recent window (%d)" % len(stream_events))
  270. stream_ids = [e["id"] for e in stream_events]
  271. check(stream_ids == sorted(stream_ids) and len(set(stream_ids)) == len(stream_ids),
  272. "the seed batch is oldest first inside the window")
  273. print("== phase 2: chunks/results ingestion — auth, idempotency ==")
  274. # Token auth on both push channels (plan 10: the per-saddle
  275. # bearer authorises every daemon->web call).
  276. status, _ = saddle_api("POST", "/api/saddle/chunks", None,
  277. {"session_id": raw_session, "events": []})
  278. check(status == 401, "chunks endpoint rejects a missing token")
  279. status, _ = saddle_api("POST", "/api/saddle/results", "saddle-bogus-token",
  280. {"session_id": raw_session, "state": "working"})
  281. check(status == 401, "results endpoint rejects a wrong token")
  282. status, _ = saddle_api("POST", "/api/saddle/chunks", fake_token,
  283. {"session_id": 99999, "events": []})
  284. check(status == 400, "chunks for an unknown session rejected")
  285. status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
  286. {"session_id": raw_session, "state": "done"})
  287. check(status == 200, "the owning saddle's session accepts its results")
  288. # Pushed event idempotency: re-pushing a batch (the outbox's
  289. # at-least-once redelivery after a lost response) consumes the
  290. # duplicates and stores only the tail.
  291. def session_event_count(session):
  292. conn = db()
  293. try:
  294. return conn.execute(
  295. "select count(*) from events where session_id = ?",
  296. (session,)).fetchone()[0]
  297. finally:
  298. conn.close()
  299. before = session_event_count(raw_session)
  300. redelivery = [{"seq": r[0], "kind": r[1]} for r in rows[:3]]
  301. status, body = saddle_api("POST", "/api/saddle/chunks", fake_token,
  302. {"session_id": raw_session, "events": redelivery})
  303. check(status == 200 and body.get("ok") is True and int(body.get("stored") or 0) == 0,
  304. "a fully-duplicate batch stores nothing (duplicates consumed)")
  305. fresh = [{"seq": 9000, "kind": "progress",
  306. "payload": {"text": "late-flush\r\n"}},
  307. {"seq": 9001, "kind": "progress", "payload": {}}]
  308. status, body = saddle_api("POST", "/api/saddle/chunks", fake_token,
  309. {"session_id": raw_session, "events": fresh})
  310. check(status == 200 and int(body.get("stored") or 0) == 2,
  311. "a partial-redelivery batch stores only the new events")
  312. check(session_event_count(raw_session) == before + 2,
  313. "redelivered events never duplicate rows")
  314. status, body = saddle_api("POST", "/api/saddle/chunks", fake_token,
  315. {"session_id": raw_session,
  316. "events": [{"kind": "progress", "payload": {}}]})
  317. check(status == 400, "an event without a positive seq is refused")
  318. # A seq-less batch shape is the daemon contract violation the web
  319. # rejects wholesale (nothing of the batch stores).
  320. count_before = session_event_count(raw_session)
  321. status, _ = saddle_api("POST", "/api/saddle/chunks", fake_token,
  322. {"session_id": raw_session,
  323. "events": [{"seq": 9100, "kind": "progress"},
  324. {"kind": "usage", "payload": {}}]})
  325. check(status == 400 and session_event_count(raw_session) == count_before,
  326. "a batch with a seq-less event is refused atomically")
  327. # The retired daemon routes 404 (phase 2 deleted /output, /log
  328. # and /start serving) — and the web app never calls them.
  329. for method, path in (("GET", "/output?since=0"), ("GET", "/log?since=0"),
  330. ("POST", "/start")):
  331. request = urllib.request.Request(
  332. "http://127.0.0.1:%d%s" % (fake.port, path),
  333. data=b"{}" if method == "POST" else None, method=method)
  334. try:
  335. urllib.request.urlopen(request, timeout=5)
  336. gone = False
  337. except urllib.error.HTTPError as error:
  338. gone = error.code == 404
  339. check(gone, "the fake daemon's %s %s is deleted (404)" % (method, path))
  340. with open(os.path.join(WORK, "app.log")) as handle:
  341. app_log = handle.read()
  342. check("Saddle output request" not in app_log and "Saddle log request" not in app_log,
  343. "the web never polls /output or /log anymore")
  344. print("== capabilities: tag-based routing ==")
  345. saddles_state = slot_public(auth, "/saddles", "saddles")
  346. act(auth, saddles_state["create_capability"], {"name": "gpu"})
  347. act(auth, saddles_state["create_capability"], {"name": "bigmem"})
  348. saddles_state = slot_public(auth, "/saddles", "saddles")
  349. caps = {c["name"]: c for c in saddles_state["capabilities"]}
  350. check(len(caps) == 2 and all(int(c["saddle_count"]) == 0 and int(c["task_count"]) == 0
  351. for c in caps.values()),
  352. "capabilities created and listed with zero usage")
  353. fake_card = saddle_card(auth, "fake-saddle")
  354. check(fake_card["capabilities"] == [],
  355. "saddle card starts with no capability chips")
  356. # A task requiring 'gpu' waits while the only idle saddle lacks it;
  357. # a later task without requirements still dispatches (skip-and-continue).
  358. tasks_state = slot_public(auth, "/tasks", "tasks")
  359. task_id_tmp = create_queued(auth, {
  360. "project_id": "1",
  361. "title": "GPU task",
  362. "priority": "2",
  363. })
  364. gpu_task = task_id_tmp
  365. detail = task_state(auth, gpu_task)
  366. act(auth, detail["attach_capability"], {"capability_id": str(int(caps["gpu"]["id"]))})
  367. check(wait_for(lambda: [c["name"] for c in
  368. task_state(auth, gpu_task)["capabilities"]] == ["gpu"]
  369. or False, timeout=90, what="capability attached") is not None,
  370. "task page lists the required capability")
  371. detail = task_state(auth, gpu_task)
  372. # The only idle saddle lacks the tag, so the queue flags the wait
  373. # (read before any other task claims the idle saddle). The state is
  374. # stable until the any-saddle task below dispatches — poll for it
  375. # rather than sampling once (a single read under load once sampled
  376. # the row before the attach's pulse settled).
  377. check(wait_for(lambda: (lambda row: row["status"] == "queued"
  378. and row["capabilities"] == ["gpu"]
  379. and row.get("waiting_for_saddle") is True)(
  380. [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  381. if int(t["id"]) == gpu_task][0]) or False,
  382. timeout=20, what="waiting flag") is not None,
  383. "queue row shows the waiting task with its tag and wait flag")
  384. task_id_tmp = create_queued(auth, {
  385. "project_id": "1",
  386. "title": "Any-saddle task",
  387. "priority": "3",
  388. })
  389. any_task = task_id_tmp
  390. check(wait_for(lambda: task_state(auth, any_task).get("status") != "queued" or False,
  391. timeout=30, what="uncapable task dispatches") is not None,
  392. "task without requirements dispatches past the waiting task")
  393. def capability_wait_logged():
  394. with open(os.path.join(WORK, "app.log")) as handle:
  395. app_log = handle.read()
  396. return "waits for a capable saddle" in app_log and "requires: gpu" in app_log
  397. check(wait_for(capability_wait_logged, timeout=90,
  398. what="capability wait log") is not None,
  399. "dispatcher logs the capability wait")
  400. # Attaching the tag to the saddle releases the task.
  401. fake_card = saddle_card(auth, "fake-saddle")
  402. act(auth, fake_card["attach_capability"], {"capability_id": str(int(caps["gpu"]["id"]))})
  403. fake_card = saddle_card(auth, "fake-saddle")
  404. check([c["name"] for c in fake_card["capabilities"]] == ["gpu"],
  405. "saddle card shows the attached chip")
  406. check(wait_task_done(auth, gpu_task, timeout=60) is not None,
  407. "task dispatches once the saddle carries the required tag")
  408. queue = [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  409. if int(t["id"]) == gpu_task][0]
  410. check(queue.get("waiting_for_saddle") is not True,
  411. "wait flag clears once dispatched")
  412. # Multi-tag: gpu + bigmem required, only gpu carried → waits; adding
  413. # bigmem completes the superset. Both requirements ride the create
  414. # form (atomic — attaching them one at a time would race a tick
  415. # into dispatching on the partial set).
  416. task_id_tmp = create_queued(auth, {
  417. "project_id": "1",
  418. "title": "Big task",
  419. "priority": "2",
  420. "capability_id": [str(int(caps["gpu"]["id"])), str(int(caps["bigmem"]["id"]))],
  421. })
  422. big_task = task_id_tmp
  423. check(wait_for(lambda: ([t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  424. if int(t["id"]) == big_task][0].get("waiting_for_saddle")
  425. is True) or False, timeout=30, what="big task waits") is not None,
  426. "task waits when the saddle's tags are not a superset")
  427. fake_card = saddle_card(auth, "fake-saddle")
  428. act(auth, fake_card["attach_capability"], {"capability_id": str(int(caps["bigmem"]["id"]))})
  429. check(wait_task_done(auth, big_task, timeout=60) is not None,
  430. "task dispatches once the superset is complete")
  431. # Detach a requirement (on a draft — capabilities lock once a
  432. # worker has the job); delete a capability everywhere.
  433. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  434. "project_id": "1",
  435. "title": "Detach probe task",
  436. "priority": "4",
  437. "capability_id": [str(int(caps["gpu"]["id"])), str(int(caps["bigmem"]["id"]))],
  438. })
  439. detach_probe = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  440. if t["title"] == "Detach probe task"][0])
  441. act(auth, [c for c in task_state(auth, detach_probe)["capabilities"]
  442. if c["name"] == "bigmem"][0]["detach"], {})
  443. check([c["name"] for c in task_state(auth, detach_probe)["capabilities"]] == ["gpu"],
  444. "detached requirement disappears from the task")
  445. bigmem_row = [c for c in slot_public(auth, "/saddles", "saddles")["capabilities"]
  446. if c["name"] == "bigmem"][0]
  447. act(auth, bigmem_row["delete"], {})
  448. saddles_state = slot_public(auth, "/saddles", "saddles")
  449. check(all(c["name"] != "bigmem" for c in saddles_state["capabilities"]),
  450. "deleted capability gone from the catalogue")
  451. check([c["name"] for c in task_state(auth, big_task)["capabilities"]] == ["gpu"],
  452. "deleting a capability drops its task requirements")
  453. check(all(c["name"] != "bigmem"
  454. for c in saddle_card(auth, "fake-saddle")["capabilities"]),
  455. "deleting a capability drops it from saddles")
  456. print("== B4: required capabilities on the create form ==")
  457. tasks_state = slot_public(auth, "/tasks", "tasks")
  458. gpu_option = [o for o in tasks_state.get("capability_options", [])
  459. if o["name"] == "gpu"][0]
  460. check(len(tasks_state.get("capability_options", [])) >= 1,
  461. "queue page offers capability options in the create form")
  462. conn = db()
  463. before = conn.execute("select count(*) from tasks").fetchone()[0]
  464. conn.close()
  465. act(auth, tasks_state["create"], {
  466. "project_id": "1",
  467. "title": "Bad cap task",
  468. "capability_id": [gpu_option["id"], "999"],
  469. })
  470. conn = db()
  471. after = conn.execute("select count(*) from tasks").fetchone()[0]
  472. conn.close()
  473. check(after == before, "unknown capability id rejects the create")
  474. task_id_tmp = create_queued(auth, {
  475. "project_id": "1",
  476. "title": "Cap-on-create task",
  477. "capability_id": [gpu_option["id"]],
  478. })
  479. cap_create = task_id_tmp
  480. detail = task_state(auth, cap_create)
  481. check([c["name"] for c in detail["capabilities"]] == ["gpu"],
  482. "capabilities from the create form land on the task")
  483. queue = [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  484. if int(t["id"]) == cap_create][0]
  485. check(queue["capabilities"] == ["gpu"],
  486. "queue row shows the created task's requirements")
  487. # The waiting flag must hold it back until the capable saddle frees
  488. # up (the fake saddle still carries gpu from the routing checks).
  489. check(queue.get("waiting_for_saddle") is not True,
  490. "capable saddle available: no wait flag on the new task")
  491. # Drain barrier: the cap task must be CLAIMED before the flaky
  492. # arc arms fail_next (under parallel-shard load the dispatch tick
  493. # can lag task creation; an unclaimed predecessor would consume
  494. # the one-shot failure flag instead of the flaky task).
  495. check(wait_for(lambda: task_state(auth, cap_create).get("status")
  496. in ("working", "done", "failed") or False,
  497. timeout=60, what="cap task claimed") is not None,
  498. "the capable task is claimed before the flaky arc starts")
  499. print("== retry + resume ==")
  500. # A daemon-side failure fails the task with its explanation.
  501. fake.fail_next = True
  502. task_id_tmp = create_queued(auth, {
  503. "project_id": "1",
  504. "title": "Flaky task",
  505. "priority": "2",
  506. })
  507. flaky = task_id_tmp
  508. check(wait_for(lambda: task_state(auth, flaky).get("status") == "failed" or False,
  509. timeout=90, what="flaky task fails") is not None,
  510. "daemon failure fails the task")
  511. detail = task_state(auth, flaky)
  512. check("bash -n rejected" in (detail.get("summary") or ""),
  513. "failure summary shows the daemon's explanation")
  514. # Retry: the terminal summary dies with its attempt (plan 16 S4 —
  515. # it is NOT copied to the thread), the task requeues, history
  516. # stays, and the re-dispatch restores the workspace.
  517. comments_before = len(detail.get("comments", []))
  518. progress_before = len(detail.get("progress", []))
  519. act(auth, detail["retry"], {})
  520. detail = task_state(auth, flaky)
  521. check(detail["status"] == "queued" and (detail.get("summary") or "") == "",
  522. "retry requeues the task and clears the stale summary")
  523. check(not any("bash -n rejected" in c["body"] for c in detail["comments"]),
  524. "no summary→comment copy joins the thread (the terminal cache dies with its attempt, S4)")
  525. check(any(c["body"] == "Task retried by user" and c["author"] == "system"
  526. and c.get("is_system") for c in detail["comments"]),
  527. "retry lands its audit comment as a system row (never an agent row)")
  528. check(len(detail["comments"]) >= comments_before,
  529. "retry keeps the comment history")
  530. pushes_before = len([p for p in fake.start_payloads
  531. if int(p.get("task", {}).get("id") or 0) == flaky])
  532. check(wait_for(lambda: len([p for p in fake.start_payloads
  533. if int(p.get("task", {}).get("id") or 0) == flaky])
  534. > pushes_before or False, timeout=90,
  535. what="flaky task re-dispatched") is not None,
  536. "retried task dispatches again")
  537. # The resumed arc surfaces on the next /status read — in the
  538. # push world that is the operator's manual refresh.
  539. act(auth, saddle_card(auth, "fake-saddle")["refresh"], {})
  540. check(wait_task_done(auth, flaky, timeout=60) is not None,
  541. "retried task completes on the restored workspace")
  542. detail = task_state(auth, flaky)
  543. check(all("restored the session context" not in c["body"]
  544. for c in detail.get("comments", [])),
  545. "the session-restore stays OUT of the user-facing thread (U2: raw terminal only)")
  546. check(all("restored the session context" not in p["summary"]
  547. for p in detail.get("progress", [])),
  548. "the resume note stays OUT of the progress feed (F8: status is not progress)")
  549. # Plan 16 S4: terminal text has ONE representation — the failed
  550. # attempt's summary lived in tasks.summary alone (asserted above:
  551. # no agent comment), and a HUMAN comment lands as exactly one
  552. # user-authored row (the web is the single writer; the daemon's
  553. # terminal echo is raw-log only).
  554. act(auth, task_state(auth, flaky)["comment"],
  555. {"body": "The retry landed — nice recovery"})
  556. flaky_comments = task_state(auth, flaky)["comments"]
  557. check(len([c for c in flaky_comments
  558. if c["body"] == "The retry landed — nice recovery"]) == 1
  559. and all(c["author"] != "agent"
  560. for c in flaky_comments
  561. if c["body"] == "The retry landed — nice recovery"),
  562. "a human comment lands as exactly ONE user row (single writer, S4)")
  563. # Manual resume: a task stuck working on an offline saddle.
  564. task_id_tmp = create_queued(auth, {
  565. "project_id": "1",
  566. "title": "Stuck task",
  567. "priority": "2",
  568. })
  569. stuck = task_id_tmp
  570. check(wait_for(lambda: task_state(auth, stuck).get("status") == "working" or False,
  571. timeout=60, what="stuck task starts working") is not None,
  572. "stuck task reaches working")
  573. # Deterministic claim for the resume leg below: the round's end,
  574. # the refresh probe AND the dispatcher's wake for the requeued task
  575. # all trigger self-asks that could steal it — freeze every trigger
  576. # until the manual ask below has made the claim.
  577. fake.ask_on_terminal = False
  578. fake.ask_on_idle_status = False
  579. fake.ask_on_wake = False
  580. fake.settle_asks()
  581. conn = db()
  582. conn.execute("update saddles set status = 'offline' where name = 'fake-saddle'")
  583. conn.commit()
  584. conn.close()
  585. detail = task_state(auth, stuck)
  586. check(detail.get("resume_available") is True,
  587. "task page offers resume while its saddle is offline")
  588. # The resume POSTs task-update cancel to the daemon; the abort
  589. # handler fires an ungated re-ask that would race the manual claim
  590. # below — slow it past the manual ask (the knob the slow-ask
  591. # regression uses).
  592. fake.ask_delay = 5.0
  593. act(auth, detail["resume"], {})
  594. detail = task_state(auth, stuck)
  595. check(detail["status"] == "queued",
  596. "manual resume requeues the stuck task")
  597. check(any(c["body"] == "Saddle became unreachable; task requeued"
  598. and c["author"] == "system" and c.get("is_system")
  599. for c in detail["comments"]),
  600. "manual resume lands the interruption comment as a system row")
  601. check(all(s["status"] == "offline"
  602. for s in slot_public(auth, "/saddles", "saddles")["saddles"]),
  603. "saddle stays offline after the manual requeue")
  604. # Bringing the saddle back lets the requeued task resume and finish.
  605. fake.state = "idle"
  606. fake_card = saddle_card(auth, "fake-saddle")
  607. act(auth, fake_card["refresh"], {})
  608. check(any(s["status"] == "idle"
  609. for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  610. if s["name"] == "fake-saddle"),
  611. "refreshed saddle returns idle")
  612. # The refreshed daemon is idle-awake: it asks (the real loop's
  613. # own ask; driven directly for determinism — every self-ask trigger
  614. # is frozen and the abort re-ask is slowed past it; joining that
  615. # delayed thread here would fire its ask first, so it is left to
  616. # sleep out the rest of the bundle).
  617. status, payload = fake.ask_now()
  618. fake.ask_on_idle_status = True
  619. fake.ask_on_terminal = True
  620. fake.ask_on_wake = True
  621. fake.ask_delay = 0.0
  622. check(status == 200, "the refreshed daemon's ask claims the requeued task")
  623. check(wait_task_done(auth, stuck, timeout=60) is not None,
  624. "requeued task resumes on the refreshed saddle and completes")