replay.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. """The replay 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: replay — plan 13 B1/B2: the daemon-global entry dedup and the
  6. # idempotent terminal/narrative projections. Replays the live-failure
  7. # post-mortem shapes directly against the push channels: an outbox
  8. # redelivery arriving under the WRONG session id after churn (and after
  9. # a web restart), and repeated failure pushes that must still project
  10. # exactly once. Includes this shard's one deliberate app restart (the
  11. # dedup must survive it — it is database state, not memory).
  12. # =============================================================================
  13. def run(ctx):
  14. auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
  15. app = ctx.app
  16. def rows_for(session):
  17. conn = db()
  18. try:
  19. return conn.execute(
  20. "select count(*) from session_commands where session_id = ?",
  21. (session,)).fetchone()[0]
  22. finally:
  23. conn.close()
  24. def dedup_rows():
  25. conn = db()
  26. try:
  27. return conn.execute("select count(*) from saddle_entry_dedup").fetchone()[0]
  28. finally:
  29. conn.close()
  30. def open_session_on_fake(task_id):
  31. """A fixture open session row on the shard's saddle (the churn
  32. shape: a fresh web-side run row the daemon never really drove)."""
  33. conn = db()
  34. try:
  35. conn.execute(
  36. "insert into sessions (task_id, saddle_id, started_at)"
  37. " values (?, ?, strftime('%s','now'))", (task_id, fake_id))
  38. conn.commit()
  39. return conn.execute(
  40. "select max(id) from sessions where task_id = ?",
  41. (task_id,)).fetchone()[0]
  42. finally:
  43. conn.close()
  44. print("== plan 13 B1: entry dedup is daemon-global, not per-session ==")
  45. # A first task runs to completion; its session is the replay source.
  46. first = create_queued(auth, {"project_id": "1", "title": "Replay source task",
  47. "priority": "2"})
  48. check(wait_task_done(auth, first, timeout=60) is not None,
  49. "the replay-source task completes")
  50. conn = db()
  51. src_session = conn.execute(
  52. "select max(id) from sessions where task_id = ?", (first,)).fetchone()[0]
  53. conn.close()
  54. # The outbox batch: two command entries the daemon already
  55. # delivered once (their ids landed with the source session).
  56. burst = [
  57. {"id": 5001, "command": "make test", "output": "ok", "exit_code": 0,
  58. "type": "command", "timestamp": "2026-09-11T00:00:00Z"},
  59. {"id": 5002, "command": "grep -r flange src/", "output": "src/flange.vala",
  60. "exit_code": 0, "type": "command", "timestamp": "2026-09-11T00:00:01Z"},
  61. ]
  62. status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
  63. {"session_id": src_session, "state": "done",
  64. "entries": burst})
  65. check(status == 200 and rows_for(src_session) >= 2,
  66. "the entries batch stores under its session")
  67. stored_once = rows_for(src_session)
  68. dedup_after_first = dedup_rows()
  69. # Redelivery under the SAME session: consumed, nothing new.
  70. status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
  71. {"session_id": src_session, "state": "done",
  72. "entries": burst})
  73. check(status == 200 and rows_for(src_session) == stored_once
  74. and dedup_rows() == dedup_after_first,
  75. "a same-session redelivery stores nothing new (consumed silently)")
  76. # Session churn: a brand-new session id on the same saddle replays
  77. # the SAME entry ids — the post-mortem's mis-keyed outbox send.
  78. # The entries' ids already populated another session, so the push
  79. # may not create rows here either.
  80. conn = db()
  81. conn.execute(
  82. "insert into tasks (project_id, title, status, priority, created_at,"
  83. " completed_at) values (1, 'Churn target task', 'done', 5,"
  84. " strftime('%s','now'), strftime('%s','now'))")
  85. conn.commit()
  86. churn_task = conn.execute(
  87. "select max(id) from tasks where title = 'Churn target task'").fetchone()[0]
  88. conn.close()
  89. churn_session = open_session_on_fake(churn_task)
  90. status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
  91. {"session_id": churn_session, "state": "done",
  92. "entries": burst})
  93. check(status == 200 and rows_for(churn_session) == 0,
  94. "replay-under-wrong-session stores nothing new (daemon-global dedup)")
  95. check(dedup_rows() == dedup_after_first,
  96. "the consumed redelivery claims no fresh dedup rows")
  97. # A fresh entry id under the churned session DOES store — the dedup
  98. # keys on the entry id, never on the session.
  99. fresh = [{"id": 5003, "command": "git status", "output": "clean",
  100. "exit_code": 0, "type": "command", "timestamp": "2026-09-11T00:00:02Z"}]
  101. status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
  102. {"session_id": churn_session, "state": "done",
  103. "entries": fresh})
  104. check(status == 200 and rows_for(churn_session) == 1
  105. and dedup_rows() == dedup_after_first + 1,
  106. "a fresh entry id stores under any session of its saddle")
  107. # Web restart: the dedup survives (database state, not memory).
  108. app.terminate()
  109. app.wait(timeout=10)
  110. app = start_app()
  111. auth = login("admin", "adminpass123")
  112. ctx.app = app # teardown tracks the restarted process
  113. ctx.auth = auth
  114. # The restart wiped the in-memory ask bookkeeping; the daemon
  115. # re-establishes sleeping with a fresh ask (the real daemon's
  116. # idle-awake ask / 15-minute safety self-ask).
  117. fake.ask_now()
  118. status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
  119. {"session_id": churn_session, "state": "done",
  120. "entries": burst + fresh})
  121. check(status == 200 and rows_for(churn_session) == 1
  122. and rows_for(src_session) == stored_once,
  123. "a post-restart redelivery still stores nothing (dedup is persistent)")
  124. print("== plan 13 B2: duplicate failed entries project once (task) ==")
  125. # The post-mortem's honest burst: the same failure entry redelivered
  126. # under a churned session AND a second, DISTINCT failure entry id
  127. # in the same session — one transition, one comment, ever.
  128. def agent_comments(task_id, body=None):
  129. conn = db()
  130. try:
  131. if body is None:
  132. return conn.execute(
  133. "select count(*) from task_comments"
  134. " where task_id = ? and author = 'agent'",
  135. (task_id,)).fetchone()[0]
  136. return conn.execute(
  137. "select count(*) from task_comments"
  138. " where task_id = ? and author = 'agent' and body = ?",
  139. (task_id, body)).fetchone()[0]
  140. finally:
  141. conn.close()
  142. conn = db()
  143. conn.execute(
  144. "insert into tasks (project_id, title, status, priority, created_at)"
  145. " values (1, 'Burst failure task', 'working', 5, strftime('%s','now'))")
  146. conn.commit()
  147. burst_task = conn.execute(
  148. "select max(id) from tasks where title = 'Burst failure task'").fetchone()[0]
  149. conn.close()
  150. burst_session = open_session_on_fake(burst_task)
  151. first_fail = [{"id": 6001, "command": "saddle failure",
  152. "output": "bash -n rejected the script", "exit_code": 1,
  153. "type": "failed", "timestamp": "2026-09-11T00:01:00Z"}]
  154. status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
  155. {"session_id": burst_session, "state": "failed",
  156. "entries": first_fail,
  157. "summary": "bash -n rejected the script"})
  158. check(status == 200
  159. and task_state(auth, burst_task)["status"] == "failed",
  160. "the failure entry + state pair fails the task")
  161. check(agent_comments(burst_task, "bash -n rejected the script") == 0
  162. and "bash -n rejected the script"
  163. in (task_state(auth, burst_task).get("summary") or ""),
  164. "the failure explanation lives ONCE — in tasks.summary, the terminal cache (S4: no duplicate agent comment)")
  165. # The burst: a DISTINCT failure entry id in the same session, plus
  166. # both ids redelivered under a churned session — nothing re-runs.
  167. second_fail = [{"id": 6002, "command": "saddle failure",
  168. "output": "bash -n rejected the script", "exit_code": 1,
  169. "type": "failed", "timestamp": "2026-09-11T00:01:01Z"}]
  170. churned_burst = open_session_on_fake(burst_task)
  171. for target in (burst_session, churned_burst):
  172. status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
  173. {"session_id": target, "state": "failed",
  174. "entries": first_fail + second_fail,
  175. "summary": "bash -n rejected the script"})
  176. check(status == 200,
  177. "the burst push under %s is consumed, not rejected" % (
  178. "its session" if target == burst_session else "a churned session"))
  179. check(task_state(auth, burst_task)["status"] == "failed"
  180. and agent_comments(burst_task) == 0
  181. and "bash -n rejected the script"
  182. in (task_state(auth, burst_task).get("summary") or ""),
  183. "duplicate failed entries leave one transition, one summary, zero agent comments (S4)")
  184. print("== plan 13 B2: goal failure projects once ==")
  185. # Goal context: `saddle failure` fails the goal ONCE — the 115
  186. # goal-failed events of the live failure are structurally gone.
  187. def goal_failed_events(goal_id):
  188. # Plan 15 C3: the narrative derives at read time — the
  189. # failed-goal row IS the goal-failed record (one row, ever).
  190. return len([e for e in goal_state(goal_id)["events"]
  191. if e["kind"] == "goal-failed"])
  192. act(auth, slot_public(auth, "/goals", "goals")["create"], {
  193. "project_id": "1", "title": "Burst goal", "brief": "Fails honestly.",
  194. "branch_name": "goal/burst"})
  195. conn = db()
  196. burst_goal = conn.execute(
  197. "select id from goals where title = 'Burst goal'").fetchone()[0]
  198. conn.close()
  199. fake.goal_script = [("suspend",)]
  200. act(auth, goal_state(burst_goal)["start"], {})
  201. conn = db()
  202. run_task = conn.execute(
  203. "select id from tasks where goal_id = ? and kind = 'goal_run'",
  204. (burst_goal,)).fetchone()[0]
  205. conn.close()
  206. check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
  207. or False, timeout=60, what="burst goal agent parks") is not None,
  208. "the burst goal's agent parks first")
  209. conn = db()
  210. run_session = conn.execute(
  211. "select max(id) from sessions where task_id = ?",
  212. (run_task,)).fetchone()[0]
  213. conn.close()
  214. goal_fail = [{"id": 7001, "command": "saddle failure",
  215. "output": "the harness collapsed", "exit_code": 1,
  216. "type": "failed", "timestamp": "2026-09-11T00:02:00Z"},
  217. {"id": 7002, "command": "saddle failure",
  218. "output": "the harness collapsed", "exit_code": 1,
  219. "type": "failed", "timestamp": "2026-09-11T00:02:01Z"}]
  220. status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
  221. {"session_id": run_session, "state": "failed",
  222. "entries": goal_fail, "summary": "the harness collapsed"})
  223. check(status == 200 and goal_state(burst_goal)["status"] == "failed",
  224. "the goal agent's failure fails the goal")
  225. check(goal_failed_events(burst_goal) == 1,
  226. "a two-entry failure burst records exactly one goal-failed event")
  227. # Redeliveries — same ids under the run's session AND under a
  228. # churned session — plus the bare failed state again: still one.
  229. churned_run = open_session_on_fake(run_task)
  230. for target in (run_session, churned_run):
  231. status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
  232. {"session_id": target, "state": "failed",
  233. "entries": goal_fail,
  234. "summary": "the harness collapsed"})
  235. check(status == 200, "the goal-failure redelivery under %s is consumed"
  236. % ("its session" if target == run_session else "a churned session"))
  237. check(goal_state(burst_goal)["status"] == "failed"
  238. and goal_failed_events(burst_goal) == 1,
  239. "redelivered failures never re-run the goal failure (one event, ever)")
  240. check(task_state(auth, run_task)["status"] == "failed",
  241. "the goal-run task stays failed after the replays")
  242. conn = db()
  243. burst_blocks = conn.execute(
  244. "select count(*) from task_blocks where task_id = ?", (run_task,)
  245. ).fetchone()[0]
  246. conn.close()
  247. check(burst_blocks == 0,
  248. "the goal-failed path raises ZERO blocks (nothing pins the pile)")
  249. print("== plan 13 B4: the comment projection stores the body ==")
  250. # The heredoc shape: the command is the pipe invocation, the OUTPUT
  251. # is the body. The projections must carry the body — the command
  252. # echo never becomes comment text, and the literal `saddle
  253. # comment` string never renders outside the terminal.
  254. act(auth, slot_public(auth, "/goals", "goals")["create"], {
  255. "project_id": "1", "title": "Comment goal", "brief": "Reports cleanly.",
  256. "branch_name": "goal/comment"})
  257. conn = db()
  258. comment_goal = conn.execute(
  259. "select id from goals where title = 'Comment goal'").fetchone()[0]
  260. conn.close()
  261. fake.goal_script = [("suspend",)]
  262. act(auth, goal_state(comment_goal)["start"], {})
  263. conn = db()
  264. comment_run = conn.execute(
  265. "select id from tasks where goal_id = ? and kind = 'goal_run'",
  266. (comment_goal,)).fetchone()[0]
  267. conn.close()
  268. check(wait_for(lambda: task_state(auth, comment_run)["status"] == "suspended"
  269. or False, timeout=60, what="comment goal agent parks") is not None,
  270. "the comment goal's agent parks")
  271. conn = db()
  272. comment_session = conn.execute(
  273. "select max(id) from sessions where task_id = ?",
  274. (comment_run,)).fetchone()[0]
  275. conn.close()
  276. comment_body = "The flange weld holds. First sentence carries the information."
  277. comment_entry = [{"id": 8001, "command": "cat << 'EOF' | saddle comment",
  278. "output": comment_body, "exit_code": 0, "type": "comment",
  279. "timestamp": "2026-09-11T00:03:00Z"}]
  280. status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
  281. {"session_id": comment_session, "state": "suspended",
  282. "entries": comment_entry})
  283. check(status == 200, "the comment entry push is accepted")
  284. conn = db()
  285. task_comment_bodies = [r[0] for r in conn.execute(
  286. "select body from task_comments where task_id = ?", (comment_run,))]
  287. tables = {r[0] for r in conn.execute(
  288. "select name from sqlite_master where type = 'table'").fetchall()}
  289. conn.close()
  290. check(task_comment_bodies == [comment_body],
  291. "the run task's comment stores the body, not the command echo")
  292. check("goal_comments" not in tables,
  293. "the goal_comments table is gone (the run task's rows are the thread)")
  294. narrative_comments = [e["text"] for e in goal_state(comment_goal)["events"]
  295. if e["kind"] == "comment"]
  296. check(len(narrative_comments) == 1
  297. and narrative_comments[0].startswith("The flange weld holds.")
  298. and "saddle comment" not in narrative_comments[0]
  299. and "EOF" not in narrative_comments[0],
  300. "the derived narrative's comment rows carry the body text")
  301. state = goal_state(comment_goal)
  302. check(state["current_command"] == comment_body,
  303. "the goal page's current-activity line shows the comment body")
  304. check(state["comments"] and state["comments"][0]["body"] == comment_body
  305. and any(e["kind"] == "comment"
  306. and e["text"].startswith("The flange weld holds.")
  307. for e in state["events"]),
  308. "the goal slot carries the body in its comments and narrative")
  309. page = http_get_page(auth, "/goals/%d" % comment_goal)
  310. check("saddle comment" not in page,
  311. "the rendered goal page never shows the command echo")
  312. check("event.kind == 'comment' ? ' footnote'" in page,
  313. "the narrative's comment events render with footnote styling")
  314. check(page.index('stm-markdown="comment.body"')
  315. < page.index('stm-text="comment.quote"'),
  316. "the comment card shows the body before the quote")
  317. # Drain: nothing queued remains for the shard's shutdown.
  318. check(queued_count() == 0, "the replay shard drains its queue")