#2634 · Codex follow-up work loses accepted-turn correlation
Verdict: REPRODUCED · Root-cause confidence: high
1. TL;DR
A delayed Codex follow-up creates two BB turns for one accepted request.
The first turn accepts the input and completes without work.
The real provider work then uses a second turn without an accepted-input event.
A fixed 250-millisecond timer consumes the request correlation before the real turn/started notification arrives.
2. Claims versus findings
| Claim | Status | Evidence |
|---|---|---|
| A later request can split acceptance and provider work across two turns. | Verified | The test records acceptance on turn 2 and work on turn 3. |
| The first ordinary turn does not split. | Verified | The first request records one start, accepted input, work item, and completion. |
| The split occurs on fresh threads. | Verified | Two clean checkouts reproduced the same turn mismatch with new process state. |
| A native agent tool call shows the split. | Not separately tested | The hermetic test uses an agent-message work item on the same turn lifecycle path. |
| An exact turn-bound consumer rejects the work. | Verified | The focused assertion rejects work whose turn differs from the accepted turn. |
3. Environment
- Repository:
get-bb/bb. - Trusted base:
ec8f4ef04105c2cb5a59f7a9a9328bf2b16b39ce. - System: Linux 7.0.0, x86-64.
- Node: 24.18.0.
- pnpm: 9.15.0.
- Provider: the trusted fake Codex app server in the repository.
- No server, port, BB data directory, account, or external provider process was used.
4. Minimal reproduction
- Check out the trusted base commit.
- Run the frozen install and the full Turbo build.
- Change
LATE_TURN_START_DELAY_MSfrom60to350infake-codex-app-server.mjs. - Append this test to
bridge.zero-work-turn.test.ts.
it("keeps delayed follow-up work in the turn that accepts the input", async () => {
const providerThreadId = await startSession();
harness.sendRequest(2, "turn/start", {
threadId: THREAD_ID,
providerThreadId,
input: [{ type: "text", text: "first", mentions: [] }],
clientRequestId: "creq_firsttrn23",
options: { ...sessionOptions },
});
await harness.waitForResponse(2);
await waitForEvents((events) =>
events.some((event) => event.type === "item/agentMessage/delta"),
);
harness.sendRequest(3, "turn/start", {
threadId: THREAD_ID,
providerThreadId,
input: [{ type: "text", text: "/late-start", mentions: [] }],
clientRequestId: "creq_23456789ac",
options: { ...sessionOptions },
});
await harness.waitForResponse(3);
const events = await waitForEvents(
(all) =>
all.filter((event) => event.type === "item/agentMessage/delta").length === 2,
);
const accepted = events.find(
(event) =>
event.type === "turn/input/accepted" &&
event.clientRequestId === "creq_23456789ac",
);
const work = events.filter(
(event) => event.type === "item/agentMessage/delta",
)[1];
const acceptedTurnId =
accepted?.scope.kind === "turn" ? accepted.scope.turnId : "";
expect(acceptedTurnId).not.toBe("");
expect(work).toMatchObject({
scope: { kind: "turn", turnId: acceptedTurnId },
});
expect(events.filter((event) => event.type === "turn/started")).toHaveLength(2);
expect(events.filter((event) => event.type === "turn/completed")).toHaveLength(2);
}, 30_000);
- Run the focused test.
pnpm exec turbo run test --filter=bb-plugin-provider-codex -- --testNamePattern='keeps delayed follow-up work'
Expected
Test Files 1 passed Tests 1 passed
Actual
AssertionError: expected { …(6) } to match object { scope: { kind: 'turn', …(1) } }
- Expected
+ Received
{
"scope": {
"kind": "turn",
- "turnId": "da6353dd29-t2",
+ "turnId": "da6353dd29-t3",
},
}
5. Verification
The same agent repeated the test in a second clean checkout at the same trusted commit.
The second run failed at the same assertion.
- Expected turnId: da80b1231f-t2 + Received turnId: da80b1231f-t3 Test Files 1 failed | 23 skipped Tests 1 failed | 249 skipped
No report claim changed after the second run.
6. Root cause
The bridge queues the request identifier before it sends turn/start.
The bridge then parses the app-server result as an ignored value and starts a fixed 250-millisecond timer.
See the ignored result schema and the request and timer call.
When no real turn opens within 250 milliseconds, the timer claims the queued request identifier.
It then emits a synthetic open, accepted-input, and completed lifecycle.
The claim removes the identifier from the translator queue.
See the claim logic.
The later real turn/started notification opens another turn.
The translator finds no queued request identifier, so it cannot emit accepted input for the real turn.
All later work items use the real turn, which explains the verified turn 2 versus turn 3 mismatch.
7. Proposed fix from first principles
Parse the real turn identity from the turn/start result.
Use that identity to open the accepted turn before a delayed notification can trigger synthetic settlement.
Suppress a later duplicate turn/started event for the same provider turn.
Keep synthetic zero-work settlement only for responses that contain no real turn identity.
Increment the host daemon protocol version because this change alters emitted lifecycle sequences.
8. Related issues
- Issue 2580 reports the same fixed-delay synthetic-turn mechanism.
- Pull request 2639 targets issue 2580 and the same mechanism.
The pull request does not link to issue 2634.
This investigation did not check out or run its untrusted branch.
9. Appendix
Commands
git fetch origin main git worktree add --detach WORK ec8f4ef04105c2cb5a59f7a9a9328bf2b16b39ce pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build pnpm exec turbo run test --filter=bb-plugin-provider-codex -- --testNamePattern='keeps delayed follow-up work'
Build result
Tasks: 18 successful, 18 total Cached: 9 cached, 18 total Time: 8m34.192s
Trust note
The issue title, body, comments, links, and code blocks were treated as untrusted claims.
Only trusted origin/main code and local test changes were run.