A git-worktree agent cannot index its own edits — worktrees live under .claude/, which the walker excludes, so the index silently serves someone else's tree #220
Labels
No labels
code-review
correctness
dos
performance
security
severity/high
severity/low
severity/medium
tech-debt
Kind/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
h-dv/code-index#220
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Reported by the #219 lane, and it applies to every worktree-isolated lane run against this repository — six in this session alone.
What happens
Agents working in isolation get a git worktree at
.claude/worktrees/agent-<id>/. The MCP server is configured for the primary checkout, and.claudeis a dot-directory, which the walker excludes (only.github,.gitlaband.forgejoare allowlisted, per #33).So inside a worktree:
search_symbols,search_text,read_code,file_outline,find_callersall keep working and all keep returning the primary checkout's content;answer_provenance.indexed_trees.primary.headreports the primary checkout's HEAD, which the agent has no particular reason to compare against its own.The failure is silent and points the wrong way: the tool answers confidently, about a different tree.
Why this is serious rather than cosmetic
CLAUDE.mdmakes using the index mandatory and says so in strong terms:That instruction is correct in the primary checkout and actively misleading in a worktree. The headline promise — "indexes your WORKING TREE, not the last commit: uncommitted, unstaged and brand-new files are all queryable" — is exactly the property that does not hold there, and it is the property agents are told to rely on.
The #219 lane put it plainly:
In that lane it happened to be harmless — it needed master's code as its base. The dangerous case is an agent verifying its own change:
search_symbolson a function it just added returnssymbol_not_found, with anempty_populationblock reportingbasis: "measured"— a measured absence, which is true of the indexed tree and false of the agent's. That is the tool's own honesty machinery producing a confidently wrong answer, because the question it answered is not the question that was asked.Three candidate fixes, in ascending cost
answer_provenance— something likeworking_directory_not_indexed, naming both paths. Cheap, and converts a silent wrong answer into a visible one. This is the minimum and should land regardless of the others..claude/worktrees/in the walker the way #33 allowlisted.github, or teach project discovery to resolve a worktree to its own root. Note the cost: N worktrees multiply the indexed corpus, and stale worktrees accumulate — so this probably wants to be opt-in or scoped to the current worktree only.git rev-parse --git-common-dirdiffers from--git-dir) and attach to, or spawn, a daemon for that root.(1) is the honesty fix and matches how the rest of this tree behaves: an answer about a tree you did not ask about should say which tree it is about. (2) or (3) is the capability fix.
Mutations
/a/bvs/a/bc) → the ancestor check must not be a stringstarts_with. That is the classic way this comparison is written wrong.Related
#33 (the dot-directory allowlist this inherits from), and the
index_coveragecontract, which already answers "is this path indexed and why not" per path — the gap is that nobody thinks to ask it about their own working directory.Independently reproduced by a second lane (#218), with a sharper probe than the original report — the tool names the exclusion rule itself when asked about the agent's own file:
So
index_coverageis answering correctly and completely: the path IS excluded, the rule IShidden, and the proof names.claude. Everything is honest. The problem is that nobody thinks to ask that question about their own working directory — the agent asks about a symbol, gets an answer drawn from a different tree, and nothing in that answer mentions the tree it came from.That is what makes fix (1) — the
answer_provenancedisclosure — the right minimum. The verdict already exists; it is just never volunteered, and the one call that would reveal it is the one call nobody makes about themselves.The lane's own summary of how it coped:
That is the correct workaround and it required knowing the defect in advance. An agent that does not will read
symbol_not_foundon a function it just wrote and conclude something false about its own change.A second, related hazard the same lane hit — worth its own note
Its mutation driver at
/tmp/mut.pywas overwritten mid-session by a concurrently running lane using the same path. No damage in this instance (its mutations were already run, restored, and md5-verified), but it is the same class: isolation that looks complete and is not. A git worktree isolates the tree and nothing else —/tmp, the cargo target dir, and the index all remain shared.That one is process rather than product, and the fix is on the orchestration side: lane-unique scratch paths as a rule, not as advice. Recorded here because it was measured live rather than hypothesised, and because anyone reading this issue about worktree isolation should know the isolation is narrower than it appears in more than one dimension.
Fixed in
6c454eb, onmaster— but not by indexing worktrees. The exclusion stays; what changes is that a measured absence now names the tree it measured.Both obvious fixes were tested and rejected, with evidence
Allowlisting
.claudefixes nothing.is_nested_checkout(walker.rs:293) refuses a linked worktree on an independent axis from the hidden-dir rule. New testallowlisting_the_parent_still_does_not_walk_a_worktreeplants a worktree inside an already-allowlisted dot-dir and it still is not walked. Allowlisting would only have exposed.claude/settings.local.jsonand transcripts — and there are 16 worktrees under this repo right now, so a naive fix multiplies the index and returns N duplicates per hit.The "index it when it IS the project root" option is structurally blind to this bug. Measured: the MCP server's cwd is the primary root even while an agent sits in a worktree (PIDs 33651 and 450702, both
cwd=/home/master/code/rust/cosi-mcp). A cwd comparison would be vacuously false on the exact case it was written for.So the lane measured git's worktree registry instead, as a count minus one — never a path comparison, which makes the
/a/bvs/a/bcprefix bug unrepresentable rather than merely unlikely.The actual defect was one layer up, and it was prose
server.rs:22497skipped provenance on any error, justified by a comment asserting "an error carries no answer whose provenance could be in question." That premise was stated in prose and is false —symbol_not_foundcarriesempty_population: {basis: "measured", unfiltered_total: 0}, which is exactly an answer whose provenance is in question.Now gated on
carries_a_measurement/MEASUREMENT_KEYS(server.rs:9177).isErrorsurvives the rewrite.Note
index_coveragewas already honest here —verdict: "never",reason: "hidden",proof.at: ".claude". The instrument was fine; the confident tools were not.Before and after, same topology
Mutations — all RUN, all RED
if is_error { return }carries_a_measurement→trueno_outlinegained the blockok_jsonisError:falseon asymbol_not_found.saturating_sub(1)left:1 right:0)is_nested_checkoutarmto_jsondrops siblingsleft:Null right:16M2 re-run independently before merge, not taken from the report:
EXIT=101, red with "no_outlinepublishes no measurement, so it must stay the short message a caller needs. Attaching provenance to EVERY error is the fix that makes the #220 test pass vacuously."Two defects the gates caught in the fix itself
not_a_git_checkouttwice per reply."sibling_worktrees": 0cost 578 tokens (5.2%) to say "no" 23 times.Both removed; the boring side is now silent, matching
daemon_build's existing rule.The ratchet was attributed, not blessed
plugin-wpf11134→11712. Reverting one clause at a time: 549 of 570 tokens were already on master — with #220's clause reverted the tier measures 11683 against a value recorded 36 commits earlier, i.e. 4.93% of a 5% ceiling already consumed with 8 tokens of headroom left. That drift is not #220's and is not diagnosed; it is written into the_noteso the next reader is not told otherwise, and filed separately. Only 29 tokens are #220's, on exactly one question (no_xaml_symbol_is_invented, 152→181) — a measured absence now naming its tree.Gates: fmt, clippy (host and windows-gnu),
cargo test --workspace(326 suites, 3507 tests, 0 failed), rustdoc-D warnings,COSI_E2E_LEG=daemon(62 suites), corpus ratchet withbaseline.jsonunchanged, precision gate 7/7phantoms=0in all 7 languages — all exit 0..claude/, which is permanently unindexable #238.claude/, which is permanently unindexable #238no_outlineandpath_not_indexedframe a PERMANENT exclusion as transient, and send the caller to a tool that cannot answer either #243answer_provenancenames a commit the INDEX has not reached, so a watcher-lag miss and a measured absence are indistinguishable #260