dispatch.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690
  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 chunks -> raw log + terminal view ==")
  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("select seq, chunk from session_raw_log where session_id = ?"
  230. " order by seq", (raw_session,)).fetchall()
  231. conn.close()
  232. check(len(rows) >= 3, "session_raw_log captured the pushed chunks (%d)" % len(rows))
  233. seqs = [r[0] for r in rows]
  234. check(seqs == sorted(seqs) and len(set(seqs)) == len(seqs),
  235. "pushed chunks stored once, seq strictly increasing")
  236. text = "".join(r[1] for r in rows)
  237. check(fake.PROMPT in text and "echo hello" in text and "hello" in text,
  238. "pushed raw log keeps the prompt and echoed output byte-exact")
  239. check("working-heartbeat" in text,
  240. "the flush-timer heartbeat chunks keep arriving mid-round")
  241. terminal = slot_public(auth, "/tasks/%d/terminal" % task_id_of("Char cap dispatch"),
  242. "terminal")
  243. check(terminal.get("has_raw_log") is True and int(terminal["session_id"]) == raw_session,
  244. "terminal view selects raw rendering for the raw session")
  245. # Old sessions (typed entries, no raw rows) keep the fallback view
  246. # (a dedicated fixture task: its only session is the legacy one,
  247. # so the terminal view cannot prefer a dispatched raw session).
  248. conn = db()
  249. conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
  250. " values (1, 'Legacy terminal task', 'working', 5, strftime('%s','now'))")
  251. legacy_task = conn.execute("select max(id) from tasks").fetchone()[0]
  252. conn.execute("insert into sessions (task_id, saddle_id, started_at, ended_at, outcome)"
  253. " values (?, ?, strftime('%s','now'), strftime('%s','now'), 'done')",
  254. (legacy_task, fake_id))
  255. conn.commit()
  256. legacy_session = conn.execute("select max(id) from sessions").fetchone()[0]
  257. conn.execute("insert into session_commands (session_id, command_text, output_text,"
  258. " type, started_at) values (?, 'ls', 'old-output', 'command',"
  259. " strftime('%s','now'))", (legacy_session,))
  260. conn.commit()
  261. conn.close()
  262. legacy = slot_public(auth, "/tasks/%d/terminal" % legacy_task, "terminal")
  263. check(legacy.get("has_raw_log") is False,
  264. "legacy session falls back to the entry-type terminal view")
  265. request = urllib.request.Request(WEB + "/terminal-stream/%d" % raw_session)
  266. with urllib.request.urlopen(request, timeout=15) as stream:
  267. raw_events = []
  268. deadline = time.monotonic() + 8
  269. while time.monotonic() < deadline and len(raw_events) < 3:
  270. line = stream.readline().decode().strip()
  271. if not line.startswith("data: ") or line == "data: ping":
  272. continue
  273. event = json.loads(line[6:])
  274. if event.get("type") == "raw":
  275. raw_events.append(event)
  276. check(len(raw_events) >= 3 and raw_events[0]["text"] == fake.PROMPT
  277. and raw_events[0]["seq"] < raw_events[1]["seq"],
  278. "terminal stream serves pushed chunks in seq order (prompt first)")
  279. print("== phase 2: chunks/results ingestion — auth, idempotency ==")
  280. # Token auth on both push channels (plan 10: the per-saddle
  281. # bearer authorises every daemon->web call).
  282. status, _ = saddle_api("POST", "/api/saddle/chunks", None,
  283. {"session_id": raw_session, "chunks": []})
  284. check(status == 401, "chunks endpoint rejects a missing token")
  285. status, _ = saddle_api("POST", "/api/saddle/results", "saddle-bogus-token",
  286. {"session_id": raw_session, "state": "working"})
  287. check(status == 401, "results endpoint rejects a wrong token")
  288. status, _ = saddle_api("POST", "/api/saddle/chunks", fake_token,
  289. {"session_id": 99999, "chunks": []})
  290. check(status == 400, "chunks for an unknown session rejected")
  291. status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
  292. {"session_id": raw_session, "state": "done"})
  293. check(status == 200, "the owning saddle's session accepts its results")
  294. # Pushed chunk idempotency: re-pushing a batch (the outbox's
  295. # at-least-once redelivery after a lost response) consumes the
  296. # duplicates and stores only the tail.
  297. conn = db()
  298. before = conn.execute("select count(*) from session_raw_log where session_id = ?",
  299. (raw_session,)).fetchone()[0]
  300. conn.close()
  301. redelivery = [{"seq": r[0], "text": r[1]} for r in rows[:3]]
  302. status, body = saddle_api("POST", "/api/saddle/chunks", fake_token,
  303. {"session_id": raw_session, "chunks": redelivery})
  304. check(status == 200 and body.get("ok") is True and int(body.get("stored") or 0) == 0,
  305. "a fully-duplicate batch stores nothing (duplicates consumed)")
  306. fresh = [{"seq": 9000, "text": "late-flush\r\n"},
  307. {"seq": 9001, "text": ""}] # truly empty text: no row
  308. status, body = saddle_api("POST", "/api/saddle/chunks", fake_token,
  309. {"session_id": raw_session, "chunks": fresh})
  310. check(status == 200 and int(body.get("stored") or 0) == 1,
  311. "a partial-redelivery batch stores only the new chunks")
  312. conn = db()
  313. after = conn.execute("select count(*) from session_raw_log where session_id = ?",
  314. (raw_session,)).fetchone()[0]
  315. empty_row = conn.execute(
  316. "select count(*) from session_raw_log where session_id = ? and seq = 9001",
  317. (raw_session,)).fetchone()[0]
  318. conn.close()
  319. check(after == before + 1 and empty_row == 0,
  320. "redelivered chunks never duplicate rows; empty text stores nothing")
  321. # Pushed entries: today's OutputEntry shape with the daemon's
  322. # global entry ids — a redelivered results push stores its
  323. # entries exactly once.
  324. def command_rows(session):
  325. conn = db()
  326. try:
  327. return conn.execute(
  328. "select command_text, type from session_commands"
  329. " where session_id = ? order by id", (session,)).fetchall()
  330. finally:
  331. conn.close()
  332. entries = [{"id": 8101, "command": "sed -n 1p README.md", "output": "# demo",
  333. "exit_code": 0, "type": "command", "timestamp": "2026-09-06T00:00:00Z"}]
  334. status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
  335. {"session_id": raw_session, "state": "working",
  336. "entries": entries})
  337. check(status == 200, "entries push accepted")
  338. status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
  339. {"session_id": raw_session, "state": "working",
  340. "entries": entries})
  341. check(status == 200, "redelivered entries push accepted (at-least-once)")
  342. stored = command_rows(raw_session)
  343. check(len([r for r in stored if r[0] == "sed -n 1p README.md"]) == 1,
  344. "redelivered entries store exactly once (daemon-global dedup)")
  345. # The retired daemon routes 404 (phase 2 deleted /output, /log
  346. # and /start serving) — and the web app never calls them.
  347. for method, path in (("GET", "/output?since=0"), ("GET", "/log?since=0"),
  348. ("POST", "/start")):
  349. request = urllib.request.Request(
  350. "http://127.0.0.1:%d%s" % (fake.port, path),
  351. data=b"{}" if method == "POST" else None, method=method)
  352. try:
  353. urllib.request.urlopen(request, timeout=5)
  354. gone = False
  355. except urllib.error.HTTPError as error:
  356. gone = error.code == 404
  357. check(gone, "the fake daemon's %s %s is deleted (404)" % (method, path))
  358. with open(os.path.join(WORK, "app.log")) as handle:
  359. app_log = handle.read()
  360. check("Saddle output request" not in app_log and "Saddle log request" not in app_log,
  361. "the web never polls /output or /log anymore")
  362. print("== capabilities: tag-based routing ==")
  363. saddles_state = slot_public(auth, "/saddles", "saddles")
  364. act(auth, saddles_state["create_capability"], {"name": "gpu"})
  365. act(auth, saddles_state["create_capability"], {"name": "bigmem"})
  366. saddles_state = slot_public(auth, "/saddles", "saddles")
  367. caps = {c["name"]: c for c in saddles_state["capabilities"]}
  368. check(len(caps) == 2 and all(int(c["saddle_count"]) == 0 and int(c["task_count"]) == 0
  369. for c in caps.values()),
  370. "capabilities created and listed with zero usage")
  371. fake_card = saddle_card(auth, "fake-saddle")
  372. check(fake_card["capabilities"] == [],
  373. "saddle card starts with no capability chips")
  374. # A task requiring 'gpu' waits while the only idle saddle lacks it;
  375. # a later task without requirements still dispatches (skip-and-continue).
  376. tasks_state = slot_public(auth, "/tasks", "tasks")
  377. task_id_tmp = create_queued(auth, {
  378. "project_id": "1",
  379. "title": "GPU task",
  380. "priority": "2",
  381. })
  382. gpu_task = task_id_tmp
  383. detail = task_state(auth, gpu_task)
  384. act(auth, detail["attach_capability"], {"capability_id": str(int(caps["gpu"]["id"]))})
  385. check(wait_for(lambda: [c["name"] for c in
  386. task_state(auth, gpu_task)["capabilities"]] == ["gpu"]
  387. or False, timeout=90, what="capability attached") is not None,
  388. "task page lists the required capability")
  389. detail = task_state(auth, gpu_task)
  390. # The only idle saddle lacks the tag, so the queue flags the wait
  391. # (read before any other task claims the idle saddle). The state is
  392. # stable until the any-saddle task below dispatches — poll for it
  393. # rather than sampling once (a single read under load once sampled
  394. # the row before the attach's pulse settled).
  395. check(wait_for(lambda: (lambda row: row["status"] == "queued"
  396. and row["capabilities"] == ["gpu"]
  397. and row.get("waiting_for_saddle") is True)(
  398. [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  399. if int(t["id"]) == gpu_task][0]) or False,
  400. timeout=20, what="waiting flag") is not None,
  401. "queue row shows the waiting task with its tag and wait flag")
  402. task_id_tmp = create_queued(auth, {
  403. "project_id": "1",
  404. "title": "Any-saddle task",
  405. "priority": "3",
  406. })
  407. any_task = task_id_tmp
  408. check(wait_for(lambda: task_state(auth, any_task).get("status") != "queued" or False,
  409. timeout=30, what="uncapable task dispatches") is not None,
  410. "task without requirements dispatches past the waiting task")
  411. def capability_wait_logged():
  412. with open(os.path.join(WORK, "app.log")) as handle:
  413. app_log = handle.read()
  414. return "waits for a capable saddle" in app_log and "requires: gpu" in app_log
  415. check(wait_for(capability_wait_logged, timeout=90,
  416. what="capability wait log") is not None,
  417. "dispatcher logs the capability wait")
  418. # Attaching the tag to the saddle releases the task.
  419. fake_card = saddle_card(auth, "fake-saddle")
  420. act(auth, fake_card["attach_capability"], {"capability_id": str(int(caps["gpu"]["id"]))})
  421. fake_card = saddle_card(auth, "fake-saddle")
  422. check([c["name"] for c in fake_card["capabilities"]] == ["gpu"],
  423. "saddle card shows the attached chip")
  424. check(wait_task_done(auth, gpu_task, timeout=60) is not None,
  425. "task dispatches once the saddle carries the required tag")
  426. queue = [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  427. if int(t["id"]) == gpu_task][0]
  428. check(queue.get("waiting_for_saddle") is not True,
  429. "wait flag clears once dispatched")
  430. # Multi-tag: gpu + bigmem required, only gpu carried → waits; adding
  431. # bigmem completes the superset. Both requirements ride the create
  432. # form (atomic — attaching them one at a time would race a tick
  433. # into dispatching on the partial set).
  434. task_id_tmp = create_queued(auth, {
  435. "project_id": "1",
  436. "title": "Big task",
  437. "priority": "2",
  438. "capability_id": [str(int(caps["gpu"]["id"])), str(int(caps["bigmem"]["id"]))],
  439. })
  440. big_task = task_id_tmp
  441. check(wait_for(lambda: ([t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  442. if int(t["id"]) == big_task][0].get("waiting_for_saddle")
  443. is True) or False, timeout=30, what="big task waits") is not None,
  444. "task waits when the saddle's tags are not a superset")
  445. fake_card = saddle_card(auth, "fake-saddle")
  446. act(auth, fake_card["attach_capability"], {"capability_id": str(int(caps["bigmem"]["id"]))})
  447. check(wait_task_done(auth, big_task, timeout=60) is not None,
  448. "task dispatches once the superset is complete")
  449. # Detach a requirement (on a draft — capabilities lock once a
  450. # worker has the job); delete a capability everywhere.
  451. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  452. "project_id": "1",
  453. "title": "Detach probe task",
  454. "priority": "4",
  455. "capability_id": [str(int(caps["gpu"]["id"])), str(int(caps["bigmem"]["id"]))],
  456. })
  457. detach_probe = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  458. if t["title"] == "Detach probe task"][0])
  459. act(auth, [c for c in task_state(auth, detach_probe)["capabilities"]
  460. if c["name"] == "bigmem"][0]["detach"], {})
  461. check([c["name"] for c in task_state(auth, detach_probe)["capabilities"]] == ["gpu"],
  462. "detached requirement disappears from the task")
  463. bigmem_row = [c for c in slot_public(auth, "/saddles", "saddles")["capabilities"]
  464. if c["name"] == "bigmem"][0]
  465. act(auth, bigmem_row["delete"], {})
  466. saddles_state = slot_public(auth, "/saddles", "saddles")
  467. check(all(c["name"] != "bigmem" for c in saddles_state["capabilities"]),
  468. "deleted capability gone from the catalogue")
  469. check([c["name"] for c in task_state(auth, big_task)["capabilities"]] == ["gpu"],
  470. "deleting a capability drops its task requirements")
  471. check(all(c["name"] != "bigmem"
  472. for c in saddle_card(auth, "fake-saddle")["capabilities"]),
  473. "deleting a capability drops it from saddles")
  474. print("== B4: required capabilities on the create form ==")
  475. tasks_state = slot_public(auth, "/tasks", "tasks")
  476. gpu_option = [o for o in tasks_state.get("capability_options", [])
  477. if o["name"] == "gpu"][0]
  478. check(len(tasks_state.get("capability_options", [])) >= 1,
  479. "queue page offers capability options in the create form")
  480. conn = db()
  481. before = conn.execute("select count(*) from tasks").fetchone()[0]
  482. conn.close()
  483. act(auth, tasks_state["create"], {
  484. "project_id": "1",
  485. "title": "Bad cap task",
  486. "capability_id": [gpu_option["id"], "999"],
  487. })
  488. conn = db()
  489. after = conn.execute("select count(*) from tasks").fetchone()[0]
  490. conn.close()
  491. check(after == before, "unknown capability id rejects the create")
  492. task_id_tmp = create_queued(auth, {
  493. "project_id": "1",
  494. "title": "Cap-on-create task",
  495. "capability_id": [gpu_option["id"]],
  496. })
  497. cap_create = task_id_tmp
  498. detail = task_state(auth, cap_create)
  499. check([c["name"] for c in detail["capabilities"]] == ["gpu"],
  500. "capabilities from the create form land on the task")
  501. queue = [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  502. if int(t["id"]) == cap_create][0]
  503. check(queue["capabilities"] == ["gpu"],
  504. "queue row shows the created task's requirements")
  505. # The waiting flag must hold it back until the capable saddle frees
  506. # up (the fake saddle still carries gpu from the routing checks).
  507. check(queue.get("waiting_for_saddle") is not True,
  508. "capable saddle available: no wait flag on the new task")
  509. # Drain barrier: the cap task must be CLAIMED before the flaky
  510. # arc arms fail_next (under parallel-shard load the dispatch tick
  511. # can lag task creation; an unclaimed predecessor would consume
  512. # the one-shot failure flag instead of the flaky task).
  513. check(wait_for(lambda: task_state(auth, cap_create).get("status")
  514. in ("working", "done", "failed") or False,
  515. timeout=60, what="cap task claimed") is not None,
  516. "the capable task is claimed before the flaky arc starts")
  517. print("== retry + resume ==")
  518. # A daemon-side failure fails the task with its explanation.
  519. fake.fail_next = True
  520. task_id_tmp = create_queued(auth, {
  521. "project_id": "1",
  522. "title": "Flaky task",
  523. "priority": "2",
  524. })
  525. flaky = task_id_tmp
  526. check(wait_for(lambda: task_state(auth, flaky).get("status") == "failed" or False,
  527. timeout=90, what="flaky task fails") is not None,
  528. "daemon failure fails the task")
  529. detail = task_state(auth, flaky)
  530. check("bash -n rejected" in (detail.get("summary") or ""),
  531. "failure summary shows the daemon's explanation")
  532. # Retry: the terminal summary dies with its attempt (plan 16 S4 —
  533. # it is NOT copied to the thread), the task requeues, history
  534. # stays, and the re-dispatch restores the workspace.
  535. comments_before = len(detail.get("comments", []))
  536. progress_before = len(detail.get("progress", []))
  537. act(auth, detail["retry"], {})
  538. detail = task_state(auth, flaky)
  539. check(detail["status"] == "queued" and (detail.get("summary") or "") == "",
  540. "retry requeues the task and clears the stale summary")
  541. check(not any("bash -n rejected" in c["body"] for c in detail["comments"]),
  542. "no summary→comment copy joins the thread (the terminal cache dies with its attempt, S4)")
  543. check(any(c["body"] == "Task retried by user" and c["author"] == "system"
  544. and c.get("is_system") for c in detail["comments"]),
  545. "retry lands its audit comment as a system row (never an agent row)")
  546. check(len(detail["comments"]) >= comments_before,
  547. "retry keeps the comment history")
  548. pushes_before = len([p for p in fake.start_payloads
  549. if int(p.get("task", {}).get("id") or 0) == flaky])
  550. check(wait_for(lambda: len([p for p in fake.start_payloads
  551. if int(p.get("task", {}).get("id") or 0) == flaky])
  552. > pushes_before or False, timeout=90,
  553. what="flaky task re-dispatched") is not None,
  554. "retried task dispatches again")
  555. # The resumed arc surfaces on the next /status read — in the
  556. # push world that is the operator's manual refresh.
  557. act(auth, saddle_card(auth, "fake-saddle")["refresh"], {})
  558. check(wait_task_done(auth, flaky, timeout=60) is not None,
  559. "retried task completes on the restored workspace")
  560. detail = task_state(auth, flaky)
  561. check(all("restored the session context" not in c["body"]
  562. for c in detail.get("comments", [])),
  563. "the session-restore stays OUT of the user-facing thread (U2: raw terminal only)")
  564. check(all("restored the session context" not in p["summary"]
  565. for p in detail.get("progress", [])),
  566. "the resume note stays OUT of the progress feed (F8: status is not progress)")
  567. # Plan 16 S4: terminal text has ONE representation — the failed
  568. # attempt's summary lived in tasks.summary alone (asserted above:
  569. # no agent comment), and a HUMAN comment lands as exactly one
  570. # user-authored row (the web is the single writer; the daemon's
  571. # terminal echo is raw-log only).
  572. act(auth, task_state(auth, flaky)["comment"],
  573. {"body": "The retry landed — nice recovery"})
  574. flaky_comments = task_state(auth, flaky)["comments"]
  575. check(len([c for c in flaky_comments
  576. if c["body"] == "The retry landed — nice recovery"]) == 1
  577. and all(c["author"] != "agent"
  578. for c in flaky_comments
  579. if c["body"] == "The retry landed — nice recovery"),
  580. "a human comment lands as exactly ONE user row (single writer, S4)")
  581. # Manual resume: a task stuck working on an offline saddle.
  582. task_id_tmp = create_queued(auth, {
  583. "project_id": "1",
  584. "title": "Stuck task",
  585. "priority": "2",
  586. })
  587. stuck = task_id_tmp
  588. check(wait_for(lambda: task_state(auth, stuck).get("status") == "working" or False,
  589. timeout=60, what="stuck task starts working") is not None,
  590. "stuck task reaches working")
  591. # Deterministic claim for the resume leg below: the round's end,
  592. # the refresh probe AND the dispatcher's wake for the requeued task
  593. # all trigger self-asks that could steal it — freeze every trigger
  594. # until the manual ask below has made the claim.
  595. fake.ask_on_terminal = False
  596. fake.ask_on_idle_status = False
  597. fake.ask_on_wake = False
  598. fake.settle_asks()
  599. conn = db()
  600. conn.execute("update saddles set status = 'offline' where name = 'fake-saddle'")
  601. conn.commit()
  602. conn.close()
  603. detail = task_state(auth, stuck)
  604. check(detail.get("resume_available") is True,
  605. "task page offers resume while its saddle is offline")
  606. # The resume POSTs task-update cancel to the daemon; the abort
  607. # handler fires an ungated re-ask that would race the manual claim
  608. # below — slow it past the manual ask (the knob the slow-ask
  609. # regression uses).
  610. fake.ask_delay = 5.0
  611. act(auth, detail["resume"], {})
  612. detail = task_state(auth, stuck)
  613. check(detail["status"] == "queued",
  614. "manual resume requeues the stuck task")
  615. check(any(c["body"] == "Saddle became unreachable; task requeued"
  616. and c["author"] == "system" and c.get("is_system")
  617. for c in detail["comments"]),
  618. "manual resume lands the interruption comment as a system row")
  619. check(all(s["status"] == "offline"
  620. for s in slot_public(auth, "/saddles", "saddles")["saddles"]),
  621. "saddle stays offline after the manual requeue")
  622. # Bringing the saddle back lets the requeued task resume and finish.
  623. fake.state = "idle"
  624. fake_card = saddle_card(auth, "fake-saddle")
  625. act(auth, fake_card["refresh"], {})
  626. check(any(s["status"] == "idle"
  627. for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  628. if s["name"] == "fake-saddle"),
  629. "refreshed saddle returns idle")
  630. # The refreshed daemon is idle-awake: it asks (the real loop's
  631. # own ask; driven directly for determinism — every self-ask trigger
  632. # is frozen and the abort re-ask is slowed past it; joining that
  633. # delayed thread here would fire its ask first, so it is left to
  634. # sleep out the rest of the bundle).
  635. status, payload = fake.ask_now()
  636. fake.ask_on_idle_status = True
  637. fake.ask_on_terminal = True
  638. fake.ask_on_wake = True
  639. fake.ask_delay = 0.0
  640. check(status == 200, "the refreshed daemon's ask claims the requeued task")
  641. check(wait_task_done(auth, stuck, timeout=60) is not None,
  642. "requeued task resumes on the refreshed saddle and completes")