Skip to content

Queued native child continuation output is dropped after root completion #19228

Description

@not-compromised

Native child continuation events can be dropped after a root turn finishes, even on current main with synchronous SQLite. This is the separate defect discussed by the maintainer in #19211.

Confirmed on main commit 11fd54fcbbfb39f824bb4c908d428c20d801267c, Node 24.21.0. Only the test file changes; no SQLite worker or new provider-event fields are involved. Server typecheck passes.

A focused RunExecutionService test queues these normalized events while the adapter's live pending-work probe already returns false:

  1. A child is running when the root terminal arrives.
  2. The child's subagent completion arrives, emptying the ingestion tracking sets.
  3. That child's native continuation starts, sends GRANDCHILD_OK, and completes.

shouldStopProviderEventIngestion consults the live probe at step 2 and closes before consuming step 3. Both retained-root and lost-root ownership cases discard all continuation events. Expected observations are running, GRANDCHILD_OK, completed; actual observations are []. Both tests fail by assertion immediately, without sleeps, polling, timeout changes, or database delays.

Apply the focused patch below to the pinned main revision, then run:

vp test run apps/server/src/orchestration-v2/RunExecutionService.test.ts --testNamePattern='retains queued child output'

This is a service-level reproduction with the ingestion persistence dependency mocked, distinct from a physical-client reproduction. The full-worker investigation also reproduced the missing response in the real OpenCode HTTP replay with SQLite persistence. It does not establish the historical phone incident's exact cause.

Focused regression patch for current main
diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts
index d877922a6..9a0e40d63 100644
--- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts
+++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts
@@ -12,9 +12,11 @@ import {
   type OrchestrationV2DomainEvent,
   type OrchestrationV2ExecutionNode,
   type OrchestrationV2ProviderThread,
+  type OrchestrationV2ProviderTurn,
   type OrchestrationV2Run,
   type OrchestrationV2RunAttempt,
   type OrchestrationV2Subagent,
+  type OrchestrationV2StoredEvent,
   type OrchestrationV2TurnItem,
   ProviderDriverKind,
   ProviderInstanceId,
@@ -1687,6 +1689,92 @@ it.effect("ends a nested native subagent and its thread when the root run is int
   }),
 );
 
