← reports

#2538 · Bug - Refusing to start a competing turn for thread “<threadid>” while another turn is active or starting (Codex)

Bug Priority: High Effort: not set providers threads provider-codex open on GitHub 2026-08-27 · base ad79bbb5ec90

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

After BB recreates a Codex translator, followup_task can resume a historical child.

The new translator does not know that child.

It ignores the interacted event and emits the child turn without parentToolCallId.

The runtime then treats the child as foreground work.

BB can reject the next user prompt while the child remains active.

2. Claims vs findings

Claim from the issueStatusEvidence
A fresh translator loses the historical child map. Verified The translator stores the map only in closure state. A new harness models a new translator.
An unknown interacted activity produces no delegation link. Verified The base test returns an empty array for the activity.
The next child turn has no parentToolCallId. Verified The test receives only an unparented turn/started event.
The runtime treats that child as foreground work. Verified RuntimeTurnState records each unparented turn as the active turn.
The guard rejects another user turn while that child stays active. Verified The guard throws the reported error whenever the runtime has an active turn.
The original live translator does not show this restart gap. Verified The test re-arms the parent link when a completed subagent is interacted with again passes at the base commit. The saved base log contains its output.

3. Environment

The first install hit the shared /tmp inode limit. The revision used dependency directories from the initial checkout. The full Turbo build then passed all 18 tasks.

4. Minimal reproduction

Start in a local copy of the directory that contains this HTML file and its 2538 directory.

  1. Save the report directory, clone BB, and enter the clone.
    report_dir=$PWD
    work_dir=$(mktemp -d)
    git clone https://github.com/get-bb/bb.git "$work_dir/bb"
    cd "$work_dir/bb"
    git switch --detach ad79bbb5ec909524f8f281e62d860c588a86f332
    pnpm install --frozen-lockfile --prefer-offline
  2. Check and apply the saved local test patch.
    git apply --check "$report_dir/2538/repro/base-repro.patch"
    git apply "$report_dir/2538/repro/base-repro.patch"
  3. Run the focused test from plugins/provider-codex.
    cd plugins/provider-codex
    pnpm exec vitest run src/translator.test.ts -t "links a rawless resumed subagent"

Expected output

Test Files  1 passed (1)
Tests       1 passed

Actual output

FAIL  |bb-plugin-provider-codex| src/translator.test.ts
AssertionError: expected [ { type: 'turn/started', … } ]
to deep equally contain ObjectContaining { type: "item/started" }

Received:
[
  {
    "type": "turn/started",
    "scope": { "kind": "turn", "turnId": "cxt-test-t1" },
    "providerThreadId": "",
    "threadId": ""
  }
]

Test Files  1 failed (1)
Tests       1 failed | 35 skipped (36)

Reproduction test

it("links a rawless resumed subagent when its child turn starts", () => {
  const harness = createHarness();

  expect(
    harness.translate(
      subAgentActivity({
        id: "synthetic-followup-call",
        kind: "interacted",
        agentThreadId: "synthetic-agent-thread",
      }),
    ),
  ).toEqual([]);

  const events = harness.translate(childTurnStarted("synthetic-child-turn"));
  expect(events).toContainEqual(
    expect.objectContaining({
      type: "item/started",
      item: expect.objectContaining({
        type: "delegation",
        childRef: "synthetic-agent-thread",
      }),
    }),
  );
  expect(events).toContainEqual(
    expect.objectContaining({
      type: "turn/started",
      parentToolCallId: harness.itemId("synthetic-followup-call"),
    }),
  );
});

Repro files: applicable patch, test source, and defect and negative-control output.

This defect has no visual component, so the investigation used no screenshots.

5. Root cause

The translator keeps child correlation only in local maps.

A recreated translator starts with empty maps.

The interacted branch only restores a child that the same translator already tracks as terminal.

const tracked = findTrackedSubAgentByAgentThreadId(
  activity.item.agentThreadId,
);
if (tracked?.terminal) {
  tracked.pendingFollowups += 1;
  rearmTrackedSubAgent(tracked);
  ...
}
return [];

