← reports

#5672 · Resumed child lifecycles lose delegation correlation

BugPriority: MediumEffort: Lowprovidersprovider-codexIssue2026-10-17 · base 6a639cfc0d66e0c789a1bd7309266973228d1a80

Verdict: REPRODUCED (translator lifecycle defect) · Root-cause confidence: high

1. TL;DR

A resumed Codex child can be mistaken for the main conversation when its turn-start notification arrives before its follow-up activity notification. After the first child turn ends, the translator clears active parent links but still knows the child's identity; it does not consult that identity when the next turn starts. The resulting start lacks a parent marker, which the runtime uses to distinguish a child from a root turn. A second mapping defect prevents the resumed delegation from closing correctly after the late activity arrives. A new regression reproduces the lifecycle failure in two clean trusted checkouts; no real account, user thread, or live Codex process was used.

2. Claims vs findings

ClaimStatusEvidence
A known resumed child can emit an unparented turn start.VerifiedThe late-activity case fails in both clean checkouts: the assembled turn/started event lacks parentToolCallId.
A new activity must own the already-started resumed turn.VerifiedAfter adding only the identity fallback, completion still resolves to the old call unless the turn mapping is rebound; the final regression also requires the new delegation's completed item.
This can overwrite runtime root-turn state and disrupt steering.Supported by codeRuntimeTurnState ignores only starts with parentToolCallId; all others replace active-turn state. The steer path compares against that state and forwards expectedTurnId to Codex.
A particular production thread failed with particular turn identifiers.UnverifiedNo personal runtime data was accessed. The exact live incident, frequency, and provider notification timing were not independently observed.

3. Environment

4. Minimal reproduction

  1. Start the root turn, spawn a child, start and complete its first turn on a distinct provider thread.
  2. Emit a raw followup_task intent for that child.
  3. Start the child's next turn before its activity notification, then deliver that activity and complete the child.
  4. Require a parented child start and a completed item for the new delegation. Compare against the control where activity arrives before turn start.

The following commands contain the complete newly authored patch. It extends the existing owning translator tests and reuses only trusted-main helpers. Expected: both ordering cases pass. Actual on the base: the early-activity control passes; the late-activity case fails because the child start has no parent.

git clone https://github.com/get-bb/bb.git bb-5672
cd bb-5672
git checkout --detach 6a639cfc0d66e0c789a1bd7309266973228d1a80
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build --filter=bb-plugin-provider-codex
git apply - <<'PATCH'
diff --git a/plugins/provider-codex/src/translator.test.ts b/plugins/provider-codex/src/translator.test.ts
index 808d52ec5..34919492e 100644
--- a/plugins/provider-codex/src/translator.test.ts
+++ b/plugins/provider-codex/src/translator.test.ts
@@ -876,6 +876,66 @@ describe("codex subagent activity correlation", () => {
     ]);
   });
 
