answers.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. """The answers 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: answers — the answer-resume machinery in both daemon shapes
  6. # (sleeping affine wake, mid-round verdict delivery), offline requeue
  7. # keeping parked-with-answered tasks, the staleness probe, and the
  8. # startup token migration (this shard's one deliberate app restart).
  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. print("== phase 2: answer resume — wake -> ask -> affinity claim ==")
  14. # The full live sequence against a daemon that asks after its
  15. # park: ask -> assignment -> blocked push (park) -> ask -> 204
  16. # -> sleeps (row idle) -> human answers -> the dispatcher WAKES
  17. # the sleeping affine saddle -> its ask allocates the resume,
  18. # the verdict riding the assignment -> the session continues.
  19. # A private capability tag keeps the fleet's other sleeping
  20. # saddles from claiming these tasks.
  21. saddles_state = slot_public(auth, "/saddles", "saddles")
  22. act(auth, saddles_state["create_capability"], {"name": "answerflow"})
  23. answerflow_cap = int([c["id"] for c in slot_public(auth, "/saddles", "saddles")["capabilities"]
  24. if c["name"] == "answerflow"][0])
  25. def answerflow_daemon(**kwargs):
  26. daemon = FakeSaddle()
  27. daemon.ask_on_register = False # manual, deterministic asks
  28. daemon.hold_state = True # the tests drive the lifecycle
  29. for key, value in kwargs.items():
  30. setattr(daemon, key, value)
  31. daemon.serve()
  32. return daemon
  33. def register_answerflow(daemon, name):
  34. act(auth, slot_public(auth, "/saddles", "saddles")["register"], {
  35. "name": name, "host": "127.0.0.1:%d" % daemon.port})
  36. act(auth, saddle_card(auth, name)["attach_capability"],
  37. {"capability_id": str(answerflow_cap)})
  38. def create_answerflow_task(title):
  39. return create_queued(auth, {
  40. "project_id": "1", "title": title, "priority": "2",
  41. "capability_id": [str(answerflow_cap)]})
  42. def retire_daemon(daemon, name):
  43. # deterministic teardown: a dead daemon and an offline row
  44. # never interfere with the sections that follow
  45. daemon.kill()
  46. act(auth, saddle_card(auth, name)["refresh"], {})
  47. sleeper = answerflow_daemon(ask_on_wake=True)
  48. register_answerflow(sleeper, "resume-sleeper")
  49. sleeping_task = create_answerflow_task("Sleeping resume task")
  50. ask_status, assignment = sleeper.ask_now()
  51. check(ask_status == 200 and int(assignment["task"]["id"]) == sleeping_task,
  52. "the daemon's ask claims the queued task")
  53. sleeping_session = assignment.get("session_id")
  54. sleeper.state = "blocked"
  55. check(sleeper.push_results("blocked", block={
  56. "question": "Pick a side", "type": "question",
  57. "options": ["Port", "Starboard"]}) == 200,
  58. "the blocked push parks the session")
  59. check(wait_for(lambda: task_state(auth, sleeping_task).get("status") == "blocked"
  60. or False, timeout=30, what="sleeping task parked") is not None,
  61. "the task parks as blocked")
  62. check(saddle_card(auth, "resume-sleeper")["status"] == "idle",
  63. "the parked session's saddle row reads idle")
  64. ask_status, _ = sleeper.ask_now()
  65. check(ask_status == 204,
  66. "the parked daemon's ask returns 204 — it sleeps until woken")
  67. # Converted sleep(3): ticks pass with the daemon asleep and no
  68. # wake may arrive — a wake during the window fails loudly.
  69. check(quiet_window(quiet_seconds(),
  70. lambda: len(sleeper.wake_log) == 0,
  71. "sleeping daemon never woken"),
  72. "the sleeping daemon is never woken while the block waits")
  73. detail = task_state(auth, sleeping_task)
  74. side_block = [b for b in detail["blocks"] if b["question"] == "Pick a side"][0]
  75. act(auth, side_block["answer_action"], {"option": "Starboard"})
  76. check(wait_for(lambda: len(sleeper.wake_log) > 0 or False,
  77. timeout=30, what="wake after the answer") is not None,
  78. "answering the block wakes the sleeping affine saddle")
  79. # Answering = allocating (plan 11): the resume assignment carries
  80. # the verdict as resume_notice (stable wording) AND a FRESH
  81. # session id — the parked row closed, the daemon keys its resumed
  82. # pushes on the new one.
  83. check(wait_for(lambda: any(
  84. int(p.get("task", {}).get("id") or 0) == sleeping_task
  85. and p.get("session_id") != sleeping_session
  86. and p.get("resume_notice") == "Human selected: Starboard"
  87. and any(a.get("answer") == "Starboard"
  88. for a in p.get("task", {}).get("answers", []))
  89. for p in sleeper.start_payloads[1:]) or False,
  90. timeout=30, what="resume allocation carries the verdict") is not None,
  91. "the woken daemon's ask allocates the resume — fresh session, notice carried")
  92. # Barrier: the resume's own working push must commit before the
  93. # manual done push (a late working write would otherwise race the
  94. # completion's full-row write).
  95. check(wait_for(lambda: task_state(auth, sleeping_task).get("status") == "working"
  96. or False, timeout=15, what="resumed round working") is not None,
  97. "the resumed round reports working before its completion push")
  98. # The resumed round runs to completion keyed on the fresh session.
  99. sleeper.push_results("done", summary="sleep-resumed round complete")
  100. check(wait_task_done(auth, sleeping_task, timeout=30) is not None,
  101. "the resumed session continues to completion")
  102. conn = db()
  103. rows = conn.execute(
  104. "select id, outcome, ended_at is not null from sessions where task_id = ?"
  105. " order by id", (sleeping_task,)).fetchall()
  106. conn.close()
  107. check(len(rows) == 2 and rows[0][1] == "resumed" and rows[0][2]
  108. and rows[1][1] == "done" and rows[1][2],
  109. "parked session closed 'resumed'; the resumed round ran on a fresh row to done (%s)"
  110. % (rows,))
  111. retire_daemon(sleeper, "resume-sleeper")
  112. print("== phase 2: answer resume — the parked daemon is woken (POST /answer retired) ==")
  113. # Post Task A every park FREES the daemon: after the blocked push
  114. # it lands idle and asks (204). The retired POST /answer channel
  115. # always refuses; the verdict reaches the parked workspace purely
  116. # through the wake -> ask -> affinity allocation carrying the
  117. # resume_notice + a fresh session id.
  118. parked = answerflow_daemon(ask_on_wake=True,
  119. ask_on_terminal=False)
  120. register_answerflow(parked, "resume-parked")
  121. parked_task = create_answerflow_task("Parked resume task")
  122. ask_status, assignment = parked.ask_now()
  123. check(ask_status == 200 and int(assignment["task"]["id"]) == parked_task,
  124. "the daemon's ask claims the task")
  125. parked_session = assignment.get("session_id")
  126. parked.state = "blocked"
  127. check(parked.push_results("blocked", block={
  128. "question": "Which database?", "type": "question",
  129. "options": ["SQLite", "Postgres"]}) == 200,
  130. "the blocked push parks the session mid-round")
  131. check(wait_for(lambda: task_state(auth, parked_task).get("status") == "blocked"
  132. or False, timeout=30, what="parked task blocked") is not None,
  133. "the task parks as blocked")
  134. check(saddle_card(auth, "resume-parked")["status"] == "idle",
  135. "the parked session's saddle row reads idle")
  136. # The freed daemon re-asks (its idle loop) and sleeps on 204.
  137. ask_status, _ = parked.ask_now()
  138. check(ask_status == 204,
  139. "the freed parked daemon asks again and sleeps (204)")
  140. # The retired channel always refuses — the web never depends on it.
  141. request = urllib.request.Request(
  142. "http://127.0.0.1:%d/answer" % parked.port,
  143. data=json.dumps({"answer": "SQLite", "branch_advanced": False}).encode(),
  144. method="POST")
  145. request.add_header("Content-Type", "application/json")
  146. try:
  147. urllib.request.urlopen(request, timeout=5)
  148. refused = False
  149. except urllib.error.HTTPError as error:
  150. refused = error.code == 409
  151. check(refused, "POST /answer always refuses (409) — parks resume via allocation")
  152. detail = task_state(auth, parked_task)
  153. db_block = [b for b in detail["blocks"] if b["question"] == "Which database?"][0]
  154. # branch_advanced on the resume assignment (the /poll verdict
  155. # channel's hint lives here now): a sibling on the same shared
  156. # working branch completed strictly after the block's park marker
  157. # — the resumed allocation must report branch_advanced=true so the
  158. # daemon knows to pull before continuing.
  159. conn = db()
  160. parked_block_created = conn.execute(
  161. "select created_at from task_blocks where id = ?", (db_block["id"],)
  162. ).fetchone()[0]
  163. conn.execute("update tasks set task_branch = 'shared/resume-branch'"
  164. " where id = ?", (parked_task,))
  165. conn.commit()
  166. conn.close()
  167. wait_for(lambda: int(time.time()) > parked_block_created or False,
  168. timeout=3, what="next clock second past the park marker")
  169. conn = db()
  170. conn.execute("insert into tasks (project_id, title, status, priority,"
  171. " task_branch, summary, completed_at, created_at)"
  172. " values (1, 'Resume sibling', 'done', 5, 'shared/resume-branch',"
  173. " 'sibling finished', strftime('%s','now'),"
  174. " strftime('%s','now','-300 seconds'))")
  175. conn.commit()
  176. conn.close()
  177. act(auth, db_block["answer_action"], {"option": "SQLite"})
  178. check(wait_for(lambda: len(parked.wake_log) > 0 or False,
  179. timeout=30, what="wake after the answer") is not None,
  180. "answering the block wakes the parked (idle) affine saddle")
  181. check(wait_for(lambda: any(
  182. int(p.get("task", {}).get("id") or 0) == parked_task
  183. and p.get("session_id") != parked_session
  184. and p.get("resume_notice") == "Human selected: SQLite"
  185. and p.get("branch_advanced") is True
  186. for p in parked.start_payloads[1:]) or False,
  187. timeout=30, what="parked resume allocation") is not None,
  188. "the parked daemon's ask allocates the resume — fresh session, formatted notice,"
  189. " branch_advanced=true after the sibling's completion")
  190. # The hold_state daemon drives its round manually: the resumed
  191. # round completes on the fresh session row.
  192. parked.push_results("done", summary="parked round resumed and finished")
  193. check(wait_task_done(auth, parked_task, timeout=90) is not None,
  194. "the parked session continues to completion")
  195. conn = db()
  196. row = conn.execute("select outcome from sessions where id = ?",
  197. (parked_session,)).fetchone()
  198. session_rows = conn.execute(
  199. "select id, outcome from sessions where task_id = ? order by id",
  200. (parked_task,)).fetchall()
  201. conn.close()
  202. check(row is not None and row[0] == "resumed"
  203. and len(session_rows) == 2 and session_rows[0] == (parked_session, "resumed"),
  204. "the parked session closed 'resumed' and the resumed round kept its own row (%s)"
  205. % (session_rows,))
  206. retire_daemon(parked, "resume-parked")
  207. print("== plan 11: plan acceptance — accept / reject / auto-approve ==")
  208. # A type 'plan' block parks like any other; Accept and
  209. # Reject-with-feedback drive the same wake -> ask -> allocation
  210. # resume as any verdict, the stable wording riding the
  211. # resume_notice. Plan 12: the daemon only SENDS plan blocks on
  212. # goal runs now (the goal page owns the review); this covers the
  213. # generic verdict path a goal-run's mirrored block also uses. The
  214. # auto-approve option is gone.
  215. planner = answerflow_daemon(ask_on_wake=True, ask_on_terminal=False)
  216. register_answerflow(planner, "plan-planner")
  217. def park_on_plan(task_id, markdown):
  218. planner.state = "blocked"
  219. check(planner.push_results("blocked", block={
  220. "question": "Plan submitted for review", "type": "plan",
  221. "body": markdown}) == 200,
  222. "the plan push parks the session")
  223. # Wait for the block row itself (the auto-approve variant is
  224. # answered — and may resume — before any status poll lands).
  225. check(wait_for(lambda: any(b["block_type"] == "plan"
  226. for b in task_state(auth, task_id)["blocks"])
  227. or False, timeout=30, what="plan block raised") is not None,
  228. "the plan block reaches the task")
  229. return [b for b in task_state(auth, task_id)["blocks"]
  230. if b["block_type"] == "plan"][0]
  231. # (a) Accept resumes with the approval notice.
  232. accept_task = create_answerflow_task("Plan accept task")
  233. ask_status, accept_assignment = planner.ask_now()
  234. check(ask_status == 200 and int(accept_assignment["task"]["id"]) == accept_task,
  235. "the planner claims the accept task")
  236. accept_parked_session = accept_assignment.get("session_id")
  237. plan_block = park_on_plan(accept_task, "# Steps\n\n1. Wire\n2. Test")
  238. check(plan_block.get("answered") is not True,
  239. "the plan block waits for a human verdict")
  240. # Plan 16 S2: the accept form posts the verdict marker; the server
  241. # builds the stable constant (byte-identical wire).
  242. act(auth, plan_block["answer_action"], {"verdict": "accept"})
  243. check(wait_for(lambda: any(
  244. int(p.get("task", {}).get("id") or 0) == accept_task
  245. and p.get("session_id") != accept_parked_session
  246. and p.get("resume_notice") == "Plan approved. Proceed with implementation."
  247. for p in planner.start_payloads[1:]) or False,
  248. timeout=30, what="accept resume allocation") is not None,
  249. "accept resumes the parked daemon with the approval notice")
  250. planner.push_results("done", summary="plan accepted and implemented")
  251. check(wait_task_done(auth, accept_task, timeout=60) is not None,
  252. "the accepted plan's task completes")
  253. planner.set_state("idle")
  254. # (b) Reject-with-feedback loops the feedback notice back.
  255. reject_task = create_answerflow_task("Plan reject task")
  256. ask_status, reject_assignment = planner.ask_now()
  257. check(ask_status == 200 and int(reject_assignment["task"]["id"]) == reject_task,
  258. "the planner claims the reject task")
  259. reject_parked_session = reject_assignment.get("session_id")
  260. plan_block = park_on_plan(reject_task, "# Draft\n\nJust wing it")
  261. act(auth, plan_block["answer_action"], {"feedback": "Add a test section"})
  262. reject_block = [b for b in task_state(auth, reject_task)["blocks"]
  263. if b["block_type"] == "plan"][0]
  264. check(reject_block.get("answer")
  265. == "Plan feedback received:\nAdd a test section",
  266. "reject stores the feedback in the stable wording (%s)"
  267. % reject_block.get("answer"))
  268. check(wait_for(lambda: any(
  269. int(p.get("task", {}).get("id") or 0) == reject_task
  270. and p.get("session_id") != reject_parked_session
  271. and p.get("resume_notice") == "Plan feedback received:\nAdd a test section"
  272. for p in planner.start_payloads[1:]) or False,
  273. timeout=30, what="reject resume allocation") is not None,
  274. "reject resumes the parked daemon with the feedback notice")
  275. planner.push_results("done", summary="plan revised and delivered")
  276. check(wait_task_done(auth, reject_task, timeout=60) is not None,
  277. "the revised plan's task completes")
  278. planner.set_state("idle")
  279. retire_daemon(planner, "plan-planner")
  280. print("== phase 2: offline requeue keeps parked-with-answered tasks ==")
  281. # A daemon dying while its parked task carries an answered
  282. # verdict: going offline must not requeue the task or close its
  283. # session — the resume machinery (delivery once the daemon
  284. # returns, or the affinity allocation on its next ask) needs
  285. # both. The question park leaves the row idle; the review park
  286. # keeps it busy@task (`saddle review` blocks the round without
  287. # a parking state push), exercising the unreachable path's
  288. # requeue decision both ways.
  289. stranded = answerflow_daemon()
  290. register_answerflow(stranded, "resume-stranded")
  291. stranded_task = create_answerflow_task("Stranded answered task")
  292. ask_status, _ = stranded.ask_now()
  293. check(ask_status == 200, "the stranded daemon claims its task")
  294. stranded.state = "blocked"
  295. stranded.push_results("blocked", block={
  296. "question": "Hold or fold?", "type": "question", "options": ["Hold", "Fold"]})
  297. check(wait_for(lambda: task_state(auth, stranded_task).get("status") == "blocked"
  298. or False, timeout=30, what="stranded task parked") is not None,
  299. "the stranded task parks as blocked")
  300. detail = task_state(auth, stranded_task)
  301. hold_block = [b for b in detail["blocks"] if b["question"] == "Hold or fold?"][0]
  302. act(auth, hold_block["answer_action"], {"option": "Hold"})
  303. stranded.kill() # the daemon dies before the verdict is delivered
  304. act(auth, saddle_card(auth, "resume-stranded")["refresh"], {})
  305. check(wait_for(lambda: saddle_card(auth, "resume-stranded")["status"] == "offline"
  306. or False, timeout=30, what="stranded saddle offline") is not None,
  307. "the dead daemon's saddle row goes offline")
  308. # Converted sleep(7): offline ticks run while the task MUST stay
  309. # parked — a requeue during the window fails loudly.
  310. check(quiet_window(quiet_seconds(),
  311. lambda: task_state(auth, stranded_task)["status"] == "blocked",
  312. "stranded task not requeued"),
  313. "the parked-with-answered task is not requeued by the offline pass")
  314. conn = db()
  315. open_session = conn.execute(
  316. "select count(*) from sessions where task_id = ? and ended_at is null",
  317. (stranded_task,)).fetchone()[0]
  318. conn.close()
  319. check(open_session == 1,
  320. "the parked-with-answered task keeps its open session for the resume")
  321. reviewer = answerflow_daemon(ask_on_terminal=False)
  322. register_answerflow(reviewer, "resume-reviewer")
  323. review_task = create_answerflow_task("Review park task")
  324. ask_status, _ = reviewer.ask_now()
  325. check(ask_status == 200, "the reviewer claims its task")
  326. # Post Task A the review park frees the daemon: the blocked push
  327. # carries the review block and parks the row idle.
  328. reviewer.state = "blocked"
  329. check(reviewer.push_results("blocked", block={
  330. "question": "Review requested", "type": "review",
  331. "body": "Ready for review: wired and tested."}) == 200,
  332. "the review park push carries the review block")
  333. check(wait_for(lambda: task_state(auth, review_task).get("status") == "review_requested"
  334. or False, timeout=30, what="review park raised") is not None,
  335. "the review block parks the task as review_requested (row freed)")
  336. # The verdict lands (nothing delivers it anymore); THEN the daemon
  337. # dies and the manual refresh offlines it — the parked-with-answered
  338. # task must keep its session for the eventual resume.
  339. detail = task_state(auth, review_task)
  340. verdict_block = [b for b in detail["blocks"]
  341. if b["block_type"] == "review"][0]
  342. act(auth, verdict_block["answer_action"], {"verdict": "accept"})
  343. reviewer.kill()
  344. act(auth, saddle_card(auth, "resume-reviewer")["refresh"], {})
  345. check(wait_for(lambda: saddle_card(auth, "resume-reviewer")["status"] == "offline"
  346. or False, timeout=30, what="reviewer offline") is not None,
  347. "the dead reviewer goes offline on the failed probe")
  348. # Converted sleep(7): offline ticks run while the task MUST stay
  349. # review-parked — a requeue during the window fails loudly.
  350. check(quiet_window(quiet_seconds(),
  351. lambda: task_state(auth, review_task)["status"] == "review_requested",
  352. "review-parked task not requeued"),
  353. "the review-parked task with an answered verdict is never requeued")
  354. conn = db()
  355. open_session = conn.execute(
  356. "select count(*) from sessions where task_id = ? and ended_at is null",
  357. (review_task,)).fetchone()[0]
  358. conn.close()
  359. check(open_session == 1,
  360. "the review-parked task keeps its open session for the resume")
  361. print("== phase 2: staleness — one probe decides ==")
  362. # A silent-but-alive runner (long think, nothing to flush): after
  363. # SADDLE_STALE_S (3s here) of silence it owes ONE exceptional
  364. # GET /status; the answer keeps it running and the silence
  365. # episode gets no second probe.
  366. task_id_tmp = create_queued(auth, {
  367. "project_id": "1",
  368. "title": "Silent thinker",
  369. "priority": "2",
  370. })
  371. thinker = task_id_tmp
  372. check(wait_for(lambda: task_state(auth, thinker).get("status") == "working"
  373. or False, timeout=60, what="thinker working") is not None,
  374. "silent thinker reaches working")
  375. fake.silent = True # the daemon goes quiet mid-round (alive)
  376. probes_at_start = fake.status_hits
  377. check(wait_for(lambda: fake.status_hits > probes_at_start or False,
  378. timeout=30, what="exceptional probe arrives") is not None,
  379. "a silent runner gets its exceptional GET /status probe")
  380. # Converted sleep(8)+sleep(6): a quiet window across several
  381. # ticks in which the probe count MUST NOT move — a second probe
  382. # (the poll drumbeat this guards against) fails loudly.
  383. second_window = fake.status_hits
  384. check(quiet_window(quiet_seconds(),
  385. lambda: fake.status_hits == second_window,
  386. "no second staleness probe"),
  387. "the silence episode is probed exactly once (no poll drumbeat)")
  388. check(task_state(auth, thinker).get("status") == "working"
  389. and saddle_card(auth, "fake-saddle")["status"] == "busy",
  390. "the answered probe keeps the task running (no requeue)")
  391. # Output flows again: the episode ends, the clock resets.
  392. fake.silent = False
  393. fake.push_chunks(["resumed output\r\n"])
  394. check(wait_task_done(auth, thinker, timeout=60) is not None,
  395. "the silent thinker completes once output flows again")
  396. # The daemon vanishing mid-task: silence for the window, the
  397. # probe fails, the task requeues with an agent comment and the
  398. # saddle goes offline.
  399. task_id_tmp = create_queued(auth, {
  400. "project_id": "1",
  401. "title": "Vanish victim",
  402. "priority": "2",
  403. })
  404. vanish = task_id_tmp
  405. check(wait_for(lambda: task_state(auth, vanish).get("status") == "working"
  406. or False, timeout=60, what="vanish victim working") is not None,
  407. "vanish victim reaches working")
  408. fake.kill()
  409. check(wait_for(lambda: saddle_card(auth, "fake-saddle")["status"] == "offline" or False,
  410. timeout=60, what="daemon death -> offline") is not None,
  411. "vanishing daemon drives the saddle offline (failed staleness probe)")
  412. check(wait_for(lambda: any("unreachable" in c["body"]
  413. and c.get("is_system")
  414. for c in task_state(auth, vanish).get("comments", []))
  415. or False, timeout=30, what="unreachable comment") is not None,
  416. "the affected task gets an unreachable system row (never an agent comment)")
  417. check(task_state(auth, vanish)["status"] == "queued",
  418. "the task requeues after the daemon death")
  419. card = saddle_card(auth, "fake-saddle")
  420. check(card.get("health") == "offline",
  421. "health badge reports offline")
  422. # The unreachable row flag marks the detection window: a task the
  423. # web still sees working while its saddle is offline/fenced, before
  424. # the requeue pass moves it. Controlled window (the real
  425. # transitions above are atomic with their requeues).
  426. conn = db()
  427. conn.execute("insert into tasks (project_id, title, status, priority,"
  428. " created_at) values (1, 'Unreachable row', 'working', 5,"
  429. " strftime('%s','now'))")
  430. unreachable_row = conn.execute("select max(id) from tasks").fetchone()[0]
  431. conn.execute("insert into sessions (task_id, saddle_id, started_at)"
  432. " values (?, ?, strftime('%s','now'))", (unreachable_row, fake_id))
  433. conn.commit()
  434. conn.close()
  435. check(wait_for(lambda: (lambda row: row.get("unreachable") is True)(
  436. [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  437. if int(t["id"]) == unreachable_row][0]) or False,
  438. timeout=20, what="unreachable flag") is not None,
  439. "queue row shows the unreachable state in the window")
  440. print("== phase 1: saddles.token is the single credential store (arch 24) ==")
  441. # The legacy api_tokens table and every fallback read died with the
  442. # poll era: a restart proves the migrated schema carries no
  443. # api_tokens table, the row token survives and still authenticates,
  444. # and a legacy-shaped token string authorises nothing (no fallback
  445. # read exists to honour it). Fixture: this shard's own racer.
  446. fake2 = FakeSaddle()
  447. fake2.ask_on_register = False
  448. fake2.serve(port=free_port())
  449. saddles_state = slot_public(auth, "/saddles", "saddles")
  450. act(auth, saddles_state["register"], {"name": "racer-two",
  451. "host": "127.0.0.1:%d" % fake2.port})
  452. racer_two_id = int([s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  453. if s["name"] == "racer-two"][0]["id"])
  454. racer_two_token = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  455. if s["name"] == "racer-two"][0]["token"]
  456. app.terminate()
  457. app.wait(timeout=10)
  458. app = start_app()
  459. auth = login("admin", "adminpass123")
  460. ctx.app = app # teardown tracks the restarted process
  461. ctx.auth = auth
  462. def schema_tables():
  463. conn = db()
  464. try:
  465. return {row[0] for row in conn.execute(
  466. "select name from sqlite_master where type = 'table'")}
  467. finally:
  468. conn.close()
  469. check("api_tokens" not in schema_tables(),
  470. "the fresh schema carries no api_tokens table (arch 24)")
  471. conn = db()
  472. row_token = conn.execute("select token from saddles where id = ?",
  473. (racer_two_id,)).fetchone()[0]
  474. conn.close()
  475. check(row_token == racer_two_token and (row_token or "").startswith("saddle-"),
  476. "the per-saddle row token survives the restart unchanged")
  477. status, _ = saddle_api("POST", "/api/saddle/next-task", racer_two_token)
  478. check(status in (200, 204),
  479. "the row token still authenticates after the restart")
  480. status, _ = saddle_api("POST", "/api/saddle/next-task", "saddle-legacy-copied-token")
  481. check(status == 401,
  482. "a legacy api_tokens-shaped credential authorises nothing (fallback gone)")
  483. fake2.token = racer_two_token
  484. fake2.ask_now()
  485. check(True, "the fleet re-asks after the restart (sleeping re-established)")