|
@@ -2400,7 +2400,7 @@ def bundle_dispatch(ctx):
|
|
|
check(status == 200, "redelivered entries push accepted (at-least-once)")
|
|
check(status == 200, "redelivered entries push accepted (at-least-once)")
|
|
|
stored = command_rows(raw_session)
|
|
stored = command_rows(raw_session)
|
|
|
check(len([r for r in stored if r[0] == "sed -n 1p README.md"]) == 1,
|
|
check(len([r for r in stored if r[0] == "sed -n 1p README.md"]) == 1,
|
|
|
- "redelivered entries store exactly once (entry-id cursor)")
|
|
|
|
|
|
|
+ "redelivered entries store exactly once (daemon-global dedup)")
|
|
|
|
|
|
|
|
# The retired daemon routes 404 (phase 2 deleted /output, /log
|
|
# The retired daemon routes 404 (phase 2 deleted /output, /log
|
|
|
# and /start serving) — and the web app never calls them.
|
|
# and /start serving) — and the web app never calls them.
|
|
@@ -5822,8 +5822,140 @@ def bundle_goals(ctx):
|
|
|
check(wait_task_done(auth, probe_id, timeout=60) is not None,
|
|
check(wait_task_done(auth, probe_id, timeout=60) is not None,
|
|
|
"the probe task completes after its resume")
|
|
"the probe task completes after its resume")
|
|
|
|
|
|
|
|
|
|
+
|
|
|
|
|
+# =============================================================================
|
|
|
|
|
+# Bundle: replay — plan 13 B1/B2: the daemon-global entry dedup and the
|
|
|
|
|
+# idempotent terminal/narrative projections. Replays the live-failure
|
|
|
|
|
+# post-mortem shapes directly against the push channels: an outbox
|
|
|
|
|
+# redelivery arriving under the WRONG session id after churn (and after
|
|
|
|
|
+# a web restart), and repeated failure pushes that must still project
|
|
|
|
|
+# exactly once. Includes this shard's one deliberate app restart (the
|
|
|
|
|
+# dedup must survive it — it is database state, not memory).
|
|
|
|
|
+# =============================================================================
|
|
|
|
|
+
|
|
|
|
|
+def bundle_replay(ctx):
|
|
|
|
|
+ auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
|
|
|
|
|
+ app = ctx.app
|
|
|
|
|
+
|
|
|
|
|
+ def rows_for(session):
|
|
|
|
|
+ conn = db()
|
|
|
|
|
+ try:
|
|
|
|
|
+ return conn.execute(
|
|
|
|
|
+ "select count(*) from session_commands where session_id = ?",
|
|
|
|
|
+ (session,)).fetchone()[0]
|
|
|
|
|
+ finally:
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+
|
|
|
|
|
+ def dedup_rows():
|
|
|
|
|
+ conn = db()
|
|
|
|
|
+ try:
|
|
|
|
|
+ return conn.execute("select count(*) from saddle_entry_dedup").fetchone()[0]
|
|
|
|
|
+ finally:
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+
|
|
|
|
|
+ def open_session_on_fake(task_id):
|
|
|
|
|
+ """A fixture open session row on the shard's saddle (the churn
|
|
|
|
|
+ shape: a fresh web-side run row the daemon never really drove)."""
|
|
|
|
|
+ conn = db()
|
|
|
|
|
+ try:
|
|
|
|
|
+ conn.execute(
|
|
|
|
|
+ "insert into sessions (task_id, saddle_id, started_at)"
|
|
|
|
|
+ " values (?, ?, strftime('%s','now'))", (task_id, fake_id))
|
|
|
|
|
+ conn.commit()
|
|
|
|
|
+ return conn.execute(
|
|
|
|
|
+ "select max(id) from sessions where task_id = ?",
|
|
|
|
|
+ (task_id,)).fetchone()[0]
|
|
|
|
|
+ finally:
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+
|
|
|
|
|
+ print("== plan 13 B1: entry dedup is daemon-global, not per-session ==")
|
|
|
|
|
+ # A first task runs to completion; its session is the replay source.
|
|
|
|
|
+ first = create_queued(auth, {"project_id": "1", "title": "Replay source task",
|
|
|
|
|
+ "priority": "2"})
|
|
|
|
|
+ check(wait_task_done(auth, first, timeout=60) is not None,
|
|
|
|
|
+ "the replay-source task completes")
|
|
|
|
|
+ conn = db()
|
|
|
|
|
+ src_session = conn.execute(
|
|
|
|
|
+ "select max(id) from sessions where task_id = ?", (first,)).fetchone()[0]
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+
|
|
|
|
|
+ # The outbox batch: two command entries the daemon already
|
|
|
|
|
+ # delivered once (their ids landed with the source session).
|
|
|
|
|
+ burst = [
|
|
|
|
|
+ {"id": 5001, "command": "make test", "output": "ok", "exit_code": 0,
|
|
|
|
|
+ "type": "command", "timestamp": "2026-09-11T00:00:00Z"},
|
|
|
|
|
+ {"id": 5002, "command": "grep -r flange src/", "output": "src/flange.vala",
|
|
|
|
|
+ "exit_code": 0, "type": "command", "timestamp": "2026-09-11T00:00:01Z"},
|
|
|
|
|
+ ]
|
|
|
|
|
+ status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
|
|
|
|
|
+ {"session_id": src_session, "state": "done",
|
|
|
|
|
+ "entries": burst})
|
|
|
|
|
+ check(status == 200 and rows_for(src_session) >= 2,
|
|
|
|
|
+ "the entries batch stores under its session")
|
|
|
|
|
+ stored_once = rows_for(src_session)
|
|
|
|
|
+ dedup_after_first = dedup_rows()
|
|
|
|
|
+
|
|
|
|
|
+ # Redelivery under the SAME session: consumed, nothing new.
|
|
|
|
|
+ status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
|
|
|
|
|
+ {"session_id": src_session, "state": "done",
|
|
|
|
|
+ "entries": burst})
|
|
|
|
|
+ check(status == 200 and rows_for(src_session) == stored_once
|
|
|
|
|
+ and dedup_rows() == dedup_after_first,
|
|
|
|
|
+ "a same-session redelivery stores nothing new (consumed silently)")
|
|
|
|
|
+
|
|
|
|
|
+ # Session churn: a brand-new session id on the same saddle replays
|
|
|
|
|
+ # the SAME entry ids — the post-mortem's mis-keyed outbox send.
|
|
|
|
|
+ # The entries' ids already populated another session, so the push
|
|
|
|
|
+ # may not create rows here either.
|
|
|
|
|
+ conn = db()
|
|
|
|
|
+ conn.execute(
|
|
|
|
|
+ "insert into tasks (project_id, title, status, priority, created_at,"
|
|
|
|
|
+ " completed_at) values (1, 'Churn target task', 'done', 5,"
|
|
|
|
|
+ " strftime('%s','now'), strftime('%s','now'))")
|
|
|
|
|
+ conn.commit()
|
|
|
|
|
+ churn_task = conn.execute(
|
|
|
|
|
+ "select max(id) from tasks where title = 'Churn target task'").fetchone()[0]
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+ churn_session = open_session_on_fake(churn_task)
|
|
|
|
|
+ status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
|
|
|
|
|
+ {"session_id": churn_session, "state": "done",
|
|
|
|
|
+ "entries": burst})
|
|
|
|
|
+ check(status == 200 and rows_for(churn_session) == 0,
|
|
|
|
|
+ "replay-under-wrong-session stores nothing new (daemon-global dedup)")
|
|
|
|
|
+ check(dedup_rows() == dedup_after_first,
|
|
|
|
|
+ "the consumed redelivery claims no fresh dedup rows")
|
|
|
|
|
+
|
|
|
|
|
+ # A fresh entry id under the churned session DOES store — the dedup
|
|
|
|
|
+ # keys on the entry id, never on the session.
|
|
|
|
|
+ fresh = [{"id": 5003, "command": "git status", "output": "clean",
|
|
|
|
|
+ "exit_code": 0, "type": "command", "timestamp": "2026-09-11T00:00:02Z"}]
|
|
|
|
|
+ status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
|
|
|
|
|
+ {"session_id": churn_session, "state": "done",
|
|
|
|
|
+ "entries": fresh})
|
|
|
|
|
+ check(status == 200 and rows_for(churn_session) == 1
|
|
|
|
|
+ and dedup_rows() == dedup_after_first + 1,
|
|
|
|
|
+ "a fresh entry id stores under any session of its saddle")
|
|
|
|
|
+
|
|
|
|
|
+ # Web restart: the dedup survives (database state, not memory).
|
|
|
|
|
+ app.terminate()
|
|
|
|
|
+ app.wait(timeout=10)
|
|
|
|
|
+ app = start_app()
|
|
|
|
|
+ auth = login("admin", "adminpass123")
|
|
|
|
|
+ ctx.app = app # teardown tracks the restarted process
|
|
|
|
|
+ ctx.auth = auth
|
|
|
|
|
+ status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
|
|
|
|
|
+ {"session_id": churn_session, "state": "done",
|
|
|
|
|
+ "entries": burst + fresh})
|
|
|
|
|
+ check(status == 200 and rows_for(churn_session) == 1
|
|
|
|
|
+ and rows_for(src_session) == stored_once,
|
|
|
|
|
+ "a post-restart redelivery still stores nothing (dedup is persistent)")
|
|
|
|
|
+
|
|
|
|
|
+ # Drain: nothing queued remains for the shard's shutdown.
|
|
|
|
|
+ check(queued_count() == 0, "the replay shard drains its queue")
|
|
|
|
|
+
|
|
|
BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
|
|
BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
|
|
|
- "allocation", "lifecycle", "answers", "push", "goals")
|
|
|
|
|
|
|
+ "allocation", "lifecycle", "answers", "push", "goals",
|
|
|
|
|
+ "replay")
|
|
|
|
|
|
|
|
|
|
|
|
|
def run_bundle(name):
|
|
def run_bundle(name):
|