Skip to content

ISS-10812: route each session's calls to its own worktree (PLN-2070 PR 2) - #33

Merged
wongk merged 9 commits into
mainfrom
campaign-iss-10812-20260921-c2
Sep 21, 2026
Merged

wongk merged 9 commits into
mainfrom
campaign-iss-10812-20260921-c2

Conversation

@wongk

@wongk wongk commented Sep 21, 2026

Copy link
Copy Markdown

Campaign iss-10812-20260921, chunk C2 of 2: route each session's calls to its own worktree.

This PR builds PLN-2070 PR 2, for ISS-10812. It builds on C1 (#32), which gave each linked worktree an index copied from the main checkout's.

What this lands

Sessions start in the main checkout and enter a worktree when work begins, so they share the main checkout's daemon. Until now, about 90% of their code searches (1,642 of 1,817 in the transcripts ISS-10812 cites) were answered from the main checkout's index. With this PR:

  • The hook records where each session is working. The plugin's PreToolUse hook on every lc call (mcp_read_allow.py) records the session's cwd under <store>/session_cwd/<session_id>. It writes before any early return, only when the value changes, atomically and 0700. session_start.py prunes records older than 7 days.

  • Each request resolves a session root (gateway/adapters/mcp/session_root.py), in this order:

    1. an explicit repo_root or root;
    2. absolute paths that all fall inside one linked worktree (paths spanning two worktrees are refused, naming both);
    3. the session's recorded cwd, if it is the workspace root or a linked worktree of it;
    4. for relative edit and read paths only, the session's last bash cwd, now kept per session (256 entries at most, dropped after 24 h idle);
    5. the workspace root.

    The process-global _last_session_cwd, and its lc-debt note, are gone.

  • Code tools and relative paths follow the session root: code_search, smart_search, code_query/relations (through the engine factory), relative read and edit paths, bash with no cwd, and the three review tools. Workspace-level state stays keyed by the workspace; the full call-site table is below.

  • Results name the checkout when it isn't the workspace root: repo_root: <path>.

  • Edit's "resolved against worktree" note now appears only when a relative path was actually resolved against the worktree.

  • A fresh worktree's first code calls wait for its first refresh, for up to 10 s from the refresh's start, so the first answer is already the worktree's. On a timeout the answer is marked as refreshing.

  • Answers read while the index is refreshing don't count toward near-repeat suppression, so a retry gets answered.

Accepted limitation, the operator's decision: a session and its subagents share one session id. A subagent running in its own worktree (Agent isolation: worktree) while its parent stays in the main checkout will keep switching the recorded cwd. Even then, calls that name the worktree through paths or repo_root route correctly, and edits to existing files stay protected by the ambiguity refusal.

Known limitation, pre-existing: linked_worktree_of, which C1 moved over unchanged, resolves a relative gitdir: (from git worktree add --relative-paths) against the process cwd. It returns None, so such a worktree falls back safely to the workspace root.

Acceptance ledger

8 criteria: 6 met, 2 met differently, 0 not met.

Criterion Verdict Evidence
AC-2.1 search in an entered worktree answers from it met two-session test through the daemon dispatcher finds the worktree-only symbol, with the worktree's line numbers
AC-2.2 two sessions on one daemon stay separate met two threads with distinct Mcp-Session-Ids each get their own worktree; relative creates land in each caller's worktree
AC-2.3 paths inside a worktree route to it met recorded cwd is main, and paths into wt_a still find the wt_a-only symbol
AC-2.4 main-checkout sessions unchanged met differently existing lanes green, plus a golden comparison; two differences the plan itself requires, listed under deviations
AC-2.5 note only when true met an absolute-path edit carries no note
AC-2.6 foreign cwd never used met another repo, a plain directory, a missing path and a relative path all fall back to main
AC-2.7 pruning and bounds met 7-day prune, run as a subprocess; the bash map is capped and forgets idle sessions
AC-2.8 two live sessions (rollout) met differently not run live; it needs this build installed. Proven locally by the gate below and the concurrent two-session test

Gate (PR 2 change 1): passed. A headless claude -p run (2.1.278) in a throwaway repo, with a stand-in MCP server, showed two things:

  • after EnterWorktree, the PreToolUse payload's cwd is the worktree path;
  • its session_id equals CLAUDE_CODE_SESSION_ID in the MCP server's environment, which is what the bridge sends as Mcp-Session-Id.

Deviations from the plan

  1. The process global is gone. 14 tests in test_edit_mcp_handler.py that monkeypatched _last_session_cwd now set a per-session bash cwd. The constraint allowed this, and the tests are named in the build outcome.
  2. _session_worktree_root stays as a thin adapter over the session root, so the review-tool tests that monkeypatch it are unedited.
  3. "The workspace root" is read strictly. A recorded cwd in a subdirectory of the main checkout is ignored and falls back to today's behavior.
  4. AC-2.4 differences the plan mandates. A recorded cwd equal to the main checkout outranks a bash cwd in a worktree (D2 step 3 before step 4). Absolute-path edits no longer carry the "resolved against worktree" note (AC-2.5).
  5. AC-2.8 is deferred to rollout.

_workspace_root() call sites

10 follow the session, 13 stay on the workspace, and 5 grep hits are not call sites.

base line site verdict reason
1509 _default_workflow_agent_executor stays the workflow executor is workspace-level
2234 tool_agent stays agent routing and session state are workspace-level
3483 _symbol_recall stays memory recall is keyed by the workspace
3630 / 3680 / 3725 tool_review_rationale / _evidence / _feedback_addressed follow through _session_worktree_root → session root
3867 _bootstrap_context_status stays bootstrap memory is keyed by the workspace's repo_id
3992 tool_get_context stays workspace-level (its mode=symbols engine call follows through the factory)
4633 _compress_context stays ledger compression
7822 tool_smart_edit stays (confinement root) relative paths resolve against the session root
8644 / 8685 _write_handover_packet / _compact_advise stays handover and compression
8933 / 9007 / 9040 / 9061 _code_repo_root / _code_context_engine / _scoped_context_capability / _workspace_code_router follow relative roots resolve against the session code root
10442 tool_scan stays a workspace-level audit; an explicit repo_root is still honored
11049 / 11134 / 11506 _attach_code_search_savings / tool_code_search / tool_smart_search follow answer from the session code root
14224 / 14300 / 14362 auto-init and the two warmers stays Zoekt stays main's (D4)

Review

The deep review (5 reviewers, run cr-48218, at a775115b) was NEEDS_ATTENTION: 3 verified findings and 1 rejected. It proved it read this worktree at that commit.

Fixed:

  • HIGH, near-repeat code_search suppression keyed by session only: a session that searched in the main checkout and then repeated the search in its worktree got a blank answer. It is now keyed by the answering checkout too, with a regression test that fails on the old code (eec5e595).
  • Sibling bug, found while fixing it: bash's "already read in full" check resolved relative paths against the main checkout while a cwd-less command ran in the worktree (12415c8b).
  • MEDIUM, "session_cwd" hardcoded twice: both places now use session_root.SESSION_CWD (a2935c2d).

Declined, reason verbatim:

  • routed_paths() re-derives the tool broker's call shape: "The duplication is real, but both readers decode the published input schema of the tool broker (action, name, arguments), which every MCP client and the server's own instructions already depend on. Renaming those keys would be a breaking wire change that has to touch clients, instructions and both readers whether or not they share a helper, so a shared extractor protects against a rename nobody plans. The two readers also need different behavior: _tool_broker_handler validates and raises on a malformed call, while routed_paths must quietly return no routing paths for anything malformed and let the handler report the error. A shared cross-module abstraction for two call sites with different error contracts costs more than it buys at this stage."

Re-review: the gate fired because resolve touched a file with a HIGH finding. The re-review (cr-67896, --since-last-review, at a2935c2d) was APPROVED, with one new MEDIUM: _session_worktree_cwd() lacked _run_bash_tool's is_dir() fallback.

Second round, the operator's decision. The orchestrator's own end-to-end check found that the first search in a fresh worktree answered from the copied main-checkout index, unmarked, while the first refresh ran. That missed C1's AC-1.3 "marked refreshing". The same session's reworded retry was then blanked as a near-repeat. The operator chose to wait for the refresh and fix it without a third fleet review:

  • The first code calls in a fresh worktree wait up to 10 s for its first refresh. The wait holds no lock, and a session on another checkout answered in 0.14 s while a refresh was held open (4d1310b6).
  • Answers read while the index is refreshing are not recorded for near-repeat suppression (8474c560).
  • The re-review MEDIUM: the command and its dump check now resolve their directory through one helper (37c1cefd).

These last three commits were not reviewed by the fleet. Each has a regression test that fails on the pre-fix code, and the orchestrator's probe re-ran unchanged against them:

  • a fresh worktree, with a file only it has, created before the first call;
  • the first search now finds that file;
  • a relative create lands in the worktree and is searchable there, not in main.

Verification

  • tests/gateway/test_session_root_routing.py: 20 passed.
  • The worktree-seed tests plus the unedited test_reads_during_reindex.py: 16 passed.
  • Code tools during reindex, tool handlers, tool-shape recovery and cache invalidation: 211 passed.
  • The edit handler and bash pipeline tests: 104 passed.
  • The mypyc compile-safety test: 3 passed.
  • mypy and ruff: clean.
  • The hook tests (test_mcp_read_allow_hook.py, test_plugin_runtime_hooks.py) and the review-tool suites passed in the build and in the first resolve.
  • Counterfactuals: every new test fails when its fix is removed.
  • Flake noted, not caused here: test_workspace_root_edit_reindexes_once_after_the_response failed once in an early build run. This change does not touch it, and it passed in 5 later runs and in isolation.
  • Not run here: the full suite, which CI runs.

Rollout

After this merges, reinstall. Then two live sessions in two worktrees of symphony-alpha should each find code that exists only in their own worktree (AC-2.8). Also record daemon RSS with five worktree sessions attached.

This is the last chunk of the campaign.

wongk and others added 9 commits September 21, 2026 13:57
PLN-2070 PR 2. The plugin's PreToolUse hook records each session's cwd;
the daemon picks a request's root by D2's precedence (explicit root,
worktree paths, recorded cwd, per-session bash cwd, workspace) for code
tools, relative read/edit paths and cwd-less bash. Code tools say which
worktree answered; edit's resolved-against note needs a relative path.

Co-Authored-By: Claude <noreply@anthropic.com>
A session that searched in the main checkout and repeated the query after
entering a worktree (or naming one through paths) got a blank result
stamped with the worktree's repo_root. The recent-query bucket is now keyed
by (session, answering checkout).

Co-Authored-By: Claude <<redacted-email>>
…nd runs

A cwd-less bash now runs in the session's worktree, but the "already read
in full" check still resolved its relative path against the main checkout,
so `cat` of a worktree file was refused after a full read of the main
checkout's copy.

Co-Authored-By: Claude <<redacted-email>>
…SSION_CWD

Co-Authored-By: Claude <<redacted-email>>
@wongk
wongk merged commit f4b3705 into main Sep 21, 2026
9 checks passed
@wongk
wongk deleted the campaign-iss-10812-20260921-c2 branch September 21, 2026 20:38
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