Repository navigation
fix(session): concurrent sessions write disjoint transcripts - #1408
Conversation
|
No changes needed from you. A maintainer will review this pull request. |
2f30f8c to
abf0ca4
Compare
A fresh context store whose transcript is foreign to the on-disk owner (a concurrent session sharing the dir) previously rewrote the shared turns.jsonl at commit, losing the other session's records. Add an explicit ownership marker (session-owner.json) recorded when a store first claims the shared base, and divert only when the on-disk owner is genuinely someone else: - marker matches this store's owner id (same session resuming): any rewrite, including compaction that replaces history, stays canonical on the shared base; - marker belongs to a foreign owner and the incoming transcript is not an extension of the on-disk history: divert turns and prompt to unique versioned bases (turns-<epoch>-<uuid>.jsonl); - prefix-extension resume without a stable id: continue the shared base. The first-write ownership decision is serialized with the per-dir lock so two concurrent fresh stores cannot both claim the shared base (one diverts). Diverted writes are staged and land at commit, so a crash before commit leaves no orphaned versioned file. Versioned names use a UUID suffix for same-millisecond uniqueness. The primary session caller passes its session id as the owner id so resumed sessions recognize their own marker. Tests: concurrent disjoint repro, same-session resume regression, same-session compaction-on-resume canonical, interleaved first writes, crash-mid-divert no orphan, and versioned-name uniqueness.
abf0ca4 to
6c83635
Compare
TheGreatAxios
left a comment
There was a problem hiding this comment.
Review · Request changes
Concurrent sessions still clobber turns.jsonl. A missing ownership marker, a process-local lock, and a per-process random owner id each drop or sideline the other transcript.
Findings
-
src/session/optimized-context-store.ts:684— a missingsession-owner.jsonis treated as "I own this", andwriteSegmentedwrites straight ontoturns.jsonlwith no prefix check and no versioned divert. Session A commits[a-0…a-4], the marker is deleted, session BwriteTurns([b-0,b-1,b-2]). Before B commits, the file is already B's turns. Every pre-upgrade context dir is in this state. The new test plants the marker via store A first, so it never hits this path. -
src/session/optimized-context-store.ts:673—withResolvedDirLockis a process-wide mutex (src/session/session-dir-lock.ts:5). Two OS processes on an empty dir, differentownerIds, eachwriteTurnspluscommit: in 6 of 15 runs onlyturns.jsonlremained, including a run where both exited 0 and only B's turns were left. The interleaved test is a single-processPromise.all. -
src/session/optimized-context-store.ts:600— omittingownerIdassigns a newrandomUUID(), and a non-prefix first write is diverted.loadandloadRecentTurnsonly readturns.jsonl. A second store with noownerIdthat commits["[Compacted]",c,d]leaves the live file as[a,b,c,d]. The compact exists only as a versioned file readers ignore.src/subagent/run.ts:1327still callscreateSessionStores(workdir)withoutownerId. The compaction test passes only because both stores setownerId: "session-S". -
src/session/optimized-context-store.ts:692— same-owner resume compaction writes the replacement toturns.jsonlinsidewriteTurns, before commit. Commit snapshots and restores only whenunpublishedRewriteis set. Forcing commit to throw leaves disk andload()as the compact whilereadAt(previous)is still the old turns. The same-store contract test expects a failed commit to keepload()on HEAD. This path does not. -
src/session/optimized-context-store.ts:710— a foreign prefix-extension continuesturns.jsonl. A (session-A) commits[a,b]; B commits[a,b,from-B]onto the shared file; A then commits["[Compacted]",b].from-Bis gone, including fromB.load(). The same happens when the marker exists and the turns file is empty, which is what a crash afterwriteOwnerMarkerand beforewriteSegmentedleaves behind. -
src/session/optimized-context-store.ts:784— prompt writes hitprompt.jsonlbefore ownership is decided. A commits turns and a prompt; B callswritePromptthenwriteTurnsand commits. Turns divert, butprompt.jsonlis overwritten with B's prompt and that clobber is what gets committed.
The commit body walks the design and several lines exceed 72 characters. Not a behavior defect.
Summary
Fixes CL-10372: a session's transcript was overwritten by concurrent session activity. Verified evidence: a transient 107-record
turns.jsonlwas clobbered.Root cause
In the custom context store (
src/session/optimized-context-store.ts), a fresh store whose firstwriteTurnsdiverges from the on-disk transcript (i.e. the on-disk transcript was authored by another session sharing the dir) staged anunpublishedRewrite; atcommit()the fresh segmented writer then rewrote the sharedturns.jsonlfrom scratch, truncating the other session's records.Fix (minimal, disjoint-write)
When a fresh store detects that its transcript is not an extension of the on-disk history it did not author, it diverts its own turns + prompt writes to unique versioned base names (
turns-<epoch>-<rand>.jsonl,prompt-<epoch>-<rand>.jsonl) instead of clobbering the shared base. Same-session resume (prefix-append) and same-session compaction rewrites are untouched — only fresh-store divergence diverts.Repro
src/session/optimized-context-store.test.ts→ "concurrent sessions write disjoint transcripts (CL-10372)": two stores on one dir, first lands a 107-record transcript and commits, second commits its own transcript; asserts the first transcript is intact (no overwrite), the second survives in a disjoint versioned file (no lost records), and every record exists exactly once.turns.jsonlended up holding the second session's records (b-0...instead ofa-0...).Verification
bun test src/session/optimized-context-store.test.ts→ 37 pass, 0 fail (was 36 pass / 1 fail pre-fix)bun test src/session→ 518 pass, 0 failbun run typecheck→ exit 0oxfmt --check+oxlinton changed files → cleanDiff stat: 2 files changed, 128 insertions(+), 14 deletions(-)