Skip to content

mcp: label change answers instead of refusing on pending edits - #1228

Draft
zzet wants to merge 3 commits into
mainfrom
perf/scoped-change-freshness
Draft

zzet wants to merge 3 commits into
mainfrom
perf/scoped-change-freshness

Conversation

@zzet

@zzet zzet commented Oct 31, 2026

Copy link
Copy Markdown
Owner

Status: work in progress. The handler, hook and helper changes are done and pass their focused tests. Two pieces wait for #1169 and #1159, which change the same file: deleting the old freshness barrier, and the docs/mcp.md paragraph.

explain_change_impact and detect_changes waited up to 3 s on every pending reindex receipt in the repository. Then they failed with "refused a stale graph". This caused three problems:

  • The edit behind the wait was usually in a file unrelated to the answer.
  • An id that did not resolve widened the wait to every receipt.
  • Concurrent agents got refusals for most impact calls, and each refused call held a dispatch slot for the full wait.

Changes

  • Both handlers wait at most 250 ms, and only for edits that can change the answer. That means:

    • the queried symbols' own files;
    • files with edges into those symbols;
    • files whose current bytes name a symbol as a whole identifier;
    • for detect_changes, the diff's changed files. The diff is now mapped files-first, so the scope is known before the wait.

    An unresolved id adds the file and name its spelling carries, instead of widening the scope.

  • Lagging edits label the answer instead of refusing it. Anything in scope that is still pending or failed adds these fields: complete:false, graph_freshness, freshness_scope, pending_files (up to 16), pending_file_count, and recovery_hint when failed. The walk always runs, and every answer reports barrier_wait_ms.

  • Text formats show the label first.

    • TOON and plain text start with a relations: <state> (N files) line.
    • GCX keeps GCX1 as line 1, carries the label as header meta, and adds the relations line as a comment on line 2.
    • JSON is unchanged.
  • Deadlines and refusals. An earlier wait_deadline ends the wait sooner, including a deadline that passed between admission and the handler reading it. Only require_fresh still refuses, once its deadline passes with the scope still lagging.

  • detect_changes also covers the changed symbols' callers. After the symbol join, it waits for and labels the changed symbols' relation scope: their files, files with edges into them, and files that name them. This wait shares the deadline of the changed-files wait. Before this, with scope staged or compare, the working tree is not in the diff, so a pending edit in a caller file was walked without a label.

  • Hooks. The Stop briefing and the PostToolUse briefing now call detect_changes through callServerToolWithin with a 300 ms budget.

    • The call carries a wait_deadline 150 ms out, so the daemon labels pending edits instead of waiting past the budget.
    • An empty reply after that deadline counts as a stall. A stall, or an answer labelled pending or failed, yields a short briefing with the relations line and the changed files. Before, the hook printed nothing, or diagnostics computed over lagging relations.
    • A session that recorded no writes gets no stall line.
    • renderDeadCodeHits never reports as dead a symbol listed on a pending_unknown line of analyze dead_code.
  • The label lives in relation_freshness.go, so the relation-reading mutators in the follow-up PR can share it.

Status

Done: the three commits on this branch. Production code no longer calls the old barrier.

Not done. These wait for #1169 and #1159, which change edit_serialization.go:

  • Delete the barrier, together with its tests: awaitMutationFreshnessForRepos, awaitMutationFreshness, mutationReposForSymbolIDs, mutationSafetyWait and defaultMutationSafetyWait. Until then, their tests keep them in use, so lint stays clean.
  • Add a docs/mcp.md paragraph on change-tool labels.
  • Rebase on main. The branch merges cleanly today.

No test on this branch is red by design.

