Skip to content

fix(session): concurrent sessions write disjoint transcripts - #1408

Merged
corbits-triage[bot] merged 2 commits into
mainfrom
cl-10372-transcript-overwrite
Oct 13, 2026
Merged

corbits-triage[bot] merged 2 commits into
mainfrom
cl-10372-transcript-overwrite

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Collaborator

Summary

Fixes CL-10372: a session's transcript was overwritten by concurrent session activity. Verified evidence: a transient 107-record turns.jsonl was clobbered.

Root cause

In the custom context store (src/session/optimized-context-store.ts), a fresh store whose first writeTurns diverges from the on-disk transcript (i.e. the on-disk transcript was authored by another session sharing the dir) staged an unpublishedRewrite; at commit() the fresh segmented writer then rewrote the shared turns.jsonl from 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.

  • Pre-fix: FAIL — turns.jsonl ended up holding the second session's records (b-0... instead of a-0...).
  • Post-fix: PASS.

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 fail
  • bun run typecheck → exit 0
  • oxfmt --check + oxlint on changed files → clean

Diff stat: 2 files changed, 128 insertions(+), 14 deletions(-)

@linear-code

linear-code Bot commented Oct 12, 2026

Copy link
Copy Markdown

CL-10372

@corbits-triage

corbits-triage Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

No changes needed from you. A maintainer will review this pull request.

@TheGreatAxios
TheGreatAxios force-pushed the cl-10372-transcript-overwrite branch from 2f30f8c to abf0ca4 Compare October 12, 2026 05:52
@TheGreatAxios TheGreatAxios changed the title fix(session): concurrent sessions write disjoint transcripts (CL-10372) fix(session): concurrent sessions write disjoint transcripts Oct 12, 2026
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.
@TheGreatAxios
TheGreatAxios force-pushed the cl-10372-transcript-overwrite branch from abf0ca4 to 6c83635 Compare October 12, 2026 06:32

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 missing session-owner.json is treated as "I own this", and writeSegmented writes straight onto turns.jsonl with no prefix check and no versioned divert. Session A commits [a-0…a-4], the marker is deleted, session B writeTurns([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 — withResolvedDirLock is a process-wide mutex (src/session/session-dir-lock.ts:5). Two OS processes on an empty dir, different ownerIds, each writeTurns plus commit: in 6 of 15 runs only turns.jsonl remained, including a run where both exited 0 and only B's turns were left. The interleaved test is a single-process Promise.all.

  • src/session/optimized-context-store.ts:600 — omitting ownerId assigns a new randomUUID(), and a non-prefix first write is diverted. load and loadRecentTurns only read turns.jsonl. A second store with no ownerId that 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:1327 still calls createSessionStores(workdir) without ownerId. The compaction test passes only because both stores set ownerId: "session-S".

  • src/session/optimized-context-store.ts:692 — same-owner resume compaction writes the replacement to turns.jsonl inside writeTurns, before commit. Commit snapshots and restores only when unpublishedRewrite is set. Forcing commit to throw leaves disk and load() as the compact while readAt(previous) is still the old turns. The same-store contract test expects a failed commit to keep load() on HEAD. This path does not.

  • src/session/optimized-context-store.ts:710 — a foreign prefix-extension continues turns.jsonl. A (session-A) commits [a,b]; B commits [a,b,from-B] onto the shared file; A then commits ["[Compacted]",b]. from-B is gone, including from B.load(). The same happens when the marker exists and the turns file is empty, which is what a crash after writeOwnerMarker and before writeSegmented leaves behind.

  • src/session/optimized-context-store.ts:784 — prompt writes hit prompt.jsonl before ownership is decided. A commits turns and a prompt; B calls writePrompt then writeTurns and commits. Turns divert, but prompt.jsonl is 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.

@corbits-triage
corbits-triage Bot merged commit 39f7736 into main Oct 13, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant