In a git worktree the index cannot read the lane's own files: a relative path resolves against the PRIMARY root and fails with a raw internal_error, not the documented path_outside_known_roots #201

Closed
opened 2026-09-06 21:05:01 +02:00 by buildagent · 1 comment
Member

Found by a lane working #41/#45/#51, and it forced that lane onto grep for code it had just written. Worktrees are the standing workflow here, so this is the common case rather than an edge one.

Measured

From inside a git worktree, asking for a lane-local file by relative path:

read_code("crates/indexer/tests/ignored_test_reachability.rs:395-440")
 -> internal_error: canonicalize failed for
    /home/master/code/rust/cosi-mcp/crates/indexer/tests/ignored_test_reachability.rs

The path is correct relative to the worktree. The server resolved it against the primary root, where that file does not exist — the lane created it in its own worktree.

Two separate problems, and the second is the one to fix first

1. The routing. Relative paths resolve against primary. For a session whose working directory is a worktree, every relative path to its own new work is unresolvable, and the tool is unusable for exactly the files the lane is being asked to reason about. Absolute paths auto-route by longest root-prefix and are unaffected — but an agent naturally types the relative path it just used with Write.

2. The error shape is wrong, and this is the cheap, high-value half. The documented, designed behaviour for a path the server cannot place is path_outside_known_roots, carrying did_you_mean with the known roots and a hint about [[links]] in .code-index.toml. That is a corrective error: it tells the caller what happened and what to do.

What comes back instead is a raw internal_error with a canonicalize failed message and an absolute path the caller never typed. That reads as a server malfunction, not as a routing miss. An agent seeing internal_error reasonably concludes the tool is broken and falls back to the shell — which is precisely what happened, in a repo whose CLAUDE.md makes preferring the index a hard rule.

Note the contrast, because it shows the corrective path already exists and is good: querying an unlinked corpus path gives path_outside_known_roots with did_you_mean: ["primary=/home/master/code/rust/cosi-mcp"]. Same underlying situation — a path the server cannot place — with a completely different quality of answer. The worktree case simply misses that branch and falls through to a generic failure.

Why existing gates do not catch it

Every test creates a fixture project and queries it as primary. No test queries an index from a working directory that is a git worktree of the indexed root, so the configuration is unrepresentable in the harness — the same structural blindness recorded for #181 (binary vs tree skew) and #182 (which tree was read).

That matters here because the project's own policy is that lanes work in worktrees. The one configuration the team uses all day is the one configuration nothing tests.

What must NOT be done

  • Do not fix it by making relative paths resolve against the process's CWD. That would silently change which project a relative path means for every existing caller, and the auto-routing rule (longest root-prefix wins, relative defaults to primary) is documented behaviour other things depend on.
  • Do not auto-link every worktree of the primary root. A worktree at a different commit is a different tree; linking it invisibly would reintroduce #182 — answers from a tree the caller did not ask about, with no field saying so.
  • Do not close this by telling agents to use absolute paths. True, and it is the correct workaround today, but an agent will type the relative path first and get internal_error; the honest minimum is that the error says so.
  • Do not lose the canonicalize detail. It is the actual cause and belongs in the corrective error's body — just not as the whole of it.

Suggested shape, in priority order

  1. Make the failure corrective. A relative path that does not resolve under the routed project should return path_outside_known_roots with did_you_mean naming the known roots, and say plainly that relative paths resolve against primary. Cheap, no behaviour change, and it converts a dead end into a next step.
  2. Then decide the routing question on its own merits, with #182's lesson attached: if a worktree is ever made reachable, the reply must disclose which tree answered.

#182 (which tree produced the answer — any routing fix must not reintroduce it), #191 (the same shape for the pinned corpus: capability exists, discoverability does not), #175.

Reported by a lane that then had to grep its own new file, 2026-09-06, against code-index-mcp 0.26.1 (8d90075).

Found by a lane working #41/#45/#51, and it forced that lane onto `grep` for code **it had just written**. Worktrees are the standing workflow here, so this is the common case rather than an edge one. ## Measured From inside a git worktree, asking for a lane-local file by relative path: ``` read_code("crates/indexer/tests/ignored_test_reachability.rs:395-440") -> internal_error: canonicalize failed for /home/master/code/rust/cosi-mcp/crates/indexer/tests/ignored_test_reachability.rs ``` The path is correct **relative to the worktree**. The server resolved it against the **primary** root, where that file does not exist — the lane created it in its own worktree. ## Two separate problems, and the second is the one to fix first **1. The routing.** Relative paths resolve against primary. For a session whose working directory *is* a worktree, every relative path to its own new work is unresolvable, and the tool is unusable for exactly the files the lane is being asked to reason about. Absolute paths auto-route by longest root-prefix and are unaffected — but an agent naturally types the relative path it just used with `Write`. **2. The error shape is wrong, and this is the cheap, high-value half.** The documented, designed behaviour for a path the server cannot place is `path_outside_known_roots`, carrying `did_you_mean` with the known roots and a hint about `[[links]]` in `.code-index.toml`. That is a *corrective* error: it tells the caller what happened and what to do. What comes back instead is a raw `internal_error` with a `canonicalize failed` message and an absolute path the caller never typed. That reads as a server malfunction, not as a routing miss. An agent seeing `internal_error` reasonably concludes the tool is broken and falls back to the shell — which is precisely what happened, in a repo whose CLAUDE.md makes preferring the index a hard rule. Note the contrast, because it shows the corrective path already exists and is good: querying an unlinked corpus path gives `path_outside_known_roots` with `did_you_mean: ["primary=/home/master/code/rust/cosi-mcp"]`. Same underlying situation — a path the server cannot place — with a completely different quality of answer. The worktree case simply misses that branch and falls through to a generic failure. ## Why existing gates do not catch it Every test creates a fixture project and queries it as primary. **No test queries an index from a working directory that is a git worktree of the indexed root**, so the configuration is unrepresentable in the harness — the same structural blindness recorded for #181 (binary vs tree skew) and #182 (which tree was read). That matters here because the project's own policy is that lanes work in worktrees. The one configuration the team uses all day is the one configuration nothing tests. ## What must NOT be done - **Do not fix it by making relative paths resolve against the process's CWD.** That would silently change which project a relative path means for every existing caller, and the auto-routing rule (longest root-prefix wins, relative defaults to primary) is documented behaviour other things depend on. - **Do not auto-link every worktree of the primary root.** A worktree at a different commit is a *different tree*; linking it invisibly would reintroduce #182 — answers from a tree the caller did not ask about, with no field saying so. - **Do not close this by telling agents to use absolute paths.** True, and it is the correct workaround today, but an agent will type the relative path first and get `internal_error`; the honest minimum is that the error says so. - **Do not lose the `canonicalize` detail.** It is the actual cause and belongs in the corrective error's body — just not as the whole of it. ## Suggested shape, in priority order 1. **Make the failure corrective.** A relative path that does not resolve under the routed project should return `path_outside_known_roots` with `did_you_mean` naming the known roots, and say plainly that relative paths resolve against primary. Cheap, no behaviour change, and it converts a dead end into a next step. 2. **Then** decide the routing question on its own merits, with #182's lesson attached: if a worktree is ever made reachable, the reply must disclose which tree answered. ## Related #182 (which tree produced the answer — any routing fix must not reintroduce it), #191 (the same shape for the pinned corpus: capability exists, discoverability does not), #175. Reported by a lane that then had to `grep` its own new file, 2026-09-06, against `code-index-mcp 0.26.1 (8d90075)`.
Author
Member

Fixed and released in v0.27.0 via 730bf15. Both stated problems are addressed, and I verified the remedy live rather than reading the diff.

Problem 2 (the error shape) — fixed, and it was hiding a deeper bug

The valuable part is what the fix found. The issue framed this as a worktree problem; it reproduced against a plain typo, so it was never about worktrees at all. It was a daemon/snapshot parity break of exactly the shape I035 is written about:

LocalIndex::read_span answers a missing file Ok(None) — the contract read_code's file_not_readable arm is written against. Under the daemon that entire arm was unreachable, because the RPC handler canonicalized first and turned every ENOENT into invalid_params. Every existing test read a file that exists, so nothing could see it.

ENOENT now answers null on both legs (the bound check still runs on every path that resolves, and every other canonicalize error still fails loudly). Mutation M3 (restore the daemon's ENOENT invalid_params) → RED, daemon leg ONLY — which is the proof the parity runner was the thing missing, not the arm.

The corrective error now names WHICH ROOT it looked under and why a relative path landed there, from ONE helper shared by read_code, file_outline and index_coverage. Error paths only, so a successful call pays nothing. M4 (relative_path_routing_note never fires) → RED; M5 (drop with_did_you_mean from no_outline) → RED.

Problem 1 (the routing) — answered by decision, and the remedy is real

Relative paths still resolve against the routed project's root, never the caller's working directory, and the server now says so along with two remedies. That is the right call: the MCP server has no reliable view of a client's cwd, and silently reinterpreting relative paths per-session would make identical requests mean different things.

I checked that the stated remedy actually works, because a remedy in an error message is a claim like any other. From this session, against a live lane worktree:

read_code("/home/master/code/rust/cosi-mcp/.claude/worktrees/agent-aeb3.../crates/guest/src/budget.rs:88-95")
 -> served the correct bytes

It routed to primary by longest root prefix and read the file off disk. So for the exact call in this issue's repro, passing an absolute path is a working answer, not just advice.

What does NOT work, split out as its own issue

read_code serves bytes off disk and does not need the file indexed. The search and symbol tools do, and a worktree here lives under .claude/worktrees/, which is permanently excluded:

index_coverage(".../.claude/worktrees/agent-.../crates/guest/src/budget.rs")
 -> verdict: "never", reason: "hidden", stage: "walk"
    proof.at: ".claude"
    detail:   "hidden directory; only .github, .gitlab and .forgejo are allowlisted"
    hint:     "Permanent. Use shell for this path, or change the rule that excludes it."

So the original lane's actual complaint — "it forced that lane onto grep for code it had just written" — is only half-answered: it can now read_code its own files, but it cannot search_symbols or search_text them. That is a distinct defect with a distinct fix (and the obvious fix, allowlisting .claude/worktrees/, is the wrong one — it would fan every symbol out N times across N concurrent lanes). Filed separately so it does not ride along on a closed issue.

Closing this one: both problems it states are resolved.

Fixed and released in **v0.27.0** via `730bf15`. Both stated problems are addressed, and I verified the remedy live rather than reading the diff. ## Problem 2 (the error shape) — fixed, and it was hiding a deeper bug The valuable part is what the fix found. The issue framed this as a worktree problem; it **reproduced against a plain typo**, so it was never about worktrees at all. It was a daemon/snapshot **parity break** of exactly the shape I035 is written about: `LocalIndex::read_span` answers a missing file `Ok(None)` — the contract `read_code`'s `file_not_readable` arm is written against. Under the daemon that entire arm was **unreachable**, because the RPC handler canonicalized first and turned every ENOENT into `invalid_params`. Every existing test read a file that exists, so nothing could see it. ENOENT now answers null on both legs (the bound check still runs on every path that resolves, and every other canonicalize error still fails loudly). Mutation M3 (restore the daemon's ENOENT `invalid_params`) → **RED, daemon leg ONLY** — which is the proof the parity runner was the thing missing, not the arm. The corrective error now names WHICH ROOT it looked under and why a relative path landed there, from ONE helper shared by `read_code`, `file_outline` and `index_coverage`. Error paths only, so a successful call pays nothing. M4 (`relative_path_routing_note` never fires) → RED; M5 (drop `with_did_you_mean` from `no_outline`) → RED. ## Problem 1 (the routing) — answered by decision, and the remedy is real Relative paths still resolve against the routed project's root, never the caller's working directory, and the server now *says so* along with two remedies. That is the right call: the MCP server has no reliable view of a client's cwd, and silently reinterpreting relative paths per-session would make identical requests mean different things. **I checked that the stated remedy actually works**, because a remedy in an error message is a claim like any other. From this session, against a live lane worktree: ``` read_code("/home/master/code/rust/cosi-mcp/.claude/worktrees/agent-aeb3.../crates/guest/src/budget.rs:88-95") -> served the correct bytes ``` It routed to primary by longest root prefix and read the file off disk. So for the exact call in this issue's repro, passing an absolute path is a working answer, not just advice. ## What does NOT work, split out as its own issue `read_code` serves bytes off disk and does not need the file indexed. The **search and symbol** tools do, and a worktree here lives under `.claude/worktrees/`, which is permanently excluded: ``` index_coverage(".../.claude/worktrees/agent-.../crates/guest/src/budget.rs") -> verdict: "never", reason: "hidden", stage: "walk" proof.at: ".claude" detail: "hidden directory; only .github, .gitlab and .forgejo are allowlisted" hint: "Permanent. Use shell for this path, or change the rule that excludes it." ``` So the original lane's actual complaint — *"it forced that lane onto `grep` for code it had just written"* — is only half-answered: it can now `read_code` its own files, but it cannot `search_symbols` or `search_text` them. That is a distinct defect with a distinct fix (and the obvious fix, allowlisting `.claude/worktrees/`, is the wrong one — it would fan every symbol out N times across N concurrent lanes). Filed separately so it does not ride along on a closed issue. Closing this one: both problems it states are resolved.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
h-dv/code-index#201
No description provided.