Repository navigation
Proposal: centralize daemon retirement and session lifetime ownership #3116
Description
Activity
Review against
620cecb208None of the cited files changed between the
e4716e6f51baseline and620cecb208, so the links below use the baseline. The direction and the design gates hold. The 19-site inventory is accurate. The findings below sharpen four gates and add two gaps: delete sites, and the shutdown path that runs outside request locks.1. Make lock possession the single authority for registration-adjacent state
Shutdown report (#3104).
- The daemon writes the report while it holds the lock, and releases the lock at L816.
- It clears the report right after acquiring the lock, before
daemon.jsonis published. - So fix(daemon): a superseded daemon overwrites the shared daemon-shutdown.json #3104's direction, reusing
readRegisteredDaemonOwnership, cannot fence the clear: at that point no record names the new daemon. - Lock possession fences both mutations.
- With a sound lifetime lock, and clients that refuse removal when exit is unconfirmed, a daemon that still holds the lock cannot have been superseded. The superseded-writer case then reduces to two requirements: write before release, and an assert-still-held check.
Client retirement and recovery. This answers "specify how exclusion covers each destructive filesystem operation".
- After confirmed exit, the client acquires the registration lock. Host-kit reclaims the dead claim.
- The client then retires
daemon.jsononly if the record names the dead identity, and releases the lock. - The owned replay directory follows the same pattern: acquire the lock inside it, then remove the directory. The directory is a private
mkdtemp, so its only possible claimant is the daemon this client spawned, and no separate exclusion mechanism is needed. recoverAbandonedDaemonRegistrationtherefore becomes acquire, retire, release.cleanupStaleDaemonLockIfSafeandrecoverDaemonLockHoldershould be deleted, not migrated: only host-kit acquisition should ever reclaim a lock.- Consequence to design: while a client briefly holds the lock, a daemon that starts concurrently exits with "lock held" (L651). The client that started it must treat that exit as retryable.
2. Host-kit gaps for a lifetime daemon lock, beyond the two race fixes
- No fail-fast acquisition.
acquireProcessLockloopswhile (Date.now() < deadline), sotimeoutMs: 0makes zero attempts and throws a timeout.- The daemon needs exactly one attempt.
- No holder inspection.
- Clients read the lock holder today:
isDaemonLockHeldByAnotherDaemonand failed-startup cleanup. - Host-kit exports no read API. Either it gains one, or these readers move to
daemon.json.
- Clients read the lock holder today:
- Duplicated lock type.
- Liveness becomes fail-closed.
isAgentDeviceDaemonProcessreturnsfalsewhen the start time or the command is missing or unreadable.acquireDaemonLockthen steals the lock, so today's behaviour is fail-open.- Host-kit's
classifyOwnerLivenessis fail-closed. The migration is safer, but a holder whose liveness cannot be read will then block every daemon start. - The cutover gate should define the operator path for that case: a hint, a command, or both.
3. Concrete evidence for the R7 blind spot
appLogFailureis inSTORE_OWNED_SESSION_STATE_FIELDS, which claims that the store establishes the field and nothing mutates it later.- But
app-log-session-resource.ts:120,138changes it through a spread rebuild outsidesession-store.ts. - R7 walks only assignments, so it misses this. That makes the site a ready-made fixture for the "plant a forbidden writer" requirement.
4. Set-site inventory: confirmed, with notes
I enumerated the sites independently. All 19 sites, their categories and the 7/8/4 counts match. No
SessionStore.setcaller is missing. Notes:- Capture-kit port.
- Capture-kit receives the full
SessionStorethroughDurableCaptureSessionStore<S>(definition.ts:17), with no adapter. - That narrowed port is the natural place for the lifetime-checked write.
- Capture-kit receives the full
- Command-layer writes.
- Command-layer
runtime.sessions.setgoes throughcreateDaemonRuntimeSessionStore(runtime-session.ts:47). - It reaches three of the table's sites, plus a no-op at
screenshot-runtime.ts:128. - Include that adapter in the "lower capture publication interface" gate.
- Command-layer
- Fallback re-inserts.
- Several rebuilds re-read with
get(address) ?? session:session-perf-runtime.ts:271and the teardown callers atsession-teardown.ts:34,211,226. - The
?? sessionfallback re-inserts a deleted record. Name these sites under "a late update must not insert a deleted session".
- Several rebuilds re-read with
- Stale-record spreads.
- The app-log rebuilds at L120 and L138 spread a record the caller already holds, without re-reading the store.
- They can therefore revert another rebuild that landed in between. "Rebuild from the latest record" applies to them.
- Same-object re-set removal changes behaviour slightly.
- The re-sets are no-ops only while the stored object is unchanged.
- After any rebuild, a stale holder's re-set reverts the store today. Without the re-set, the write lands on a detached object instead.
- That is strictly better, but say so in the PR.
5. Delete sites are not inventoried
Guarded retirement needs the same classification for the 8 production
sessionStore.deletecallers. One already looks wrong:lease-lifecycle.ts:80and:103passsession.nameto teardown and todelete, althoughparams.sessionName(the address) is in scope.- For any session whose address differs from its name, two things go wrong:
- Teardown writes, including the capture-kit slot clears, target the wrong slot.
- The delete misses, so the expired record stays live.
- Either prove that leased sessions always have address === name, or pass the address.
6. Add the one schedule that runs outside request locks
The qualifier that ordinary request locks serialize
openandcloseis correct. Daemon shutdown is the exception:teardownDaemonSessionstakes no execution locks.closeDaemonServersforce-closes after 5 s.- Socket close cancels in-flight requests cooperatively (
transport.ts:54), but nothing waits for their handlers to return.
Proposed acceptance row: shutdown teardown while a canceled request (e.g. a long
open) is still running. It is the place where lifetime checks are not backed by request locks, so it is the right place to test whether the replacement schedule is reachable.7. The lifetime-representation gate is nearly closed already
Taken together, the gate's constraints leave one practical representation: a stable per-lifetime entry object held in the store map, which refs carry alongside the record they captured. The constraints are:
- identity lives at the canonical store entry;
- no generation scheme;
.sessionis not a live getter;- newer rebuilt records of the same lifetime stay resolvable.
Stating that representation explicitly would let the gate close without re-arguing the alternatives.
Updated the proposal after checking the review against main at
35b7b4113d7071804acd8faf5528a771266762c3. The cited implementation files are unchanged from the original audit.Changes mapped to the seven review points:
- Lock authority and retirement: report clearing/writing now belongs to the held acquisition, including startup before metadata publication. Client retirement confirms exit, acquires the same lock, matches metadata and retires under exclusion. Private replay-directory removal uses that lock too. Both client stale-lock recovery helpers are deleted in the plan; startup overlapping client-held retirement receives a bounded, typed retry.
- Host-kit integration: added nonblocking acquisition, typed holder inspection and a held-capability assertion. The proposal explicitly rejects using
timeoutMs: 0as an attempt with today's loop. Unknown liveness retains state and gets an operator hint/recovery policy. Legacy lock types/parsers are removed at cutover; parsedstartedAthas no production consumer and lockversionhas no reader. - R7: added the concrete
appLogFailurecounterexample, its owning transition and planted forbidden-writer coverage for spread/rebuild paths as well as assignment. - Write boundaries: named the capture-kit port and command runtime adapter, including screenshot's no-op setter. Named the perf/teardown fallback sites, required latest-record rebuilding, and documented how deleting stale re-sets prevents rollback but still requires routing valid writes through the lifetime owner.
- Deletion: added the full inventory and migration policy. I found six direct production
SessionStore.deletecallers; the store also contains two internalMap.deleteoperations. Lease expiry must carry the stored address through teardown and retirement, with a scoped-address/public-name regression case. The helper-contract defect is explicit without claiming an unproved production lease schedule. - Shutdown: added real forced-close/cooperative-cancellation overlap, plus timed-out cleanup continuing after retirement. The tests must also cover unpublished drafts: lifetime identity alone cannot prevent their first publication after shutdown admission closes.
- Lifetime representation: explicitly chose a stable canonical entry, carried alongside the captured record.
.sessionstays a captured record; same-lifetime refresh resolves the entry's latest record. The representation-choice gate is closed; holder migration/enforcement still needs evidence.
The body includes the updated interfaces, inventories, lock-retirement sequence diagram and acceptance matrix. It remains
needs-triagebecause the hardened lock algorithm and enforceable mixed-version cutover still need to be settled and proved.Re-review of the updated body (
c237027737)The revision covers all seven points. The lifetime representation is settled, and the remaining open gates are the right ones: the hardened lock algorithm and the mixed-version cutover. One correction to my earlier comment: six delete callers is right. My 8 counted the two
Map.deletecalls insideSessionStore.delete.Five things are still worth settling in the body.
1. Same-object re-set removal is no longer an independent cleanup
Removing a re-set does not prevent a lost update. It changes which update is lost:
- Today, a stale holder's re-set rolls the store back, which loses the rebuild.
- After removal, the stale holder's mutation lands on a detached object, which loses the mutation.
The body now pairs removal with owner-write migration, which is correct. But item 3 still sits under "Independent small cleanups … not prerequisites", and the first delivery checkbox still bundles it with the path and forwarder work.
Either:
- move it into the session migration; or
- keep as independent only the sites shown not to straddle a rebuild within one request.
2. Startup contention needs the holder's role, not only its liveness
The launcher keeps waiting after its own daemon exits early only when
isDaemonLockHeldByAnotherDaemonis true. That check requires the holder's command to be an agent-device daemon.When a retirement client holds the lock, the launcher therefore returns
early_exittoday. The "retry after release" contract needs one of these:- Holder role in the record. The host-kit inspection reports the holder's role (daemon or retirement). This means the owner record must carry a role, and
ProcessLockOwnerhas no such field. - Relaunch on release. The launcher relaunches when its own daemon lost and the holder released without publishing
daemon.json.
Pick one in the "complete registration job inputs" gate.
3. The stop policy belongs in
DaemonRetirementInputThe two client paths stop the daemon differently:
- Timeout reset sends SIGKILL immediately (
resetDaemonAfterTimeout). - Takeover sends TERM, then KILL.
The earlier sketch carried this as
mode: 'graceful' | 'force', and the current input drops it. Keep it in the input, so that no caller pre-signals before calling the job.4. Enforce R7 at the update boundary, not by tracing values
As written, the R7 requirement amounts to a data-flow analysis:
- trace locally bound replacement records;
- distinguish construction from update;
- ignore spread fields that were copied unchanged.
Once every published-lifetime write goes through the owner's update operation, two simpler rules cover the same ground:
- Check the field keys that each call site of the update operation overrides against
SESSION_STATE_FIELD_OWNERS. - Allow building a
SessionStatefrom an existing one (by spread) only insession-store.tsand in the declared draft constructors.
The
appLogFailurefixture fails both rules as it stands. That gives the same proof with a much smaller scanner.5. The owned-directory release needs a test, not new behaviour
Host-kit release already treats a missing owner file as
not-owner. It returns without recreating anything (process-lock.ts:176-179)."Release must handle the deliberately removed private lock" is therefore already true. Pin it with a test rather than specifying it as a change.
None of these blocks the current design gates. Keeping
needs-triageuntil the lock algorithm and the cutover policy are proved is the right call.Addressed the five follow-up points in the body after checking main at
c237027737bf4737e324adae2146ba63891152a3:- Re-set removals: moved all seven into the session migration and separated their delivery item from path/forwarder work. The proof now requires both the intervening rebuild and the stale holder's later valid mutation to survive.
- Startup contention: chose relaunch after release. The losing child reports a dedicated internal busy disposition through its monitored exit result; the launcher waits regardless of holder role, attaches to a validated winner if one publishes, or launches a fresh attempt after release. Deadline/attempt bounds remain intact. Generic early exits remain distinct. No holder-role field is required in host-kit.
- Stop policy: restored required
mode: 'graceful' | 'force'inDaemonRetirementInput, with TERM-then-KILL for takeover/ordinary shutdown and KILL-first for timeout reset. The job owns every signal and awaited exit. Abandoned recovery has no stop mode. - R7: replaced value tracing with checks of explicit update keys and a restriction on whole-session spreads to the store and declared draft constructors. Opaque variable patches, computed keys and patch spreads are rejected; field-owner functions use explicit patches. Capture slot adapters participate in this migration, and direct-assignment checks remain. The app-log fixture covers both forbidden spread syntax and unauthorized update keys.
- Owned-directory release: now states that missing-owner handling already exists. The task is to pin it with a release-after-directory-removal regression test.
The hardened lock algorithm and enforceable mixed-version cutover remain the open design gates, so the issue stays
needs-triage.Completed and user-landed. All seven keeper PRs are verified included in main at
2212eacf5be976acc78378a7e65539be5fc81489; #3127 was the final merge. #3116, #3104 and #3105 are closed for their implemented scope. Request-scoped timeout recovery remains open in #3177. No PR was merged by the agent.PR Scope Dependency Head / status #3186 Artifact paths main Landed #3127 Daemon startup and retirement main 5b12609ef5— landed; local gates, 21 CI checks and human approval complete#3135 Session lifetimes and owned resources main 9252d8251f— landed; inclusion in main verified#3140 Session journals, observations and request health #3135; #3127 prerequisite 42ce911992— landed; inclusion in main verified#3144 Session replay, open, interaction and shutdown #3140 62d6af6340— landed; inclusion in main verified#3152 Complete explicit-lifetime fixture migration #3144 d5472b0523— landed; inclusion in main verified#3155 Session state ownership enforcement #3152 3312b9869b— landed; inclusion in main verifiedOriginal work split (the five session groups are now landed):
Loadingflowchart LR M[main] --> R[3127 Registration ownership] M --> S[3135 Session lifetimes and resources] S --> J[3140 Journals and observations] R -. prerequisite .-> J J --> F[3144 Open, replay and interactions] F --> T[3152 Fixture migration] T --> G[3155 R7 enforcement]
The owning helpers enforce process identity, session lifetime, and cleanup sequencing. Paths are ordinary functions; launch identity belongs to the monitored launch; optional alert/settings journaling uses the shared action recorder.
The latest registration correction recovers a missed initial birth probe only from an active monitored child and agreeing held-lock/registration records. Kernel birth verification before signaling remains required. Both sendToDaemon controls were red before the fix; 69 focused controls and 6,336 related tests plus documentation/tooling passed. Original regression proof.
The omitted alert/settings recorder is deleted. Completed native actions can skip journaling after their lifetime ends without writing to a successor. Both alert controls were red; clipboard and first-read button-shape mutants were red; all 52 focused controls passed. #3144's complete affected gate passed 13,443 tests with one skipped plus documentation/tooling. Descendant gates passed (2,177 related tests for #3152; selected static/tooling and twelve documentation controls for #3155).
Native screenshots and observed JSON prove the real Cancel/Open recognition shape and refusal when the app runs behind an unresolved prompt. They do not prove successful acceptance. The controlled setup pre-raised a URL prompt and then the CLI sent its URL, so attribution to a new native regression is unproven. The reviewer accepted current-head iOS Smoke Tests as the alternative single-dispatch route evidence; the older double-handoff result remains a qualified negative observation. The task-created simulator, sessions, daemons and runners are cleaned up.
Current-head iOS CI at
62d6af6340succeeds through cold URL open, destination wait and event/payload reads (artifact 11301510447), followed by snapshots, interactions and close. Verified evidence reply. The scenario's accepted flag is notdata.launchConfirmation; that response field was not separately retained. The reviewer accepts this run as satisfying the native-route requirement. All five session keepers have external ready-for-human labels and successful current-head CI replacements; canceled duplicate runs are not treated as passes.#3127 is now rebased onto the landed session stack at
052cba83c1, preserving #3200 cancellation and awaited timeout retirement. The marker conflict preserves scoped reads, owner-checked clearing of owned expired markers, strict finite timestamp/source-path validation and fail-closed cleanup scanning. Both registration fixtures pass their owner argument. A real 409/hanging-health/abort control proves typed cancellation, one RPC and zero timeout-retirement calls; removing the post-probe abort check is red. All 96 focused controls and the exact-head affected gate pass: 6,579 related tests in 803 files, twelve documentation controls and selected runnable tooling checks. Conflict-resolution review evidence. The final head received fresh review and ready-for-human approval with all 21 CI checks passing, then the user merged it. Already merged branches were not rebased or republished.#3140's optional publish-time marker-clear suggestion is retained as a follow-up: ADR 0012 defines repair evidence by scoped session key and clears it on fresh armed replay, while failed-commit evidence also protects private directory retention. Lifetime diagnostic invalidation must not erase durable failure evidence. Cross-platform fallback lookup remains excluded to preserve address isolation. Review disposition.
Physical recording-health remains blocked by Xcode signing. The phone was connected/unlocked, but no recording started. Simulator evidence cannot replace that predicate. This remains an accepted, explicitly unverified risk; it is not reported as passing.
The architecture audit also identifies client-side host-wide timeout pkill and shared-daemon reset as a separate cleanup-authority gap. It was audited, not changed by this work, and remains tracked separately in #3177.
The registration/session composition now preserves the scoped repair-marker API and strict parser together; both registration fixtures use the owner argument. The earlier audit resolution remains historical evidence; historical tree equality does not describe these later corrected heads. The 21 superseded PRs remain closed with their evidence intact. Implementation, required review/CI evidence and user-landed inclusion are verified. The physical signing limitation is preserved as an accepted risk, and the recurring review monitor is stopped.
Completed and verified in main at
2212eacf5b: all seven keeper PRs are landed. #3127 was the final merge, with fresh human approval, all 21 GitHub checks passing, 6,579 related tests, twelve documentation controls and selected local gates passing on its exact head.The final merge preserves both scoped repair-marker ownership and strict cleanup validation. Registration/retirement, host-kit lock exclusion, session lifetimes/resources, journals/observations, caller/fixture migration and R7 enforcement are complete. #3104 and #3105 are closed with their specific ownership evidence.
Physical recording-health verification remains explicitly unperformed because of Xcode signing; review accepted that risk. The unresolved request-scope policy—host-wide runner cleanup/shared-daemon reset after one request's timeout—remains open in #3177. This issue does not claim that broader policy is fixed.
The final checklist and implementation record are reconciled. Recurring reviews are stopped; temporary validation worktrees are cleaned while logs, native evidence and historical commits are retained. Closing as completed within the agreed scope.
Put identity checks, sequencing and cleanup inside the functions that own the work. Callers should be able to retire a daemon or finish a session resource with one operation, without repeating the safety rules.
Priority: registration and the daemon lock first, session lifetimes second. The payoff is correctness and fewer caller obligations. Changing classes to factories is optional.
Status: completed and landed. #3186, #3135, #3140, #3144, #3152, #3155 and #3127 are merged; inclusion is verified in main at
2212eacf5be976acc78378a7e65539be5fc81489. The implementation record retains helpers, diagrams, exact-head gates, review and native evidence.The contract below preserves the accepted baseline and inventories. Physical CoreDevice recording-health verification was not run because Xcode signing was unavailable; review accepted this recorded risk. Request-scoped timeout recovery remains separate in #3177.
Baseline: main at c237027737, verified 2026-10-02. The cited source files are unchanged from e4716e6. Recheck the inventories against each implementation base.
Make each function own the whole operation
The repeated problem is an address passed separately from the resource: registration paths plus a process identity, or a session name plus a record. Bind them at the owning operation.
stopAndRetireDaemon/recoverAbandonedDaemonRegistrationSessionRefflowchart TD C["Client lifecycle"] --> R["Shared registration jobs"] D["Daemon lifecycle"] --> R R --> H["host-kit process identity and lock"] Q["Routes and session lifecycle"] --> J["Recording and resource jobs"] J --> S["One session lifetime owner"] J --> A["Artifact paths and journal operations"] O["Device-claim recovery"] --> AUse ordinary functions and composition. Keep private state where ownership needs it, in a small class or factory. Keep adapters that translate records or restrict authority; replacing one broad store with several broad objects does not reduce caller obligations.
Retire a daemon safely
Deepen the existing registration module into a shared root module, such as
src/daemon-registration.ts. It already distinguishes matching, replaced, unproven, absent, unreadable and ownerless records. Client runtime imports from daemon internals are forbidden, so this implementation must sit above both.Move takeover, replay shutdown, failed-startup cleanup and timeout reset to the shared operations. Timeout reset starts a fallback stop without awaiting it, then removes metadata. Awaiting the existing stop function is also insufficient: it can decline signaling or exhaust its exit wait.
Return proof and cleanup outcomes
The proposed boundary is:
Finalize the observation, private-directory capability, partial-failure details and error mapping before implementation.
absentmeans metadata is absent under the lock; it does not prove process exit or authorize directory removal.The function owns signaling and configured TERM/KILL wait budgets:
forcegracefulNo caller pre-signals or starts a fire-and-forget fallback.
Use one retirement sequence
daemon.jsononly if it still names the confirmed dead identity. Retain replaced, unreadable or unproven metadata.Abandoned-registration recovery uses the same protected core. It never rediscovers and stops a live winner. Even an
absentresult requires acquisition and inspection under the lock.sequenceDiagram participant C as Client retirement job participant P as Observed daemon lifetime participant L as Shared registration lock participant M as Daemon metadata C->>P: Verified stop and awaited exit P-->>C: Confirmed termination C->>L: Acquire; host-kit recovers dead claim L-->>C: Held acquisition C->>M: Read current registration alt Record names the dead identity C->>M: Remove while lock is held else Replaced or unproven Note over C,M: Retain current metadata end C->>L: Release this acquisitionProcess identity permits signaling and matching a record. Lock possession prevents concurrent mutation. Both are required; checking identity and then unlinking without the lock leaves a race.
Bind daemon writes to its acquired lock
Daemon-side registration functions close over the actual acquisition. They assert it is still held immediately before each metadata/report mutation. Never reconstruct release authority from a path or PID.
Preserve these sequences:
Report clearing happens before
daemon.jsonexists, so metadata ownership cannot authorize it. A released or lost acquisition cannot write or clear a report. Keep report readers, contents and best-effort reporting, and preserve primary errors plus normalizedhint,diagnosticId,logPathand typed details.Retire only the private replay directory you own
The directory capability comes from private
mkdtempcreation and binds to its startup/daemon attempt. Before deletion:Use the same lock for exclusion. This authority does not permit recursive deletion of a shared state directory. The immediate defect is deleting a private replay directory beneath its still-running daemon; normal successor occupancy has not been established.
Host-kit release already handles a missing owner file as
not-ownerwithout recreating the path. Add a regression test after guarded directory removal; no new release behavior is needed.Relaunch startup after a transient holder releases
Failed-startup cleanup stays bound to its own attempt and cannot stop or remove a winning contender. The existing startup wait handles another daemon as holder; the replacement must also handle a client holding the lock during retirement:
lock-busydisposition through a shared named exit code and monitored launch result. Generic early exits, clean exits and stderr text are not contention proof.Do not add daemon/retirement roles to host-kit's generic owner record.
After migration, delete raw info/lock removal exports,
cleanupStaleDaemonLockIfSafeandrecoverDaemonLockHolder. Every metadata/report mutation belongs to a held registration owner; every lock reclaim belongs to host-kit acquisition.Replace the daemon lock
The bespoke daemon lock permits both contenders to succeed:
These are demonstrated unsafe interleavings, not frequency measurements. Use one host-kit process lock for the daemon's whole lifetime, with acquisition identity for reclaim/release.
The host-kit primitive needs:
timeoutMs: 0makes zero attempts in the existing loop. Bounded client acquisition uses the same protected reclaim mechanism.isAgentDeviceDaemonProcess(...) === falsealso covers missing start time, missing command and unreadable identity. Reclaiming on that value is unsafe. Blocking uncertain holders with diagnostics is a deliberate behavior change.Remove the duplicate server lock type and client lock parser at cutover. Lock
versionhas no reader andstartedAtis parsed but unused; do not copy them into host-kit. Preservedaemon.jsonversion/code-signature negotiation.Cutover is settled in ADR 0030. Hardened daemon acquisition uses a directory at the same
daemon.lockpath used by the legacy exclusive file. It refuses an existing legacy file; legacy acquisition cannot unlink the directory. Real-child tests cover already-running and concurrently-starting legacy daemons. Mixed host-kit protocol access remains unsupported: stop legacy users before upgrading, and keep a single deployed version or separate environments. An uncertain mutation guard is retained; external confirmation that all users stopped is required for manual recovery.The proven host-kit primitive, inspection/assertion APIs and cutover are integrated in the registration stack.
Keep session work tied to one lifetime
SessionRefcaptures a stored address and a mutable record. Lookup/list/find return fresh wrappers; rebuilding a record does not retarget an older wrapper.Keep three concepts distinct:
cwd:<hash>:default; use it for paths, journals and store operationsRepeated successful open rebuilds the same lifetime. Keep its resources, recording handle, actions, creation time, claims and idle-expiry handling. Preserve record construction and the second-open script-authoring abort rule, which differs from screen recording. Delete followed by publication at the same address creates a new lifetime. No inventoried caller needs a separate occupied-address/new-lifetime
replaceoperation.Store one stable entry per lifetime
Replace the existing map with stable entries. Each ref carries the entry's opaque identity and its captured record;
.sessionstays a captured record, not a live getter.Keep entry contents and construction private. This is the canonical map, with no second registry or generated generations.
The store owns three operations:
stateDiagram-v2 [*] --> Draft Draft --> Live: publish at the existing adoption point Draft --> [*]: preparation or adoption fails Live --> Live: rebuild record or reopen app Live --> Retired: guarded deletion Retired --> [*] note right of Retired Address reuse creates a new lifetime. Old work gains no authority over it. end notePut lifetime checks inside session operations
Recording, resource finish and teardown accept one ref. They resolve the latest matching record, run the owning field transition and use
ref.addressinternally.Old work cannot gain authority by looking up its address again. Audit refs held by request binding, lock policy, replay, inventory and asynchronous cleanup. Read projections may intentionally use the captured record; durable writers resolve the matching current record inside their owning functions.
Cleaning up an old captured resource can continue after retirement. Guard the store mutation separately: clearing a slot requires both the lifetime and matching resource identity. Preserve durable resource fences. If required capture adoption loses its lifetime, run existing failed-adoption disposal/recovery; do not report success or overwrite successor evidence.
Keep publication points unchanged:
Shutdown can overlap canceled handlers and timed-out cleanup. Updates cannot resurrect a retired entry. An unpublished draft also needs the existing shutdown/admission owner's check before first publication; entry identity alone cannot prevent that late publication.
Preserve execution locks, idle-reaper lock order, shutdown order, wire errors, hints and artifact naming. Keep close and teardown sequencing separate: platform close, browser cleanup and materialized paths differ.
Keep field ownership enforceable
Use one lifetime-checked update for published-record replacement. Field owners pass explicit patches; the store merges them into the latest matching record:
Keep this operation private to the owner/adapters. Domain callers use functions such as
clearSessionAppLogFailure(ref). Holding a ref does not authorize every field. Existing owner-controlled in-place transitions remain in their domain functions and resolve the matching latest record.The R7 scanner checks assignments but misses whole-record spread overrides.
appLogFailureis declared store-established, yet the app-log owner writes it through spreads. Declareapp-log-session-resource.tsas its owner and keep resource-slot transitions in their field owner.Extend R7 with two syntax rules:
SESSION_STATE_FIELD_OWNERS. Accept an explicit patch literal or inline synchronous callback returning explicit named keys. Reject computed keys, patch spreads and opaque patch variables.SessionStatespreads from an existing record only insidesession-store.tsand declared draft constructors for open, sessionless snapshot and record-only capture. Existing-session branches use patches. Nested value spreads, such as a lease, remain allowed.A callback reads the latest record when derivation needs it. Keep it synchronous and non-reentrant: one ownership check is enough. Do not add asynchronous callbacks or general value tracing.
Migrate
sessionSlot.replacewhole-session copies to explicit slot patches through a ref-bound capability. Capture-kit supplies the resource transition; the daemon adapter supplies its named field update. Do not bypass the update with a constructed whole record.R7 fixtures must reject the existing app-log spread, unauthorized failure patches, computed keys, spread patches and opaque patches; pass the declared owner's explicit patch; and retain direct-assignment checks.
Migrate the callers together
There are 19 production session-record set sites: 16 in the daemon and three in capture-kit. Seven re-set the same object, eight rebuild it, and four mix creation with rebuilding. There are six direct delete callers. Exclude tests, runtime hints and internal Map operations.
Set inventory: all 19 sites and their migration
Delete inventory: all six callers and the policies to preserve
The two
Map.deleteoperations insideSessionStore.deleteremove hints and the record; they are not extra callers.Pair all seven re-set removals with their owner-write migration. A stale re-set loses an intervening rebuild. Removing it alone loses the later mutation on a detached object instead. Route the write through the latest matching record, remove the re-set, and prove both updates survive. Keep all seven in the session migration.
The boundary changes are:
DurableCaptureSessionStoreis only a narrowed type over the full store. Replace that dependency with a ref-bound read/write/path adapter that reports retired/resource-changed outcomes. Give drafts a separately admitted publication capability at the existing adoption point.createDaemonRuntimeSessionStoregetters and its three write projections to the captured lifetime. Screenshot's no-op setter is a read projection, not a twentieth set site.get(address) ?? oldRecordfrom perf and teardown:34,211,226. It can reinsert a deleted record or adopt a successor. App-log patches must also preserve intervening resource updates.session.nameinstead of the lookup's storedparams.sessionName. Useref.addressfor paths, slots and deletion; public name stays for display. Test a scoped leased address plus a separate slot under its public name. This is a helper-contract defect; production reachability with unequal names remains unproved.Remove unrestricted upsert and
resolveStoredSessionNameafter migration. Its object-identity scan and public-name fallback must not survive as compatibility. A rebuilt cwd-scopeddefaultmust usecwd:<hash>:defaultfor journals, without creating a phantom public-name record.Reduce the store interface in this change. Convert it to a factory only if that helps the resulting composition. Keep runtime projection, ref-frame authorization and capture merging adapters that still own useful behavior.
Land independent cleanups
These do not gate registration or include the seven re-set removals:
src/daemon/session-artifact-paths.ts. Device-claim recovery constructs a store only for these paths; remove that dependency while preserving validation, stored addresses, layout and errors.SessionStore.expandHomewith host-kit'sexpandSessionPathand remove the forwarder.SessionScriptWriterwithwriteSessionScript(sessionsDir, session, options). It stores one directory and its only production user is SessionStore. Keep formatting, no-clobber/force behavior, commit transitions, diagnostics, failure routing and public-name-based default filenames together.Prove the behavior
Use deterministic interleavings and real temporary filesystem/process tests for exclusion and exit guarantees. A Map-only test cannot prove filesystem exclusion.
Registration regression checklist
Session regression checklist
Prove reachable schedules separately from interface hazards. Request locks serialize ordinary open/close. Shutdown is an exception: session teardown takes no execution locks, server close forces closure after five seconds, and socket closure cancels without joining handlers. Exercise that real transport path and a precise handler continuation; existing open cancellation checks can block a particular interleaving.
Regression tests must fail when the relevant ownership check is removed or cleanup is made unconditional. Plant the concrete forbidden app-log spread for R7. Colocate tests with source and retain existing tests for pure moves.
Each slice runs focused checks and
pnpm check:affected --runon its exact head, plus the full deterministic gate for a broad refactor as repository guidance requires. Preserve CI-owned provider/native/device checks. Report production additions/deletions separately from tests and moves, and name the mechanism or caller obligation removed.Deliver in dependency order
Related work: #3102 already adds daemon metadata publication/removal fencing; deepen it. Coordinate timeout reset with #3105 and report mutations with #3104. Preserve the #2833 idle-reaper lock seam/order. This follows #3069 without reopening it or closing related issues by association.
The implementation stack is fully landed. The implementation record preserves the original branch order, exact-head validation, completed review findings and the physical recording-health limitation. Request-scoped timeout cleanup is tracked in #3177; it is outside the retirement-ownership contract completed here.
Out of scope: a generic request executor, selector-observation aggregation, global immutable-state migration, actors/manager frameworks and promised line-count/performance gains. Request finalization and diagnostics may be separate bounded follow-ups; they do not gate this work.