Skip to content

mcp: label relation-reading mutators and block deletes on pending callers - #1231

Draft
zzet wants to merge 4 commits into
mainfrom
perf/relation-freshness-labels
Draft

zzet wants to merge 4 commits into
mainfrom
perf/relation-freshness-labels

Conversation

@zzet

@zzet zzet commented Oct 31, 2026

Copy link
Copy Markdown
Owner

Status: work in progress.

change_contract, preview_edit and safe_delete_symbol read callers and references from the graph, then act on them or give a verdict. They say nothing about edits the graph has not published yet. Once edit acknowledgements stop waiting for the reindex, unpublished edits become routine, and these tools would answer from the old graph without saying so. Worse, safe_delete_symbol could delete a symbol whose only caller was added a moment ago in another file.

Changes (commit c60eb82)

  • Labels. Each of the three tools snapshots the unpublished edits before it walks. Once the walk has named the symbols involved, it labels the answer with the in-scope edits: graph_freshness, freshness_scope, pending_files and pending_file_count. TOON and plain text start with the relations line from mcp: label change answers instead of refusing on pending edits #1228. These tools never wait by default and never refuse.
  • Provisional references in safe_delete_symbol. It scans the files of those edits for lines that name the target as a whole identifier, and reports each line as a reference of kind provisional. This has two effects:
    • The delete is rejected through the existing rejected_has_references status.
    • With propagate, each line becomes a manual patch, which blocks the propagation (propagation_blocked_manual).
  • Pending edit to the target's own file. It counts as one provisional reference for the whole file, for two reasons:
    • the edit may have added a caller in that file;
    • the graph's span of the target, which the delete cuts by, may no longer hold.
  • Lines the graph already knows. If the graph already holds a reference for a line, the graph handles it. The line is not counted twice and does not become a manual patch.
  • cascade:"apply" scans every closure member the same way, because the graph's edges alone decide which members are orphaned. Any hit refuses the cascade, whatever force says.
  • preview_edit maps the touched files to graph keys before it scopes the label, so a file named by an absolute URI still counts.
  • Stand-in for projection stubs. The scan stands in for the unresolved call stubs of a pending projection (mcp: hold a projection of each tool-written file until the store catches up #1226) until those stubs exist.

Status

Done: change_contract, preview_edit and safe_delete_symbol.

Not done. These live in tools_enhancements.go and tools_coding.go, which #1169 also changes, so they wait for it:

Not included: #1218, the stale-line fix for propagate deletes (also part of #1019). It merges cleanly with this branch.

No test on this branch is red by design.

Tests

  • On the base, the new tests fail: the target is deleted, or the answer carries no label.

  • TestSafeDeleteRejectsTargetCalledFromPendingFile has these subtests:

    • caller file: a pending edit in another file calls the target;
    • definition file: a pending edit adds a caller in the target's own file;
    • graph reference: a line the graph already knows is counted once and does not become a manual patch;
    • cascade member: a pending file names a closure member.

    In every case the delete is rejected or the propagation is blocked, and the files stay byte-identical.

  • TestRelationToolsLabelAndNeverRefuseWithUnresolvedReceipt covers change_contract, preview_edit (including a file URI) and safe_delete_symbol.

  • Review fixes each came with a test that failed first. Examples: the "definition file" case deleted the target; a graph reference was counted twice; the cascade member was deleted; and preview_edit with a file URI carried no graph_freshness.

  • At this head, under -race, these pass: the mcp change-tool set plus SafeDelete, Propagate, ChangeContract, PreviewEdit and Simulat.

  • Static checks: go vet and gofmt are clean. golangci-lint over ./internal/mcp/... reports 0 issues.

Untested: the full package suites. CI runs them.

Refs #1019

zzet added 4 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.
…lers

change_contract, preview_edit and safe_delete_symbol read callers and
references from the graph and act on them or give a verdict, with no word
about edits the graph has not published yet. Once edit acknowledgements
stop waiting for the reindex, those windows become routine and the tools
would answer from the old graph without saying so; safe_delete could
delete a symbol whose only caller was added a moment ago in another file.

Each tool now snapshots the unpublished edits before it walks, and once the
walk has named the symbols involved labels the answer with the in-scope
ones (graph_freshness, freshness_scope, pending_files, pending_file_count),
leading TOON and plain text with the relations line. There is no default
wait. safe_delete_symbol also scans the files of those edits for lines that
name the target as a whole identifier and reports each one as a
provisional reference, so the delete is rejected through the existing
rejected_has_references status, and in propagate mode as a manual patch
that blocks the propagation. An edit to the target's own file is one
provisional reference for the whole file: it may add a caller there, and
the graph's span of the target, which the delete cuts by, may no longer
hold. A line the graph already holds a reference for is left to the
graph, so it is neither counted twice nor turned into a manual patch. With
cascade:"apply" every closure member gets the same scan, since the closure
is orphaned by the graph's edges only, and a hit refuses the cascade. The
scan stands in for the unresolved call stubs of a pending projection until
those exist. preview_edit maps the touched files to graph keys before
scoping the label, so a file named by an absolute URI still counts.
@zzet zzet added this to the v0.64.12 milestone Oct 31, 2026
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