#2538 · Bug - Refusing to start a competing turn for thread “<threadid>” while another turn is active or starting (Codex)
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 issue | Status | Evidence |
|---|---|---|
| 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
- bb commit:
ad79bbb5ec909524f8f281e62d860c588a86f332. - OS: Linux 7.0.0-29-generic, x86_64.
- Node:
v24.18.0. pnpm:9.15.0. - Codex CLI:
0.150.1. - The source test used no BB dev instance, port, or data directory.
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.
-
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
-
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"
-
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.
- Use the raw
followup_taskcall when Codex supplies it. - Store an unknown rawless
interactedevent until a child turn proves a resume. - Match that turn by its agent thread identity.
- Discard a message-only interaction at its parent turn boundary.
- Do not infer a child from an unrelated root-thread turn.
- 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.
| Severity | Finding | Evidence |
|---|---|---|
| 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
- PR translator test: 40 of 40 passed.
- PR provider suite: 243 of 243 passed.
- PR provider typecheck: passed.
- PR host daemon contract suite: 1 of 51 failed.
- Base host daemon contract suite: 51 of 51 passed.
git diff --check: passed.
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
- #807 describes the same failure class before the current provider bridge design.
- #1361 fixed resumes that occur inside one live translator.
- #2370 concerns a real busy root turn and a different input path.
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.