replay.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  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 shapes, rebuilt on the plan-17 ledger:
  6. # the idempotent events push (UNIQUE(session_id, seq) — database state,
  7. # so it survives a web restart), the session-churn redelivery shape, and
  8. # repeated failure pushes that must still project exactly once.
  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. def rows_for(session):
  14. conn = db()
  15. try:
  16. return conn.execute(
  17. "select count(*) from events where session_id = ?",
  18. (session,)).fetchone()[0]
  19. finally:
  20. conn.close()
  21. def open_session_on_fake(task_id):
  22. """A fixture open session row on the shard's saddle (the churn
  23. shape: a fresh web-side run row the daemon never really drove)."""
  24. conn = db()
  25. try:
  26. conn.execute(
  27. "insert into sessions (task_id, saddle_id, started_at)"
  28. " values (?, ?, strftime('%s','now'))", (task_id, fake_id))
  29. conn.commit()
  30. return conn.execute(
  31. "select max(id) from sessions where task_id = ?",
  32. (task_id,)).fetchone()[0]
  33. finally:
  34. conn.close()
  35. print("== plan 17 §7.2: event pushes are idempotent per session ==")
  36. # A first task runs to completion; its session is the replay source.
  37. first = create_queued(auth, {"project_id": "1", "title": "Replay source task",
  38. "priority": "2"})
  39. check(wait_task_done(auth, first, timeout=60) is not None,
  40. "the replay-source task completes")
  41. conn = db()
  42. src_session = conn.execute(
  43. "select max(id) from sessions where task_id = ?", (first,)).fetchone()[0]
  44. conn.close()
  45. # The outbox batch: two events the daemon already delivered once.
  46. burst = [
  47. {"seq": 5001, "kind": "tool_call", "actor": "model",
  48. "payload": {"turn": 7, "tool": "run_command",
  49. "args": {"command": "make test"}}},
  50. {"seq": 5002, "kind": "tool_result", "actor": "harness",
  51. "payload": {"turn": 7, "stdout": "ok", "exit_code": 0}},
  52. ]
  53. status, body = saddle_api("POST", "/api/saddle/chunks", fake_token,
  54. {"session_id": src_session, "events": burst})
  55. check(status == 200 and int(body.get("stored") or 0) == 2,
  56. "the event batch stores under its session")
  57. stored_once = rows_for(src_session)
  58. # Redelivery under the SAME session: consumed, nothing new.
  59. status, body = saddle_api("POST", "/api/saddle/chunks", fake_token,
  60. {"session_id": src_session, "events": burst})
  61. check(status == 200 and int(body.get("stored") or 0) == 0
  62. and rows_for(src_session) == stored_once,
  63. "a same-session redelivery stores nothing new (consumed silently)")
  64. # Session churn: a mis-keyed outbox send under a brand-new session
  65. # id replays the SAME seqs. The ledger's idempotency key is
  66. # (session_id, seq) — the churned session is a different key, so
  67. # the events store under it (the daemon contract makes the session
  68. # id part of the key; the old daemon-global entry dedup is gone).
  69. conn = db()
  70. conn.execute(
  71. "insert into tasks (project_id, title, status, priority, created_at,"
  72. " completed_at) values (1, 'Churn target task', 'done', 5,"
  73. " strftime('%s','now'), strftime('%s','now'))")
  74. conn.commit()
  75. churn_task = conn.execute(
  76. "select max(id) from tasks where title = 'Churn target task'").fetchone()[0]
  77. conn.close()
  78. churn_session = open_session_on_fake(churn_task)
  79. status, body = saddle_api("POST", "/api/saddle/chunks", fake_token,
  80. {"session_id": churn_session, "events": burst})
  81. check(status == 200 and int(body.get("stored") or 0) == 2
  82. and rows_for(churn_session) == 2,
  83. "a churned-session replay stores under the new key (per-session seq)")
  84. # Web restart: the idempotency survives (database constraint, not
  85. # memory) — the same-session redelivery still stores nothing.
  86. app.terminate()
  87. app.wait(timeout=10)
  88. app = start_app()
  89. auth = login("admin", "adminpass123")
  90. ctx.app = app # teardown tracks the restarted process
  91. ctx.auth = auth
  92. # The restart wiped the in-memory ask bookkeeping; the daemon
  93. # re-establishes sleeping with a fresh ask (the real daemon's
  94. # idle-awake ask / 15-minute safety self-ask).
  95. fake.ask_now()
  96. status, body = saddle_api("POST", "/api/saddle/chunks", fake_token,
  97. {"session_id": churn_session, "events": burst})
  98. check(status == 200 and int(body.get("stored") or 0) == 0
  99. and rows_for(churn_session) == 2,
  100. "a post-restart redelivery still stores nothing (persistent)")
  101. print("== plan 13 B2: duplicate failed pushes project once (task) ==")
  102. # The post-mortem's honest burst, rebuilt on the state channel: the
  103. # failed state redelivered repeatedly — one transition, one
  104. # summary, ever (terminal pushes are consumed silently).
  105. def agent_comments(task_id, body=None):
  106. conn = db()
  107. try:
  108. if body is None:
  109. return conn.execute(
  110. "select count(*) from task_comments"
  111. " where task_id = ? and author = 'agent'",
  112. (task_id,)).fetchone()[0]
  113. return conn.execute(
  114. "select count(*) from task_comments"
  115. " where task_id = ? and author = 'agent' and body = ?",
  116. (task_id, body)).fetchone()[0]
  117. finally:
  118. conn.close()
  119. conn = db()
  120. conn.execute(
  121. "insert into tasks (project_id, title, status, priority, created_at)"
  122. " values (1, 'Burst failure task', 'working', 5, strftime('%s','now'))")
  123. conn.commit()
  124. burst_task = conn.execute(
  125. "select max(id) from tasks where title = 'Burst failure task'").fetchone()[0]
  126. conn.close()
  127. burst_session = open_session_on_fake(burst_task)
  128. status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
  129. {"session_id": burst_session, "state": "failed",
  130. "summary": "bash -n rejected the script"})
  131. check(status == 200
  132. and task_state(auth, burst_task)["status"] == "failed",
  133. "the failure state push fails the task")
  134. check(agent_comments(burst_task, "bash -n rejected the script") == 0
  135. and "bash -n rejected the script"
  136. in (task_state(auth, burst_task).get("summary") or ""),
  137. "the failure explanation lives ONCE — in tasks.summary, the terminal cache (S4: no duplicate agent comment)")
  138. # The burst: the same failure redelivered under the session AND a
  139. # churned session — nothing re-runs.
  140. churned_burst = open_session_on_fake(burst_task)
  141. for target in (burst_session, churned_burst):
  142. status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
  143. {"session_id": target, "state": "failed",
  144. "summary": "bash -n rejected the script"})
  145. check(status == 200,
  146. "the failed-state push under %s is consumed, not rejected" % (
  147. "its session" if target == burst_session else "a churned session"))
  148. check(task_state(auth, burst_task)["status"] == "failed"
  149. and agent_comments(burst_task) == 0
  150. and "bash -n rejected the script"
  151. in (task_state(auth, burst_task).get("summary") or ""),
  152. "duplicate failed pushes leave one transition, one summary, zero agent comments (S4)")
  153. print("== plan 13 B2: goal failure projects once ==")
  154. # Goal context: `saddle failure` fails the goal ONCE — the 115
  155. # goal-failed events of the live failure are structurally gone.
  156. def goal_failed_events(goal_id):
  157. # Plan 15 C3: the narrative derives at read time — the
  158. # failed-goal row IS the goal-failed record (one row, ever).
  159. return len([e for e in goal_state(goal_id)["events"]
  160. if e["kind"] == "goal-failed"])
  161. act(auth, slot_public(auth, "/goals", "goals")["create"], {
  162. "project_id": "1", "title": "Burst goal", "brief": "Fails honestly.",
  163. "branch_name": "goal/burst"})
  164. conn = db()
  165. burst_goal = conn.execute(
  166. "select id from goals where title = 'Burst goal'").fetchone()[0]
  167. # (Parking guard: the scripted park needs a live wake source.)
  168. conn.execute("insert into tasks (project_id, goal_id, kind, title, status,"
  169. " priority, mode, creator, created_at) values (1, ?, 'task',"
  170. " 'Burst live worker', 'blocked', 5, 'implement', 'goal',"
  171. " strftime('%s','now'))", (burst_goal,))
  172. conn.commit()
  173. conn.close()
  174. fake.goal_script = [("suspend",)]
  175. act(auth, goal_state(burst_goal)["start"], {})
  176. conn = db()
  177. run_task = conn.execute(
  178. "select id from tasks where goal_id = ? and kind = 'goal_run'",
  179. (burst_goal,)).fetchone()[0]
  180. conn.close()
  181. check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
  182. or False, timeout=60, what="burst goal agent parks") is not None,
  183. "the burst goal's agent parks first")
  184. conn = db()
  185. run_session = conn.execute(
  186. "select max(id) from sessions where task_id = ?",
  187. (run_task,)).fetchone()[0]
  188. conn.close()
  189. status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
  190. {"session_id": run_session, "state": "failed",
  191. "summary": "the harness collapsed"})
  192. check(status == 200 and goal_state(burst_goal)["status"] == "failed",
  193. "the goal agent's failure fails the goal")
  194. check(goal_failed_events(burst_goal) == 1,
  195. "the failure push records exactly one goal-failed event")
  196. # Redeliveries — under the run's session AND a churned session —
  197. # plus the bare failed state again: still one.
  198. churned_run = open_session_on_fake(run_task)
  199. for target in (run_session, churned_run):
  200. status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
  201. {"session_id": target, "state": "failed",
  202. "summary": "the harness collapsed"})
  203. check(status == 200, "the goal-failure redelivery under %s is consumed"
  204. % ("its session" if target == run_session else "a churned session"))
  205. check(goal_state(burst_goal)["status"] == "failed"
  206. and goal_failed_events(burst_goal) == 1,
  207. "redelivered failures never re-run the goal failure (one event, ever)")
  208. check(task_state(auth, run_task)["status"] == "failed",
  209. "the goal-run task stays failed after the replays")
  210. conn = db()
  211. burst_blocks = conn.execute(
  212. "select count(*) from task_blocks where task_id = ?", (run_task,)
  213. ).fetchone()[0]
  214. conn.close()
  215. check(burst_blocks == 0,
  216. "the goal-failed path raises ZERO blocks (nothing pins the pile)")
  217. print("== plan 17: agent remarks ride the ledger as comment events ==")
  218. # The heredoc `cat << 'EOF' | saddle comment` shape: the daemon
  219. # emits a comment event carrying the body. The body is the record —
  220. # no command echo ever renders, and the goal page's current-activity
  221. # line reads the newest remark.
  222. act(auth, slot_public(auth, "/goals", "goals")["create"], {
  223. "project_id": "1", "title": "Comment goal", "brief": "Reports cleanly.",
  224. "branch_name": "goal/comment"})
  225. conn = db()
  226. comment_goal = conn.execute(
  227. "select id from goals where title = 'Comment goal'").fetchone()[0]
  228. # (Parking guard: the scripted park needs a live wake source.)
  229. conn.execute("insert into tasks (project_id, goal_id, kind, title, status,"
  230. " priority, mode, creator, created_at) values (1, ?, 'task',"
  231. " 'Comment live worker', 'blocked', 5, 'implement', 'goal',"
  232. " strftime('%s','now'))", (comment_goal,))
  233. conn.commit()
  234. conn.close()
  235. fake.goal_script = [("suspend",)]
  236. act(auth, goal_state(comment_goal)["start"], {})
  237. conn = db()
  238. comment_run = conn.execute(
  239. "select id from tasks where goal_id = ? and kind = 'goal_run'",
  240. (comment_goal,)).fetchone()[0]
  241. conn.close()
  242. check(wait_for(lambda: task_state(auth, comment_run)["status"] == "suspended"
  243. or False, timeout=60, what="comment goal agent parks") is not None,
  244. "the comment goal's agent parks")
  245. conn = db()
  246. comment_session = conn.execute(
  247. "select max(id) from sessions where task_id = ?",
  248. (comment_run,)).fetchone()[0]
  249. conn.close()
  250. comment_body = "The flange weld holds. First sentence carries the information."
  251. status, body = saddle_api("POST", "/api/saddle/chunks", fake_token,
  252. {"session_id": comment_session,
  253. "events": [{
  254. "seq": 8001, "kind": "comment",
  255. "actor": "model",
  256. "payload": {"body": comment_body}}]})
  257. check(status == 200 and int(body.get("stored") or 0) == 1,
  258. "the comment event stores on the ledger")
  259. conn = db()
  260. tables = {r[0] for r in conn.execute(
  261. "select name from sqlite_master where type = 'table'").fetchall()}
  262. comment_events = conn.execute(
  263. "select count(*) from events where scope_id = ? and kind = 'comment'"
  264. " and actor = 'model'",
  265. (comment_run,)).fetchone()[0]
  266. conn.close()
  267. check(comment_events == 1
  268. and "goal_comments" not in tables,
  269. "the ledger carries the remark (the goal_comments table is gone;"
  270. " system bookkeeping comments are separate)")
  271. narrative_comments = [e["text"] for e in goal_state(comment_goal)["events"]
  272. if e["kind"] == "comment"]
  273. check(len(narrative_comments) == 1
  274. and narrative_comments[0].startswith("The flange weld holds.")
  275. and "saddle comment" not in narrative_comments[0]
  276. and "EOF" not in narrative_comments[0],
  277. "the derived narrative's comment rows carry the body text")
  278. state = goal_state(comment_goal)
  279. check(state["current_command"] == comment_body,
  280. "the goal page's current-activity line shows the remark body")
  281. check(any(e["kind"] == "comment"
  282. and e["text"].startswith("The flange weld holds.")
  283. for e in state["events"]),
  284. "the goal slot carries the remark in its narrative")
  285. page = http_get_page(auth, "/goals/%d" % comment_goal)
  286. check("saddle comment" not in page,
  287. "the rendered goal page never shows the command echo")
  288. check("event.kind == 'comment' ? ' footnote'" in page,
  289. "the narrative's comment events render with footnote styling")
  290. print("== P1/P2: event ledger reader indexes + batched ingestion ==")
  291. # P1 (i): a FRESH database (this shard's app migrated it at
  292. # startup) carries both M0003 reader indexes, and the
  293. # scope-filtered reader shapes actually use them — no full
  294. # 'SCAN events' for the page/cursor walks or the resume
  295. # coalescer's terminal-transition scan.
  296. def event_indexes():
  297. conn = db()
  298. try:
  299. return {r[0] for r in conn.execute(
  300. "select name from sqlite_master"
  301. " where type = 'index' and tbl_name = 'events'").fetchall()}
  302. finally:
  303. conn.close()
  304. def query_plan(sql, params=()):
  305. conn = db()
  306. try:
  307. return " ".join(r[3] for r in conn.execute(
  308. "explain query plan " + sql, params).fetchall())
  309. finally:
  310. conn.close()
  311. check({"idx_events_scope", "idx_events_terminal"} <= event_indexes(),
  312. "M0003 creates both event reader indexes on a fresh database")
  313. scope_plan = query_plan(
  314. "select id from events where scope_type = 'task' and scope_id = ?"
  315. " and id < ? order by id desc limit 30", (first, 2 ** 40))
  316. check("idx_events_scope" in scope_plan and "SCAN events" not in scope_plan,
  317. "the scope-filtered readers search idx_events_scope (no table scan)")
  318. terminal_plan = query_plan(
  319. "select id, created_at from events where kind = 'state_change'"
  320. " and scope_type = 'task' and scope_id = ?", (first,))
  321. check("idx_events_terminal" in terminal_plan
  322. and "SCAN events" not in terminal_plan,
  323. "the terminal-transition reader searches the partial idx_events_terminal")
  324. # P2 (iii): batched ingestion stays idempotent — a redelivered
  325. # batch stores 0 new rows and errors nothing, and a MIXED batch
  326. # (one duplicate + one new event) stores exactly the new row.
  327. batch_target = open_session_on_fake(first)
  328. one_event = [{"seq": 6001, "kind": "progress",
  329. "payload": {"text": "batched"}}]
  330. status, body = saddle_api("POST", "/api/saddle/chunks", fake_token,
  331. {"session_id": batch_target, "events": one_event})
  332. check(status == 200 and int(body.get("stored") or 0) == 1,
  333. "a fresh batched event stores one row")
  334. status, body = saddle_api("POST", "/api/saddle/chunks", fake_token,
  335. {"session_id": batch_target,
  336. "events": one_event + [
  337. {"seq": 6002, "kind": "progress",
  338. "payload": {"text": "second"}}]})
  339. check(status == 200 and int(body.get("stored") or 0) == 1,
  340. "a mixed redelivery batch stores only the new row, no error")
  341. status, body = saddle_api("POST", "/api/saddle/chunks", fake_token,
  342. {"session_id": batch_target, "events": one_event})
  343. check(status == 200 and int(body.get("stored") or 0) == 0,
  344. "a redelivered batch stores 0 new rows and errors nothing")
  345. # P2 (iv): a batch containing one invalid event is refused WHOLE
  346. # — staging validates before any insert, so nothing partially
  347. # stores (the invalid event sits between two valid ones).
  348. conn = db()
  349. target_rows = conn.execute(
  350. "select count(*) from events where session_id = ?",
  351. (batch_target,)).fetchone()[0]
  352. conn.close()
  353. status, body = saddle_api("POST", "/api/saddle/chunks", fake_token,
  354. {"session_id": batch_target, "events": [
  355. {"seq": 6101, "kind": "progress",
  356. "payload": {}},
  357. {"seq": 0, "kind": "progress",
  358. "payload": {}},
  359. {"seq": 6102, "kind": "progress",
  360. "payload": {}}]})
  361. check(status == 400, "a batch with an invalid event is refused (HTTP 400)")
  362. conn = db()
  363. after_rows = conn.execute(
  364. "select count(*) from events where session_id = ?",
  365. (batch_target,)).fetchone()[0]
  366. conn.close()
  367. check(after_rows == target_rows,
  368. "the refused batch stores nothing (no partial storage)")
  369. # P1 (ii): a serial-2 database upgraded in place gains the
  370. # indexes — strip M0003's record and both indexes off this
  371. # shard's database, restart the app, and the startup migration
  372. # must reapply them over the existing ledger.
  373. conn = db()
  374. conn.execute("drop index if exists idx_events_scope")
  375. conn.execute("drop index if exists idx_events_terminal")
  376. conn.execute("delete from __migrations"
  377. " where namespace = 'clanker-saddle' and serial = 3")
  378. conn.commit()
  379. conn.close()
  380. app.terminate()
  381. app.wait(timeout=10)
  382. app = start_app()
  383. auth = login("admin", "adminpass123")
  384. ctx.app = app
  385. ctx.auth = auth
  386. conn = db()
  387. serial3 = conn.execute(
  388. "select count(*) from __migrations"
  389. " where namespace = 'clanker-saddle' and serial = 3").fetchone()[0]
  390. kept_rows = conn.execute(
  391. "select count(*) from events where session_id = ?",
  392. (batch_target,)).fetchone()[0]
  393. conn.close()
  394. check({"idx_events_scope", "idx_events_terminal"} <= event_indexes()
  395. and serial3 == 1,
  396. "a serial-2 database upgraded in place gains both indexes")
  397. check(kept_rows == after_rows,
  398. "the in-place upgrade leaves the ledger's rows untouched")
  399. # Drain: nothing queued remains for the shard's shutdown.
  400. check(queued_count() == 0, "the replay shard drains its queue")