mcp: the execute timeout no longer severs an approved mutating run - #117
Merged
Merged
Conversation
Three defects on one path, all from issue #113. **One budget covered the park and the work.** `approval_timeout + 120s` bounded the whole subprocess, so a human approving near the end of the park left roughly two minutes for the run itself -- and a governed run's agent phases delegate to Claude Code, which reads files, edits them and verifies. The likely outcome was not a wedged process being cleaned up, it was a human-approved, tree-mutating run being SIGKILLed partway through mutating the tree. The non-mutating branch passed `timeout=None` and was not bounded at all. Separate budgets now: `DEFAULT_WORK_TIMEOUT` is 1800s, the number `GRAPHARC_SLACK_WORK_TIMEOUT` already uses for this exact case, overridable via `GRAPHARC_MCP_WORK_TIMEOUT` -- which refuses a non-numeric or non-positive value rather than substituting a ceiling nobody chose. **plan.json could not catch the reissue.** `executed_run_id` is stamped after `loop.run()` returns, so a killed run never reaches it; the record said "never executed" and the #100 guard waved a second `go` through on one human approval. The guard now reads the trace, which is written as the run proceeds and is the only place that evidence survives a kill. Deliberately not a pre-stamp: that would mark a plan executed when it had merely parked and been denied. The phase vocabulary is pinned by a test, because the obvious spelling -- "not one of `observe.metrics`'s loop phases" -- counts `approval_response` as an execution and so refuses a denied plan forever. A torn trace reports no evidence rather than wedging the plan beside it. **The kill left grandchildren.** No new session and `process.kill()` signals the direct child only, so a delegated Claude Code process outlived the run and kept the workspace. `start_new_session=True` plus a process-group SIGKILL, falling back to the single process where `killpg` is unavailable. The docstring promised "a timeout leaves the plan unexecuted and this call safe to reissue", true of an unanswered park and false of everything else. It distinguishes them now and sends the agent to `show_graph` first; the `DriverError` says the same. Both asserted -- that docstring is the agent's whole briefing. Verified: 2171 selected, 13 deselected, ruff clean. Red without the fix (reverting only `grapharc/` fails the reissue test `assert 0 == 2`), while the two over-refusal guards pass on both sides. deep-dive.md's figure updated 2,151 -> 2,171. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two gaps in the fix, found reviewing it rather than running it. **The vocabulary was duplicated.** `cli.plan` carried its own copy of which phases are bookkeeping, alongside the copy `observe.metrics` already had for its own purpose. A phase classified one way in one file and the other way in the other is a bug in whichever is wrong, with nothing in the tree to say which -- and the two are read for different questions, so the copies would have drifted quietly. `LOOP_PHASES`, `SHAPE_PHASES`, `DRIVER_PHASES` and `began_execution()` now live in `observe.trace`, beside the `TraceEvent` they classify; both callers defer to them, and a test asserts identity rather than equality, because two frozensets that happen to match today is the state that assertion exists to rule out. The docstring now states the direction the predicate errs in, which was implicit before: **an unlisted phase reads as an execution.** That is the safe way round. A new bookkeeping phase makes `go` refuse a plan it could have run, recoverable with `--again`; the opposite would re-run a half-finished mutating plan and spend a human approval that was given once. **Bare `go` was covered but not pinned.** `grapharc plan … && grapharc go` is the flow the README teaches, and bare `go` reaches its plan by a different route: `find_unexecuted_plan` passes over any record with an `executed_run_id`, and a killed run never wrote one, so a half-finished plan looks *unexecuted* to the selector and is the newest candidate. The refusal lands after the selection, so it holds -- verified against main, where the same scenario exits 0 and puts a third run in the trace. It holds for a reason a refactor of the selector could undo without touching the guard, so it now has a test of its own. Verified: 2173 selected, 13 deselected, ruff clean. The metrics refactor changes no drawing behaviour -- its local names alias the shared ones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the count The previous commit claimed the vocabulary had one owner. It had three: `observe.viewmodel` kept its own pair -- under a comment reading "mirrors `metrics`", which was true and was precisely the problem -- and `slack.live` kept its own `_SHAPE_PHASES`. Both now defer to `observe.trace`, so the four modules that ask which phases are bookkeeping get one answer. The identity assertions only cover names a test knows to look for, which cannot notice a fifth copy appearing under a new name. So there is a second check that reads the source: exactly one file in the package may define these sets. It asserts on *files* rather than file:line, because a line number would fail on any unrelated edit to trace.py, which is noise and not a finding -- the first version of it did exactly that, and it also missed `DRIVER_PHASES`. The predicate's direction moves into a test of its own rather than riding along at the end of another, and that test also asserts the three groups partition the bookkeeping set with nothing dropped between them. No behaviour change in any of the four: every local name aliases the shared object, and the drawing and feed logic reads as it did. Verified: 2175 selected, 13 deselected, ruff clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts: # docs/deep-dive.md
This was referenced Sep 26, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #113.
The issue names three defects on the MCP
executepath. All three are fixed here, each with a test that is red without its fix.1. One budget covered both the park and the work
timeout=approval_timeout + 120.0 if mutating else Nonebounded the whole subprocess, so a human who approved near the end of the park left roughly 120 seconds for the run itself — and a governed run's agent phases delegate to Claude Code, which reads files, edits them and verifies. The likely outcome was not a wedged process being cleaned up; it was a human-approved, tree-mutating run being SIGKILLed partway through mutating the tree. The non-mutating branch passedtimeout=Noneand so was not bounded at all.The park and the work now have separate budgets.
DEFAULT_WORK_TIMEOUTis 1800s, matchingGRAPHARC_SLACK_WORK_TIMEOUT— the Slack path carved exactly this case out of one shared timeout for the same reason, and its comment already said why. Overridable viaGRAPHARC_MCP_WORK_TIMEOUT, which refuses a non-numeric or non-positive value rather than falling back to the default: an operator who sets a ceiling has one in mind, and silently substituting a different one is how a run gets killed at a limit nobody chose.Mutating calls are bounded at
approval_timeout + work; non-mutating ones atwork, so that branch is bounded at all now.2.
plan.jsoncould not catch the reissueexecuted_run_idis stamped afterloop.run()returns, so a run killed partway through never reaches the stamp. The record then said "never executed", the #100 guard waved a secondgothrough, and the plan ran again over a tree the first run had half-changed — on one human approval, which is the property #100 was closed to protect.Rather than pre-stamping (which would mark a plan as executed when it had merely parked and been denied), the guard reads the trace. The trace is written as the run proceeds, so it is the only place that evidence survives a kill — and it is already the audit trail this project treats as authoritative.
gonow refuses a plan whose trace holds a run that did node-level work and which the record does not name, pointing the reader at the trace and offering--again.The phase vocabulary this depends on is pinned by a test, and deliberately so. A node did work iff its phase is outside the loop's own bookkeeping (
plan,admission,round), the viewer's shape events (topology,approval_request,approval_response) and a barestop. Using "not a loop phase" — the splitobserve.metricsmakes for a different purpose — would have countedapproval_responseas an execution and so refused a plan a human had denied, forever. That case has its own test.A torn or unreadable trace returns "no evidence" rather than refusing: a damaged file must not wedge every plan sitting beside it.
3. The kill left grandchildren
run_clidid not start a new session andprocess.kill()signals the direct child only, so a delegated Claude Code process could outlive the run that spawned it and keep holding the workspace. The child is now started withstart_new_session=Trueand the timeout path SIGKILLs the whole process group, falling back to the single process wherekillpgis unavailable. An already-dead group is the success case arriving early, not an error.What the agent is told
The docstring's "A timeout leaves the plan unexecuted and this call safe to reissue" was true of an unanswered park and false of everything else. It now distinguishes the two and sends the agent to
show_graphbefore reissuing anything; theDriverErrorraised on a timeout says the same. Both are asserted by tests, because that docstring is the agent's whole briefing.Verification
ruff check .clean, on Python 3.14.7.grapharc/and re-running: the reissue test failsassert 0 == 2— the bug reproducing,gosilently executing a second time.test_again_still_runs_an_unfinished_planandtest_planning_paperwork_is_not_mistaken_for_a_half_runpass both before and after, which is the point of them: they guard against the fix over-refusing.docs/deep-dive.md's selected-count figure updated 2,151 → 2,171, astests/test_deep_dive.pyrequires.No CHANGELOG entry, matching #109 and #112 — those entries are written in the release commit.
🤖 Generated with Claude Code
Follow-up commits, from reviewing the above rather than running it
The phase vocabulary now has one owner. The first version of this PR added a fourth copy of "which phases are bookkeeping" —
observe.metrics,observe.viewmodel(under a comment reading "mirrorsmetrics", which was true and was the problem),slack.live, and the new one incli.plan. Four modules asking the same question with four answers, read for different purposes, is a bug in whichever is wrong with nothing in the tree to say which.LOOP_PHASES,SHAPE_PHASES,DRIVER_PHASESandbegan_execution()now live inobserve.trace, beside theTraceEventthey classify. All four callers defer to them; every local name aliases the shared object, so no drawing or feed behaviour changes.Two guards, because one is not enough:
file:line; my first attempt used line numbers, which fails on any unrelated edit totrace.py, and it also missedDRIVER_PHASES.The direction the predicate errs in is now asserted, not assumed. An unlisted phase reads as an execution. That is the safe way round: a new bookkeeping phase makes
gorefuse a plan it could have run, which--againrecovers; the opposite would re-run a half-finished mutating plan and spend a human approval given once.Bare
gohas a test.grapharc plan … && grapharc gois the flow the README teaches, and baregoreaches its plan by a different route —find_unexecuted_planpasses over any record carrying anexecuted_run_id, and a killed run never wrote one, so a half-finished plan looks unexecuted to the selector and is the newest candidate. The refusal lands after the selection, so it holds; verified againstmain, where the same scenario exits 0 and puts a third run in the trace. It holds for a reason a refactor of the selector could undo without touching the guard, so it is pinned.Figure now 2,175 selected.
One thing I considered and did not do
Short-circuiting
_unfinished_executionso it stops at the first qualifying event instead of parsing the whole trace. These traces are tens to low hundreds of lines, so it would buy nothing measurable and would cost a hand-rolled JSONL parse where the module currently usesTraceRecorderlike everything else.