Skip to content

Commit 1a42985

Browse files
go <dir> refuses a plan that already ran, and the record stops forgetting
Bare `grapharc go` has always skipped executed plans — `find_unexecuted_plan` passes over any record carrying an `executed_run_id`. The explicitly-named form made no such check, so a second `go <run-dir>` re-ran the whole graph, exit 0, and overwrote the stamp. Three executions left a plan.json naming one while the trace — the audit trail, and the one that was right — held all three. Two things were wrong and the second is the one that matters. The record disagreed with the trace. `plan.json` is what `show_graph` / `graph_status` and the MCP driver read to answer "did this plan run, and as what?", and a scalar that the next run clobbers cannot answer it. The record now accumulates `executed_run_ids`, oldest first, alongside an `executed_at` stamp. The scalar stays as the newest, because `find_unexecuted_plan` and `grapharc/mcp/driver.py` read it and a plan.json written before this change must keep working — `_executed_run_ids` falls back to it, so an old record reports its one run rather than reporting none. And one approval could be spent N times. An approval binds to a proposal fingerprint, which does not change between runs of the same saved plan, so re-issuing `go <dir>` on a `mutating: true` plan was an agent editing the tree once per invocation on the strength of a single human yes. A plan carrying an `executed_run_id` is now refused with exit 2 — before anything executes, naming the previous run and when it happened — unless `--again` asks for the re-run in as many words. Explicit re-runs stay possible; silent ones stop. `--again` is `go`-only by design, which is why the flag-parity test in tests/test_cli.py grew a second exemption: it governs re-executing a saved plan, and `plan` never executes one. It is not a planning flag. The eight new tests in tests/test_go_rerun.py cover the refusal, the message, the `--again` escape, the accumulating record, the scalar-only upgrade path, and that bare `go` still skips quietly rather than refusing a directory it was never given. Neutering the guard and the accumulation turns four of them red. Closes #100 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6b7ad91 commit 1a42985

4 files changed

Lines changed: 274 additions & 1 deletion

File tree

grapharc/cli/main.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,7 @@ def _cmd_go(args: argparse.Namespace) -> int:
405405
config_path=args.config,
406406
approve=args.approve,
407407
approval_timeout=args.approval_timeout,
408+
again=args.again,
408409
as_json=args.json,
409410
)
410411
candidate = Path(target)
@@ -423,6 +424,7 @@ def _cmd_go(args: argparse.Namespace) -> int:
423424
config_path=args.config,
424425
approve=args.approve,
425426
approval_timeout=args.approval_timeout,
427+
again=args.again,
426428
as_json=args.json,
427429
)
428430
return plan(
@@ -996,6 +998,14 @@ def build_parser() -> argparse.ArgumentParser:
996998
metavar="MODULE:ATTR",
997999
help="the node kinds a planner may propose (default: grapharc.stdlib:build_registry)",
9981000
)
1001+
go.add_argument(
1002+
"--again",
1003+
action="store_true",
1004+
help=(
1005+
"execute a saved plan that has already run; without this, a second "
1006+
"`go <run-dir>` is refused rather than silently re-running the graph"
1007+
),
1008+
)
9991009
_add_planning_flags(go)
10001010
go.set_defaults(handler=_cmd_go)
10011011

grapharc/cli/plan.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,21 @@ def _write_plan_file(
218218
)
219219

220220

221+
def _executed_run_ids(record: dict[str, Any]) -> list[str]:
222+
"""Every run that has executed this plan, oldest first.
223+
224+
Reads the list when there is one and falls back to the scalar, so a
225+
`plan.json` written before the list existed reports its single run rather
226+
than reporting none. A malformed list is treated the same way — this is a
227+
record for a human to read, not a place to raise.
228+
"""
229+
history = record.get("executed_run_ids")
230+
if isinstance(history, list):
231+
return [str(item) for item in history]
232+
scalar = record.get("executed_run_id")
233+
return [str(scalar)] if scalar else []
234+
235+
221236
def find_unexecuted_plan(runs_root: Path | None = None) -> Path | None:
222237
"""The newest saved plan `go` has not executed yet, or None."""
223238
import json
@@ -293,6 +308,7 @@ def execute_plan(
293308
config_path: Path | None = None,
294309
approve: bool = False,
295310
approval_timeout: float | None = None,
311+
again: bool = False,
296312
as_json: bool = False,
297313
) -> int:
298314
"""`grapharc go [<run-dir>]` — execute a plan `grapharc plan` saved.
@@ -305,8 +321,22 @@ def execute_plan(
305321
only an answered yes — the gate an external driver relies on when the
306322
plan can change things. These flags used to be accepted here and
307323
silently dropped, which was worse than refusing them.
324+
325+
**An executed plan is not re-executed by accident.** Bare `go` has always
326+
skipped executed plans — `find_unexecuted_plan` passes over them — but the
327+
explicitly-named-directory form did not make the same check, so a second
328+
`go <dir>` ran the whole graph again and silently overwrote the record of
329+
the first. That matters twice over: the plan record disagreed with the
330+
trace, which is the audit trail and was right; and because an approval
331+
binds to a proposal fingerprint that does not change between runs, one
332+
human yes could be spent on N executions of a `mutating` plan. So a plan
333+
that already carries an `executed_run_id` is refused here unless `again`
334+
asks for the re-run in as many words, and the record accumulates
335+
`executed_run_ids` rather than clobbering a scalar — the scalar stays as
336+
the newest, which is what `find_unexecuted_plan` and the MCP driver read.
308337
"""
309338
import json
339+
from datetime import UTC, datetime
310340

