fix(daemon): keep a session that an open took over alive at record stop
Summary
record start --scope device on a session the daemon does not hold creates one and marks it record-only. That mark tells record stop the recording is the session's only owner, so retiring the record is that stop's teardown. The mark stayed set whatever else the session later took on, so a later record stop retired a session that by then owned a live resource. SessionStore.retire runs no teardown, so that resource stayed owned by a record no command could address and no close could reach.
Fixed at the consumer, per the needs-changes review on dd4b22c0: promotion at every attaching path was fragile — the next path would forget it. Both record-only retire sites in the record lane now go through one helper that re-reads the session and retires only while it is still record-only and sessionHasOwnersBeyondRecording finds nothing else that owns it — appBundleId, deviceClaim, lease, appLog, audioProbe, perfCapture, trace. Anything else keeps its record and stays releasable by close, the command that owns teardown. promoteRecordOnlySession is gone and session-open-state.ts is back at base — no attaching path changed, so none can be missed. Five files.
The predicate lives beside SessionState and mirrors isIdleExpirableSession (server/session-idle-expiry.ts), which asks the same "does this session still hold anything" question over the same capture fields.
agent-device record start run.mp4 --scope device --session s1 # creates record-only s1
agent-device audio probe start 120000 1000 --session s1 # s1 gains a capture
agent-device record stop --session s1 # stops the recording; s1 survives
agent-device audio probe stop --session s1 # the capture is still reachable
agent-device close --session s1 # releases it
Two scope notes:
- The stop response still reports
recordOnlySession: true. That field is the recording's own start-time fact (screenRecordingStartInput→ completion), not the retirement decision, and it stays one. - A session that stayed record-only but holds a remote lease is now left for
closeinstead of being retired by its stop. Retirement drops the record withoutclose's lease release, which is the same class of leak in the remote lane; the predicate namesleasefor that reason.
Validation
Base f7879cf2d266ee1dd476d0342b2819bdeceb647b, head 950b3dab4035a0cc88a96452a5f98355feeffe04.
request-router-record-only-retirement.test.ts runs four stories through one router, one daemon, one session, and attaches every resource with the command that really attaches it (the dd4b22c0 test injected an appLog by hand, which is why it could not see either leak). Verified failing on dd4b22c0 by restoring that head's four production files under this suite, then green at this head:
| Story | On dd4b22c0 | Here |
|---|---|---|
record start → open → logs start → record stop → close | expected undefined to be true (origin cleared) | passed |
record start → audio probe start → record stop → audio probe stop → close | expected undefined to be defined at sessionStore.get(SESSION) — the stop retired the session with the sampler live | passed |
record start → trigger-app-event → logs start → record stop → logs stop → close | same, with the log stream live | passed |
record start → record stop, nothing attached | passed | passed (still retired; the recording's durable record terminalizes) |
- Neighbours:
record-runtime-request/-response/-start/-stop-recovery,leased-recording-retention,session-open-execution-runtime(back at base,toBe(true)),request-router-record-runtime-lock,session-relaunch-guards,daemon-runtime-recording-teardown—7 files / 71 tests passed. Wider sweep:packages/daemon/src/{sessions,server,core}—333 files / 2462 tests passed. pnpm typecheckexit 0 (the lane that caught the first draft's type errors — vitest alone does not typecheck).pnpm check:layeringOK: all 31SessionStatefields classified with every write inside its declared owner (R7).check:doc-proseclean,pnpm formatclean,oxlintclean,command-doc-coverage12 passed.- Changed-line coverage over the two production files: the retire helper covered on both guard branches and the retire, both call sites covered,
sessionHasOwnersBeyondRecordingcovered. pnpm check:affected --run --head 950b3dab40: see the disposition comment for the receipt.- The live route (
record start→logs start→record stop→close) was already proven end-to-end on iPhone 17 26.2 with the worktree build0.21.36-devfordd4b22c0: the stop returnedrecorder: confirmed/nativePathDisposition: retired, the session stayed listed with its claim, andclosecleared it withclaims: []and acompletedapp-log envelope. It is not re-run here: the commands still issue the same requests and the recording's own finish/terminalize path is untouched — this change moves only the one retire decision.
PR #4065 is already merged (35d5d6bb54); its only record-runtime.ts hunk is already in this checkout, so no conflict here.
Round-1 independent review requested changes (audio-probe and app-event → logs routes); this head addresses it and round-2 review is approve with no blocking findings. The four findings it adjudicated as follow-ups are listed in this comment, and every review thread is answered and resolved.