+it.effect.each([false, true])(
+  "retains queued child output when the adapter is already idle and root ownership lost=%s",
+  (rootOwnershipLost) =>
+    Effect.gen(function* () {
+      const childEvents = yield* Ref.make<ReadonlyArray<string>>([]);
+      yield* runBackgroundItemScenario(
+        "ordered-background-continuation",
+        (ids) => {
+          const childProviderThreadId = ProviderThreadId.make(
+            "provider-thread:ordered-background-continuation:child",
+          );
+          const snapshot = (populated: boolean, child = false) => ({
+            type: "provider_thread.updated" as const,
+            driver,
+            providerThread: {
+              id: child ? childProviderThreadId : ids.providerThreadId,
+              driver,
+              providerInstanceId: ProviderInstanceId.make("codex"),
+              providerSessionId: null,
+              appThreadId: child ? ids.childThreadId : ids.threadId,
+              ownerNodeId: null,
+              nativeThreadRef: null,
+              nativeConversationHeadRef: null,
+              status: "idle" as const,
+              firstRunOrdinal: 1,
+              lastRunOrdinal: 1,
+              handoffIds: [],
+              forkedFrom: null,
+              pendingBackgroundTasks: populated
+                ? [{ taskId: "child", kind: "subagent" as const, childThreadId: ids.childThreadId }]
+                : [],
+              createdAt: DateTime.makeUnsafe(0),
+              updatedAt: DateTime.makeUnsafe(0),
+            } satisfies OrchestrationV2ProviderThread,
+          });
+          const childTurn = (status: "running" | "completed") => ({
+            type: "provider_turn.updated" as const,
+            driver,
+            threadId: ids.childThreadId,
+            providerTurn: {
+              id: ProviderTurnId.make("provider-turn:ordered-background-continuation:child"),
+              providerThreadId: ProviderThreadId.make(
+                "provider-thread:ordered-background-continuation:child",
+              ),
+              runAttemptId: null,
+              ordinal: 2,
+              status,
+            } as OrchestrationV2ProviderTurn,
+          });
+          return [
+            childThreadCreatedEvent(ids),
+            snapshot(true),
+            snapshot(true, true),
+            subagentEvent(ids, "running"),
+            rootTerminalEvent(ids, "completed"),
+            subagentEvent(ids, "completed"),
+            snapshot(false),
+            childTurn("running"),
+            {
+              type: "message.updated",
+              driver,
+              message: { threadId: ids.childThreadId, runId: null, text: "GRANDCHILD_OK" },
+            } as ProviderAdapter.ProviderAdapterV2Event,
+            childTurn("completed"),
+            snapshot(false, true),
+            snapshot(false),
+          ];
+        },
+        {
+          keepEventStreamOpen: true,
+          hasPendingBackgroundWorkForThread: () => Effect.succeed(false),
+          rejectRootProviderThreadUpdates: rootOwnershipLost,
+          onIngest: (event) =>
+            Ref.update(childEvents, (events) =>
+              event.type === "provider_turn.updated"
+                ? [...events, event.providerTurn.status]
+                : event.type === "message.updated"
+                  ? [...events, event.message.text]
+                  : events,
+            ),
+        },
+      );
+      assert.deepEqual(yield* Ref.get(childEvents), ["running", "GRANDCHILD_OK", "completed"]);
+    }),
+);
+
 it.effect("keeps ingesting a late background command item completion after root terminal", () =>
   Effect.gen(function* () {
     const observed = yield* runBackgroundItemScenario("bg-command", (ids) => [
@@ -4331,6 +4419,11 @@ function runBackgroundItemScenario(
       ReadonlyArray<{ readonly id: TurnItemId; readonly runId: RunId }>
     >;
     readonly onSubscribe?: Effect.Effect<void>;
+    readonly onIngest?: (event: ProviderAdapter.ProviderAdapterV2Event) => Effect.Effect<void>;
+    readonly hasPendingBackgroundWorkForThread?: (
+      thread: OrchestrationV2ProviderThread,
+    ) => Effect.Effect<boolean>;
+    readonly rejectRootProviderThreadUpdates?: boolean;
   },
 ) {
   return Effect.gen(function* () {
@@ -4363,6 +4456,7 @@ function runBackgroundItemScenario(
             ingestNormalized: (input) =>
               Effect.gen(function* () {
                 const event = input.event;
+                yield* options?.onIngest?.(event) ?? Effect.void;
                 if (event.type === "turn_item.updated") {
                   yield* Ref.update(observed, (current) => [
                     ...current,
@@ -4375,6 +4469,18 @@ function runBackgroundItemScenario(
                     `subagent:${event.subagent.status}`,
                   ]);
                 }
+                if (event.type === "provider_thread.updated") {
+                  if (
+                    options?.rejectRootProviderThreadUpdates === true &&
+                    input.writeIfProviderThreadOwner !== undefined
+                  )
+                    return [];
+                  return [
+                    {
+                      event: { type: "provider-thread.updated", payload: event.providerThread },
+                    } as OrchestrationV2StoredEvent,
+                  ];
+                }
                 return [];
               }),
           }),
@@ -4391,6 +4497,11 @@ function runBackgroundItemScenario(
         providerSessionId: ProviderSessionId.make(`session:${key}`),
         session: {
           events: Stream.empty,
+          ...(options?.hasPendingBackgroundWorkForThread === undefined
+            ? {}
+            : {
+                hasPendingBackgroundWorkForThread: options.hasPendingBackgroundWorkForThread,
+              }),
           subscribeEvents: Effect.gen(function* () {
             yield* options?.onSubscribe ?? Effect.void;
             const events = Stream.fromIterable(makeEvents(ids));

Written by GPT-6.1 Sol in Codex.

Activity

  1. juliusmarminge commented on Oct 14, 2026

    @juliusmarminge
    Member

    Note

    Grok responding on behalf of Julius.

    Confirmed on main: once the root turn has completed, this ingestion stream can close between a child's subagent completion and that child's native continuation, so the continuation's output is never ingested.

    In apps/server/src/orchestration-v2/RunExecutionService.ts, shouldStopProviderEventIngestion runs after every event (Stream.takeUntilEffect). After a completed root terminal it only keeps the stream open while activeChildProviderTurns, activeChildSubagents or activeBackgroundTurnItems are non-empty, or while hasPendingBackgroundWorkForThread returns true. The child's subagent completion empties those sets. If the thread-scoped probe already reports idle, or providerThreadOwnerLost is set, the function returns true right there. The child's continuation (provider_turn.updated running → message → completed) arrives after the stream has closed, so it's dropped. Nothing in the stop check expects a child turn to start up again after its subagent item settles.

    Notes for a fix:

    • The stream needs a signal that a child continuation is still coming, either from the adapter or by holding briefly after an owned child's subagent completes. Making the live probe more permissive alone would undo the owner-loss release.
    • The reporter's two-case test (root ownership retained or lost) is a good regression test to adopt.

    Evidence: confirmed in code (stop logic as of b9afba5). I didn't run the reporter's test. The service-level repro uses a mocked ingestor, and the original phone incident isn't shown to have this cause.

  2. added
    acceptedfeature request accepted
    bugSomething is broken or behaving incorrectly.
    via-triageFiled through npx t3 triage
    on Oct 14, 2026
  3. juliusmarminge commented on Oct 21, 2026

    @juliusmarminge
    Member

    Checked on Linux, Node 24.21.0, main 70e5e384da. No matching duplicate among 950 open issues.

    The supplied test patch needs context updates on current main. With those updates, vp test run apps/server/src/orchestration-v2/RunExecutionService.test.ts --testNamePattern='retains queued child output' fails both ownership cases exactly as reported: expected ['running', 'GRANDCHILD_OK', 'completed'], received []. Failure log, adapted test patch.

    The existing opencode2_nested_background HTTP/SSE replay passes on the same main with synchronous SQLite: replay log.

    I also started the real web/server app with isolated seeded state and sent the fixture's nested-background prompt using the installed OpenCode 1.18.18 / Big Pickle. It remained at Thinking without reaching root completion or native child activity. The first attempt was interrupted by the dev file watcher during test startup; a repeat without tests running also did not reach the reported sequence. This is inconclusive, not evidence that the reported ordering is fixed.

    Live web attempt before cancellation

    Can you provide a current-main HTTP/SSE replay or app-level steps that fail with synchronous SQLite, including the exact OpenCode version, model, nesting configuration, and how to force the adapter-ahead-of-ingestion ordering? The checked-in fixture records OpenCode 2.0.18 with experimental.subagent_depth: 2; it currently passes here. The service-level failure is confirmed, but the real-app reproduction remains unverified.

  4. added
    needs more infoInitial triage showed no bug. Awaiting more info
    and removed
    needs more infoInitial triage showed no bug. Awaiting more info
    on Oct 21, 2026
  5. not-compromised commented on Oct 28, 2026

    @not-compromised
    ContributorAuthor

    Claude Opus 5.5 (Claude Code), posting for Peyton (@not-compromised), who reported #19228.

    Here is a current-main replay that fails with synchronous SQLite. It uses the checked-in opencode2_nested_background HTTP/SSE recording, the real OrchestratorV2 and OpenCode 2 adapter, and the replay harness's in-memory SQLite. Only the test file changes. The recording is OpenCode 2.0.18, openrouter/deepseek/deepseek-v4-flash, with experimental.subagent_depth: 2; replaying it needs no live model call.

    How it forces the ordering. The test wraps the session's events stream, so the hold applies to the whole session pump, not only the run's subscription. At the grandchild's completed subagent.updated (GRANDCHILD_OK), it starts holding T3's copy of the events. When the adapter emits the middle session's completed turn-2 provider_turn.updated, it delivers the held events in order. The SSE replay and the adapter's live state run as normal. Once ingestion has consumed the grandchild's completion, its tracking sets are empty, and the live hasPendingBackgroundWorkForThread probe already returns false. So ingestion closes before the middle session's queued continuation. There are no sleeps or clock changes.

    Command: vp test run apps/server/src/orchestration-v2/testkit/OpenCodeReplayFixtures.integration.test.ts. I ran the same test file on each tree, 3 times each:

    Tree Result
    main 9c6429d74b Fails 3/3 on expected [ 'MIDDLE_OK' ] to deeply equal [ 'MIDDLE_OK', 'GRANDCHILD_OK' ]. The middle thread records only provider turn 1. The other 22 replays pass.
    #21461 merge base 8d3b061121 Fails 3/3 on the same assertion.
    #21461 head 1b052a2d9d (includes 4cc74410d2 from #21460) 23/23 pass, 3/3.

    apps/server typecheck and lint pass on main and on the #21461 head. You're welcome to take the test into #21461.

    What this doesn't show is a live run that drops output. The hold stands in for ingestion trailing the adapter. I haven't measured how far ingestion trails in the real app, so I can't say how often this happens outside the test. My earlier live runs on main didn't drop output either (0 of 7).

    Test patch for current main
    diff --git a/apps/server/src/orchestration-v2/testkit/OpenCodeReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OpenCodeReplayFixtures.integration.test.ts
    index f9a594042b..79e0f54d81 100644
    --- a/apps/server/src/orchestration-v2/testkit/OpenCodeReplayFixtures.integration.test.ts
    +++ b/apps/server/src/orchestration-v2/testkit/OpenCodeReplayFixtures.integration.test.ts
    @@ -1,12 +1,16 @@
     import { describe, it } from "@effect/vitest";
     import type { ProviderReplayEntry } from "@t3tools/contracts";
    +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
     import * as Effect from "effect/Effect";
    +import * as Layer from "effect/Layer";
    +import * as Stream from "effect/Stream";
     
     import { OpenCodeOrchestratorReplayHarness } from "../Adapters/OpenCodeAdapterV2.testkit.ts";
     import {
       OPENCODE2_HTTP_PROTOCOL,
       OpenCode2OrchestratorReplayHarness,
     } from "../Adapters/OpenCode2AdapterV2.testkit.ts";
    +import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts";
     import {
       findReplayFixture,
       itRunsReplayFixtures,
    @@ -59,8 +63,81 @@ describe("OpenCode replay fixtures", () => {
             }),
           }),
       );
    +
    +  // Ingestion can trail the adapter's live state. Hold T3's copy of the events so the adapter
    +  // finishes the middle session's continuation before ingestion sees the grandchild complete.
    +  it.effect("delivers a nested continuation that the adapter finished before ingestion", () => {
    +    const fixture = findReplayFixture("opencode2_nested_background");
    +    const provider = fixture?.providers[0];
    +    if (fixture === undefined || provider === undefined) return Effect.die("missing fixture");
    +    return runReplayFixture({
    +      fixtureName: fixture.name,
    +      buildInput: fixture.buildInput,
    +      driver: provider,
    +      harness: withHeldEvents(
    +        OpenCode2OrchestratorReplayHarness,
    +        (event) =>
    +          event.type === "subagent.updated" &&
    +          event.subagent.status === "completed" &&
    +          event.subagent.result === "GRANDCHILD_OK",
    +        (event) =>
    +          event.type === "provider_turn.updated" &&
    +          event.providerTurn.ordinal === 2 &&
    +          event.providerTurn.status === "completed",
    +      ),
    +    });
    +  });
     });
     
    +type AdapterEvent = ProviderAdapter.ProviderAdapterV2Event;
    +interface HeldEvents {
    +  readonly state: "waiting" | "holding" | "released";
    +  readonly held: ReadonlyArray<AdapterEvent>;
    +}
    +
    +// Withholds session events from `from` until the adapter has emitted `until`, then delivers them in order.
    +function withHeldEvents(
    +  harness: typeof OpenCode2OrchestratorReplayHarness,
    +  from: (event: AdapterEvent) => boolean,
    +  until: (event: AdapterEvent) => boolean,
    +): typeof OpenCode2OrchestratorReplayHarness {
    +  const hold = Stream.mapAccum(
    +    (): HeldEvents => ({ state: "waiting", held: [] }),
    +    ({ state, held }, event: AdapterEvent): readonly [HeldEvents, ReadonlyArray<AdapterEvent>] => {
    +      if (state === "released" || (state === "waiting" && !from(event))) {
    +        return [{ state, held }, [event]];
    +      }
    +      const next = [...held, event];
    +      return until(event)
    +        ? [{ state: "released", held: [] }, next]
    +        : [{ state: "holding", held: next }, []];
    +    },
    +  );
    +  return {
    +    ...harness,
    +    makeProviderAdapterRegistryLayer: (transcript, options) =>
    +      Layer.effect(
    +        ProviderAdapterRegistry.ProviderAdapterRegistryV2,
    +        Effect.map(ProviderAdapterRegistry.ProviderAdapterRegistryV2, (registry) => ({
    +          ...registry,
    +          get: (instanceId) =>
    +            Effect.map(registry.get(instanceId), (adapter) => ({
    +              ...adapter,
    +              openSession: (input) =>
    +                Effect.map(adapter.openSession(input), (runtime) => {
    +                  const events = hold(runtime.events);
    +                  // A proxy keeps the runtime's live getters, such as providerSession.
    +                  return new Proxy(runtime, {
    +                    get: (target, key, receiver) =>
    +                      key === "events" ? events : Reflect.get(target, key, receiver),
    +                  });
    +                }),
    +            })),
    +        })),
    +      ).pipe(Layer.provide(harness.makeProviderAdapterRegistryLayer(transcript, options))),
    +  };
    +}
    +
     function undecodableEvent(entry: ProviderReplayEntry): ProviderReplayEntry {
       if (entry.type !== "emit_inbound") return entry;
       const frame = entry.frame as { readonly event: Record<string, unknown> };
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    acceptedfeature request acceptedbugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions