Skip to content

fix(rpc): enforce durable_session_id on the worker session registry and write a chosen id to disk at open - #2013

Merged
code-yeongyu merged 3 commits into
mainfrom
fix/2010-worker-registry-durable-id
Sep 22, 2026
Merged

code-yeongyu merged 3 commits into
mainfrom
fix/2010-worker-registry-durable-id

Conversation

@code-yeongyu

@code-yeongyu code-yeongyu commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

What

Completes the open_session.durableSessionId contract from #1956 on the registry the multi-session host actually runs, and makes a caller-chosen id durable from the moment of open.

Fixes #2010

How this was found

Not by a unit test. The registry-level suite from #1956 was green. I drove the REAL senpi --mode rpc --multi-session process over its stdio protocol with a new scenario script (.agents/skills/senpi-qa/scripts/scenarios/durable-session-id-qa.mjs, zero model tokens) and it scored 6/8:

[FAIL] duplicate live id -> session_id_in_use — undefined   (open returned success:true)
[FAIL] reopen reads the chosen id back — 01a0c984-…         (a fresh uuidv7; the file never existed)

Root causes

1. The guard was on the wrong registry. multi-session-host.ts:163-171 instantiates WorkerSessionRegistry whenever a worker configuration is present — the real host's normal shape. #1956 added the duplicate-live-id scan only to RpcSessionRegistry.openSession. WorkerSessionRegistry.openSession forwarded the profile straight to worker.prepare, so two live sessions could share one durable id. The malformed-id refusal did fire on the real host, but only as open_failed: invalid_session_id from the worker's own SessionManager after the worker had already died.

2. A caller-chosen id was not on disk until the first assistant message. SessionManager._persist defers creating the file — correct for a host-minted id that nothing references yet, wrong for an id the caller already holds in its own records. Create → close before the first reply → reopen the same path minted a different identity.

Fixes

  • worker-session-registry.ts: openSession validates the id with assertValidSessionId (→ invalid_session_id) and refuses an id any non-closed entry holds (→ session_id_in_use), both synchronously before its first await, mirroring RpcSessionRegistry. The requested id is recorded on the entry before that await so a concurrent open sees it; snapshot.state.sessionId still overwrites it after commit (so a resume keeps its header id). Re-opening the same path stays an attach.
  • session-manager.ts: when NewSessionOptions.id is present on a not-yet-existing path, the header is written at open.

Evidence

RED first — new test/suite/rpc-worker-durable-session-id.test.ts against the real worker registry (real Worker threads, production routing), 3 cases, 3 failed before the change:

AssertionError: promise resolved "{ sessionId: 'rpc-2', …(2) }" instead of rejecting
AssertionError: expected Error: invalid_session_id to match object { code: 'invalid_session_id' }
AssertionError: expected false to be true   (existsSync(sessionPath))

After:

gate result
rpc-worker-durable-session-id.test.ts (real workers) 3/3
real-surface scenario (real senpi process over stdio) 9/9, including "chosen id is in the JSONL header on disk"
rpc-open-session-durable-id + rpc-open-session-resume + rpc-worker-capacity + rpc-worker-routing + rpc-retain-on-disconnect + rpc-close-ordering 39/39
pgrep -f rpc-host-fixture.mjs | wc -l after the run 0
changelog gate PASS

Lesson recorded

When a contract lives behind an interface with two implementations, the invariant goes on both — and only a real-process scenario proves which one actually runs. The scenario script stays in the repo so the two registries cannot drift again.


Summary by cubic

Fixes the durable session id contract on WorkerSessionRegistry, the registry the real multi-session host runs, so the duplicate-live-id and malformed-id guards apply there too. A caller-chosen id is now written to disk at open, so create → close → reopen keeps the same identity.

Bug Fixes

  • WorkerSessionRegistry.openSession refuses a duplicate live id with session_id_in_use and a malformed id with invalid_session_id, synchronously before spawning a worker, mirroring RpcSessionRegistry.
  • A caller-chosen id previously left no file until the first assistant message; the header is now written at open.
  • Adds a real-process scenario over stdio and a Worker-backed test suite so the two registries cannot drift again, and records the fixes in the changelog under [Unreleased].

Written for commit d178a50. Summary will update on new commits.

Review in cubic

@code-yeongyu

Copy link
Copy Markdown
Owner Author

Rebased onto main (610d70af8f, 14 commits) — re-verified on the new head

The previous head (a3f30c35cb) conflicted with main in src/core/changes.md (the #1989 rename entries landed above mine), which is why no test workflow dispatched for it — only the label/security checks registered. Rebased; the only conflict was that tracker, resolved as a union with every entry preserved and git diff --check clean.

One trap worth recording: immediately after the rebase the real-worker tests went red with session_closing. The worker's quarantine line named the real cause — does not provide an export named 'readByProviderId' — main had added an export to packages/ai and the workers load dist, which predated the rebase. A rebuild, not a code change, fixed it.

On the rebased head (d8941cfe95):

gate result
rpc-worker-durable-session-id + rpc-open-session-durable-id + rpc-open-session-resume + rpc-worker-routing 31/31
real-surface scenario (real senpi --mode rpc --multi-session over stdio) 9/9
pgrep -f rpc-host-fixture.mjs after 0
changelog gate vs new base PASS

The earlier relocated-worker (windows-latest) failure was rmSync EBUSY on a compile-fixture temp dir in a test that never touches session code; its rerun passed, and every other recent run of that job is green — a Windows teardown flake, not this change.

Deferring the session file until an assistant message exists is the right default for a
host-minted id, which nothing references yet. It is the wrong default for a CALLER-chosen
id: the caller already holds that id in its own records, so the file has to answer to it
now. Measured on the real multi-session host: create under a chosen id, close before the
first reply, reopen the path - the file did not exist and the host minted a fresh uuidv7,
so the id the caller stored pointed at nothing.

When `NewSessionOptions.id` is present on a not-yet-existing path, write the header at once.
only. multi-session-host.ts instantiates WorkerSessionRegistry whenever a worker
configuration is present - the real host's normal shape - and that registry forwarded the
profile straight to worker.prepare. On the real host two live sessions could share one
durable id; a malformed id was refused only as `open_failed: invalid_session_id` after the
worker had already died.

Both checks now run in WorkerSessionRegistry.openSession synchronously before its first
await, mirroring RpcSessionRegistry: the requested id is recorded on the entry before the
await so a concurrent open sees it, and snapshot.state.sessionId still overwrites it after
commit. A new real-surface scenario drives the actual `senpi --mode rpc --multi-session`
process over stdio and asserts the whole contract, including the header id on disk, so the
two registries cannot drift again.

Fixes #2010
…eleased]

The entry was intended in the previous commit but the string replacement matched
nothing because the [Unreleased] block orders Fixed before Added; the changelog
gate caught it.
@code-yeongyu
code-yeongyu force-pushed the fix/2010-worker-registry-durable-id branch from d8941cf to d178a50 Compare September 22, 2026 15:36
@code-yeongyu
code-yeongyu merged commit a9f925a into main Sep 22, 2026
29 checks passed
@code-yeongyu
code-yeongyu deleted the fix/2010-worker-registry-durable-id branch September 22, 2026 15:48
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.

rpc: durable_session_id guard missing on WorkerSessionRegistry, and a caller-chosen id is not written to disk until the first assistant message

1 participant