answerwake.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. """The answerwake bundle (goal 28 task F): the park/answer-wake race.
  2. The task-187 arc, replayed end-to-end against the real allocation:
  3. - an agent's ask_question parks the session (the blocked push);
  4. - the FREED daemon asks again IMMEDIATELY — the very race that used
  5. to hand the same task back as a bare crash-restore — and the ask
  6. must 204 while the block is unanswered (the allocation guard);
  7. - a CRASHED parked task resumes with its question still open: the
  8. requeued task re-allocates and the assignment flags
  9. `pending_question` so the restored conversation's opening turn says
  10. the question is pending (F6) instead of barreling on;
  11. - a human answers while that session RUNS: the verdict is injected
  12. into the running round through the daemon's task-update channel
  13. (kind "answer"), delivery-once (F2) — no affinity resume can
  14. deliver it, the busy saddle cannot ask;
  15. - the running round parks on a further question; answering it resumes
  16. through the allocation and the resume notice carries the FRESH
  17. verdict while the already-injected one is never repeated (F3's
  18. batched composition with the delivery ledger).
  19. """
  20. from harness import * # noqa: F401,F403 — the shared wire helpers
  21. # The web-side park-in-flight hold (TaskService.PARK_SETTLE_GRACE_S is
  22. # 5s) plus a slack for the dispatcher tick — the crash-recovery sweep
  23. # must not fire until the crashed round's last push has aged past it.
  24. PARK_SETTLE_WAIT = 7.0
  25. def run(ctx):
  26. auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
  27. print("== goal 28 F1: the park race — an immediate re-ask is held ==")
  28. # A private capability tag keeps the shard's other sleeping
  29. # saddles out of these tasks.
  30. saddles_state = slot_public(auth, "/saddles", "saddles")
  31. act(auth, saddles_state["create_capability"], {"name": "answerwake"})
  32. cap_id = int([c["id"] for c in slot_public(auth, "/saddles", "saddles")["capabilities"]
  33. if c["name"] == "answerwake"][0])
  34. def race_daemon(name, **kwargs):
  35. daemon = FakeSaddle()
  36. daemon.ask_on_register = False # manual, deterministic asks
  37. daemon.hold_state = True # the test drives the lifecycle
  38. for key, value in kwargs.items():
  39. setattr(daemon, key, value)
  40. daemon.serve()
  41. act(auth, slot_public(auth, "/saddles", "saddles")["register"],
  42. {"name": name, "host": "127.0.0.1:%d" % daemon.port})
  43. act(auth, saddle_card(auth, name)["attach_capability"],
  44. {"capability_id": str(cap_id)})
  45. return daemon
  46. def race_task(title):
  47. return create_queued(auth, {
  48. "project_id": "1", "title": title, "priority": "2",
  49. "capability_id": [str(cap_id)]})
  50. runner = race_daemon("race-runner")
  51. task_id = race_task("Race: parked then re-asked")
  52. ask_status, assignment = runner.ask_now()
  53. check(ask_status == 200 and int(assignment["task"]["id"]) == task_id,
  54. "the ask claims the queued task")
  55. first_session = assignment.get("session_id")
  56. # The park: the round blocks on a question. The web raises the
  57. # block row and closes the session 'parked' synchronously inside
  58. # the /results push.
  59. runner.state = "blocked"
  60. check(runner.push_results("blocked", block={
  61. "question": "First: which side?", "type": "question",
  62. "options": ["Port", "Starboard"]}) == 200,
  63. "the blocked push parks the session")
  64. check(wait_for(lambda: task_state(auth, task_id).get("status") == "blocked"
  65. or False, timeout=30, what="raced task parked") is not None,
  66. "the task parks as blocked")
  67. conn = db()
  68. row = conn.execute(
  69. "select outcome from sessions where id = ?", (first_session,)).fetchone()
  70. conn.close()
  71. check(row is not None and row[0] == "parked",
  72. "the park closed the round's session row 'parked'")
  73. # THE RACE: the freed daemon asks again IMMEDIATELY — the block is
  74. # unanswered, so the allocation guard must hold the task (204),
  75. # never hand it back. The pre-fix web re-allocated it here and the
  76. # resumed round woke as a crash restore with its question gone.
  77. ask_status, payload = runner.ask_now()
  78. check(ask_status == 204,
  79. "F1: the immediate re-ask returns 204 while the block is unanswered")
  80. check(len([p for p in runner.start_payloads
  81. if int(p.get("task", {}).get("id") or 0) == task_id]) == 1,
  82. "F1: the parked task was never re-allocated")
  83. # The guard holds for the pre-block race too: a task whose row
  84. # still reads working/assigned while its round's session just
  85. # closed 'parked' (the transition in flight) is not claimable.
  86. conn = db()
  87. conn.execute("update tasks set status = 'working' where id = ?", (task_id,))
  88. conn.commit()
  89. conn.close()
  90. ask_status, _ = runner.ask_now()
  91. check(ask_status == 204,
  92. "F1: a working row with a fresh parked session is held (transition in flight)")
  93. conn = db()
  94. conn.execute("update tasks set status = 'blocked' where id = ?", (task_id,))
  95. conn.commit()
  96. conn.close()
  97. print("== goal 28 F6: a crashed parked task resumes with its question open ==")
  98. # The LEGIT unanswered-block resume (mandate F6): the parked task's
  99. # daemon crashed and the crash-recovery sweep requeued it — the
  100. # block row stays unanswered while the task re-enters the queue.
  101. # The sweep's post-state is applied directly (it needs a stale
  102. # OPEN session; this round's row is closed 'parked').
  103. second_task = race_task("Race: crashed parked task")
  104. ask_status, assignment = runner.ask_now()
  105. check(ask_status == 200 and int(assignment["task"]["id"]) == second_task,
  106. "the ask claims the second task")
  107. second_first_session = assignment.get("session_id")
  108. runner.state = "blocked"
  109. check(runner.push_results("blocked", block={
  110. "question": "Which side first?", "type": "question",
  111. "options": ["Port", "Starboard"]}) == 200,
  112. "the second task parks on a question")
  113. check(wait_for(lambda: task_state(auth, second_task).get("status") == "blocked"
  114. or False, timeout=30, what="second task parked") is not None,
  115. "the second task parks as blocked")
  116. # Answering is the resume: the wake -> ask -> allocation carries
  117. # the verdict on a fresh session row.
  118. detail = task_state(auth, second_task)
  119. side_block = [b for b in detail["blocks"]
  120. if b["question"] == "Which side first?"][0]
  121. act(auth, side_block["answer_action"], {"option": "Port"})
  122. check(wait_for(lambda: any(
  123. int(p.get("task", {}).get("id") or 0) == second_task
  124. and p.get("session_id") != second_first_session
  125. and p.get("resume_notice") == "Human selected: Port"
  126. for p in runner.start_payloads[1:]) or False,
  127. timeout=30, what="second task resume allocation") is not None,
  128. "the answer resumes the parked task through the allocation")
  129. resumed = [p for p in runner.start_payloads
  130. if int(p.get("task", {}).get("id") or 0) == second_task][-1]
  131. running_session = resumed.get("session_id")
  132. check(wait_for(lambda: task_state(auth, second_task).get("status") == "working"
  133. or False, timeout=15, what="second round working") is not None,
  134. "the resumed round is running")
  135. print("== goal 28 F2: an answer while a session runs is injected ==")
  136. # The running round parks on a SECOND question, then the round is
  137. # lost to a crash — the task-187 shape: the newest block is
  138. # UNANSWERED while a fresh round is re-allocated (the crash
  139. # sweep's post-state, applied directly for determinism).
  140. runner.state = "blocked"
  141. check(runner.push_results("blocked", block={
  142. "question": "Which database?", "type": "question",
  143. "options": ["SQLite", "Postgres"]}) == 200,
  144. "the running round parks on a second question")
  145. check(wait_for(lambda: task_state(auth, second_task).get("status") == "blocked"
  146. or False, timeout=30, what="second question raised") is not None,
  147. "the second question parks the round")
  148. # The crash: the round's session row is left OPEN and the task
  149. # row reads working (the daemon died between its park push and
  150. # its unwind). The next ask's crash-recovery sweep requeues it —
  151. # but NOT while the round's last push is fresh (the park-in-flight
  152. # hold); after the settle the sweep fires and the requeued task
  153. # re-allocates through the QUEUE with its question still open.
  154. conn = db()
  155. # Aged past the sweep's 120s grace (the round ran for a while
  156. # before it crashed) yet its LAST PUSH is the park transition,
  157. # seconds old — the exact task-187 shape.
  158. conn.execute(
  159. "update sessions set ended_at = null, outcome = null,"
  160. " started_at = strftime('%s','now','-10 minutes') where id = ?",
  161. (running_session,))
  162. conn.execute("update tasks set status = 'working' where id = ?", (second_task,))
  163. conn.commit()
  164. conn.close()
  165. # Park-in-flight hold: an immediate ask requeues nothing (the
  166. # round pushed moments ago — its transition may be queued).
  167. ask_status, _ = runner.ask_now()
  168. check(ask_status == 204,
  169. "F1: the crash-recovery sweep holds a round whose last push is"
  170. " fresh (park in flight)")
  171. conn = db()
  172. still_open = conn.execute(
  173. "select count(*) from sessions where id = ? and ended_at is null",
  174. (running_session,)).fetchone()[0]
  175. task_row = conn.execute(
  176. "select status from tasks where id = ?", (second_task,)).fetchone()[0]
  177. conn.close()
  178. check(still_open == 1 and task_row == "working",
  179. "F1: nothing was requeued by the early ask")
  180. # After the settle window the sweep fires on the very next ask:
  181. # requeue (the crashed row closes 'stopped') and the freed task is
  182. # claimed straight back by the same ask — one round-trip.
  183. time.sleep(PARK_SETTLE_WAIT)
  184. ask_status, assignment = runner.ask_now()
  185. check(ask_status == 200 and assignment.get("session_id") != running_session,
  186. "the settled sweep requeues the crashed round and the ask claims it"
  187. " (fresh session row)")
  188. # The answer below must find the round genuinely RUNNING: wait for
  189. # the daemon to key on the new session and push its opening batch
  190. # (the busy-owner state the delivery path keys on).
  191. live_session = assignment.get("session_id")
  192. check(wait_for(lambda: runner.session_id == live_session or False,
  193. timeout=15, what="daemon keyed on the live session") is not None,
  194. "the daemon keyed its pushes on the live session")
  195. runner.push_results("working")
  196. check(wait_for(lambda: task_state(auth, second_task).get("status") == "working"
  197. or False, timeout=15, what="live round working") is not None,
  198. "the re-allocated round is running")
  199. check(assignment.get("pending_question") is True,
  200. "F6: the assignment flags its unanswered newest block (pending_question)")
  201. # The human answers the OPEN question while this round runs: no
  202. # affinity resume can deliver it (the saddle is busy), so the
  203. # verdict must ride the task-update injection channel. The
  204. # dispatcher drains the hub every tick (TICK_S=1 in the suite).
  205. # (The barrier above guarantees the open session exists BEFORE the
  206. # answer fires — an answer against a closed task takes the resume
  207. # path instead, which is F3's business.)
  208. detail = task_state(auth, second_task)
  209. db_block = [b for b in detail["blocks"]
  210. if b["question"] == "Which database?"][0]
  211. act(auth, db_block["answer_action"], {"option": "SQLite"})
  212. check(wait_for(lambda: any(e.get("injected") and e.get("text") == "Human selected: SQLite"
  213. for e in runner.answer_log) or False,
  214. timeout=30, what="answer injected into the running session") is not None,
  215. "F2: the answer reaches the RUNNING session via task-update kind answer")
  216. conn = db()
  217. check(wait_for(lambda: conn.execute(
  218. "select count(*) from task_comments where task_id = ? and body = ?",
  219. (second_task,
  220. "verdict delivered into the running session (block #%s)" % db_block["id"])
  221. ).fetchone()[0] > 0 or False,
  222. timeout=15, what="delivery system comment") is not None,
  223. "F2: the delivery is recorded on the task timeline")
  224. conn.close()
  225. # Delivery is exactly-once: no second injection may land.
  226. check(quiet_window(quiet_seconds(),
  227. lambda: len([e for e in runner.answer_log if e.get("injected")]) == 1,
  228. "no duplicate answer injection"),
  229. "F2: the answer is injected exactly once")
  230. conn = db()
  231. injected = conn.execute(
  232. "select answers_injected from sessions where id = ?",
  233. (live_session,)).fetchone()
  234. conn.close()
  235. check(injected is not None and injected[0] >= 1,
  236. "F2: the injection is counted on the session row (answers_injected)")
  237. print("== goal 28 F3: the resume notice composes the batch ==")
  238. # The live round parks on a THIRD question; answering it resumes
  239. # through the allocation. The notice carries the FRESH verdict and
  240. # never repeats the already-injected one (the delivery ledger is
  241. # the filter) — a newer block can no longer swallow an older
  242. # answer silently.
  243. runner.state = "blocked"
  244. check(runner.push_results("blocked", block={
  245. "question": "Final check?", "type": "question",
  246. "options": ["Ship", "Hold"]}) == 200,
  247. "the live round parks on a third question")
  248. check(wait_for(lambda: task_state(auth, second_task).get("status") == "blocked"
  249. or False, timeout=30, what="third park") is not None,
  250. "the task parks on its third question")
  251. ask_status, _ = runner.ask_now()
  252. check(ask_status == 204,
  253. "the third park also holds the immediate re-ask (unanswered block)")
  254. detail = task_state(auth, second_task)
  255. final_block = [b for b in detail["blocks"]
  256. if b["question"] == "Final check?"][0]
  257. act(auth, final_block["answer_action"], {"option": "Ship"})
  258. check(wait_for(lambda: any(
  259. int(p.get("task", {}).get("id") or 0) == second_task
  260. and p.get("session_id") != live_session
  261. and "Human selected: Ship" in (p.get("resume_notice") or "")
  262. for p in runner.start_payloads[1:]) or False,
  263. timeout=30, what="third resume allocation") is not None,
  264. "F3: the answer resumes the parked task with its verdict")
  265. final = [p for p in runner.start_payloads
  266. if int(p.get("task", {}).get("id") or 0) == second_task][-1]
  267. notice = final.get("resume_notice") or ""
  268. check("Human selected: Ship" in notice,
  269. "F3: the fresh verdict rides the resume notice")
  270. check("Human selected: SQLite" not in notice,
  271. "F3: the already-injected verdict is NOT repeated as a resume verdict")
  272. check(final.get("pending_question") is not True,
  273. "the resumed assignment has no open question left")
  274. # The resume_notice persists on the session row (the terminal
  275. # page's resume-info panel reads it) and the watermark was stored.
  276. conn = db()
  277. stored = conn.execute(
  278. "select resume_notice from sessions where id = ?",
  279. (final.get("session_id"),)).fetchone()
  280. watermark = conn.execute(
  281. "select resume_since is not null from sessions where id = ?",
  282. (final.get("session_id"),)).fetchone()
  283. conn.close()
  284. check(stored is not None and "Human selected: Ship" in (stored[0] or ""),
  285. "the resume notice persists on the resumed session row")
  286. check(watermark is not None and watermark[0] == 1,
  287. "the batched-verdict watermark persists (resume_since)")
  288. # The final round completes cleanly.
  289. runner.push_results("done", summary="race rounds complete")
  290. check(wait_task_done(auth, second_task, timeout=30) is not None,
  291. "the resumed round continues to completion")
  292. runner.kill()