Keep resumed threads in their own provider sessions - #3512
Open
SawyerHood wants to merge 4 commits into
Open
Conversation
SawyerHood
added this pull request to stack #3504
September 11, 2026 18:50
SawyerHood
force-pushed
the
bb/3461-owned-sessions-stack
branch
4 times, most recently
from
September 11, 2026 22:59
d2863af to
f9e8804
Compare
`getLastStoredProviderThreadId` took the newest `provider_thread_id` stamped on any event of the thread, so one mis-stamped event, or a `thread/identity` recorded after the server itself resumed into the wrong session, redirected every later resume, archive forward, and interruption stamp into another thread's provider session (#3461). Resolution now reads only `thread/identity` events recorded since the thread's last completed context clear, newest first, and classifies each announced session against every same-provider, same-host claim: - owned: no other thread claimed it, or this thread's earliest claim is strictly earlier than every other thread's. - foreign: another thread claimed it strictly earlier. Resolution skips it and falls back to the thread's own earlier session, which repairs the persisted contamination the 2026-09-11 incident left behind. - ambiguous: another thread's earliest claim falls in the same millisecond. Daemon event batches share one timestamp and rowid order is not durable, so resolution stops instead of picking a winner. `resolveStoredProviderSessions` returns none, owned, foreign, or ambiguous, so callers can refuse explicitly instead of treating an unavailable session like a brand-new thread. `classifyStoredProviderThreadClaim` gives edit and fork paths the same classification for a completion's stamp. A partial index on identity rows backs the claim lookup. Co-Authored-By: Claude Code <noreply@anthropic.com>
…er session Sends, queued dispatch, turn submission, and goal clears resumed whatever session id the stored history named. With a contaminated stamp that was another thread's rollout (#3461); with only a foreign or tied identity, a send silently started a new session without the thread's context. Dispatch now goes through `requireDispatchableProviderThreadId`. An owned session resumes and no session since the last context clear starts fresh. A foreign or ambiguous session answers 409 `provider_session_unavailable` with the session id and claimant threads in `details`, and the message "Send /clear to start a new session; history is kept." Nothing is dispatched and no history is rewritten; a queued row records the same text as its failure reason. Message-edit rewinds and anchored or tip forks refuse a completion or source whose session is foreign or ambiguous. Empty-input fork starts record the daemon's start result as a real `thread/identity` event, so the resolver sees the session the server itself established. Test fixtures that established a session through a stamped `turn/started` alone now announce it through `seedThreadIdentity`, matching what every bridge emits. Fixes #3461 Co-Authored-By: Claude Code <noreply@anthropic.com>
bb persists a provider session only from `thread/identity`, resumes only sessions recorded that way, and now refuses a session another thread announced first or in the same millisecond. The kit checked that `thread/start` and `thread/fork` return `providerThreadId` on the result but never that the session is announced, so a third-party bridge that skipped the notification would pass conformance and start a new provider session on every send. `session/start-identity-announced` and `session/fork-identity-announced` require a `thread/identity` notification or `thread.identity` delta naming the returned session for that thread. Every in-repo and example bridge already passes, and their expected-status maps pin both checks. The protocol doc now states what resume already relied on: a `providerThreadId` is a durable handle bb sends to a new bridge process after a restart, so it must name one session among all of that provider's sessions on the host and never come from a per-process counter. Real bridges mint UUIDs, and the published echo example adds a random per-process nonce. The scripted test bridge keeps readable `prov-N` counters for single-process suites and gains `uniqueProviderThreadIds`, which the integration suite enables because it runs several bridge processes against one server store. Co-Authored-By: Claude Code <noreply@anthropic.com>
SawyerHood
force-pushed
the
bb/3461-owned-sessions-stack
branch
from
September 11, 2026 23:05
f9e8804 to
7106c27
Compare
Session resolution loaded every thread/identity event since the last context clear and deduplicated them in JavaScript. Codex announces its session on every turn, so the cost grew with turn count: 3.9 ms per lookup on a production thread with 2,464 identity events, against 0.06 ms for the previous LIMIT 1 query. Resolution now reads the newest identity event (LIMIT 1), then finds the session's earliest claim with a single statement whose ORDER BY created_at LIMIT 1 subquery seeks the partial identity index. It reads further back only when the newest session belongs to another thread. The ownership semantics are unchanged: on a copy of the production store the result matched the previous resolver for all 4,812 threads and 9,620 claim checks, and a lookup takes about 0.25 ms regardless of history length. The query-plan test now pins two statements, each using an index, for a thread with a long identity history. Co-Authored-By: Claude Code <noreply@anthropic.com>
stack merge was automatically disabled
September 12, 2026 00:42
Pull Request is not mergeable
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Human comments
What was wrong
A thread whose stored history named another conversation's provider session could resume into that conversation. The user's message landed in the other rollout, and the rightful owner was then locked out on Codex's writer lock (#3461). Merged #3496 stops the runtime from minting fresh mix-ups. However, the server still picked the newest session id stamped on any stored event, and contaminated history from before #3496 still steered resume after a restart, message edits, and anchored forks. Review of the first version of this PR found two more gaps. When two threads claimed a session in the same millisecond, the owner was chosen by
rowid, which is not a durable ordering. A thread whose only recorded session belonged to another thread silently started a new session with no context.What changed
Third layer of the stack: #3502 (Stop recovery) → #3503 (Codex turn correlation) → this PR. Review this diff against #3503's branch. The superseded runtime and writer-retry commits from #3460 are not included.
packages/db): sessions come only fromthread/identityevents recorded since the thread's last completed context clear, newest first. Each announced session is compared with every same-provider, same-host claim.rowidorder is not durable, so resolution stops instead of guessing.apps/server): sends, queued dispatch, turn submission, and goal clears go through one check. No session since the last clear starts fresh as before. A foreign or ambiguous session returns 409provider_session_unavailable, anddetailscarries the reason, session id, and claimant threads. Nothing is dispatched and no history is written. A queued row records the same message as its failure reason. Message-edit rewinds and anchored or tip forks refuse a foreign or ambiguous completion or source.session/start-identity-announcedandsession/fork-identity-announced. A bridge now fails if it returns a session but never announces it throughthread/identity, because bb would start a new session on every send. Every in-repo and example bridge already passes, and their expected-status maps pin both checks.docs/provider-bridge-protocol.md): the doc now states what resume already relied on. bb sends aproviderThreadIdto a new bridge process after a restart, so it must name one session among all of that provider's sessions on the host and never come from a per-process counter. Real bridges mint UUIDs, and the published echo example adds a random per-process nonce. The scripted test bridge keepsprov-Ncounters for single-process suites. It gainsuniqueProviderThreadIds, which the integration suite enables because it runs several bridge processes against one store. The first CI run caught exactly that collision, so this fix is in scope.thread/identityevent. Drizzle generated migration 0118, a partial identity-event index used by the claim lookup. Its SQL usesIF NOT EXISTS, like 0107, so rewound migration tests can replay it.Known limits:
thr_un673bkbeu/thr_k4xuhczhkgandthr_dg7mhskt57/thr_pnjzgs3pz9claim a shared session in the same millisecond. All four threads are archived. They now get the explicit refusal until their context is cleared. The other two production pairs have strictly ordered claims and resolve to each thread's own session.How you verified
Real authenticated Codex (
codex-cli 0.154.0), isolated fresh stores and ports, shared device-auth scratchCODEX_HOME. One script ran against both heads. It spawns five threads on one environment, runs edit, fork, and clear, and then makes the injections below. After that it restarts the daemon so every send resolves from the store, and it checks which rollout file received each message.226a657864rowidorder invertedbb thread clear, then send)These rows were injected on purpose, because #3496 prevents the original fresh trigger:
rowid. That mirrors the archived same-batch pairs, with the storage orderrowiddoesn't guarantee inverted.The earlier run for the first version of this PR used a timestamp about 100,000× too large, from
date +%s%3Nprinting nanoseconds on this host. These runs take timestamps from the store. The incident's fail-before on main, where A resumed into B's rollout and B hit the active-writer error, is from that earlier run. Main's session selector is unchanged since then.Tests (fail before, pass after where they cover changed behavior):
/clearstarting fresh; ambiguous refusal for both threads with history byte-identical; the queued failure reason; ambiguous edit, anchored fork, and tip fork refusals; Fix provider session identity mixups during concurrent thread starts #3496's checkpoint-identity suite.Commands on this head, rebased on #3503
ebcd0e5c7a, which carries #3502's SDK bump to 0.4.85:The SDK npm version guard passes: this layer changes the published conformance kit, and 0.4.85 is not yet on npm. Off-pipeline checks also pass: the server provider-corpus gate, and agent-runtime
integration.provider-basicagainst real Codex (24 tests). The EAP codename scan is clean for the working tree, HEAD, the push range, and this text.Fixes #3461
🤖 Generated with Claude Code