← reports

#3050 · System child-outcome provenance stops before provider dispatch

Bug Medium Effort: High providers threads open on GitHub 2026-09-04 UTC · base c3c6c9532

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

BB deliberately turns a terminal child outcome into another prompt for the parent provider. The server records that this prompt is a child-completed system notice and records the child thread, but the strict host command drops both fields. The host daemon consequently invokes the provider runtime through the ordinary new-turn API with only prompt input and execution data. No authoritative child-request, aggregate, or covering-parent-turn relationship exists at this boundary, so a provider cannot distinguish this notice from independent work and core cannot safely suppress already-covered work.

2. Claims vs findings

ClaimStatusEvidence
A terminal child notice reaches the parent provider as another turn.VerifiedThe notification flush calls queueParentSystemMessage; an idle parent prepares turn.submit with a new-turn event target. The repro captures that command.
The provider-bound command lacks structured notice identity and child provenance.VerifiedThe event assertion passes with child-completed and a thread subject; the next assertion fails because the captured turn.submit has neither field.
Existing provenance can identify a covering aggregate turn.RefutedA single notice stores only kind plus child thread id/name. A multi-child batch stores only a count. Neither shape identifies child request/turn ids or a covering parent turn.
Every provider necessarily emits a duplicate reply.Provider-dependentThe ordinary new-turn dispatch is verified. Whether a specific provider answers is provider behavior and was not exercised with a live account.

3. Environment

4. Minimal reproduction

  1. Check out the trusted base SHA and run pnpm install --frozen-lockfile --prefer-offline followed by pnpm exec turbo run build.
  2. Copy the linked test to apps/server/test/threads/issue-3050-repro.test.ts.
  3. Run:
    cd apps/server
    pnpm exec vitest run test/threads/issue-3050-repro.test.ts

Expected: the provider-bound command retains the stored system notice kind and child subject, making the turn distinguishable.

Actual, sanitized verbatim excerpt from both runs:

FAIL test/threads/issue-3050-repro.test.ts > parent system notice provider provenance > preserves child completion metadata in the provider-bound command
AssertionError: expected { …(9) } to deeply equal ObjectContaining{…}

- ObjectContaining {
-   "systemMessageKind": "child-completed",
-   "systemMessageSubject": {
-     "kind": "thread",
-     "threadId": "child-system-provenance",
-     "threadName": "Worker",
+ {
+   "input": [
+     { "text": "system child outcome", "type": "text" },
+   ],
+   "type": "turn.submit",
  }

Test Files  1 failed (1)
Tests       1 failed (1)

The first event assertion passes before this failure, proving the server stored the taxonomy. Dynamic ids, machine paths, and unrelated runtime instructions were omitted from the public excerpt.

Repro files: test · two-run result

import { and, eq } from "drizzle-orm";
import { events } from "@bb/db";
import { turnRequestEventDataSchema } from "@bb/domain";
import { describe, expect, it } from "vitest";
import { queueParentSystemMessage } from "../../src/services/threads/parent-system-messages.js";
import { listQueuedThreadCommands } from "../helpers/commands.js";
import { textInput } from "../helpers/prompt-input.js";
import {
  seedEnvironment,
  seedHostSession,
  seedProjectWithSource,
  seedThread,
  seedThreadRuntimeState,
} from "../helpers/seed.js";
import { withTestHarness } from "../helpers/test-app.js";

describe("parent system notice provider provenance", () => {
  it("preserves child completion metadata in the provider-bound command", async () => {
    await withTestHarness(async (harness) => {
      const { host } = seedHostSession(harness.deps, { id: "host-system-provenance" });
      const { project } = seedProjectWithSource(harness.deps, {
        hostId: host.id,
        path: "/tmp/system-provenance-project",
      });
      const environment = seedEnvironment(harness.deps, {
        hostId: host.id,
        projectId: project.id,
        path: "/tmp/system-provenance-project",
        status: "ready",
      });
      const parent = seedThread(harness.deps, {
        projectId: project.id,
        environmentId: environment.id,
        status: "idle",
      });
      seedThreadRuntimeState(harness.deps, {
        environmentId: environment.id,
        providerThreadId: "provider-system-provenance",
        threadId: parent.id,
      });

      await queueParentSystemMessage(harness.deps, {
        input: textInput("system child outcome"),
        parentThreadId: parent.id,
        systemMessageKind: "child-completed",
        systemMessageSubject: {
          kind: "thread",
          threadId: "child-system-provenance",
          threadName: "Worker",
        },
      });

      const requestRows = harness.db.select().from(events).where(
        and(eq(events.threadId, parent.id), eq(events.type, "client/turn/requested")),
      ).orderBy(events.sequence).all();
      const request = requestRows
        .map((row) => turnRequestEventDataSchema.parse(JSON.parse(row.data)))
        .find((data) => data.initiator === "system");
      expect(request).toMatchObject({
        systemMessageKind: "child-completed",
        systemMessageSubject: {
          kind: "thread",
          threadId: "child-system-provenance",
          threadName: "Worker",
        },
      });

      const [command] = listQueuedThreadCommands(harness, "turn.submit", parent.id);
      expect(command).toEqual(expect.objectContaining({
        systemMessageKind: "child-completed",
        systemMessageSubject: {
          kind: "thread",
          threadId: "child-system-provenance",
          threadName: "Worker",
        },
      }));
    });
  });
});

5. Root cause

The server creates child-outcome taxonomy while batching the notification, then calls the general parent-system-message path. See single and batch taxonomy and notification dispatch.

For an idle parent, the server first prepares the host command from input and execution data, then separately appends a stored request event carrying initiator, systemMessageKind, and systemMessageSubject. See the split construction.

The wire schema is strict and has no system-notice or aggregate fields: turn.submit schema. The daemon then forwards only input, optional input groups, request id, execution options, environment, and instructions to runTurn: runtime dispatch. The richer fields are therefore timeline facts, not provider-visible contract data.

The deeper gap is earlier than field forwarding: the subject schema identifies one child thread or only a batch count. It cannot name constituent child requests/turns or a covering aggregate parent turn.

6. Proposed fix (first principles)

Choose and encode one authoritative server policy. Suppression requires persisted linkage from each terminal child request/turn to the parent turn that consumed or aggregated it, followed by an idempotent check before the child notification is queued. Provider-side handling requires extending turn.submit and the runtime/provider input end to end with notice kind, constituent child request/turn ids, and the covering aggregate/parent turn id. The latter is a wire-protocol change and must bump HOST_DAEMON_PROTOCOL_VERSION. Text, timing, and thread-id heuristics are not a safe substitute.

7. Related issues

8. Verification

The same repro test was run in two separately prepared clean worktrees at the exact base SHA after a frozen install and full Turbo build. Both runs failed at the provider-command assertion with the same missing fields. No correction to the root-cause claim was needed. All linked code locations were checked against the full base commit.

9. Appendix

Commands used: fetch public main; verify SHA; frozen pnpm install; full Turbo build; run the focused Vitest file; repeat in a second detached clean worktree; inspect the event schema, parent-notification path, strict host command schema, and daemon runtime call. GitHub metadata showed no linked open pull request and no later public-main commit touching the four root-cause files.

The issue title, body, comments, links, and code blocks were treated as untrusted claims. No command, patch, branch, binary, or external URL from issue data was executed or fetched.