311341
from grapharc.planner import LoopLimits
312342
from grapharc.runtime.budget import Budget
@@ -343,6 +373,25 @@ def execute_plan(
343373
except (OSError, ValueError, KeyError) as exc:
344374
return fail(f"unreadable plan file {plan_file}: {exc}", as_json=as_json, command="go")
345375

376+
previous_run_id = record.get("executed_run_id")
377+
if previous_run_id and not again:
378+
# Exit 2 rather than EXIT_FAILED: nothing went wrong at run time, the
379+
# command was refused before anything executed — the same shape every
380+
# other "this is not what you meant" refusal in the CLI takes.
381+
when = record.get("executed_at")
382+
return fail(
383+
f"{plan_file} has already been executed as run {previous_run_id}"
384+
+ (f" at {when}" if when else "")
385+
+ " — pass --again to run it a second time. An approval binds to "
386+
"the plan's fingerprint, which does not change between runs, so a "
387+
"re-run of a mutating plan spends an earlier yes.",
388+
as_json=as_json,
389+
command="go",
390+
plan=str(plan_file),
391+
executed_run_id=str(previous_run_id),
392+
executed_run_ids=[str(r) for r in _executed_run_ids(record)],
393+
)
394+
346395
run_dir = plan_file.parent
347396
trace_path = run_dir / "trace.jsonl"
348397

@@ -418,7 +467,14 @@ def execute_plan(
418467

419468
executed = any(r.executed for r in result.rounds)
420469
if executed:
470+
# The scalar stays the newest run — `find_unexecuted_plan` and the MCP
471+
# driver read it, and an older reader must keep working. The list is
472+
# what stops the record from forgetting: three executions used to leave
473+
# a plan.json naming one, while the trace correctly held all three.
474+
history = [*_executed_run_ids(record), result.run_id]
421475
record["executed_run_id"] = result.run_id
476+
record["executed_run_ids"] = history
477+
record["executed_at"] = datetime.now(UTC).isoformat()
422478
plan_file.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
423479

424480
url = watch_url(trace_path, run_id=result.run_id)

tests/test_cli.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2249,7 +2249,10 @@ def test_go_and_plan_share_every_planning_flag():
22492249
# `--scripted` and `--go` are plan-only by design: go means do (no
22502250
# scripted doing), and go needs no flag to do what its name says.
22512251
assert plan_actions - go_actions == {"--scripted", "--go"}
2252-
assert go_actions - plan_actions == set()
2252+
# `--again` is go-only for the same kind of reason, in the other
2253+
# direction: it governs re-executing a plan that has already run, and
2254+
# `plan` never executes a saved one. It is not a planning flag.
2255+
assert go_actions - plan_actions == {"--again"}
22532256

22542257

22552258
# -- init: the scaffold -------------------------------------------------------

tests/test_go_rerun.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"""`grapharc go <dir>` on a plan that has already run.
2+
3+
Bare `go` has always skipped executed plans — `find_unexecuted_plan` passes
4+
over any record carrying an `executed_run_id`. The explicitly-named-directory
5+
form did not make the same check, so a second `go <dir>` re-ran the whole graph
6+
and overwrote the stamp, leaving a `plan.json` that named one run while the
7+
trace correctly held three.
8+
9+
Two separate claims are under test here, and the second is the load-bearing
10+
one. The *record* must be able to name every run that executed the plan. And
11+
the *decision* must not be spent twice: an approval binds to a proposal
12+
fingerprint, which does not change between runs, so a silent re-run of a
13+
`mutating` plan executes on the strength of an earlier human yes.
14+
15+
The planner is scripted throughout — no model backend, no network.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import json
21+
from pathlib import Path
22+
23+
from grapharc.cli.main import main
24+
from grapharc.cli.plan import _executed_run_ids
25+
26+
27+
def _saved_plan(tmp_path, capsys) -> Path:
28+
"""A run directory holding an admitted, unexecuted `plan.json`."""
29+
trace = tmp_path / "run" / "trace.jsonl"
30+
assert main(["plan", "investigate", "--scripted", "--trace", str(trace), "--json"]) == 0
31+
capsys.readouterr() # drop the plan document
32+
return trace.parent
33+
34+
35+
def _last_document(text: str) -> dict:
36+
"""The final JSON document in a stream that may carry several."""
37+
decoder = json.JSONDecoder()
38+
documents, index = [], 0
39+
while index < len(text):
40+
if text[index] != "{":
41+
index += 1
42+
continue
43+
try:
44+
document, index = decoder.raw_decode(text, index)
45+
except json.JSONDecodeError:
46+
index += 1
47+
continue
48+
documents.append(document)
49+
assert documents, f"no JSON document in: {text[:200]!r}"
50+
return documents[-1]
51+
52+
53+
def _record(run_dir: Path) -> dict:
54+
return json.loads((run_dir / "plan.json").read_text(encoding="utf-8"))
55+
56+
57+
def _runs_in_trace(run_dir: Path) -> list[str]:
58+
seen: list[str] = []
59+
for line in (run_dir / "trace.jsonl").read_text(encoding="utf-8").splitlines():
60+
if not line.strip():
61+
continue
62+
run_id = json.loads(line).get("run_id")
63+
if run_id and run_id not in seen:
64+
seen.append(run_id)
65+
return seen
66+
67+
68+
# -- the refusal ------------------------------------------------------------
69+
70+
71+
def test_a_second_go_on_an_executed_plan_is_refused(tmp_path, capsys):
72+
"""The bug: this used to exit 0 having silently run the whole graph again."""
73+
run_dir = _saved_plan(tmp_path, capsys)
74+
assert main(["go", str(run_dir), "--json"]) == 0
75+
first = _record(run_dir)["executed_run_id"]
76+
before = _runs_in_trace(run_dir)
77+
capsys.readouterr()
78+
79+
code = main(["go", str(run_dir), "--json"])
80+
81+
assert code == 2
82+
payload = _last_document(capsys.readouterr().out)
83+
assert payload["ok"] is False
84+
assert payload["executed_run_id"] == first
85+
assert "already been executed" in payload["error"]
86+
assert "--again" in payload["error"]
87+
# Refused before anything ran: the trace gained no run from the refusal.
88+
# (It holds two — the planning run, then the one execution.)
89+
assert _runs_in_trace(run_dir) == before
90+
91+
92+
def test_the_refusal_names_the_run_and_when_it_happened(tmp_path, capsys):
93+
"""A refusal a reader cannot act on is an obstacle, not a gate."""
94+
run_dir = _saved_plan(tmp_path, capsys)
95+
assert main(["go", str(run_dir), "--json"]) == 0
96+
capsys.readouterr()
97+
98+
assert main(["go", str(run_dir)]) == 2
99+
100+
message = capsys.readouterr().err
101+
assert _record(run_dir)["executed_run_id"] in message
102+
assert _record(run_dir)["executed_at"] in message
103+
104+
105+
def test_again_executes_it_a_second_time(tmp_path, capsys):
106+
"""Explicit re-runs stay possible; only the silent ones stop."""
107+
run_dir = _saved_plan(tmp_path, capsys)
108+
assert main(["go", str(run_dir), "--json"]) == 0
109+
first = _record(run_dir)["executed_run_id"]
110+
capsys.readouterr()
111+
112+
code = main(["go", str(run_dir), "--again", "--json"])
113+
114+
assert code == 0
115+
payload = _last_document(capsys.readouterr().out)
116+
assert payload["executed"] is True
117+
assert payload["run_id"] != first
118+
# The planning run, then both executions.
119+
assert _runs_in_trace(run_dir)[-2:] == [first, payload["run_id"]]
120+
121+
122+
# -- the record -------------------------------------------------------------
123+
124+
125+
def test_the_record_names_every_run_that_executed_the_plan(tmp_path, capsys):
126+
"""Three executions used to leave a plan.json naming one, while the trace
127+
— the audit trail, and the one that was right — held all three."""
128+
run_dir = _saved_plan(tmp_path, capsys)
129+
assert main(["go", str(run_dir), "--json"]) == 0
130+
assert main(["go", str(run_dir), "--again", "--json"]) == 0
131+
assert main(["go", str(run_dir), "--again", "--json"]) == 0
132+
capsys.readouterr()
133+
134+
record = _record(run_dir)
135+
assert len(record["executed_run_ids"]) == 3
136+
# The record now agrees with the trace, which was always right. The trace
137+
# also carries the planning run that produced the plan, hence the slice.
138+
assert record["executed_run_ids"] == _runs_in_trace(run_dir)[-3:]
139+
# The scalar stays the newest: `find_unexecuted_plan` and the MCP driver
140+
# read it, and an older reader must keep working.
141+
assert record["executed_run_id"] == record["executed_run_ids"][-1]
142+
143+
144+
def test_a_plan_that_never_executed_carries_no_history(tmp_path, capsys):
145+
"""Absent rather than empty: a reader that tests for the key must not see
146+
one appear merely because the plan was saved."""
147+
run_dir = _saved_plan(tmp_path, capsys)
148+
149+
record = _record(run_dir)
150+
assert "executed_run_id" not in record
151+
assert "executed_run_ids" not in record
152+
assert _executed_run_ids(record) == []
153+
154+
155+
# -- compatibility with records written before the list existed -------------
156+
157+
158+
def test_an_old_record_with_only_the_scalar_reports_its_one_run():
159+
"""A `plan.json` written before `executed_run_ids` existed must report the
160+
run it does know about, not report none."""
161+
assert _executed_run_ids({"executed_run_id": "abc123"}) == ["abc123"]
162+
assert _executed_run_ids({}) == []
163+
# A malformed list is a record for a human to read, not a place to raise.
164+
assert _executed_run_ids({"executed_run_ids": "not-a-list"}) == []
165+
166+
167+
def test_an_old_record_is_refused_and_then_accumulates_from_its_scalar(tmp_path, capsys):
168+
"""The upgrade path: a pre-existing scalar-only record still refuses a
169+
silent re-run, and `--again` grows the list from it rather than losing it."""
170+
run_dir = _saved_plan(tmp_path, capsys)
171+
record = _record(run_dir)
172+
record["executed_run_id"] = "old-run-id"
173+
(run_dir / "plan.json").write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
174+
capsys.readouterr()
175+
176+
assert main(["go", str(run_dir), "--json"]) == 2
177+
capsys.readouterr()
178+
assert main(["go", str(run_dir), "--again", "--json"]) == 0
179+
capsys.readouterr()
180+
181+
grown = _record(run_dir)
182+
assert grown["executed_run_ids"][0] == "old-run-id"
183+
assert len(grown["executed_run_ids"]) == 2
184+
185+
186+
# -- bare `go` is unchanged -------------------------------------------------
187+
188+
189+
def test_bare_go_still_skips_an_executed_plan(tmp_path, capsys, monkeypatch):
190+
"""`find_unexecuted_plan` already passed over executed plans; the new guard
191+
must not turn that quiet skip into a refusal."""
192+
monkeypatch.chdir(tmp_path)
193+
trace = tmp_path / ".grapharc" / "runs" / "r1" / "trace.jsonl"
194+
assert main(["plan", "investigate", "--scripted", "--trace", str(trace), "--json"]) == 0
195+
capsys.readouterr()
196+
197+
assert main(["go", "--json"]) == 0
198+
capsys.readouterr()
199+
200+
# Nothing left unexecuted: the bare form reports that, rather than refusing
201+
# a directory it was never given.
202+
assert main(["go", "--json"]) == 1
203+
payload = _last_document(capsys.readouterr().out)
204+
assert "no unexecuted plan" in payload["error"]

0 commit comments

Comments
 (0)