Tests

  • On the base, every new or rewritten test fails:
    • the change-tool tests fail after 3 s with "refused a stale graph";
    • the hook tests print dead code, show no relations line, and stall for 2 s with an empty briefing.
  • New or rewritten tests:
    • TestChangeImpactLabelsLaggingReceiptsInScope: pending is labelled, published is clean, failed carries a recovery hint.
    • TestDetectChangesLabelsPendingChangedFile, including wait_deadline at +50 ms and -1 s, and a pending caller under staged scope.
    • TestChangeToolsIgnorePendingReceiptOutsideScope, TestChangeToolsScopeIncludesFilesNamingTheSymbol, TestChangeToolsUnresolvedIDDoesNotWidenBarrier and TestChangeToolsRequireFreshWaitsUntilWaitDeadline.
    • TestChangeAnswersStartWithRelationsLine.
    • TestPostTaskBriefingPrintsPendingRelations and TestPostTaskBriefingBoundedWhenDetectStalls.
    • An extension of TestRunPostTask_DeadCodeFiltersToChanged.
  • Review fixes each came with a test that failed first. Examples: "detect with wait_deadline -1s: graph_freshness pending after 252 ms", and "staged detect left the pending caller unlabelled".
  • At this head, under -race, these pass:
    • the hooks post-task, mutation briefing, dead code, server tool call and per-host briefing tests;
    • the mcp change-tool set: TestChangeTools*, TestChangeAnswers*, TestChangeImpact*, TestDetectChanges*, mutation freshness, checkout mutation receipt, overlay and zero-edge caveat;
    • analysis Diff.
  • Static checks: go vet and gofmt are clean. golangci-lint over mcp, hooks and analysis reports 0 issues.

Untested: the full package suites for mcp, hooks and analysis. CI runs them.

Refs #1018

zzet added 3 commits October 31, 2026 18:32
explain_change_impact and detect_changes waited up to 3 s on every pending
reindex receipt in the repository and then failed with "refused a stale
graph". The edit that triggered the wait was usually in a file unrelated to
the answer, and an id that did not resolve widened the wait to every
receipt, so concurrent agents got refusals for most impact calls and each
one held a dispatch slot for the full wait.

Both handlers now wait at most 250 ms, and only for the edits that can
change the answer: the queried symbols' own files, files with edges into
them, files whose current bytes name them as a whole identifier (a call the
graph has not read yet), and for detect_changes the changed files of the
diff, which is now mapped files-first so the scope is known before the
wait. An unresolved id contributes the file and name its spelling carries
instead of widening the scope. Whatever in scope is still pending or failed
labels the answer (complete:false, graph_freshness, freshness_scope,
pending_files, pending_file_count, and recovery_hint when failed); the walk
always runs. An earlier wait_deadline ends the wait sooner, including one
that passed between admission and the handler reading it. TOON and plain
text answers lead with a "relations: <state> (N files)" line, and GCX keeps
GCX1 as line 1 with the label as header meta and the line as a comment on
line 2. Every answer reports barrier_wait_ms. Only require_fresh refuses,
once its wait_deadline passes with the scope still lagging.

The label lives in relation_freshness.go so the relation-reading mutators
can share it.
…lations

The Stop briefing and the Codex PostToolUse briefing call detect_changes
with only the 5 s transport bound, and fold every failure into an empty
string. While edits were pending the change tool refused, the hook could
not decode the error, and the agent was told nothing about its change set.

detect_changes now gets 300 ms through callServerToolWithin, which tells a
reply that did not arrive in time apart from an unreachable bridge. The
call carries a wait_deadline 150 ms out, so the daemon labels the edits it
is still publishing instead of waiting 250 ms for them on top of the diff
and the walk, which would land the labelled answer past the budget. An
empty reply after that deadline, which is how the daemon refuses a
deadline that passed before it took the call up, counts as a stall.

A stall, or an answer labelled pending or failed (graph_freshness on a
primary checkout, graph_status on a worktree), yields a short briefing
with the relations line and the changed files instead of silence or
diagnostics over lagging relations. A stall says only that detect_changes
did not answer, and stays silent for a session that recorded no writes, so
a slow daemon does not put a warning into every read-only turn.
renderDeadCodeHits never reports a
symbol listed on a "pending_unknown " line of analyze dead_code as dead.
…allers

detect_changes scoped its wait and label to the diff's changed files, but
its risk and blast radius come from walking the changed symbols' callers.
With scope staged or compare the working tree is not in the diff, so a
pending edit in a caller file left the walk reading the old relations
without a label.

After the symbol join it now also waits for, and labels, the relation
scope of the changed symbols: their files, the files with edges into them
and the files that name them. That wait ends at the same deadline as the
changed files' wait, so the answer still waits at most once.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant