Summary
Two gaps in the open_session.durableSessionId contract shipped in #1956 (#1951), both found by driving the REAL senpi --mode rpc --multi-session process over its stdio protocol rather than the registry unit harness.
Reproduction
Real host (.agents/skills/senpi-qa/scripts/scenarios/durable-session-id-qa.mjs style), hermetic sandbox:
open_session { sessionPath: A, durableSessionId: X } → success, state.sessionId === X.
open_session { sessionPath: B, durableSessionId: X } (a DIFFERENT path, same id, first session still live)
- Expected: refused with
session_id_in_use.
- Actual:
success: true. Two live sessions now share durable id X.
close_session on the first, then open_session { sessionPath: A } with no id.
- Expected:
state.sessionId === X (the id the caller chose for that file).
- Actual: a fresh uuidv7. A never materialized on disk because no assistant message arrived before the close, so the chosen identity evaporated.
Root cause
Gap 1 — the guard lives in the wrong registry. multi-session-host.ts:163-171 instantiates WorkerSessionRegistry whenever a worker configuration is present, which is the real host's normal shape. #1956 added the synchronous duplicate-live-id scan only to RpcSessionRegistry.openSession (session-registry.ts:229-236). WorkerSessionRegistry.openSession (worker-session-registry.ts:60-130) has no such scan; it forwards the profile to worker.prepare and only learns the durable id from snapshot.state.sessionId after commit (:123). The invalid_session_id refusal DOES work on the real host, but only because SessionManager.assertValidSessionId fires inside the worker and surfaces as open_failed: invalid_session_id.
The unit suite added in #1956 drives RpcSessionRegistry directly, so it stayed green while the real host bypassed the guard.
Gap 2 — a caller-chosen id is not durable until the first assistant message. SessionManager._persist defers creating an absent session file until an assistant message exists. That is the right default for a host-minted id (nothing else references it yet). It is the wrong default for a CALLER-chosen id: the caller already holds that id in its own records and expects the file to answer to it. Today the identity exists only in memory between open and first reply.
Expected (ideal state)
WorkerSessionRegistry.openSession refuses a durableSessionId held by any non-closed entry with session_id_in_use, taken synchronously before its first await, and records the requested id on the entry before that await so a concurrent open sees it — the same shape RpcSessionRegistry has.
WorkerSessionRegistry validates the id format at its own boundary with invalid_session_id (not the open_failed: wrapper), matching RpcSessionRegistry.validateProfile.
- When a session is CREATED under a caller-supplied id, its header is written to disk immediately, so a reopen of that path yields the chosen id even if no assistant message ever arrived.
- A real-host scenario script asserts all three so the two registries cannot drift again.
Related
Summary
Two gaps in the
open_session.durableSessionIdcontract shipped in #1956 (#1951), both found by driving the REALsenpi --mode rpc --multi-sessionprocess over its stdio protocol rather than the registry unit harness.Reproduction
Real host (
.agents/skills/senpi-qa/scripts/scenarios/durable-session-id-qa.mjsstyle), hermetic sandbox:open_session { sessionPath: A, durableSessionId: X }→ success,state.sessionId === X.open_session { sessionPath: B, durableSessionId: X }(a DIFFERENT path, same id, first session still live)session_id_in_use.success: true. Two live sessions now share durable id X.close_sessionon the first, thenopen_session { sessionPath: A }with no id.state.sessionId === X(the id the caller chose for that file).Root cause
Gap 1 — the guard lives in the wrong registry.
multi-session-host.ts:163-171instantiatesWorkerSessionRegistrywhenever a worker configuration is present, which is the real host's normal shape. #1956 added the synchronous duplicate-live-id scan only toRpcSessionRegistry.openSession(session-registry.ts:229-236).WorkerSessionRegistry.openSession(worker-session-registry.ts:60-130) has no such scan; it forwards the profile toworker.prepareand only learns the durable id fromsnapshot.state.sessionIdafter commit (:123). Theinvalid_session_idrefusal DOES work on the real host, but only becauseSessionManager.assertValidSessionIdfires inside the worker and surfaces asopen_failed: invalid_session_id.The unit suite added in #1956 drives
RpcSessionRegistrydirectly, so it stayed green while the real host bypassed the guard.Gap 2 — a caller-chosen id is not durable until the first assistant message.
SessionManager._persistdefers creating an absent session file until an assistant message exists. That is the right default for a host-minted id (nothing else references it yet). It is the wrong default for a CALLER-chosen id: the caller already holds that id in its own records and expects the file to answer to it. Today the identity exists only in memory between open and first reply.Expected (ideal state)
WorkerSessionRegistry.openSessionrefuses adurableSessionIdheld by any non-closed entry withsession_id_in_use, taken synchronously before its first await, and records the requested id on the entry before that await so a concurrent open sees it — the same shapeRpcSessionRegistryhas.WorkerSessionRegistryvalidates the id format at its own boundary withinvalid_session_id(not theopen_failed:wrapper), matchingRpcSessionRegistry.validateProfile.Related
senpi hostCLI #1782 (capability-compat umbrella)