allocation.py 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730
  1. """The allocation 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: allocation — the ask/wake/allocate machinery: concurrent ask
  6. # race, token revocation, workspace affinity, the lost-wake guard, the
  7. # slow-ask regression, assignment capability context + the tag
  8. # catalogue, and the pinned-blocks/activity dashboard.
  9. # =============================================================================
  10. def run(ctx):
  11. auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
  12. app = ctx.app
  13. r4_token = fake_token
  14. # gpu rides the primary fake from the start (the routing bundle's
  15. # early attach, replayed): its attach row precedes lostwake's, so
  16. # the assignment's saddle_tags order matches the assertion in the
  17. # capability-context section below.
  18. saddles_state = slot_public(auth, "/saddles", "saddles")
  19. act(auth, saddles_state["create_capability"], {"name": "gpu"})
  20. act(auth, saddle_card(auth, "fake-saddle")["attach_capability"],
  21. {"capability_id": str(int([c["id"] for c in
  22. slot_public(auth, "/saddles", "saddles")["capabilities"]
  23. if c["name"] == "gpu"][0]))})
  24. print("== phase 1: ask/allocation — token auth + concurrent race ==")
  25. # Plan 10 phase 1. Registration mints per-saddle tokens; the
  26. # ask endpoint authenticates by them; concurrent asks race to
  27. # exactly one winner through the allocation transaction.
  28. status, _ = saddle_api("POST", "/api/saddle/next-task", None)
  29. check(status == 401, "next-task rejects a missing token")
  30. status, _ = saddle_api("POST", "/api/saddle/next-task", "saddle-bogus-token")
  31. check(status == 401, "next-task rejects a wrong token")
  32. status, body = saddle_api("POST", "/api/saddle/next-task", fake_token)
  33. check(status == 204, "next-task answers 204 (nothing offerable — daemon sleeps)")
  34. # Two more racers: real daemons on their own ports; fake2 reports
  35. # its ssh key at /register so the key lands on the row directly.
  36. fake2 = FakeSaddle()
  37. fake2.ssh_public_key = "ssh-ed25519 AAAAFake2RacerKey saddle-2@fedora"
  38. fake2.serve(port=free_port())
  39. fake3 = FakeSaddle()
  40. fake3.serve(port=free_port())
  41. saddles_state = slot_public(auth, "/saddles", "saddles")
  42. act(auth, saddles_state["register"], {"name": "racer-two",
  43. "host": "127.0.0.1:%d" % fake2.port})
  44. act(auth, saddles_state["register"], {"name": "racer-three",
  45. "host": "127.0.0.1:%d" % fake3.port})
  46. check(wait_for(lambda: len(fake2.register_log) > 0 and len(fake3.register_log) > 0
  47. or False, timeout=15, what="racers registered") is not None,
  48. "both racers received /register")
  49. check(wait_for(lambda: "AAAAFake2RacerKey" in
  50. [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  51. if s["name"] == "racer-two"][0].get("ssh_public_key", "") or False,
  52. timeout=15, what="register-reported key") is not None,
  53. "the ssh key reported at /register lands on the saddle row")
  54. racer_cards = {}
  55. for name, daemon in (("fake-saddle", fake), ("racer-two", fake2), ("racer-three", fake3)):
  56. card = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  57. if s["name"] == name][0]
  58. racer_cards[daemon] = {"id": int(card["id"]), "token": card["token"]}
  59. # Revocation: a registered saddle's token works until the saddle
  60. # is removed, then nothing authorises its calls.
  61. fake4 = FakeSaddle()
  62. fake4.ask_on_register = False # a pre-flip daemon: never asks
  63. fake4.serve(port=free_port())
  64. saddles_state = slot_public(auth, "/saddles", "saddles")
  65. act(auth, saddles_state["register"], {"name": "old-daemon",
  66. "host": "127.0.0.1:%d" % fake4.port})
  67. old_card = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  68. if s["name"] == "old-daemon"][0]
  69. status, _ = saddle_api("POST", "/api/saddle/next-task", old_card["token"])
  70. check(status in (200, 204),
  71. "the old daemon's fresh token authenticates next-task")
  72. status, _ = saddle_api("POST", "/api/saddle/note", old_card["token"],
  73. {"project": "demo", "body": "from the old daemon"})
  74. check(status == 200, "note accepts any registered saddle's token")
  75. act(auth, old_card["unregister"], {})
  76. status, _ = saddle_api("POST", "/api/saddle/next-task", old_card["token"])
  77. check(status == 401, "removing the saddle revokes its token")
  78. status, _ = saddle_api("POST", "/api/saddle/note", old_card["token"],
  79. {"project": "demo", "body": "should fail"})
  80. check(status == 401, "revocation covers the note endpoint too")
  81. # Deterministic race: settle the queue first (this shard runs the
  82. # race before any parked work exists, so only in-flight asks need
  83. # draining), freeze every daemon's self-ask triggers, put all three
  84. # to sleep (204), then fire one task worth of concurrent asks.
  85. for daemon in (fake, fake2, fake3):
  86. daemon.ask_on_register = False
  87. daemon.ask_on_wake = False
  88. daemon.ask_on_terminal = False
  89. daemon.ask_on_idle_status = False
  90. daemon.settle_asks() # converted sleep: in-flight asks land (204)
  91. def queued_count():
  92. conn = db()
  93. try:
  94. return conn.execute(
  95. "select count(*) from tasks where status = 'queued'").fetchone()[0]
  96. finally:
  97. conn.close()
  98. check(wait_for(lambda: queued_count() == 0 or False,
  99. timeout=60, what="queue settles before the race") is not None,
  100. "the queue settles before the race")
  101. sleep_statuses = [daemon.ask_now()[0] for daemon in (fake, fake2, fake3)]
  102. check(sleep_statuses == [204, 204, 204],
  103. "all three racers sleep (204) before the task exists")
  104. task_id_tmp = create_queued(auth, {
  105. "project_id": "1",
  106. "title": "Race task",
  107. "priority": "2",
  108. })
  109. race_task = task_id_tmp
  110. race_results = []
  111. def race_ask(daemon):
  112. # A real racing daemon accepts the assignment it wins — the
  113. # manual ask path does exactly that on a 200.
  114. race_results.append(daemon.ask_now())
  115. threads = [threading.Thread(target=race_ask, args=(daemon,))
  116. for daemon in (fake, fake2, fake3)]
  117. for thread in threads:
  118. thread.start()
  119. for thread in threads:
  120. thread.join()
  121. race_statuses = sorted(r[0] for r in race_results)
  122. check(race_statuses == [200, 204, 204],
  123. "concurrent asks race to exactly one winner (%s)" % race_statuses)
  124. winner = [r for r in race_results if r[0] == 200][0][1]
  125. check(int(winner["task"]["id"]) == race_task, "the winner's payload carries the task")
  126. check(int(winner["session_id"]) > 0, "the assignment carries the session id")
  127. conn = db()
  128. race_sessions = conn.execute(
  129. "select count(*) from sessions where task_id = ?", (race_task,)).fetchone()[0]
  130. conn.close()
  131. check(race_sessions == 1,
  132. "exactly one session row — the transaction serialised the claims (%d)"
  133. % race_sessions)
  134. check(wait_task_done(auth, race_task, timeout=60) is not None,
  135. "the raced task completes on the winning daemon")
  136. for daemon in (fake, fake2, fake3):
  137. daemon.ask_on_register = True
  138. daemon.ask_on_wake = True
  139. daemon.ask_on_terminal = True
  140. daemon.ask_on_idle_status = True
  141. # Re-establish sleeping explicitly: the winner's post-race
  142. # re-ask timing is not deterministic, and the affinity section
  143. # below needs every racer provably asleep. Any stray offerable
  144. # drains into these asks first.
  145. def all_asleep():
  146. return all(daemon.ask_now()[0] == 204 for daemon in (fake, fake2, fake3))
  147. check(wait_for(lambda: all_asleep() or False, timeout=30,
  148. what="racers re-sleep after the race") is not None,
  149. "all racers re-sleep after the race")
  150. print("== phase 1: workspace affinity pins parked tasks ==")
  151. # A suspended parent parked on racer-two may be resumed ONLY by
  152. # racer-two: a tag-capable non-affine saddle asking gets 204, and
  153. # the wake targets the affine saddle. Only racer-two carries the
  154. # tag at dispatch time (the race's winner is nondeterministic, so
  155. # the park must not depend on which racers are free); racer-three
  156. # gains it afterwards to prove the pinning is by workspace, not
  157. # tags.
  158. saddles_state = slot_public(auth, "/saddles", "saddles")
  159. act(auth, saddles_state["create_capability"], {"name": "affinity-tag"})
  160. affinity_cap = int([c["id"] for c in slot_public(auth, "/saddles", "saddles")["capabilities"]
  161. if c["name"] == "affinity-tag"][0])
  162. racer2_card = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  163. if s["name"] == "racer-two"][0]
  164. racer3_card = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  165. if s["name"] == "racer-three"][0]
  166. act(auth, racer2_card["attach_capability"], {"capability_id": str(affinity_cap)})
  167. # Plan 12: the parked workspace is the goal agent's run task. The
  168. # other racers sleep (frozen ask triggers), so racer-two claims
  169. # the goal run and parks it; the goal-task event then resumes the
  170. # agent AFFINE to racer-two — racer-three (tag-capable or not)
  171. # gets 204, the dispatcher's wake targets racer-two.
  172. fake.ask_on_wake = False
  173. fake.ask_on_idle_status = False
  174. fake.settle_asks()
  175. fake3.ask_on_wake = False
  176. fake3.ask_on_idle_status = False
  177. fake3.settle_asks()
  178. act(auth, slot_public(auth, "/goals", "goals")["create"], {
  179. "project_id": "1", "title": "Affinity goal",
  180. "brief": "Parks on racer-two.", "branch_name": "goal/affinity"})
  181. conn = db()
  182. affinity_goal = conn.execute(
  183. "select id from goals where title = 'Affinity goal'").fetchone()[0]
  184. conn.close()
  185. fake2.goal_script = [("plan", "# Affinity plan\n\n1. Park")]
  186. act(auth, slot_public(auth, "/goals/%d" % affinity_goal, "goal")["start"], {})
  187. check(wait_for(lambda: slot_public(auth, "/goals/%d" % affinity_goal,
  188. "goal")["status"] == "plan_review" or False,
  189. timeout=60, what="affinity goal plans") is not None,
  190. "the affinity goal reaches plan review (racer-two hosting)")
  191. fake2.goal_script = [("suspend",)]
  192. # Freeze racer-two's ask triggers across the approval too (the
  193. # later wake measurement already does): the mirror-sync fix makes
  194. # the run resume-offerable the moment its goal row carries the
  195. # verdict, so a wake-triggered background ask would race this
  196. # manual ask_now for the claim.
  197. # (Parking guard: the scripted park needs a live wake source.)
  198. conn = db()
  199. conn.execute("insert into tasks (project_id, goal_id, kind, title, status,"
  200. " priority, mode, creator, created_at) values (1, ?, 'task',"
  201. " 'Affinity live worker', 'blocked', 5, 'implement', 'goal',"
  202. " strftime('%s','now'))", (affinity_goal,))
  203. conn.commit()
  204. conn.close()
  205. fake2.ask_on_wake = False
  206. fake2.ask_on_idle_status = False
  207. fake2.settle_asks()
  208. act(auth, slot_public(auth, "/goals/%d" % affinity_goal, "goal")
  209. ["plan_pending_block"]["answer_action"],
  210. {"answer": "Plan approved. Proceed with implementation."})
  211. conn = db()
  212. affinity_parent = conn.execute(
  213. "select id from tasks where goal_id = ? and kind = 'goal_run'",
  214. (affinity_goal,)).fetchone()[0]
  215. agent_session = conn.execute(
  216. "select max(id) from sessions where task_id = ?",
  217. (affinity_parent,)).fetchone()[0]
  218. conn.close()
  219. status, resume_payload = fake2.ask_now()
  220. check(status == 200 and int(resume_payload["task"]["id"]) == affinity_parent,
  221. "the approved plan resumes the agent on racer-two (its workspace)")
  222. check(wait_for(lambda: task_state(auth, affinity_parent).get("status") == "suspended"
  223. or False, timeout=60, what="affinity agent parks") is not None,
  224. "the affinity goal agent parks on racer-two")
  225. conn = db()
  226. affinity_session = conn.execute(
  227. "select saddle_id from sessions where task_id = ?"
  228. " order by id desc limit 1",
  229. (affinity_parent,)).fetchone()[0]
  230. conn.close()
  231. check(affinity_session == int(racer2_card["id"]),
  232. "the parked workspace sits on racer-two")
  233. # Now racer-three carries the tag too — the pinning is by
  234. # workspace, not tags. With nothing due, a non-affine ask gets 204.
  235. act(auth, racer3_card["attach_capability"], {"capability_id": str(affinity_cap)})
  236. status, _ = fake3.ask_now()
  237. check(status == 204,
  238. "a tag-capable non-affine saddle is refused the parked agent (204)")
  239. # A goal task completing makes the parked agent resume-offerable —
  240. # affine to racer-two only. The other racers wake back up (they
  241. # host the worker; their claims of the parked agent get refused as
  242. # non-affine), while racer-two's ask triggers stay frozen so the
  243. # wake measurement stays clean. A wake may have hit the frozen
  244. # racers while the goal run queued (the lost-wake guard marks them
  245. # offline): the refresh probe brings them back before the worker.
  246. fake.ask_on_wake = True
  247. fake.ask_on_idle_status = True
  248. fake3.ask_on_wake = True
  249. fake3.ask_on_idle_status = True
  250. for racer_name in ("fake-saddle", "racer-three"):
  251. act(auth, saddle_card(auth, racer_name)["refresh"], {})
  252. check(wait_for(lambda name=racer_name: [s for s in
  253. slot_public(auth, "/saddles", "saddles")["saddles"]
  254. if s["name"] == name][0]["status"] == "idle"
  255. or False, timeout=15, what="racer re-onlined") is not None,
  256. "%s re-onlined after its frozen-window wake" % racer_name)
  257. fake2.ask_on_wake = False
  258. fake2.ask_on_idle_status = False
  259. wakes_before_2 = len(fake2.wake_log)
  260. fake2.settle_asks() # converted sleep: park-time ask lands (204)
  261. status, body = saddle_api("POST", "/api/saddle/task/create", fake2.token, {
  262. "session_id": agent_session, "title": "Affinity child",
  263. "mandate": "finish the affinity fixture"})
  264. check(status == 200, "the parked agent fans its worker out via the CLI")
  265. affinity_child = int(body["task_id"])
  266. saddle_api("POST", "/api/saddle/task/enqueue", fake2.token,
  267. {"session_id": agent_session, "task_id": affinity_child})
  268. check(wait_task_done(auth, affinity_child, timeout=90) is not None,
  269. "the affinity worker completes (on a non-affine racer)")
  270. check(wait_for(lambda: len(fake2.wake_log) > wakes_before_2 or False,
  271. timeout=90, what="affine wake") is not None,
  272. "the dispatcher's wake targets the affine saddle")
  273. status, resume_payload = fake2.ask_now()
  274. fake2.ask_on_wake = True # restored whatever the claim returned
  275. fake2.ask_on_idle_status = True
  276. check(status == 200 and int(resume_payload["task"]["id"]) == affinity_parent,
  277. "the affine saddle's ask claims the parked agent")
  278. check("Affinity child" in (resume_payload.get("resume_notice") or "")
  279. and "fake daemon finished" in (resume_payload.get("resume_notice") or ""),
  280. "the resume assignment carries the goal's batched report notice")
  281. check(wait_for(lambda: task_state(auth, affinity_parent).get("status")
  282. == "suspended" or False,
  283. timeout=60, what="affinity agent re-parks") is not None,
  284. "the resumed agent re-parks (script)")
  285. act(auth, slot_public(auth, "/goals/%d" % affinity_goal, "goal")["cancel"], {})
  286. check(wait_for(lambda: slot_public(auth, "/goals/%d" % affinity_goal,
  287. "goal")["status"] == "cancelled" or False,
  288. timeout=30, what="affinity goal cancelled") is not None,
  289. "the affinity goal cancels (drain)")
  290. print("== phase 1: lost wake -> offline + re-wake of the next capable ==")
  291. # racer-three sleeps through wakes (a daemon that died after its
  292. # last 204): the guard window (SADDLE_LOST_WAKE_S=2) passes with
  293. # no ask, the saddle goes offline, and the work moves to the
  294. # next capable sleeping saddle.
  295. fake3.ask_on_wake = False
  296. fake3.ask_on_idle_status = False # sleeps through everything
  297. saddles_state = slot_public(auth, "/saddles", "saddles")
  298. act(auth, saddles_state["create_capability"], {"name": "lostwake"})
  299. lostwake_cap = int([c["id"] for c in slot_public(auth, "/saddles", "saddles")["capabilities"]
  300. if c["name"] == "lostwake"][0])
  301. racer3_card = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  302. if s["name"] == "racer-three"][0]
  303. act(auth, racer3_card["attach_capability"], {"capability_id": str(lostwake_cap)})
  304. # Racer-three must be asleep to be woken: park it with a manual
  305. # 204 ask (nothing offerable for it — the task below is not
  306. # created yet).
  307. status, _ = fake3.ask_now()
  308. check(status == 204, "racer-three sleeps before the lost-wake run")
  309. task_id_tmp = create_queued(auth, {
  310. "project_id": "1",
  311. "title": "Lost wake task",
  312. "priority": "2",
  313. "capability_id": [str(lostwake_cap)],
  314. })
  315. lost_task = task_id_tmp
  316. check(wait_for(lambda: len(fake3.wake_log) > 0 or False,
  317. timeout=30, what="wake sent to racer-three") is not None,
  318. "the only capable sleeping saddle is woken")
  319. check(wait_for(lambda: [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  320. if s["name"] == "racer-three"][0]["status"] == "offline"
  321. or False, timeout=30, what="lost-wake -> offline") is not None,
  322. "no ask after the wake: the saddle goes offline (lost-wake guard)")
  323. check(task_state(auth, lost_task)["status"] == "queued",
  324. "the task is untouched while its wake was pending")
  325. # Re-wake the next capable: the healthy saddle gains the tag and
  326. # picks the work up via its own wake -> ask -> allocate roundtrip.
  327. fake_card = saddle_card(auth, "fake-saddle")
  328. act(auth, fake_card["attach_capability"], {"capability_id": str(lostwake_cap)})
  329. wakes_before = len(fake.wake_log)
  330. check(wait_for(lambda: len(fake.wake_log) > wakes_before or False,
  331. timeout=30, what="re-wake of the next capable") is not None,
  332. "the next capable sleeping saddle is woken for the task")
  333. check(wait_task_done(auth, lost_task, timeout=60) is not None,
  334. "wake -> ask -> allocate: the task completes on the healthy saddle")
  335. check(wait_for(lambda: any(int(p.get("task", {}).get("id") or 0) == lost_task
  336. for p in fake.start_payloads) or False,
  337. timeout=10, what="lost task assignment captured") is not None,
  338. "the re-woken saddle's ask produced the assignment")
  339. fake3.ask_on_wake = True
  340. fake3.ask_on_idle_status = True
  341. print("== phase 3: capability context, catalogue, subtask tags ==")
  342. # Assignment capability context (plan 10 phase 3): every
  343. # assignment carries {saddle_tags: the allocated saddle's tags,
  344. # fleet_tags: every tag name known across the fleet} — the
  345. # daemon renders it into its system prompt. fake-saddle carries
  346. # gpu + lostwake; the catalogue is affinity-tag, gpu, lostwake.
  347. fake2.ask_on_register = False
  348. fake2.ask_on_wake = False
  349. fake2.ask_on_terminal = False
  350. fake2.ask_on_idle_status = False
  351. fake2.settle_asks() # converted sleep: in-flight trigger asks land
  352. task_id_tmp = create_queued(auth, {
  353. "project_id": "1",
  354. "title": "Capabilities context",
  355. "priority": "2",
  356. })
  357. cap_task = task_id_tmp
  358. check(wait_for(lambda: any(int(p.get("task", {}).get("id") or 0) == cap_task
  359. for p in fake.start_payloads) or False,
  360. timeout=60, what="capabilities-context task dispatched") is not None,
  361. "the capabilities-context task dispatches")
  362. cap_payload = [p for p in fake.start_payloads
  363. if int(p.get("task", {}).get("id") or 0) == cap_task][-1]
  364. context = cap_payload.get("capabilities") or {}
  365. check(context.get("saddle_tags") == ["gpu", "lostwake"],
  366. "assignment saddle_tags = the allocated saddle's tags (%s)"
  367. % context.get("saddle_tags"))
  368. check(context.get("fleet_tags") == ["affinity-tag", "gpu", "lostwake"],
  369. "assignment fleet_tags = every tag name known across the fleet (%s)"
  370. % context.get("fleet_tags"))
  371. check(wait_task_done(auth, cap_task, timeout=60) is not None,
  372. "capabilities-context task completes")
  373. # GET /api/saddle/capabilities feeds the daemon's `saddle
  374. # capabilities` CLI: per-saddle-token auth, tag order by name,
  375. # each tag with the saddles carrying it (name order).
  376. status, _ = saddle_api("GET", "/api/saddle/capabilities", None)
  377. check(status == 401, "capabilities endpoint rejects a missing token")
  378. status, body = saddle_api("GET", "/api/saddle/capabilities", "saddle-bogus")
  379. check(status == 401, "capabilities endpoint rejects a wrong token")
  380. status, body = saddle_api("GET", "/api/saddle/capabilities", fake_token)
  381. catalogue = {t["name"]: t["saddles"] for t in body.get("tags", [])}
  382. check(status == 200 and body.get("ok") is True
  383. and [t["name"] for t in body.get("tags", [])]
  384. == ["affinity-tag", "gpu", "lostwake"],
  385. "catalogue lists every tag by name")
  386. check(catalogue.get("gpu") == ["fake-saddle"]
  387. and catalogue.get("affinity-tag") == ["racer-three", "racer-two"]
  388. and catalogue.get("lostwake") == ["fake-saddle", "racer-three"],
  389. "each tag names its carrying saddles in name order (%s)" % catalogue)
  390. # Task CLI capabilities (`task create --capability`): validated
  391. # against the operator tag set; unknown tags reject with the valid
  392. # list in the error (the agent reads it verbatim in the CLI
  393. # output). A goal agent's parked session hosts the calls.
  394. fake.suspend_on_title = "Tag host"
  395. tag_host = create_queued(auth, {
  396. "project_id": "1", "title": "Tag host", "priority": "3"})
  397. check(wait_for(lambda: task_state(auth, tag_host)["status"] == "suspended"
  398. or False, timeout=60, what="tag host parks") is not None,
  399. "a parked plain task hosts nothing (no goal scope)")
  400. # The goal callbacks need a real goal agent: reuse the sequencing
  401. # goal pattern — a fresh parked agent under this shard.
  402. act(auth, slot_public(auth, "/goals", "goals")["create"], {
  403. "project_id": "1", "title": "Tag goal", "brief": "b",
  404. "branch_name": "goal/tags"})
  405. conn = db()
  406. tag_goal = conn.execute("select id from goals where title = 'Tag goal'").fetchone()[0]
  407. # (Parking guard: the parked agent hosts the CLI calls below — the
  408. # park needs a live wake source under the goal.)
  409. conn.execute("insert into tasks (project_id, goal_id, kind, title, status,"
  410. " priority, mode, creator, created_at) values (1, ?, 'task',"
  411. " 'Tag live worker', 'blocked', 5, 'implement', 'goal',"
  412. " strftime('%s','now'))", (tag_goal,))
  413. conn.commit()
  414. conn.close()
  415. fake.goal_script = [("suspend",)]
  416. act(auth, slot_public(auth, "/goals/%d" % tag_goal, "goal")["start"], {})
  417. conn = db()
  418. tag_run = conn.execute(
  419. "select id from tasks where goal_id = ? and kind = 'goal_run'",
  420. (tag_goal,)).fetchone()[0]
  421. conn.close()
  422. check(wait_for(lambda: task_state(auth, tag_run)["status"] == "suspended"
  423. or False, timeout=60, what="tag goal agent parks") is not None,
  424. "the tag goal agent parks")
  425. conn = db()
  426. tag_session = conn.execute(
  427. "select max(id) from sessions where task_id = ?",
  428. (tag_run,)).fetchone()[0]
  429. conn.close()
  430. conn = db()
  431. tag_host_saddle = conn.execute(
  432. "select saddle_id from sessions where id = ?", (tag_session,)).fetchone()[0]
  433. conn.close()
  434. racer2_card_id = int([c for c in slot_public(auth, "/saddles", "saddles")["saddles"]
  435. if c["name"] == "racer-two"][0]["id"])
  436. racer3_card_id = int([c for c in slot_public(auth, "/saddles", "saddles")["saddles"]
  437. if c["name"] == "racer-three"][0]["id"])
  438. host_daemon = {int(fake_id): fake, racer2_card_id: fake2,
  439. racer3_card_id: fake3}[int(tag_host_saddle)]
  440. status, body = saddle_api("POST", "/api/saddle/task/create", host_daemon.token, {
  441. "session_id": tag_session, "title": "Bad tag worker",
  442. "mandate": "m", "capabilities": ["gpu", "warp-drive"]})
  443. check(status == 400, "an unknown capability tag rejects the task call")
  444. error_text = body.get("error", "")
  445. check("warp-drive" in error_text and "valid tags" in error_text
  446. and "gpu" in error_text and "lostwake" in error_text,
  447. "the rejection names the unknown tag and the valid list (%s)" % error_text)
  448. status, _ = saddle_api("POST", "/api/saddle/task/create", host_daemon.token, {
  449. "session_id": tag_session, "title": "Bad shape worker",
  450. "mandate": "m", "capabilities": "gpu"})
  451. check(status == 400, "a non-array capabilities member rejects")
  452. # A valid tag creates the goal worker in draft with exactly the
  453. # explicit tag attached; the goal agent enqueues it.
  454. status, body = saddle_api("POST", "/api/saddle/task/create", host_daemon.token, {
  455. "session_id": tag_session, "title": "Explicit tag worker",
  456. "mandate": "do the tagged work", "capabilities": ["gpu"]})
  457. check(status == 200 and body.get("ok") is True,
  458. "a valid tag creates the task synchronously")
  459. tagged_worker = int(body["task_id"])
  460. detail = task_state(auth, tagged_worker)
  461. check([c["name"] for c in detail.get("capabilities", [])] == ["gpu"],
  462. "the worker carries exactly the explicit tag")
  463. conn = db()
  464. tag_rows = conn.execute(
  465. "select c.name from task_capabilities tc"
  466. " join capabilities c on c.id = tc.capability_id"
  467. " where tc.task_id = ?", (tagged_worker,)).fetchall()
  468. conn.close()
  469. check([r[0] for r in tag_rows] == ["gpu"],
  470. "the task_capabilities rows record the explicit tag")
  471. check(detail["status"] == "draft",
  472. "the callback's task lands in draft until its agent enqueues")
  473. saddle_api("POST", "/api/saddle/task/enqueue", host_daemon.token,
  474. {"session_id": tag_session, "task_id": tagged_worker})
  475. check(wait_task_done(auth, tagged_worker, timeout=90) is not None,
  476. "the tagged worker dispatches and completes")
  477. act(auth, slot_public(auth, "/goals/%d" % tag_goal, "goal")["cancel"], {})
  478. act(auth, task_state(auth, tag_host)["cancel"], {})
  479. print("== phase 3: dashboard — pinned blocks + activity timeline ==")
  480. # Pinned OPEN blocking items as link-through cards, newest
  481. # first; no inline answer forms (the card links to the task
  482. # page). Fixture: one open question + one open review raised on a
  483. # local task (stand-ins for the auto-approve grandchild's
  484. # leftovers) join the probe RFC raised below.
  485. conn = db()
  486. conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
  487. " values (1, 'Dash fixture task', 'working', 5, strftime('%s','now'))")
  488. dash_task = conn.execute("select max(id) from tasks").fetchone()[0]
  489. conn.execute("insert into sessions (task_id, saddle_id, started_at)"
  490. " values (?, ?, strftime('%s','now'))", (dash_task, fake_id))
  491. dash_session = conn.execute("select max(id) from sessions").fetchone()[0]
  492. conn.commit()
  493. conn.close()
  494. def raise_on_dash(question, kind="question", body=None, summary=None):
  495. """Push-era raises on the dashboard fixture task (the poll-era
  496. /update channel is gone): a blocked results push carries the
  497. block payload; a review verb rides a review entry."""
  498. if kind == "review":
  499. block = {"question": question, "type": "review"}
  500. if summary is not None:
  501. block["body"] = summary
  502. return saddle_api("POST", "/api/saddle/results", fake_token, {
  503. "session_id": dash_session, "state": "blocked",
  504. "block": block})[0]
  505. block = {"question": question, "type": kind}
  506. if body is not None:
  507. block["body"] = body
  508. return saddle_api("POST", "/api/saddle/results", fake_token, {
  509. "session_id": dash_session, "state": "blocked",
  510. "block": block})[0]
  511. raise_on_dash("Dash fixture question?", kind="question")
  512. raise_on_dash("dash fixture review", kind="review", summary="dash fixture review")
  513. # The activity timeline: last <= 50 events, newest first, fed
  514. # from the existing tables. Fixture rows a few seconds old
  515. # guarantee the progress kind and a human-authored comment ride
  516. # along; the probe block below proves same-source ordering.
  517. conn = db()
  518. conn.execute("insert into task_comments (task_id, author, body, created_at)"
  519. " values (?, 'user:admin', 'timeline fixture comment by a human',"
  520. " strftime('%s','now','-5 seconds'))", (cap_task,))
  521. conn.execute(
  522. "insert into events (kind, actor, scope_type, scope_id, payload,"
  523. " created_at) values ('comment', 'user', 'task', ?,"
  524. " ?, strftime('%s','now','-5 seconds'))",
  525. (cap_task, json.dumps({"comment_id": 0, "author": "user:admin",
  526. "body": "timeline fixture comment by a human"})))
  527. conn.execute(
  528. "insert into events (kind, actor, scope_type, scope_id, payload,"
  529. " created_at) values ('progress', 'model', 'task', ?,"
  530. " ?, strftime('%s','now','-5 seconds'))",
  531. (cap_task, json.dumps({"text": "timeline fixture progress"})))
  532. conn.commit()
  533. conn.close()
  534. # The probe RFC raised last: the newest pinned card and the newest
  535. # timeline event (its raise+answer below prove same-source ordering
  536. # both ways). The timeline sorts same-second events by source
  537. # materialisation order, so the probe must land in a LATER clock
  538. # second than the fixtures — wait for the boundary (bounded).
  539. fixtures_epoch = int(time.time())
  540. wait_for(lambda: int(time.time()) > fixtures_epoch or False,
  541. timeout=3, what="next clock second before the probe raise")
  542. raise_on_dash("Timeline probe: approve the dashboard flow",
  543. kind="rfc", body="# Probe")
  544. dash = slot_public(auth, "/", "dashboard")
  545. pinned = dash.get("pinned", [])
  546. pinned_ids = [int(b["id"]) for b in pinned]
  547. check(len(pinned_ids) >= 3 and pinned_ids == sorted(pinned_ids, reverse=True),
  548. "pinned open blocks order newest-first (%s)" % pinned_ids)
  549. check({b["block_type"] for b in pinned} >= {"question", "rfc", "review"},
  550. "pinned cards cover questions, RFCs and reviews")
  551. check(all(b["question"] and b["task_title"] and int(b["task_id"]) > 0
  552. for b in pinned)
  553. and all("answer_action" not in b for b in pinned),
  554. "pinned cards are link-through only (task title, kind badge, question)")
  555. activity = dash.get("activity", [])
  556. epochs = [int(e["at_epoch"]) for e in activity]
  557. check(0 < len(activity) <= 50 and epochs == sorted(epochs, reverse=True),
  558. "the timeline shows the last <=50 events newest-first (%d events)"
  559. % len(activity))
  560. kinds = {e["kind"] for e in activity}
  561. check({"created", "started", "dispatch", "comment", "progress", "completed"} <= kinds,
  562. "the timeline covers status changes, dispatches, comments, progress and"
  563. " completions (%s)" % sorted(kinds))
  564. check(any(e["kind"] == "comment" and e["text"].startswith("user:admin:")
  565. and "fixture comment" in e["text"] for e in activity),
  566. "comment events carry author + snippet")
  567. # The freshly raised probe pinned as the newest card and landed
  568. # atop the timeline as block-raised; answering it records
  569. # block-answered.
  570. check(dash["pinned"][0]["question"] == "Timeline probe: approve the dashboard flow"
  571. and int(dash["pinned"][0]["task_id"]) == dash_task,
  572. "the freshest open block pins as the first card")
  573. check(dash["activity"][0]["kind"] == "block-raised"
  574. and "Timeline probe" in dash["activity"][0]["text"],
  575. "the raised block lands atop the timeline")
  576. probe_block = [b for b in task_state(auth, dash_task)["blocks"]
  577. if b["question"] == "Timeline probe: approve the dashboard flow"][0]
  578. act(auth, probe_block["answer_action"], {"answer": "Ship the dashboard"})
  579. dash = slot_public(auth, "/", "dashboard")
  580. activity = dash.get("activity", [])
  581. raised_at = next((i for i, e in enumerate(activity)
  582. if e["kind"] == "block-raised" and "Timeline probe" in e["text"]), -1)
  583. answered_at = next((i for i, e in enumerate(activity)
  584. if e["kind"] == "block-answered"
  585. and "Ship the dashboard" in e["text"]), -1)
  586. check(answered_at >= 0 and raised_at >= 0 and answered_at < raised_at,
  587. "the answered event sorts above its raised event (newest-first, same source)")
  588. check(all(b["question"] != "Timeline probe: approve the dashboard flow"
  589. for b in dash["pinned"]),
  590. "the answered block leaves the pinned cards")
  591. print("== phase 3 regression: a slow ask after a wake is never dropped ==")
  592. # The daemon worker's lost-wake-guard regression: a saddle whose
  593. # ask arrives SLOWLY after the wake (busy winding down when the
  594. # wake lands) must not lose the wake — the delayed ask (inside
  595. # the guard window) claims the task, no re-wake storm fires
  596. # while it is pending, and the saddle stays healthy. The
  597. # never-asks path (guard -> offline + re-wake of the next
  598. # capable) is proven by the racer-three section above.
  599. check(wait_for(lambda: queued_count() == 0 or False,
  600. timeout=60, what="queue settles before the slow-wake run") is not None,
  601. "the queue settles before the slow-wake run")
  602. status, _ = fake2.ask_now()
  603. check(status == 204, "the slow-ask racer sleeps before its wake")
  604. saddles_state = slot_public(auth, "/saddles", "saddles")
  605. act(auth, saddles_state["create_capability"], {"name": "slowwake"})
  606. slowwake_cap = int([c["id"] for c in slot_public(auth, "/saddles", "saddles")
  607. ["capabilities"] if c["name"] == "slowwake"][0])
  608. racer2_card = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  609. if s["name"] == "racer-two"][0]
  610. act(auth, racer2_card["attach_capability"], {"capability_id": str(slowwake_cap)})
  611. fake2.ask_delay = 1.2 # slow: asks well after the wake, inside the 2s guard
  612. fake2.ask_on_wake = True
  613. wakes_before_slow = len(fake2.wake_log)
  614. task_id_tmp = create_queued(auth, {
  615. "project_id": "1",
  616. "title": "Slow wake task",
  617. "priority": "2",
  618. "capability_id": [str(slowwake_cap)],
  619. })
  620. slow_task = task_id_tmp
  621. check(wait_for(lambda: len(fake2.wake_log) > wakes_before_slow or False,
  622. timeout=30, what="the slow-ask racer woken") is not None,
  623. "the only capable sleeping saddle is woken")
  624. check(wait_task_done(auth, slow_task, timeout=120) is not None,
  625. "the delayed ask claims the task — the wake was not dropped")
  626. check(any(a.get("status") == 200
  627. and int((a.get("payload") or {}).get("task", {}).get("id") or 0)
  628. == slow_task for a in fake2.asks),
  629. "the slow ask's 200 carries the task")
  630. check(len(fake2.wake_log) == wakes_before_slow + 1,
  631. "the pending-wake skip re-wakes nobody while the slow ask is in flight")
  632. racer2_card = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  633. if s["name"] == "racer-two"][0]
  634. check(racer2_card["status"] in ("idle", "busy"),
  635. "the slow asker never went offline (guard did not fire)")
  636. fake2.ask_delay = 0.0
  637. fake2.ask_on_wake = False # stays out of the pool below
  638. print("== goal 4 t1: plain-task force wake — offerable on its affine saddle ==")
  639. # A suspended PLAIN task stays parked until the human force-wakes
  640. # it; the resuming claim carries the explicit wake notice and
  641. # consumes the flag (one wake per press).
  642. fake2.ask_on_wake = False
  643. fake2.ask_on_idle_status = False
  644. fake2.settle_asks()
  645. conn = db()
  646. conn.execute("insert into tasks (project_id, kind, title, status, priority, mode,"
  647. " creator, created_at, suspended_at) values (1, 'task',"
  648. " 'Park me', 'suspended', 5, 'implement', 'human',"
  649. " strftime('%s','now'), strftime('%s','now'))")
  650. plain_parked = conn.execute("select max(id) from tasks").fetchone()[0]
  651. # parked_offerable keys the affine saddle off the newest SESSION
  652. # row (plan 16 D4): a parked round's row is closed 'parked' — give
  653. # the fixture one so the wake offers on the fake saddle.
  654. conn.execute("insert into sessions (task_id, saddle_id, started_at, ended_at,"
  655. " outcome) values (?, ?, strftime('%s','now'),"
  656. " strftime('%s','now'), 'parked')",
  657. (plain_parked, racer2_card_id))
  658. conn.commit()
  659. conn.close()
  660. task_page = http_get_page(auth, "/tasks/%d" % plain_parked)
  661. check('stm-action="task.force_wake"' in task_page
  662. and 'stm-if="task.force_wake != null"' in task_page,
  663. "the task page authors the Force wake button for a suspended plain task")
  664. # No press, no wake: frozen-ask racer-two gets 204.
  665. status, _ = fake2.ask_now()
  666. check(status == 204,
  667. "without a press the parked plain task stays parked (204)")
  668. detail = task_state(auth, plain_parked)
  669. check(detail.get("force_wake") is not None,
  670. "the task state exposes the offered force-wake action")
  671. # Determinism: the wake's trigger-ask (and any idle-probe ask) must
  672. # not race the MANUAL claim the section asserts on.
  673. fake2.ask_on_wake = False
  674. fake2.ask_on_idle_status = False
  675. act(auth, detail["force_wake"], {})
  676. status, resume_payload = fake2.ask_now()
  677. check(status == 200 and int(resume_payload["task"]["id"]) == plain_parked,
  678. "the force-woke plain task is claimable again")
  679. woke_notice = resume_payload.get("resume_notice") or ""
  680. check("force-woke this parked task" in woke_notice
  681. and "resume where you left off" in woke_notice,
  682. "the plain-task resume carries the explicit wake notice")
  683. conn = db()
  684. flag_row = conn.execute("select force_wake from tasks where id = ?",
  685. (plain_parked,)).fetchone()
  686. conn.close()
  687. check(flag_row is not None and flag_row[0] in (0, False),
  688. "the claim consumed the task force-wake flag")
  689. fake2.ask_on_wake = True
  690. fake2.ask_on_idle_status = True
  691. # The woken run re-parks (fake daemon finishes then suspends? no —
  692. # fake.start_payloads script: the plain task runs to done via the
  693. # daemon's own finish path in later suites; here just let it be
  694. # requeued by completing it directly to keep the fixture short).
  695. check(wait_task_done(auth, plain_parked, timeout=60) is not None,
  696. "the woken plain task completes (daemon finish path)")
  697. # Guard: a non-suspended task offers no wake action (the template
  698. # ships the gated markup statically; absence is a STATE property).
  699. check(task_state(auth, plain_parked).get("force_wake") is None,
  700. "a done task offers no force-wake action")