Skip to content

fix(cli): canonicalise the workspace root at the pool - #270

Merged
golimpio merged 6 commits into
mainfrom
fix/canonical-workspace-root
Aug 12, 2026
Merged

fix(cli): canonicalise the workspace root at the pool#270
golimpio merged 6 commits into
mainfrom
fix/canonical-workspace-root

Conversation

@golimpio

Copy link
Copy Markdown
Contributor

Closes #263.

Workspace roots were stored exactly as reported, never symlink-resolved, so two sessions on one project reached by different spellings — the everyday macOS /tmp/private/tmp firmlink, a checkout under a symlinked parent, a $TMPDIR scratch project — disagreed about where they were.

The visible damage was in the mailbox. sameWorkspace compares with filepath.Clean alone, so leave_note took the cross-project branch for a peer sitting in the same folder, the note went to the daemon-level store, and delivery dropped it unread because [collab] cross_project is off by default — while telling the sender the recipient "is pinned to /private/tmp/myproj". A same-project message, silently dropped, with an explanation that points away from the cause. The sticky-pin guard (#182) was hit the same way: a redundant session_start naming the project by its other spelling looked like a move to a different root and was refused as a pin steal.

The fix, and the two traps in it

Canonicalise at the producer, not at each comparison. workspacePool.Detect and SynthesiseRoot return their root through a new paths.Canonical. The pool is where plumb answers "which project is this?", so it is where that answer gets one spelling — the pin, session.Folder, the boundary policy, the collab store, the persisted pin, and the (root, language) key the language-server pool is indexed by all derive from it.

Only the result is canonicalised, never Detect's starting point: the marker walk must keep following the caller's spelling, or a project reached through a symlinked parent would search a different ancestor chain and miss the .plumb/ marker beside the link.

Trap 1 — canonicalising only the acquisition sites is wrong. That is the obvious reading of the issue's "fix it at acquisition", it was the first version of this change, and it introduces a worse bug than it fixes. routingProxy.route compares the root it detects for a file against the registered primaryRoot and, on a miss, acquires a separate language server with pin=false — an entry the refcount path never reclaims, so it lives until the daemon exits. A canonical pin against a raw Detect disagrees for every absolute path a client names in its own spelling: a permanently duplicated gopls per aliased project. Caught in the test logs (pool: reusing LS root=/var/… beside new workspace root=/private/var/…) and now guarded by TestCanonicalRoot_AliasedURIRoutesToThePrimaryServer.

Trap 2 — canonicalising the root breaks every consumer that compares it to an agent-supplied path. Found by independent adversarial review after this change was already "done", and verified by running it before and after. One side became always-resolved while the other stayed whatever spelling the client knows, so filepath.Rel reports a file sitting in the project as an escape — and each of these callers treats an escape as "drop it silently":

Consumer Effect
hintRelPath returns ""memory hint injection stops firing for the whole project, no error, no log
relevant_memories answers "path … is not inside workspace …" about a path the boundary guard admitted two lines earlier (the guard resolves both sides; the tool did not)
episodicRelPath drops the file from episodic areas
peerArea session_start's peer digest renders an empty area
workspace_sessions loses the topology annotation, prints absolute paths

A new paths.WorkspaceRel is now the one place "where is this file, within this project?" is answered, used by all five. It compares the raw spellings first and canonicalises only when they disagree — the common case costs no filesystem access, which matters because hintRelPath is on the per-tool-call enrich path, and the resolution is paid only when a lexical mismatch has to be told apart from a real escape. It also retires a latent over-rejection: three of those sites used a bare strings.HasPrefix(rel, ".."), which excluded an in-workspace directory named ..config.

Two smaller spelling leaks closed alongside: resolveCLIWorkspaceDetailed synthesises a markerless root the way the daemon does instead of echoing the raw path (so plumb stats / config show / trust / run_task key on the daemon's spelling), and rootFromClient canonicalises its Detect-failure fallback, which session_start compares against peers' session.Folder.

paths.Canonical

Hands the uncleaned absolute path to EvalSymlinks. filepath.Clean collapses .. lexically, which diverges from the kernel's left-to-right resolution the moment a .. follows a symlink (the divergence #264 is fixing in the boundary layer) — cleaning first would let two paths naming different directories canonicalise to one string, turning a same-place test into a false positive.

A relative path is cleaned and returned with no filesystem access: resolving it would anchor it to the daemon's working directory, the silent cross-repository write of #181. A path that does not exist yet resolves its nearest existing ancestor and re-joins the tail. Resolution failure degrades to filepath.Clean rather than refusing — a workspace that cannot be canonicalised is still a usable workspace. It is an identity function, not an authorisation check; the boundary policy still decides what is safe.

An earlier draft refused the ancestor walk whenever the path contained ... Measured rather than assumed, that guard recovers nothing in the case it targeted (the lexical collapse has already happened, so guarded and unguarded produce the same string) and loses canonicalisation where the parent is an ordinary alias. Removed.

Deliberately not changed

sameWorkspace and collab.NotifyKey keep their filepath.Clean comparison. With canonical inputs it is sufficient, and putting EvalSymlinks in NotifyKey — the fix the issue names to avoid — would place a syscall on the delivery hot path the in-process notifier exists to avoid, while fixing only the latency symptom and leaving the silent drop. sameWorkspace's doc comment now states that dependency, and says to fix a future mismatch at the producer rather than hardening the comparison.

Upgrade effects

Both one-time and self-healing, neither a correctness loss. A persisted pin written under the old spelling is re-canonicalised and re-persisted on its first restore (verified against the schema: pinned_workspace is PRIMARY KEY(proxy_session_id), and every restore re-resolves through the pool, so no stale row can restore a pin to the wrong project). Strict-mode read records keyed to the old spelling will not rehydrate, so the first read of such a file is asked for again — read_tracking is PRIMARY KEY(proxy_session_id, workspace, path), so they orphan rather than conflict. Stats rows attributed to the old spelling stay in their own bucket.

Verification

  • make verify green (build, full suite, golangci-lint 0 issues, integration/clients vet, size, brief, tidy); make lint-cross green; go test -race ./internal/cli/ ./internal/paths/ ./internal/tools/ green.
  • Every test mutation-verified: each canonicalisation site, each behaviour of Canonical, and each half of WorkspaceRel reverted in turn, failing exactly the tests that claim to cover it. One test was rewritten after mutation testing showed it had become a tautology (it called attachSynthetic directly, which no longer canonicalises), and one mutation was redone because the first attempt did not compile and so proved nothing.
  • Two cli fixtures now hand out canonical temp dirs, because the roots they stand in for are: on macOS t.TempDir/os.MkdirTemp land under /var, itself a symlink to /private/var. Reverting them while keeping the fix breaks ~30 pre-existing test functions, so they are load-bearing, not masking — the aliasing they can no longer catch is covered by the new dedicated alias tests.
  • Not exercised against a live daemon: doing so needs restarting the shared daemon, which several concurrent agent sessions were using.

Follow-up left open

internal/tools/boundary.go's canonicalRoot/canonicalPathForBoundary is a near-duplicate of paths.Canonical, and the two disagree on the one case that caused #181 (canonicalRoot anchors a relative path with filepath.Abs; paths.Canonical refuses to). They should become one definition, with the .. refusal staying above it in PathPolicy.Check. Deliberately not touched here: #264 is in flight in that exact file, and it lands a property this PR must not undercut.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Vf6UjcJdQ8THD9NsBREAhR

@golimpio

Copy link
Copy Markdown
Contributor Author

Live end-to-end verification added — the gap noted in the PR body is closed.

The original body said this was not exercised against a live daemon, because doing so needed restarting the shared daemon several concurrent agent sessions were using. That turned out to be avoidable, and the attempt surfaced a trap worth recording.

The trap: pointing every XDG_* base at a scratch dir does not isolate a daemon. plumbRuntimeDir() (internal/cli/daemon_paths.go:40) deliberately uses os.UserCacheDir(), not paths.CacheDir(), and on macOS that resolves $HOME/Library/Caches and ignores XDG_CACHE_HOME entirely — so socket, PID and version files stay machine-global. The first run therefore attached to the shared daemon, which was built from main~1, and reported a confident failure of a fix that was not in the binary under test. HOME has to be overridden, and the harness now asserts daemon_info's source commit matches the build before trusting any result.

The evidence, two real plumb serve processes against one real daemon, one project pinned by two spellings:

main this branch
Both sessions agree on the workspace …/real/proj vs …/alias/proj ✓ both …/real/proj
A sees B in workspace_sessions ✗ "you are the only active session"
leave_note routes same-project ✗ cross-project, "the recipient is pinned to …/alias/proj"
B receives the message ✗ "No messages"

Rather than leave that as a one-off, it is now TestSmoke_TwoSessions_AliasedWorkspaceDeliversSameProject in cmd/smoke (third commit), which CI's integration job runs on both OSes. It is mutation-verified the same way as the unit tests: run against main it reproduces every symptom above. The alias is constructed explicitly rather than relying on the platform's own, so it means the same thing on Linux as on macOS.

The unit tests cover the resolution; this exists because the failure was a whole-pipeline one — pin → session registry → peer lookup → routing decision → store selection → delivery, five layers that each looked correct in isolation.

The remaining follow-up (the duplicate canonicaliser in internal/tools/boundary.go, still deferred behind #264) is now tracked as #273 rather than living only in this description.

@golimpio

Copy link
Copy Markdown
Contributor Author

Second independent review — one BLOCKING defect, now fixed, plus everything else it found.

The first review's finding produced a fix that nobody had reviewed, so a fresh reviewer went at the current state. It found the fix incomplete in one place that mattered.

BLOCKING — topology.Store.toRelative (internal/topology/store.go:275). The same shape as the five sites already migrated, and functional rather than cosmetic. s.workspace is the canonical root; the paths reaching it come from the agent (file_outline, workspace_symbols, topology_affected, the peer annotation). On a mismatch it handed the absolute path to WHERE f.path = ?, which matches nothing — every topology query for an aliased project returned empty, with no error and nothing logged. Verified against a real indexed store: real spelling: 1 nodes; alias spelling: 0 nodes. It also half-broke peerArea, whose directory this PR fixed but whose annotation went through here.

A dangerous regression in my own fix — resolveCLIWorkspaceDetailed. I had changed the markerless branch to SynthesiseRoot, which walks up to the nearest .git and, unlike Detect, has no $HOME guard. Under a dotfiles repo it escapes to $HOME — and that value reaches config.UnsetProjectValue, so plumb config unset --workspace ~/scratch would have edited $HOME/.plumb/config.toml, contradicting the flag's own help text. Reverted to plain canonicalisation. My stated justification was wrong too: the daemon only synthesises when auto_attach is on, so "matching the daemon" did not apply on that branch at all.

Also fixed: gitCommitRepo (same shape, missed), the TUI's detectWorkspaceFolder (compared against session.Folder, so an aliased cwd listed one project twice in the memory picker).

Two of my changes had zero test coverage — the reviewer applied both mutations together and go test ./internal/cli/ stayed green. rootFromClient's Detect-failure fallback and the CLI markerless branch now have real guards, and four migrated sites that had no aliasing test gained one. The mutation that covers them all: strip WorkspaceRel's canonicalising second pass, and six tests across four packages fail. (I had to redo one mutation round — the first attempt did not compile, so it proved nothing.)

Recorded rather than fixed: a project trusted under the old spelling reads as untrusted under the new one, since config.canonRoot keys on filepath.Abs alone. It fails closed and says so, which is the right direction for a trust grant to move on upgrade — now listed as the third upgrade effect.

Confirmed fine by the reviewer, by running: the two-pass shortcut is sound versus what it replaced (probed for symlink-escape, alias-inside-workspace, .. in the agent path, workspace-as-path, trailing separators); no Windows break from the slash-form return; a full-tree sweep of filepath.Rel/HasPrefix/TrimPrefix for other victims; the persisted-state migration against the actual schema; and the performance numbers — WorkspaceRel lexical hit 409 ns, Detect's added EvalSymlinks +10% on a function already at ~94 µs. The fast path is genuinely free.

make verify, lint-cross and -race green after all of it.

@golimpio
golimpio force-pushed the fix/canonical-workspace-root branch 2 times, most recently from 908fbad to 831d889 Compare August 12, 2026 08:34
Two sessions on one project reached by different path spellings — the macOS
/tmp → /private/tmp firmlink, a symlinked checkout, a $TMPDIR scratch project
— stored different roots, and every consumer compared them textually. So
leave_note routed a same-project message cross-project, where the default
config drops it unread while telling the sender the peer is in another
project; and the sticky-pin guard refused a redundant session_start naming
the same project by its other spelling as a pin steal.

workspacePool.Detect and SynthesiseRoot now return the root through a new
paths.Canonical, so the pin, the session registry, the boundary policy, the
collab store and the language-server pool key all agree by construction.
Canonicalising only the acquisition sites is not enough: route() compares
Detect's answer against primaryRoot, so a pin canonicalised alone would
acquire a second, never-reclaimed language server for every aliased path.

Not in NotifyKey — that puts a syscall on the delivery hot path and fixes
only the latency symptom, leaving the silent drop.

Closes #263
Canonicalising the workspace root fixes the consumers fed by the pool and
breaks the ones that compare the root against a path the AGENT names: one
side became always-resolved while the other stayed whatever spelling the
client knows. filepath.Rel then reports a file sitting in the project as an
escape, which every one of these callers treats as "drop it silently".

Worst case: hintRelPath returns "", so memory hint injection stops firing
for the whole project with no error and no log. relevant_memories answers
"not inside workspace" about a path the boundary guard admitted two lines
earlier, because the guard resolves both sides and the tool did not.

New paths.WorkspaceRel is the one place that question is answered. It
compares lexically first — no filesystem access on the common path, which
matters because hintRelPath is on the per-tool-call enrich path — and only
canonicalises both sides when the raw spellings disagree, since that is
either a real escape or a second spelling and only the filesystem can tell.
It also fixes a latent over-rejection: three call sites used a bare
HasPrefix(rel, ".."), which excluded an in-workspace directory named
"..config".

Found by independent adversarial review, verified end-to-end before and
after.
Two real plumb serve processes, one real daemon, one project pinned by two
spellings: the note between them must route same-project, arrive, and leave
an aliased path resolvable inside the workspace.

The unit tests already cover the resolution. This exists because the failure
was a whole-pipeline one — pin, session registry, peer lookup, routing
decision, store selection, delivery — five layers that each looked correct
in isolation. Run against main it reproduces every symptom; the alias is
built explicitly rather than relying on the platform's own, so it means the
same thing on Linux as on macOS.
Round two of independent review found the first fix incomplete in one place
that mattered and two that were cosmetic, plus two changes with no test at all.

BLOCKING — topology.Store.toRelative had the same shape as the five sites
already migrated, and this one is functional. s.workspace is the canonical
root; the paths reaching it come from the agent (file_outline,
workspace_symbols, topology_affected, the peer annotation). On a mismatch it
handed the absolute path to WHERE f.path = ?, which matches nothing: every
topology query for an aliased project returned empty, no error, nothing
logged. That also half-broke peerArea, whose directory was fixed but whose
topology annotation went through this.

resolveCLIWorkspaceDetailed's markerless branch is reverted from
SynthesiseRoot to paths.Canonical. SynthesiseRoot walks up to the nearest
.git and, unlike Detect, has no $HOME guard — under a dotfiles repo it
escaped to $HOME, and that value reaches config.UnsetProjectValue, so
'plumb config unset --workspace ~/scratch' would have edited
$HOME/.plumb/config.toml. The daemon-parity argument for it was wrong too:
the daemon only synthesises when auto_attach is on.

gitCommitRepo joins the migrated sites for the same reason.

Tests: the two changes that survived mutation (rootFromClient's Detect-failure
fallback, the CLI markerless branch) now have guards, and four migrated sites
that had none gained aliasing tests.
… TUI root

WorkspaceRel's doc claimed to be 'the one place' plumb answers where a file
sits within a project, which was never true — a walk rooted at the workspace,
or an LSP path against the root that server was initialised with, share an
origin and rightly still use filepath.Rel. It now says when to reach for it
instead, and states plainly that it answers a NAMING question: because the
lexical pass goes first, a path inside the workspace lexically but escaping
via a symlink reads as inside. That matches what these callers always did and
is harmless for choosing how to display or match a path, but it is not a
containment check and the doc should not read like one.

The TUI's detectWorkspaceFolder is canonicalised because it is compared
against session.Folder: launched from an aliased cwd it listed one project
twice in the memory picker.

Also records the third upgrade effect in the changelog — a project trusted
under the old spelling reads as untrusted under the new one, since the trust
store keys on filepath.Abs alone. It fails closed and says so.
…the TUI root

Two findings from a third independent review of the delta the second review
caused — the same position where round 2 found round 1's fix wanting.

toRelative lost its 'if filepath.IsAbs' guard when it moved onto
paths.WorkspaceRel, which put an EvalSymlinks chain on a call that returns its
input unchanged. WorkspaceRel can never answer for a relative path — Rel errors
when the base is absolute and the target is not — so both passes fail after
resolving the workspace root. The fswatcher hands Enqueue a relative path for
every filesystem event, so a checkout or an npm install paid an lstat chain
thousands of times on the single consumer goroutine: measured 2.2 ns guarded
against 9.5 us unguarded. Behaviour is identical either way, so this is not
unit-testable; a benchmark documents the line instead of a test that would only
appear to guard it.

detectWorkspaceFolder's canonicalisation was the one production line on this
branch with no test — reverting it left the whole internal/tui package green,
so 'every test mutation-verified' was not true of it. It has a test now.
@golimpio
golimpio force-pushed the fix/canonical-workspace-root branch from 831d889 to 0200076 Compare August 12, 2026 12:51
@atlas-from-plumb

Copy link
Copy Markdown
Collaborator

Merging on the user's explicit instruction. Recording how this was reviewed, since the merge is not gated on a human approval.

Three independent adversarial review rounds, each on a different state of the branch, because each round's fix created code the previous round had not seen:

  • Round 1 (commit 1 only) — found a BLOCKING regression: canonicalising the root broke every consumer comparing it against an agent-supplied path. hintRelPath returned "", so memory hint injection stopped firing entirely, silently.
  • Round 2 (commits 1–3, incl. round 1's fix) — found a second BLOCKING defect, inside round 1's fix: topology.Store.toRelative handed an absolute path to WHERE f.path = ?, so every topology query for an aliased project returned empty with no error. Also caught that my own resolveCLIWorkspaceDetailed change escaped to $HOME under a dotfiles repo, where it reached config.UnsetProjectValue.
  • Round 3 (the unreviewed delta + both rebase conflict resolutions) — cleared the SynthesiseRoot composition by differential run against the peer's Clean-only version (byte-identical on the pathological input, so the .. divergence is the peer's documented Clean, not my Canonical), and found two SHOULD-FIXes, both now applied: a lost IsAbs fast path that put an lstat chain on the fswatcher hot path for every filesystem event (2.2 ns → 9.5 µs), and the one production line on the branch with no test at all.

Every fix is mutation-verified — each canonicalisation site, each behaviour of Canonical, and each half of WorkspaceRel reverted in turn, failing exactly the tests that claim to cover it. One test was rewritten after mutation showed it had become a tautology; one production change is deliberately guarded by a benchmark rather than a test, because its behaviour is identical either way and only the syscall count differs — a test there would only appear to guard it.

Beyond unit coverage: TestSmoke_TwoSessions_AliasedWorkspaceDeliversSameProject drives two real plumb serve processes against one real daemon and is run by CI's integration job on both OSes. Against main it reproduces all four symptoms of #263.

verify (both OSes), test-race, integration (both OSes), coverage floor and govulncheck all green on the rebased branch. Merging with --rebase to preserve the Atlas commit authorship.

Follow-up left open and tracked: #273 (two canonicalisers that disagree on the #181 case).

@golimpio
golimpio merged commit 41de017 into main Aug 12, 2026
7 checks passed
@golimpio
golimpio deleted the fix/canonical-workspace-root branch August 12, 2026 12:59
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.

Workspace roots are not canonicalised: two spellings of one project silently drop same-project messages

2 participants