+  it.each(["before", "after"] as const)(
+    "keeps a resumed child nested when follow-up activity arrives %s its turn start",
+    (activityOrder) => {
+      const harness = createHarness();
+      const childThreadId = "agent-thread-1";
+      harness.translate(childTurnStarted("parent-turn"));
+      harness.translate(
+        subAgentActivity({ id: "subagent-call-1", kind: "started" }),
+      );
+      harness.translate(childTurnStarted("child-turn-1", childThreadId));
+      harness.translate(childTurnCompleted("child-turn-1", childThreadId));
+      harness.translate(
+        rawCollaborationCall({ callId: "resume-call", name: "followup_task" }),
+      );
+
+      const activity = subAgentActivity({
+        id: "resume-call",
+        kind: "interacted",
+      });
+      if (activityOrder === "before") {
+        harness.translate(activity);
+      }
+      const started = harness.translate(
+        childTurnStarted("child-turn-2", childThreadId),
+      );
+      const activityEvents =
+        activityOrder === "after" ? harness.translate(activity) : [];
+      const completed = harness.translate(
+        childTurnCompleted("child-turn-2", childThreadId),
+      );
+
+      expect(started).toContainEqual(
+        expect.objectContaining({
+          type: "turn/started",
+          scope: turnScope(harness.turnId("child-turn-2")),
+          parentToolCallId: expect.any(String),
+        }),
+      );
+      expect(completed).toEqual([
+        expect.objectContaining({
+          type: "turn/completed",
+          scope: turnScope(harness.turnId("child-turn-2")),
+        }),
+        expect.objectContaining({
+          type: "item/completed",
+          item: expect.objectContaining({
+            type: "delegation",
+            id: harness.itemId("resume-call"),
+            status: "completed",
+          }),
+        }),
+      ]);
+      expect(
+        [...started, ...activityEvents, ...completed].filter(
+          (event) => event.type === "turn/started" && !event.parentToolCallId,
+        ),
+      ).toEqual([]);
+    },
+  );
+
   it("links an unknown resumed subagent from the raw followup intent after translator restart", () => {
     const harness = createHarness();
PATCH
pnpm exec turbo run test --filter=bb-plugin-provider-codex -- src/translator.test.ts -t 'keeps a resumed child nested'

Regression test

  it.each(["before", "after"] as const)(
    "keeps a resumed child nested when follow-up activity arrives %s its turn start",
    (activityOrder) => {
      const harness = createHarness();
      const childThreadId = "agent-thread-1";
      harness.translate(childTurnStarted("parent-turn"));
      harness.translate(
        subAgentActivity({ id: "subagent-call-1", kind: "started" }),
      );
      harness.translate(childTurnStarted("child-turn-1", childThreadId));
      harness.translate(childTurnCompleted("child-turn-1", childThreadId));
      harness.translate(
        rawCollaborationCall({ callId: "resume-call", name: "followup_task" }),
      );

      const activity = subAgentActivity({
        id: "resume-call",
        kind: "interacted",
      });
      if (activityOrder === "before") {
        harness.translate(activity);
      }
      const started = harness.translate(
        childTurnStarted("child-turn-2", childThreadId),
      );
      const activityEvents =
        activityOrder === "after" ? harness.translate(activity) : [];
      const completed = harness.translate(
        childTurnCompleted("child-turn-2", childThreadId),
      );

      expect(started).toContainEqual(
        expect.objectContaining({
          type: "turn/started",
          scope: turnScope(harness.turnId("child-turn-2")),
          parentToolCallId: expect.any(String),
        }),
      );
      expect(completed).toEqual([
        expect.objectContaining({
          type: "turn/completed",
          scope: turnScope(harness.turnId("child-turn-2")),
        }),
        expect.objectContaining({
          type: "item/completed",
          item: expect.objectContaining({
            type: "delegation",
            id: harness.itemId("resume-call"),
            status: "completed",
          }),
        }),
      ]);
      expect(
        [...started, ...activityEvents, ...completed].filter(
          (event) => event.type === "turn/started" && !event.parentToolCallId,
        ),
      ).toEqual([]);
    },
  );

Actual base failure

Verbatim assertion output; checkout paths have been replaced with neutral names.

  FAIL  |bb-plugin-provider-codex| src/translator.test.ts > codex subagent activity correlation > keeps a resumed child nested when follow-up activity arrives after its turn start
bb-plugin-provider-codex:test: AssertionError: expected [ { type: 'turn/started', …(3) } ] to deep equally contain ObjectContaining{…}
bb-plugin-provider-codex:test: 
bb-plugin-provider-codex:test: - Expected:
bb-plugin-provider-codex:test: ObjectContaining {
bb-plugin-provider-codex:test:   "parentToolCallId": Any<String>,
bb-plugin-provider-codex:test:   "scope": {
bb-plugin-provider-codex:test:     "kind": "turn",
bb-plugin-provider-codex:test:     "turnId": "cxt-test-t3",
bb-plugin-provider-codex:test:   },
bb-plugin-provider-codex:test:   "type": "turn/started",
bb-plugin-provider-codex:test: }
bb-plugin-provider-codex:test: 
bb-plugin-provider-codex:test: + Received:
bb-plugin-provider-codex:test: [
bb-plugin-provider-codex:test:   {
bb-plugin-provider-codex:test:     "providerThreadId": "",
bb-plugin-provider-codex:test:     "scope": {
bb-plugin-provider-codex:test:       "kind": "turn",
bb-plugin-provider-codex:test:       "turnId": "cxt-test-t3",
bb-plugin-provider-codex:test:     },
bb-plugin-provider-codex:test:     "threadId": "",
bb-plugin-provider-codex:test:     "type": "turn/started",
bb-plugin-provider-codex:test:   },
bb-plugin-provider-codex:test: ]
bb-plugin-provider-codex:test: 
bb-plugin-provider-codex:test:  ❯ src/translator.test.ts:910:23
bb-plugin-provider-codex:test:     908|       );
bb-plugin-provider-codex:test:     909|
bb-plugin-provider-codex:test:     910|       expect(started).toContainEqual(
bb-plugin-provider-codex:test:        |                       ^
bb-plugin-provider-codex:test:     911|         expect.objectContaining({
bb-plugin-provider-codex:test:     912|           type: "turn/started",
bb-plugin-provider-codex:test: 
bb-plugin-provider-codex:test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
bb-plugin-provider-codex:test: 
bb-plugin-provider-codex:test: 
bb-plugin-provider-codex:test:

5. Root cause

Completion removes transient provider-thread and turn-to-call links but retains the tracked child identity. When the same child's next turn arrives before follow-up activity, attachCodexDelegationParentLink checks only the cleared transient links. The pending root-thread queue is not a substitute for the distinct child-thread identity, so it emits turn.open without parentRef.

Completion cleanup clears transient links

  function clearTrackedSubAgentLinks(tracked: CodexTrackedSubAgent): void {
    removePendingDelegationCall(tracked.callId);
    if (
      delegationParentToolCallIdsByProviderThreadId.get(
        tracked.agentThreadId,
      ) === tracked.callId
    ) {
      delegationParentToolCallIdsByProviderThreadId.delete(
        tracked.agentThreadId,
      );
    }
    for (const [
      turnId,
      parentToolCallId,
    ] of delegationParentToolCallIdsByTurnId) {
      if (parentToolCallId === tracked.callId) {
        delegationParentToolCallIdsByTurnId.delete(turnId);
      }
    }
  }

Turn-start correlation only consults transient mappings

    providerThreadId: string | undefined,
  ): ThreadDelta {
    const turnId = getDeltaProviderTurnId(delta);
    let parentToolCallId =
      getDeltaParentRef(delta) ??
      (turnId ? delegationParentToolCallIdsByTurnId.get(turnId) : undefined);

    if (
      !parentToolCallId &&
      delta.kind === "turn.open" &&
      delta.providerTurnId !== undefined
    ) {
      const startedTurnId = delta.providerTurnId;
      const mappedFromProviderThread = providerThreadId
        ? delegationParentToolCallIdsByProviderThreadId.get(providerThreadId)
        : undefined;
      if (mappedFromProviderThread) {
        parentToolCallId = mappedFromProviderThread;
        removePendingDelegationCall(mappedFromProviderThread);
      } else if (
        !providerThreadId ||
        !hasPendingNativeTurnStart(providerThreadId)
      ) {
        parentToolCallId = consumePendingDelegationTurnLink({
          providerThreadId,
          turnId: startedTurnId,
        });
      }
    }

    if (!parentToolCallId && providerThreadId) {
      parentToolCallId =
        delegationParentToolCallIdsByProviderThreadId.get(providerThreadId);
    }

    if (
      delta.kind === "turn.open" &&
      delta.providerTurnId !== undefined &&
      parentToolCallId
    ) {
      delegationParentToolCallIdsByTurnId.set(
        delta.providerTurnId,
        parentToolCallId,
      );
    }

    return parentToolCallId
      ? withDeltaParentRef(delta, parentToolCallId)
      : delta;
  }

The translator already has findTrackedSubAgentByAgentThreadId and a retained agent-thread-to-call map. Reusing that identity can keep the early start nested, but by itself it is insufficient: when activity later opens a fresh delegation, the existing turn-to-call entry must move from the previous call to the new one. Otherwise completeFinishedCodexSubAgentTurns finds the already-terminal old record and never closes the new delegation.

A separate local experiment applied only the newly authored identity fallback in checkout B. The late-start case then failed at the delegation-completion assertion rather than the parent-marker assertion, confirming that both repairs are necessary. Checkout B's production file was subsequently restored to the trusted base.

  FAIL  |bb-plugin-provider-codex| src/translator.test.ts > codex subagent activity correlation > keeps a resumed child nested when follow-up activity arrives after its turn start
bb-plugin-provider-codex:test: AssertionError: expected [ { type: 'turn/completed', …(5) } ] to deeply equal [ ObjectContaining{…}, …(1) ]
bb-plugin-provider-codex:test: 
bb-plugin-provider-codex:test: - Expected
bb-plugin-provider-codex:test: + Received
bb-plugin-provider-codex:test: 
bb-plugin-provider-codex:test: @@ -8,14 +8,6 @@
bb-plugin-provider-codex:test:       },
bb-plugin-provider-codex:test:       "status": "completed",
bb-plugin-provider-codex:test:       "threadId": "",
bb-plugin-provider-codex:test:       "type": "turn/completed",
bb-plugin-provider-codex:test:     },
bb-plugin-provider-codex:test: -   ObjectContaining {
bb-plugin-provider-codex:test: -     "item": ObjectContaining {
bb-plugin-provider-codex:test: -       "id": "cxt-test-i2",
bb-plugin-provider-codex:test: -       "status": "completed",
bb-plugin-provider-codex:test: -       "type": "delegation",
bb-plugin-provider-codex:test: -     },
bb-plugin-provider-codex:test: -     "type": "item/completed",
bb-plugin-provider-codex:test: -   },
bb-plugin-provider-codex:test:   ]
bb-plugin-provider-codex:test: 
bb-plugin-provider-codex:test:  ❯ src/translator.test.ts:917:25
bb-plugin-provider-codex:test:     915|         }),
bb-plugin-provider-codex:test:     916|       );
bb-plugin-provider-codex:test:     917|       expect(completed).toEqual([
bb-plugin-provider-codex:test:        |                         ^
bb-plugin-provider-codex:test:     918|         expect.objectContaining({

Retained identity and creation of a new tracked delegation

  function findTrackedSubAgentByAgentThreadId(
    agentThreadId: string,
  ): CodexTrackedSubAgent | undefined {
    const callId = trackedSubAgentCallIdsByAgentThreadId.get(agentThreadId);
    if (!callId) {
      return undefined;
    }
    return trackedSubAgentsByCallId.get(callId);
  }

  function beginCodexTrackedSubAgent(
    activity: CodexSubAgentActivityEvent,
  ): ThreadDelta[] {
    const tracked: CodexTrackedSubAgent = {
      agentPath: activity.item.agentPath,
      agentThreadId: activity.item.agentThreadId,
      callId: activity.item.id,
      parentProviderThreadId: activity.providerThreadId,
      parentTurnId: activity.turnId,
      pendingFollowups: 0,
      terminal: false,
    };
    trackedSubAgentsByCallId.set(tracked.callId, tracked);
    trackedSubAgentCallIdsByAgentThreadId.set(
      tracked.agentThreadId,
      tracked.callId,
    );

    const [openDelta] = attachCodexDelegationParentLinks(
      [buildCodexSubAgentOpenDelta(tracked)],
      activity.providerThreadId,
    );
    if (openDelta?.kind === "item.open") {
      tracked.parentToolCallId = openDelta.key.parentRef;
    }
    enqueuePendingDelegationTurnLink({
      callId: tracked.callId,
      parentTurnId: tracked.parentTurnId,
      providerThreadId: tracked.parentProviderThreadId,
    });
    return openDelta ? [openDelta] : [];
  }

Completion resolves the tracked delegation by the turn mapping

  }

  function completeFinishedCodexSubAgentTurns(
    deltas: ThreadDelta[],
  ): ThreadDelta[] {
    const completedDeltas: ThreadDelta[] = [];
    for (const delta of deltas) {
      completedDeltas.push(delta);
      if (
        delta.kind !== "turn.boundary" ||
        delta.providerTurnId === undefined
      ) {
        continue;
      }
      const callId = delegationParentToolCallIdsByTurnId.get(
        delta.providerTurnId,
      );
      const tracked = callId ? trackedSubAgentsByCallId.get(callId) : undefined;
      if (!tracked) {
        continue;
      }
      const completed = completeCodexTrackedSubAgent({
        tracked,
        status: delta.status,
      });
      if (completed) {
        completedDeltas.push(completed);
      }
    }
    return completedDeltas;

The runtime distinguishes root starts using the parent marker, not a separate explicit root flag:

Unparented starts replace the active runtime turn

  observe(event: ThreadEvent): void {
    if (event.type === "turn/started") {
      if (event.parentToolCallId) {
        return;
      }
      const turnId = requireThreadEventScopeTurnId({
        type: event.type,
        scope: event.scope,
      });
      this.activeTurnIdByThreadId.set(event.threadId, turnId);
      this.resolveWaiters(event.threadId, turnId);
      return;
    }

    if (event.type === "turn/completed") {
      const turnId = requireThreadEventScopeTurnId({
        type: event.type,
        scope: event.scope,
      });
      if (this.activeTurnIdByThreadId.get(event.threadId) === turnId) {
        this.activeTurnIdByThreadId.delete(event.threadId);
      }
    }
  }

  private resolveWaiters(threadId: string, turnId: string | null): void {
    const waiters = this.activeTurnWaitersByThreadId.get(threadId);
    if (!waiters) {

Consequently the malformed child event can replace the active root turn, or its subsequent completion can clear active state. Depending on when steering occurs, the request can be rejected as stale or forwarded with a child turn id. This consequence is traced through trusted code, not claimed as a live end-to-end reproduction.

Runtime stale-steer comparison · Codex expectedTurnId forwarding

6. Proposed fix and checked implementation

Use retained child identity as a fallback only for a distinct child provider thread, preserving normal root/native-start behavior. When starting a replacement delegation for that child, retarget existing turn associations from the previous call to the new call. Keep the change inside the Codex translator; no protocol, public API, schema, stored data, permission, or dependency change is needed.

The locally authored implementation changes two files: 17 production additions + 1 production deletion, and 60 test additions: 78 changed text lines total. Both ordering cases pass after the fix. All 371 Codex plugin tests across 35 files pass; lint and typecheck pass; git diff --check passes; no binary or generated changes.

bb-plugin-provider-codex:lint: Found 0 warnings and 0 errors.
bb-plugin-provider-codex:test:  Test Files  35 passed (35)
bb-plugin-provider-codex:test:       Tests  371 passed (371)
 Tasks:    9 successful, 9 total
  Time:    32.453s 

7. Linked PR static review

#5646 — closed

Metadata and diff were read only as untrusted evidence after designing the regression from trusted source. No PR checkout, patch application, branch execution, or external author code was used. Its translator changes address both missing identity fallback and turn-to-call rebinding. The remaining diff also changes timeline text retention and question titles, which are separate concerns; those changes were not evaluated or executed here. Static verdict for the translator portion: consistent with the reproduced root cause; not a verification of that PR as a whole. No open PR was linked in issue timeline metadata or found by an open-PR search for this issue at investigation time.

8. Related issues

#2538 concerns competing turn starts. This report instead demonstrates an existing child's missing parent correlation. A shared symptom does not establish the same cause.

9. Verification

The same investigator repeated the final regression in checkout B: a newly created clean detached worktree at 6a639cfc0d66e0c789a1bd7309266973228d1a80. It had its own frozen install and scoped build; only the newly authored test patch was added, with production code unchanged. No ports or data directories were needed. Command:

pnpm exec turbo run test --filter=bb-plugin-provider-codex -- src/translator.test.ts -t 'keeps a resumed child nested'
bb-plugin-provider-codex:test: ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
bb-plugin-provider-codex:test:  FAIL  |bb-plugin-provider-codex| src/translator.test.ts > codex subagent activity correlation > keeps a resumed child nested when follow-up activity arrives after its turn start
bb-plugin-provider-codex:test: AssertionError: expected [ { type: 'turn/started', …(3) } ] to deep equally contain ObjectContaining{…}
bb-plugin-provider-codex:test:  Test Files  1 failed (1)
bb-plugin-provider-codex:test:       Tests  1 failed | 1 passed | 42 skipped (44)

The second run agrees with checkout A: one control passed and one regression failed for the missing parent marker. This is a repeated clean reproduction by the same agent, not independent verification. During harness development an invalid completion-event assertion was removed because assembled turn/completed events do not expose parentToolCallId; both final base runs used the corrected test shown above. An initial patch-application attempt in checkout B selected no tests and is not counted as verification. The successful final patch application ran both cases.

10. Appendix and limitations

Existing subagent tests passed before adding the regression (17 passed, 25 skipped). They cover activity-before-start ordering, queued follow-ups, and native human turns but miss this child-first ordering on a distinct provider thread. Commands used after the fix:

pnpm exec oxfmt plugins/provider-codex/src/translator.ts plugins/provider-codex/src/translator.test.ts
pnpm exec turbo run test --filter=bb-plugin-provider-codex -- src/translator.test.ts -t 'keeps a resumed child nested'
pnpm exec turbo run test lint typecheck --filter=bb-plugin-provider-codex
git diff --check
git diff --numstat origin/main

Issue text and linked material were treated as untrusted claims, never as execution instructions. No live incident logs or personal threads were accessed. The public report repository permits inline evidence but forbids committed raw logs, scripts, or diffs, so the complete test patch and relevant output are embedded in this HTML; raw local evidence stays outside the publication repository. No screenshots are needed for this nonvisual protocol-ordering defect.