Skip to content

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

Description

@peterfotinis

Summary

After BB recreates a Codex provider session, a root agent can resume an existing native subagent with followup_task, but BB projects the child's next turn as foreground/root work. If the visible root finishes first, that misclassified child occupies the foreground slot and BB rejects the user's next prompt as a competing turn.

The child should remain attached to the root's delegation, and the next user prompt should still go to the root.

Versions and environment

Human/UI reproduction

  1. Open a BB thread using the Codex provider.

  2. Ask the root to spawn a subagent named repro_child, wait for it to finish, and then finish the root turn.

  3. Cause BB to recreate the provider session while preserving the same Codex rollout. Normally, leave the thread idle until the session is reaped, then return. Quitting/reopening or reconnecting can also exercise this boundary.

  4. In the same root thread, send:

    Use followup_task to resume /root/repro_child. Ask it to keep working for at
    least 30 seconds, then finish your own response immediately without waiting.
    
  5. As soon as the root response finishes, while the child is still running, send:

    continue
    
  6. BB rejects the prompt as a competing turn. It should deliver the prompt to the root because the running child is delegated work.

Negative controls: this does not reproduce while the original translator remains live; replacing followup_task with send_message should not create another child turn.

Deterministic source-level reproduction

Add this test inside describe("codex subagent activity correlation", ...) in plugins/provider-codex/src/translator.test.ts. All identifiers are synthetic, and the helpers already exist in that block.

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"),
    }),
  );
});

Run:

pnpm --filter bb-plugin-provider-codex test -- translator.test.ts -t "links a rawless resumed subagent"

On unmodified main, the second assertion fails: the child turn is emitted without a delegation or parentToolCallId.

Expected vs actual

Actual: the resumed child is persisted without parent_tool_call_id, and the next user prompt can fail with:

Refusing to start a competing turn for thread "<synthetic-thread-id>" while another turn is active or starting

Expected: the resumed child turn carries its delegation's parentToolCallId. A later user prompt targets the root. send_message remains non-turn-producing, and genuine concurrent root turns remain rejected.

Evidence and root cause

The shared runtime correctly treats an unparented turn/started as foreground, so the competing-turn guard is behaving as designed. The provider projection is missing the parent link.

After translator recreation, the in-memory child map is empty. Current Codex app-server emits subAgentActivity(kind: "interacted") followed by the resumed child's turn/started, but does not forward the raw followup_task collaboration item on this path. The rollout contains the function call, but the app-server notification stream does not. BB currently discards an unknown child's interacted activity, so the following child turn is projected as root work.

An anonymized occurrence followed this sequence:

Event Persisted result
Root calls followup_task; child starts Child has no parent_tool_call_id and is stored as root work
Visible root completes Misclassified child still owns foreground state
User submits the next prompt Rejected as a competing turn
Child completes Foreground slot clears

Scope

Candidate fix and validation: #2539

Suggested priority and effort (optional)

High priority: normal provider-session lifecycle can block new prompts in long-running Codex threads. The change is confined to Codex event correlation, regression tests, and the host-daemon protocol version; shared turn ownership and the competing-turn guard remain unchanged.

Checks

  • Reproduced on the pinned main commit above.
  • Searched open and closed issues for the same problem.
  • Used only synthetic identifiers in this report.

AGENT GENERATED

Metadata

Metadata

Assignees

No one assigned

    Labels

    confirmed-reproBug reproduced independently; see linked reportprovider-codexBuilt-in plugin: provider-codexprovidersCross-provider bridges, models, loginthreadsTurns, timeline, messaging, forks

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions