#3461 · Stored event metadata redirects provider identity
2026-09-11 UTC · Trusted base 8d32c61eec4a825446da3efeec5a24d638913381
Verdict: REPRODUCED · Root-cause confidence: high · Reproduction label: confirmed-repro
1. TL;DR
A stored completion event can change which provider session the server selects for a thread. The database selector trusts the latest event carrying a provider ID, even when a different thread in the same environment has established that ID as its own. A regression test on unmodified main demonstrates this with an actual migrated in-memory SQLite database, and a second clean checkout produces the same failure. Static tracing connects that selector to the turn-submit resume context. No real Codex process was launched, so the reported writer-lock incident and its historical cause were not replayed.
2. Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| A later non-identity event can replace the selected provider identity. | Verified dynamically | Both test runs select session-beta for the first thread after one completion event; its established identity is session-alpha. |
| A second thread owning the ID in the same environment prevents selection. | Refuted dynamically | The second thread remains bound to session-beta and does not prevent the first selector returning that same ID. |
| The server uses this ID for turn submission without a cross-thread ownership check. | Verified by static tracing | The selector flows through getLastProviderThreadId and prepareTurnSubmitCommandPayload into resumeContext.providerThreadId. Runtime config resolution does not compare provider identity ownership. |
| The cited line 476 is another resume path. | Refuted | That line belongs to dispatchArchivedThreadProviderArchiveCommand. The relevant ready-thread path is thread-lifecycle.ts:905–938. |
| The reported production turns crossed Codex rollouts and triggered an active-writer error. | Unverified | No production database, daemon logs, or provider rollouts were accessed. This report proves the selector defect, not the historical incident. |
3. Environment
Trusted origin/main was fetched and its full SHA matched the GitHub main commit for the public get-bb/bb repository. Two separate detached worktrees used that exact commit. macOS/Darwin arm64; Node v22.22.3; pnpm 9.15.0; source package version 0.42.1. No provider CLI, listening port, or persistent database was used. Each run created and migrated its own :memory: database and closed it afterward.
The host pnpm launcher was broken before installation. A temporary Corepack shim selected the repository's declared pnpm version; both frozen installs then succeeded without dependency changes. Full Turbo builds passed in both checkouts: 20/20 tasks, with 4 cached tasks in the first and 15 in the second.
4. Minimal reproduction
- Prepare a clean checkout with the commands below.
- Save the inline test at the indicated path. It creates two active, non-archived threads in one environment and establishes distinct provider identities.
- The test verifies both initial selections, then adds a completion event carrying the second identity to the first thread.
- Run the test. The final assertion expects the first identity to stay session-alpha and fails because the selector returns session-beta.
git clone https://github.com/get-bb/bb.git bb-repro cd bb-repro git checkout --detach 8d32c61eec4a825446da3efeec5a24d638913381 pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build # Save the test below as packages/db/test/data/issue-3461.test.ts pnpm exec turbo run test --filter=@bb/db --force -- issue-3461.test.ts
Expected: preserve the first established identity (or reject the conflicting binding before dispatch). Actual output excerpt from both runs:
AssertionError: expected 'session-beta' to be 'session-alpha' // Object.is equality
Expected: "session-alpha"
Received: "session-beta"
Test Files 1 failed (1)
Tests 1 failed (1)
Complete reproduction test, authored from trusted repository APIs:
import { expect, it } from "vitest";
import { threadScope, turnScope } from "@bb/domain";
import { createConnection, migrate } from "../../src/index.js";
import { noopNotifier } from "../../src/notifier.js";
import { upsertHost } from "../../src/data/hosts.js";
import { createProject } from "../../src/data/projects.js";
import { createEnvironment } from "../../src/data/environments.js";
import { createThread } from "../../src/data/threads.js";
import { appendStoredThreadEvent, getLastStoredProviderThreadId } from "../../src/data/events.js";
it("keeps an established identity when a completion carries another thread identity", () => {
const db = createConnection(":memory:");
migrate(db);
try {
const host = upsertHost(db, noopNotifier, { name: "repro-host", type: "persistent" });
const { project } = createProject(db, noopNotifier, {
name: "repro-project",
source: { type: "local_path", hostId: host.id, path: "/tmp/identity-repro" },
});
const environment = createEnvironment(db, noopNotifier, {
projectId: project.id, hostId: host.id, path: "/tmp/identity-repro",
status: "ready", providerOwnsPath: false,
});
const first = createThread(db, noopNotifier, {
projectId: project.id, environmentId: environment.id, providerId: "codex",
});
const second = createThread(db, noopNotifier, {
projectId: project.id, environmentId: environment.id, providerId: "codex",
});
for (const [thread, identity] of [[first, "session-alpha"], [second, "session-beta"]] as const) {
appendStoredThreadEvent(db, noopNotifier, {
threadId: thread.id, scope: threadScope(), type: "thread/identity",
providerThreadId: identity, data: { providerThreadId: identity },
});
}
expect(getLastStoredProviderThreadId(db, first.id)).toBe("session-alpha");
expect(getLastStoredProviderThreadId(db, second.id)).toBe("session-beta");
appendStoredThreadEvent(db, noopNotifier, {
threadId: first.id, scope: turnScope("repro-turn"), type: "turn/completed",
providerThreadId: "session-beta",
data: { providerThreadId: "session-beta", status: "completed" },
});
expect(getLastStoredProviderThreadId(db, second.id)).toBe("session-beta");
expect(getLastStoredProviderThreadId(db, first.id)).toBe("session-alpha");
} finally {
db.$client.close();
}
});
5. Root cause
getLastStoredProviderThreadId filters only the current thread, non-null provider IDs, and events after the most recent completed context clear. It orders by descending sequence and takes one row. It neither distinguishes authoritative identity events from ordinary event metadata nor consults other threads' ownership.
AND provider_thread_id IS NOT NULL ORDER BY sequence DESC LIMIT 1
getLastProviderThreadId simply delegates to this query. prepareTurnSubmitCommandPayload takes that value unless a caller supplied an explicit ID; the command builder places it into the turn.submit resume context. prepareReadyThreadTurnCommand also gets the selected ID and passes it explicitly when choosing turn.submit instead of thread.start. runtime configuration resolution loads environment, host, instructions, and provider configuration, without validating cross-thread provider ownership.
The injected completion is the controlled precondition, not proof that every normal turn creates corruption. Once that precondition exists, the selector deterministically adopts the foreign ID. A live writer collision additionally depends on the daemon and provider resuming that selected ID; that downstream interaction was not exercised here. The issue's other cited location, line 476, concerns archive forwarding.
6. Proposed fix
Define authoritative identity transitions and resolve resume targets from those transitions rather than every event stamp. Audit fork, rewind, context clear, imported history, and provider-specific identity changes before narrowing the query. Enforce ownership consistently at acceptance and dispatch, with provider and environment scoping and concurrency handling; filtering event types alone cannot protect against a misattributed identity event. The reproduction above should become a regression, alongside legitimate identity-transition and duplicate-ownership tests.
No fix branch or pull request was created: open PR #3460 already links this issue, so the automation's duplicate-PR rule excludes a second PR.
7. PR review
#3460 — static review only
Open PR #3460 changes agent-runtime identity attribution and pending-slot consumption, adds process-local ownership checks, and changes Codex writer retry handling. Its diff does not change the database selector or server command preparation examined above. The runtime check uses the hosted provider-state identity registry, so it does not establish a persistent server ownership invariant across separate processes or daemon restarts.
Finding (Medium): packages/agent-runtime/src/runtime.ts, resumeThread ownership-check addition — the guard is useful for hosted threads but leaves the verified server selector gap. Verdict: related mitigation; server gap remains outside this diff. No PR branch or tests were executed; both reproduction runs used trusted main only. The PR's claimed validation was not independently rerun.
8. Related issues
PR metadata identifies #2327 as the upstream daemon identity-attribution issue and distinguishes #2328 as a recovery-storm issue. Their historical incidents were not re-investigated here.
9. Verification
The same agent repeated the test in a second clean detached checkout at the recorded SHA, with a separate frozen installation and new migrated in-memory database. The first command was pnpm exec turbo run test --filter=@bb/db -- issue-3461.test.ts; the second added --force before the argument separator to force execution. Both commands exited 1 at the final assertion (line 44), returning session-beta instead of session-alpha. Initial identity checks and the second thread's identity check passed. Test durations were 2.11 s and 1.60 s; the tests themselves took 176 ms and 123 ms.
The two checkouts had unchanged tracked production files and only the newly authored reproduction test as an untracked source file. Both full builds completed successfully. Static code links were checked against the recorded commit. Report correction: line 476 is an archive path; no live incident replay or end-to-end command dispatch is claimed.
10. Appendix
The exact reproduction test and relevant failure output are included above; raw build and test logs remain local. The public reports repository prohibits separate test and log artifacts, so the HTML is self-contained. GitHub reads covered issue fields, labels, comments (none), main SHA, visibility, and the linked open PR metadata and diff. Writes filled missing Bug / High / Medium / threads only; no pre-existing classifications were replaced.
Issue content and PR content were treated as untrusted evidence. No instructions, scripts, URLs, or branches supplied inside that content were executed or fetched. No live app or provider was started and no user runtime data was read.
> AGENT GENERATED