From 4cee35b0d3d71934ee2e767a48e2c14f94eed012 Mon Sep 17 00:00:00 2001 From: Tuomo Vendelin Date: Fri, 9 Oct 2026 14:22:23 +0000 Subject: [PATCH 1/2] fix: validate handoffs and clarify interrupted-round recovery Co-authored-by: Codex --- .agent/commands/bootstrap.md | 4 +++ .agent/commands/handoff.md | 6 ++-- .agent/commands/reconcile.md | 3 +- .agent/commands/takeoff.md | 4 +++ SPEC.md | 13 +++++-- examples/one-round.md | 24 +++++++++++++ scripts/round.py | 19 ++++++++++ templates/assignment.md | 9 ++++- tests/test_round.py | 69 +++++++++++++++++++++++++++++++++--- 9 files changed, 139 insertions(+), 12 deletions(-) diff --git a/.agent/commands/bootstrap.md b/.agent/commands/bootstrap.md index 300c081..c669902 100644 --- a/.agent/commands/bootstrap.md +++ b/.agent/commands/bootstrap.md @@ -12,6 +12,10 @@ Input: project repository and its architecture, README and backlog. 3. Merge [the project contract](../../templates/AGENTS.md) into the target AGENTS.md; do not copy the pattern library's contributor contract. Adapt `.agent/config.example.toml` into `.agent/config.toml` with actual architecture/backlog paths, verification argv and merge authority. + Identify and link the project's existing resource-safety, verification, review/merge and + publication/privacy policies where applicable. Resolve conflicting instructions before dispatch; + missing policy decisions remain named setup gaps. Keep these rules in their project sources, + not copied into this protocol or a second policy document. 4. Preserve the project's README; add a short workflow pointer. Ensure `.agent-runs/` and HANDOFF.md are ignored. Keep durable acceptance and decisions in tracked records or PRs. 5. Make one small item executable: approved target references, outcome, boundaries, dependencies, diff --git a/.agent/commands/handoff.md b/.agent/commands/handoff.md index 662539d..983a8da 100644 --- a/.agent/commands/handoff.md +++ b/.agent/commands/handoff.md @@ -10,5 +10,7 @@ Keep it at most 50 lines / 500 words. Validate SHA and links. Do not copy archit full logs, secrets or the conversation transcript. Durable decisions/evidence belong in tracked files or PRs. The local ignored handoff is advisory and may be reconstructed if lost. -Report its location and remaining gates. End the planner session after reconciliation; do not claim -to clear your own conversation. A fresh planner should recover using only repository/PR records. +Report its location and remaining gates. An interrupted session may hand off an open round; its +successor must recover that round before starting another. Only reconciliation closes the round. +Do not claim to clear your own conversation. A fresh planner should recover using only +repository/PR records. diff --git a/.agent/commands/reconcile.md b/.agent/commands/reconcile.md index 8fdfacd..f8d70ba 100644 --- a/.agent/commands/reconcile.md +++ b/.agent/commands/reconcile.md @@ -27,4 +27,5 @@ reconciliation PR. Use the actual default branch and handoff path. No report-pat required. The CLI checks terminal assignments, merge ancestry and the handoff SHA. It checks local evidence files at final main; it records URLs without fetching them. Verify the reference actually shows this round's reconciliation into tracked state and that all required gates passed before -closing. Only then end the planner session and start a fresh round. Cancelled rounds also reconcile. +closing. Only then start a fresh round. An interrupted session may hand off this round while it +remains open; cancelled rounds also reconcile. diff --git a/.agent/commands/takeoff.md b/.agent/commands/takeoff.md index 5ba37f3..5859355 100644 --- a/.agent/commands/takeoff.md +++ b/.agent/commands/takeoff.md @@ -9,6 +9,10 @@ remote knowledge or unavailable PR access. Do not mutate a dirty checkout during Read the optional handoff. Compare its Main-SHA with known main: equal=current, ancestor=stale, otherwise invalid. Reconstruct missing/stale information from Git, backlog and PR evidence. Inspect any open round with `python3 scripts/round.py status ROUND`; recover it before planning another. +For an interrupted round, compare saved results with actual PR heads and merges, inspect surviving +workers and resource ownership, and resume only outstanding work. Do not duplicate a worker or +replay a merge because local state is stale. Record unavailable gates with the affected head, +recovery owner and next action in existing result/PR evidence. Output purpose, accepted outcomes, active work/conflicts, decisions owed, next authoritative reads and handoff freshness. Stop before selecting work. Conversation history is not required input. diff --git a/SPEC.md b/SPEC.md index d1b6071..201b0c4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -72,7 +72,9 @@ operating envelope and the actual behavior delta. A failed required gate keeps t Do not silently edit the target to match implementation. The CLI requires terminal outcomes, supplied merges reachable from final main, a reconciliation -evidence reference, and a bounded handoff naming the same full SHA. `--reconciliation` accepts an +evidence reference, and a bounded handoff naming the same full SHA, a valid +`Generated-UTC: YYYY-MM-DDTHH:MM:SSZ` timestamp, and nonempty `Round delta`, `Open work` and `Next action` sections (`None` is valid for +open work). These are structural checks, not a judgment of the prose. `--reconciliation` accepts an existing tracked file path or an HTTP(S) evidence URL, such as the reconciliation PR. Local files must be nonempty and committed at final main. URLs are recorded without network access; the reviewer verifies that the reference is durable, accessible, and records the round's actual reconciliation @@ -80,8 +82,8 @@ into tracked state. The caller also supplies final verification evidence. These evidence truth, required-gate completeness, resource release, or whether the ref really is remote main. `init` refuses another round while any local round remains open. Cancelling work does not waive -reconciliation. Only after closure should the planner session end and a new one start. No automated -supervisor is required; a human or agent can drive each phase. +reconciliation. A new round requires closure; an interrupted planner session may end while its round +remains open. No automated supervisor is required; a human or agent can drive each phase. ## Recovery and limits @@ -90,6 +92,11 @@ keep it at most 50 lines / 500 words with full Main-SHA, UTC timestamp and next must also live in PRs or tracked files so lost local records are recoverable. If the ledger is lost, reconstruct and inspect active workers before initiating anything; the CLI cannot detect remote orphaned work. Do not blindly replay a command that may have changed state. +On resumption, recover the existing round and compare its results with current PR heads and actual +merges. Inspect surviving workers and mutable-resource ownership before dispatching replacements. +An unavailable required gate stays pending: record its affected head, recovery owner and next action +in the existing result/PR evidence. Resume the owed work without changing delivered packets or +repeating completed external actions. A fresh planner without prior chats must be able to name the remaining work, assigned branch/head, required evidence and next permitted action. The tests exercise ledger behavior in temporary Git diff --git a/examples/one-round.md b/examples/one-round.md index 8e87b8d..7436aa9 100644 --- a/examples/one-round.md +++ b/examples/one-round.md @@ -61,3 +61,27 @@ A new round is refused until this succeeds. Stop the planner session. A fresh pl the outcome, remaining runtime work and next permitted action from the repository/PRs alone. This is enough for a human or agent to drive the loop; there is no supervisor process to install. + +## Interrupted round with CI unavailable + +Suppose a later round has two assignments. A1 is merged; A2 has a PR but its required CI is queued +because runners are unavailable. Verify A1's actual merge and record its merged outcome if it has +not already been recorded. For A2, submit a candidate result with the gate marked `unavailable`; +retain earlier results unchanged. Its evidence names the affected head, the coordinator as recovery owner, and the +next action: restore runner availability and verify that head's required checks. A `blocked` +result with the same details is also appropriate if execution cannot continue. + +Write the bounded handoff and end the interrupted session with the round still open. Do not cancel +A2 just to close the round. The replacement planner: + +1. Reads the open ledger, original packets, saved results and actual PR/merge state. +2. Checks whether A2's worker survives and who owns its mutable resources; reuses it or confirms it + stopped and releases ownership before assigning a replacement the unchanged packet. +3. Records an already-completed merge missing from the ledger only after verifying it; never merges + A1 again or re-executes completed work solely because the session changed. +4. Restores access through the project's authorized operator, runs only outstanding or invalidated + checks, and imports a new A2 result with actual evidence. A changed PR head requires fresh review. +5. Reviews and merges A2, verifies combined state, reconciles both outcomes and closes the same round. + +Runner restoration is project policy, not a CLI capability. No additional round, runtime state or +separate status document is needed for this recovery. diff --git a/scripts/round.py b/scripts/round.py index 5555169..02cec19 100644 --- a/scripts/round.py +++ b/scripts/round.py @@ -6,6 +6,7 @@ import re import subprocess import sys +from datetime import datetime from pathlib import Path from urllib.parse import urlsplit @@ -242,6 +243,24 @@ def close(repo, root, manifest, args): "Handoff is too long", ) require(text(args.evidence), "Closure needs final verification evidence") + timestamp = re.search( + r"^Generated-UTC: ([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z)$", + handoff, + re.MULTILINE, + ) + require(timestamp is not None, "Handoff needs Generated-UTC: YYYY-MM-DDTHH:MM:SSZ") + try: + datetime.strptime(timestamp[1], "%Y-%m-%dT%H:%M:%SZ") + except ValueError: + raise ValueError("Handoff Generated-UTC must be a valid UTC timestamp") from None + for heading in ("Round delta", "Open work", "Next action"): + section = re.search( + rf"^## {heading}[ \t]*\n(.*?)(?=^## |\Z)", + handoff, + re.MULTILINE | re.DOTALL, + ) + body = re.sub(r"", "", section[1], flags=re.DOTALL) if section else "" + require(re.search(r"\w", body), f"Handoff needs a nonempty '{heading}' section") manifest.update( state="closed", final_main_sha=final, diff --git a/templates/assignment.md b/templates/assignment.md index 8a2581a..29f34cf 100644 --- a/templates/assignment.md +++ b/templates/assignment.md @@ -6,9 +6,16 @@ - In scope / non-goals: - Expected files and editable records: - Resources / artifacts: -- Acceptance and verification: +- Acceptance: - Connections / scale: - Publication authority and PR evidence: +List only applicable gates (local, integration, live or final combined state). An unavailable required +gate remains pending; name its recovery owner and next action in the existing result/PR evidence. + +| Gate | Command / environment | Owner | Required evidence | +| --- | --- | --- | --- | +| | | | | + The round CLI adds identity, base and branch to the final packet. The packet must be sufficient in an isolated checkout without access to planner-local records. No target changes or self-merging. diff --git a/tests/test_round.py b/tests/test_round.py index f652ad5..bf13077 100644 --- a/tests/test_round.py +++ b/tests/test_round.py @@ -92,6 +92,65 @@ def blocked_result(self, reason="Required fixture is missing"): path.write_text(json.dumps(data)) return path + def handoff(self, sha=None): + return ( + "# Handoff\nGenerated-UTC: 2026-10-09T10:00:00Z\n" + f"Main-SHA: {sha or self.base}\n\n" + "## Round delta\nTASK-1 reconciled in the backlog.\n\n" + "## Open work\nNone.\n\n" + "## Next action\nRead the backlog and select executable work.\n" + ) + + def test_close_rejects_incomplete_handoff_without_closing_round(self): + self.start() + self.cli( + "resolve", "R1", "A1", "--outcome", "cancelled", "--evidence", "Stopped" + ) + valid = self.handoff() + invalid = [ + (f"Main-SHA: {self.base}\n", "Generated-UTC"), + (valid.replace("Generated-UTC: 2026-10-09T10:00:00Z\n", ""), "Generated-UTC"), + (valid.replace("2026-10-09", "2026-02-30"), "Generated-UTC"), + (valid.replace("10:00:00Z", "10:00:00+00:00"), "Generated-UTC"), + ] + for heading, body in ( + ("Round delta", "TASK-1 reconciled in the backlog."), + ("Open work", "None."), + ("Next action", "Read the backlog and select executable work."), + ): + invalid.extend([ + (valid.replace(f"## {heading}", f"## Other {heading}"), heading), + (valid.replace(body, ""), heading), + (valid.replace(body, ""), heading), + ]) + ledger = self.repo / ".agent-runs/R1/round.json" + original = ledger.read_text() + for handoff, diagnostic in invalid: + ledger.write_text(original) + with self.subTest(handoff=handoff): + (self.repo / "HANDOFF.md").write_text(handoff) + rejected = self.cli( + "close", "R1", "--main", "main", "--reconciliation", "README.md", + "--handoff", "HANDOFF.md", "--evidence", "Final checks passed", ok=False, + ) + self.assertIn(diagnostic, rejected.stderr) + self.assertEqual(self.manifest()["state"], "open") + + def test_close_preserves_handoff_size_limits(self): + self.start() + self.cli( + "resolve", "R1", "A1", "--outcome", "cancelled", "--evidence", "Stopped" + ) + for extra in ("\n" * 51, "word " * 501): + with self.subTest(extra=extra): + (self.repo / "HANDOFF.md").write_text(self.handoff() + extra) + rejected = self.cli( + "close", "R1", "--main", "main", "--reconciliation", "README.md", + "--handoff", "HANDOFF.md", "--evidence", "Final checks passed", ok=False, + ) + self.assertIn("too long", rejected.stderr) + self.assertEqual(self.manifest()["state"], "open") + def test_blocked_result_needs_no_pr_and_keeps_round_open(self): self.start() self.cli("record", "R1", "--result", str(self.blocked_result())) @@ -358,7 +417,7 @@ def test_cancel_reconcile_then_next_round(self): self.git("commit", "-qm", "reconcile cancelled assignment") final_sha = self.git("rev-parse", "HEAD") (self.repo / "HANDOFF.md").write_text( - f"# Handoff\nMain-SHA: {final_sha}\nNext: plan TASK-1.\n" + self.handoff(final_sha) ) self.cli( "close", @@ -392,7 +451,7 @@ def test_close_accepts_reconciliation_pr_without_separate_report(self): self.git("add", "backlog.md") self.git("commit", "-qm", "reconcile task status in existing project state") final_sha = self.git("rev-parse", "HEAD") - (self.repo / "HANDOFF.md").write_text(f"Main-SHA: {final_sha}\n") + (self.repo / "HANDOFF.md").write_text(self.handoff(final_sha)) reference = "https://example.invalid/project/pull/2" self.cli( "close", @@ -416,7 +475,7 @@ def test_close_rejects_malformed_reconciliation_url(self): self.cli( "resolve", "R1", "A1", "--outcome", "cancelled", "--evidence", "Stopped" ) - (self.repo / "HANDOFF.md").write_text(f"Main-SHA: {self.base}\n") + (self.repo / "HANDOFF.md").write_text(self.handoff()) for reference in ( "https:///missing-host", "https://", @@ -443,7 +502,7 @@ def test_local_evidence_requires_committed_file_and_current_handoff(self): "resolve", "R1", "A1", "--outcome", "cancelled", "--evidence", "Stopped" ) (self.repo / "reconcile.md").write_text("Uncommitted evidence\n") - (self.repo / "HANDOFF.md").write_text(f"Main-SHA: {self.base}\n") + (self.repo / "HANDOFF.md").write_text(self.handoff()) args = ( "close", "R1", @@ -479,7 +538,7 @@ def test_merged_round_closes_and_terminal_result_cannot_change(self): "Independent review and actual merge verified", ) self.cli("record", "R1", "--result", str(result), ok=False) - (self.repo / "HANDOFF.md").write_text(f"Main-SHA: {self.base}\n") + (self.repo / "HANDOFF.md").write_text(self.handoff()) self.cli( "close", "R1", From 0ffbc073e0ccd9458d178ece065d63fe69673a74 Mon Sep 17 00:00:00 2001 From: Tuomo Vendelin Date: Sat, 10 Oct 2026 17:55:06 +0300 Subject: [PATCH 2/2] fix: reject template placeholders in handoff sections; keep takeoff read-only A handoff copied from templates/handoff.md with only the timestamp and SHA filled passed the section check, because <...> placeholder lines counted as content. Strip placeholder-only lines (but not autolinks) and test against the real template. Takeoff now reports interrupted-round recovery needs instead of recording or resuming work. Co-Authored-By: Claude Opus 5.5 --- .agent/commands/takeoff.md | 8 ++++---- SPEC.md | 5 +++-- scripts/round.py | 4 ++++ tests/test_round.py | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/.agent/commands/takeoff.md b/.agent/commands/takeoff.md index 5859355..ed1507d 100644 --- a/.agent/commands/takeoff.md +++ b/.agent/commands/takeoff.md @@ -9,10 +9,10 @@ remote knowledge or unavailable PR access. Do not mutate a dirty checkout during Read the optional handoff. Compare its Main-SHA with known main: equal=current, ancestor=stale, otherwise invalid. Reconstruct missing/stale information from Git, backlog and PR evidence. Inspect any open round with `python3 scripts/round.py status ROUND`; recover it before planning another. -For an interrupted round, compare saved results with actual PR heads and merges, inspect surviving -workers and resource ownership, and resume only outstanding work. Do not duplicate a worker or -replay a merge because local state is stale. Record unavailable gates with the affected head, -recovery owner and next action in existing result/PR evidence. +For an interrupted round, compare saved results with actual PR heads and merges and inspect +surviving workers and resource ownership. Report the outstanding work, unavailable gates with their +affected head, and the recovery owner and next action; recovery itself follows the protocol after +takeoff. Do not duplicate a worker or replay a merge because local state is stale. Output purpose, accepted outcomes, active work/conflicts, decisions owed, next authoritative reads and handoff freshness. Stop before selecting work. Conversation history is not required input. diff --git a/SPEC.md b/SPEC.md index 201b0c4..0d61667 100644 --- a/SPEC.md +++ b/SPEC.md @@ -73,8 +73,9 @@ Do not silently edit the target to match implementation. The CLI requires terminal outcomes, supplied merges reachable from final main, a reconciliation evidence reference, and a bounded handoff naming the same full SHA, a valid -`Generated-UTC: YYYY-MM-DDTHH:MM:SSZ` timestamp, and nonempty `Round delta`, `Open work` and `Next action` sections (`None` is valid for -open work). These are structural checks, not a judgment of the prose. `--reconciliation` accepts an +`Generated-UTC: YYYY-MM-DDTHH:MM:SSZ` timestamp, and nonempty `Round delta`, `Open work` and +`Next action` sections (`None` is valid for open work; template placeholders are not content). +These are structural checks, not a judgment of the prose. `--reconciliation` accepts an existing tracked file path or an HTTP(S) evidence URL, such as the reconciliation PR. Local files must be nonempty and committed at final main. URLs are recorded without network access; the reviewer verifies that the reference is durable, accessible, and records the round's actual reconciliation diff --git a/scripts/round.py b/scripts/round.py index 02cec19..e34cc1a 100644 --- a/scripts/round.py +++ b/scripts/round.py @@ -260,6 +260,10 @@ def close(repo, root, manifest, args): re.MULTILINE | re.DOTALL, ) body = re.sub(r"", "", section[1], flags=re.DOTALL) if section else "" + # Template placeholders such as are not content; autolinks are. + body = re.sub( + r"^[ \t]*<(?![^>\n]*://)[^<>\n]*>[ \t]*$", "", body, flags=re.MULTILINE + ) require(re.search(r"\w", body), f"Handoff needs a nonempty '{heading}' section") manifest.update( state="closed", diff --git a/tests/test_round.py b/tests/test_round.py index bf13077..833b2c5 100644 --- a/tests/test_round.py +++ b/tests/test_round.py @@ -122,6 +122,7 @@ def test_close_rejects_incomplete_handoff_without_closing_round(self): (valid.replace(f"## {heading}", f"## Other {heading}"), heading), (valid.replace(body, ""), heading), (valid.replace(body, ""), heading), + (valid.replace(body, ""), heading), ]) ledger = self.repo / ".agent-runs/R1/round.json" original = ledger.read_text() @@ -136,6 +137,41 @@ def test_close_rejects_incomplete_handoff_without_closing_round(self): self.assertIn(diagnostic, rejected.stderr) self.assertEqual(self.manifest()["state"], "open") + def test_close_rejects_unfilled_handoff_template_sections(self): + self.start() + self.cli( + "resolve", "R1", "A1", "--outcome", "cancelled", "--evidence", "Stopped" + ) + template = (Path(__file__).resolve().parents[1] / "templates/handoff.md").read_text() + (self.repo / "HANDOFF.md").write_text( + template.replace("", "2026-10-09T10:00:00Z").replace( + "", self.base + ) + ) + rejected = self.cli( + "close", "R1", "--main", "main", "--reconciliation", "README.md", + "--handoff", "HANDOFF.md", "--evidence", "Final checks passed", ok=False, + ) + self.assertIn("Round delta", rejected.stderr) + self.assertEqual(self.manifest()["state"], "open") + + def test_close_accepts_autolink_as_section_content(self): + self.start() + self.cli( + "resolve", "R1", "A1", "--outcome", "cancelled", "--evidence", "Stopped" + ) + (self.repo / "HANDOFF.md").write_text( + self.handoff().replace( + "Read the backlog and select executable work.", + "", + ) + ) + self.cli( + "close", "R1", "--main", "main", "--reconciliation", "README.md", + "--handoff", "HANDOFF.md", "--evidence", "Final checks passed", + ) + self.assertEqual(self.manifest()["state"], "closed") + def test_close_preserves_handoff_size_limits(self): self.start() self.cli(