fix(daemon): address cwd-scoped sessions by their store key, not their public name - #2068
fix(daemon): address cwd-scoped sessions by their store key, not their public name#2068NicolasBataille wants to merge 1 commit into
Conversation
…r public name An implicitly cwd-scoped session is NAMED `default` and STORED under `cwd:<hash>:default`. Three surfaces built caller-facing text from the name, so each pointed at something that does not exist: - `DEVICE_IN_USE` reported `session "default"` and its recovery hint said `agent-device close --session default`. `--session` marks the session explicit, which disables cwd scoping, so following the hint addressed a different, absent session: `SESSION_NOT_FOUND`, and the device stayed held — the residual half of callstack#2031 that callstack#2057's superseded-daemon reconciliation does not cover. - `session list` reported `sessionStateDir` and `runnerLogPath` under `<state>/sessions/default`, a directory that is never created; the session's real artifacts sit in `<state>/sessions/cwd_<hash>_default`, which is what `open` already answers with. `SessionStore.entries()` exposes the key alongside the record, and `buildSessionRecoveryHint` takes the session's address explicitly. Call sites that only hold the session the current request named pass its name — the address there — so their text is unchanged, as are explicitly named sessions everywhere. Live before/after on an iOS simulator: `open` (cwd-scoped default), then `open --session qa` on the same device. Before, the hint's own `close --session default` answered `SESSION_NOT_FOUND` and the retry failed again; after, the hint names `cwd:8bea844ab16aa9b3:default`, that close releases the device, and the retry opens. Refs callstack#2031, callstack#1394
|
[P1] Thread the resolved session address through every recovery producer instead of falling back to |
Refs #2031, #1394 (deliberately not
Closes— see below)Status of #2031
#2031 is closed, by b1ee2a7 (#2057), which is in
main. That fix covers the reportedmechanism: a replaced-but-still-running daemon keeping the device claim, now classified
owner-daemon-supersededand reconciled like a dead owner. I re-read that diff; it does what theissue's headline asks.
The follow-up comment on the same issue describes a different mechanism, and that one still
reproduces on
main. This PR fixes that half.Root cause
resolveEffectiveSessionNamescopes an implicit session to the caller's worktree: a session openedwithout
--sessionis nameddefaultand stored undercwd:<hash>:default. The two arenot interchangeable, because passing
--sessionsetssessionExplicit, which disables scoping —so
--session defaultaddresses the literal keydefault, a different session.Three surfaces built caller-facing text from
SessionState.name:buildDeviceInUseBySessionError→Device is already in use by session "default".buildSessionRecoveryHint→agent-device close --session defaultpublicSessionInfo(session list) →sessionStateDir: <state>/sessions/defaultEach names something unreachable. Reproduced on
main(iOS simulator, this clone):That is the comment's report exactly: a
DEVICE_IN_USEnaming a session the documented recoverycannot close. The same mismatch makes
session listreport asessionStateDirandrunnerLogPathunder
<state>/sessions/default— a directory that is never created — while the session's realartifacts,
runner.logincluded, sit in<state>/sessions/cwd_<hash>_default, which is whatopenalready answers with. An operator following
session listto read a runner log finds nothing there.This is the #1394 family: a session's identity and its address diverged, and the surfaces that
report identity kept publishing it as an address.
Fix
SessionStore.entries()exposes the key alongside the record.findNewSessionDeviceConflictreads the conflicting session through it and passes the key tobuildDeviceInUseBySessionError, which reports it in the message, indetails.session, and inthe hint.
buildSessionRecoveryHinttakes the session's address explicitly. The two other callers(
session-selector.ts,request-lock-policy.ts) act on the session the current request named,where the name is the address, so they pass
session.nameand their text is unchanged.session listresolvessessionStateDir/runnerLogPathfrom the store key.Explicitly named sessions are unaffected everywhere; the
DEVICE_IN_USEhelp-conformance sample isbyte-identical (its fixture session is named
checkout).Tests
Three new files; all five assertions were observed red against the pre-fix code.
src/daemon/session-recovery-hints.test.ts— device-in-use, selector-conflict and recordingrecoveries all name the address; an explicitly named session addresses itself unchanged.
src/daemon/handlers/__tests__/session-open-device-in-use.test.ts— the producer reports theaddress in message, details and hint.
src/daemon/handlers/__tests__/session-inventory-scoped-paths.test.ts—session listkeeps thepublic
namebut resolves paths tocwd_<hash>_default.pnpm check:quick,pnpm test:unit(8093 passed) andpnpm check:layeringare green.Live check
Same sequence as the repro above, after the fix:
session listnow reports.../sessions/cwd_8bea844ab16aa9b3_default, which exists.The issue's original close→plain-open sequence does not reproduce on
main— a plainclosefollowed by a plain
openin the same worktree succeeds. Its--device "iPhone 17"variant nowalso raises
AMBIGUOUS_MATCHwhen two simulators share that name, which is separate and correct.What a maintainer might push back on
close --session cwd:<hash>:defaultis ugly, and it isa hash the caller never chose. It is the value that works today, and a hint that works beats a
hint that reads nicely. The alternative — teaching
close/opento resolve a public nameagainst scoped keys within the caller's scope — is a bigger change to
resolveEffectiveSessionNameand its
sessionExplicitcontract, and belongs in its own PR. Say the word and I will draft itinstead.
entries()onSessionStore. The key can also be reconstructed assession.sessionScope ? cwd:<id>:<name> : name, which needs no new accessor — but thatreconstruction is wrong for exactly the case session list omits cwd-scoped sessions opened via replay's internal open dispatch #1394 documents, a session whose
sessionScopewasnever propagated. Reading the key the store actually used cannot be wrong.
session.nameexplicitly, which looks likeboilerplate. Making the parameter optional would hide the question at the sites most likely to be
wrong next; the comment at each site records why the name is the address there.
its own issue first — the reproduction above is self-contained.