← reports

#2634 · Codex follow-up work loses accepted-turn correlation

Bug High Effort: Medium providers threads provider-codex open on GitHub 2026-08-28 · base ec8f4ef04105

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

ClaimStatusEvidence
A later request can split acceptance and provider work across two turns.VerifiedThe test records acceptance on turn 2 and work on turn 3.
The first ordinary turn does not split.VerifiedThe first request records one start, accepted input, work item, and completion.
The split occurs on fresh threads.VerifiedTwo clean checkouts reproduced the same turn mismatch with new process state.
A native agent tool call shows the split.Not separately testedThe hermetic test uses an agent-message work item on the same turn lifecycle path.
An exact turn-bound consumer rejects the work.VerifiedThe focused assertion rejects work whose turn differs from the accepted turn.

3. Environment

4. Minimal reproduction

  1. Check out the trusted base commit.
  2. Run the frozen install and the full Turbo build.
  3. Change LATE_TURN_START_DELAY_MS from 60 to 350 in fake-codex-app-server.mjs.
  4. 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);
  1. 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.

See the synthetic settlement.

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.

See the correlation shift.

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

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.