Source: translator.ts lines 1053–1120.

An unknown historical child therefore gets no map entry and no FIFO link.

The next turn.open cannot find a parent and remains unparented.

Source: translator.ts lines 887–939.

The shared runtime correctly skips parented child turns and records unparented turns as foreground.

if (event.type === "turn/started") {
  if (event.parentToolCallId) {
    return;
  }
  ...
  this.activeTurnIdByThreadId.set(event.threadId, turnId);
}

Source: runtime-turn-state.ts lines 73–83.

The turn guard then rejects the next prompt because the child owns the foreground slot.

Source: runtime.ts lines 929–936.

The runtime guard is not the defect. The Codex projection lacks the durable parent link.

6. Proposed fix (first principles)

The Codex bridge must restore intent and child identity after translator recreation.

  1. Use the raw followup_task call when Codex supplies it.
  2. Store an unknown rawless interacted event until a child turn proves a resume.
  3. Match that turn by its agent thread identity.
  4. Discard a message-only interaction at its parent turn boundary.
  5. Do not infer a child from an unrelated root-thread turn.
  6. Increment the host daemon protocol version because turn-parent meaning changes across the boundary.

A durable call identifier from Codex would remove the need for any ambiguous root-thread fallback.

7. PR review

#2539 · Fix Codex subagent relinking after session resume

The PR records raw collaboration intent and defers unknown interactions until a child turn starts.

It also adds the correct protocol bump from 171 to 172.

The core translator change addresses the root cause, and the issue test passes on the PR head.

SeverityFindingEvidence
Major The PR changes unrelated gzip size snapshots and breaks the contract suite on supported Node 24. payload-size.test.ts lines 76–92. The base suite passes 51 tests. The PR suite fails one test with the same payload JSON sizes.
Risk The rawless fallback treats an eligible root-thread turn as proof of a resume. translator.ts lines 1205–1233. The PR should keep a test for an unrelated root-multiplexed child after a rawless message.

Tests

Test log: 2538/repro/pr-2539-tests.txt.

Verdict: REQUEST CHANGES. Revert the six gzip snapshot changes or make the test independent of the zlib version.

8. Related issues

Current origin/main equals the base commit. It does not contain a later fix.

9. Appendix

Commands

git apply --check /tmp/bb-reports/issues/2538/repro/base-repro.patch
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
gh issue view 2538 --comments
gh pr view 2539 --comments
gh pr diff 2539
pnpm exec vitest run src/translator.test.ts -t "links a rawless resumed subagent"
pnpm exec vitest run src/translator.test.ts -t "re-arms the parent link when a completed subagent is interacted with again"
gh pr checkout 2539
pnpm exec vitest run src/translator.test.ts
pnpm exec turbo run test --filter=bb-plugin-provider-codex --force
pnpm exec turbo run typecheck --filter=bb-plugin-provider-codex
pnpm exec turbo run test --filter=@bb/host-daemon-contract --force
git checkout --detach ad79bbb5ec909524f8f281e62d860c588a86f332
pnpm exec turbo run test --filter=@bb/host-daemon-contract --force
git fetch origin main:refs/remotes/origin/main
git diff --check ad79bbb5ec909524f8f281e62d860c588a86f332...c2b3f62fd16052c8895a3a16457793e94b217a8d

Revision build log: 2538/repro/revise-build.txt.

Limits

The investigation reproduced the exact provider projection with synthetic identifiers.

It did not wait for a real session reap or spend a provider turn.

The deterministic runtime code establishes the user-visible rejection after the unparented child start.

Verification

The verifier applied the local patch and observed the exact unparented turn/started event. The verifier also confirmed that the base contract suite passed 51 tests. This revision repeated that base suite with an isolated temporary directory and passed all 51 tests.

The verifier confirmed the PR tests passed and its Node 24 contract suite failed one of 51 tests. This revision removed the unavailable patch URL and added exact clone, directory, and local patch commands.

This revision also names the negative-control test and saves its passing output with the defect output.