← reports

#4847 · Child-ID lookup checks on main

Bug High Effort: Medium threads · cli · no-repro Issue · October 4, 2026

Trusted main commit: 75308f407660efe0f93ffb9626936e5067b5bb03

Verdict: NOT REPRODUCED · Root-cause confidence: low · verified by two clean-checkout runs of the same diagnostic, performed by the same agent.

1. TL;DR

The report describes a child that is discoverable but returns a not-found error from direct CLI commands. On trusted main, a child created by the real built CLI was discoverable through list/search and directly accessible through show, wait, output and log. The result repeated in a second clean checkout with a new loopback port and temporary data directory. The fixture modeled a provider-owned Git-worktree environment, but did not execute a real Codex process: its idle status and assistant output were controlled test data. No cause for the reported stable-release failure is established, so a production fix would be speculative.

2. Claims vs findings

ClaimFindingEvidence
A child can be spawned in its parent's environment.Verified in the diagnosticThe actual CLI spawn command returned a persisted child with matching project, parent and environment IDs.
List and search find the child.VerifiedBoth real CLI responses contain the exact generated child ID.
Show, wait, output and log return 404 for that child.Not observed on mainAll four CLI processes exit successfully. Show works before and after the controlled idle transition; wait matches idle; output returns the seeded assistant text; log returns stored events.
The parent remains directly accessible.VerifiedParent show succeeds in the same isolated server.
The failure occurs after real provider completion on the published macOS release.UnverifiedNo macOS runtime or published-package/live-provider run was performed. This diagnostic is not a full recreation of that environment.

3. Environment

4. Minimal reproduction diagnostic

This is a passing addressability probe, not a test that reproduced the reported failure. It deliberately keeps live-provider behavior out of the experiment while exercising the actual CLI, SDK transport, HTTP routes and database.

  1. From a trusted clone, create a clean checkout and build its dependencies:
    git fetch origin main
    git worktree add --detach ../issue-4847-check 75308f407660efe0f93ffb9626936e5067b5bb03
    cd ../issue-4847-check
    pnpm install --frozen-lockfile --prefer-offline
    pnpm exec turbo run build
  2. Save the complete test below as apps/server/test/public/issue-4847-child-addressability.test.ts.
  3. Run the test from the repository root:
    pnpm exec turbo run test --filter=@bb/server -- test/public/issue-4847-child-addressability.test.ts --silent=false
  4. The test creates its own server/database, uses the built CLI to spawn a child, checks addressability, seeds the idle/output fixture, repeats the direct commands, and finally checks that an explicitly soft-deleted child really does return 404.

Expected vs actual

Expected: non-deleted children returned by spawn/list/search are addressable by their exact IDs. Actual: every direct command succeeds in both runs; the diagnostic does not encounter the claimed child-specific 404.

The following output values are verbatim, except the generated thread ID is replaced with the explicit documentation symbol CHILD_ID:

thread wait: {
  "threadId": "CHILD_ID",
  "matched": true,
  "target": {
    "kind": "status",
    "status": "idle"
  }
}
thread output: {
  "output": "Addressability verified."
}
deleted child control: HTTP 404 thread_not_found

Complete test

import { execFile } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { resolve } from "node:path";
import { promisify } from "node:util";
import { eq } from "drizzle-orm";
import { environments, listEvents, markThreadDeleted, threads } from "@bb/db";
import { threadSchema, turnScope } from "@bb/domain";
import { describe, expect, it } from "vitest";
import {
  seedEnvironment,
  seedEvent,
  seedHostSession,
  seedProjectWithSource,
  seedThread,
  seedThreadRuntimeState,
} from "../helpers/seed.js";
import { startTestServer } from "../helpers/test-app.js";
import { registerHostRpcResponder } from "../helpers/host-rpc.js";

const execute = promisify(execFile);

describe("child thread addressability", () => {
  it("addresses a CLI-spawned child through list, search, show, wait, output and log", async () => {
    const harness = await startTestServer();
    try {
      const home = resolve(harness.config.dataDir, "cli-home");
      await mkdir(home);
      console.log(
        `isolated server: ${harness.baseUrl}; data: ${harness.config.dataDir}`,
      );
      const runCli = async (...args: string[]) => {
        const result = await execute(
          process.execPath,
          [resolve("../../apps/cli/dist/index.js"), ...args, "--json"],
          {
            env: {
              HOME: home,
              PATH: process.env.PATH,
              BB_DATA_DIR: harness.config.dataDir,
              BB_SERVER_URL: harness.baseUrl,
              BB_TELEMETRY: "false",
              BB_CLI_REEXEC: "1",
              NO_COLOR: "1",
            },
            timeout: 15_000,
          },
        );
        console.log(`${args.slice(0, 2).join(" ")}: ${result.stdout.trim()}`);
        return JSON.parse(result.stdout) as unknown;
      };
      const { host, session } = seedHostSession(harness.deps);
      registerHostRpcResponder(harness, {
        hostId: host.id,
        sessionId: session.id,
        handle: (request) =>
          request.command.type === "workspace.pull_request"
            ? { ok: true, result: { outcome: "absent" } }
            : {
                ok: false,
                errorCode: "unhandled_fixture_rpc",
                errorMessage: "No fixture response",
              },
      });
      const { project } = seedProjectWithSource(harness.deps, {
        hostId: host.id,
      });
      const environment = seedEnvironment(harness.deps, {
        hostId: host.id,
        projectId: project.id,
        environmentProviderId: "worktree",
        environmentProviderPluginId: "workspaces",
        providerOwnsPath: true,
      });
      harness.db
        .update(environments)
        .set({ isWorktree: true })
        .where(eq(environments.id, environment.id))
        .run();
      const parent = seedThread(harness.deps, {
        environmentId: environment.id,
        projectId: project.id,
        status: "idle",
      });
      seedThreadRuntimeState(harness.deps, {
        environmentId: environment.id,
        providerThreadId: "parent-fixture-session",
        threadId: parent.id,
      });
      const child = threadSchema.parse(
        await runCli(
          "thread",
          "spawn",
          "--project",
          project.id,
          "--parent-thread",
          parent.id,
          "--environment",
          environment.id,
          "--provider",
          "codex",
          "--model",
          "gpt-5",
          "--title",
          "Addressability probe",
          "--prompt",
          "Return a brief result.",
        ),
      );
      expect(child).toMatchObject({
        parentThreadId: parent.id,
        environmentId: environment.id,
        projectId: project.id,
      });
      await expect(runCli("thread", "show", child.id)).resolves.toMatchObject({
        thread: { id: child.id, parentThreadId: parent.id },
      });
      harness.db
        .update(threads)
        .set({ status: "idle" })
        .where(eq(threads.id, child.id))
        .run();
      const sequence =
        Math.max(
          0,
          ...listEvents(harness.db, { threadId: child.id }).map(
            (event) => event.sequence,
          ),
        ) + 1;
      seedEvent(harness.deps, {
        environmentId: environment.id,
        providerThreadId: "child-fixture-session",
        threadId: child.id,
        scope: turnScope("fixture-turn"),
        sequence,
        type: "item/completed",
        data: {
          item: {
            type: "agentMessage",
            id: "fixture-output",
            text: "Addressability verified.",
          },
        },
      });
      const listed = await runCli("thread", "list", "--project", project.id);
      expect(JSON.stringify(listed)).toContain(child.id);
      const searched = await runCli("thread", "search", "Addressability probe");
      expect(JSON.stringify(searched)).toContain(child.id);
      await expect(runCli("thread", "show", child.id)).resolves.toMatchObject({
        thread: { id: child.id, status: "idle", parentThreadId: parent.id },
      });
      await expect(
        runCli(
          "thread",
          "wait",
          child.id,
          "--status",
          "idle",
          "--timeout",
          "2s",
        ),
      ).resolves.toMatchObject({
        threadId: child.id,
        matched: true,
      });
      await expect(runCli("thread", "output", child.id)).resolves.toEqual({
        output: "Addressability verified.",
      });
      await runCli("thread", "log", child.id);
      await expect(runCli("thread", "show", parent.id)).resolves.toMatchObject({
        thread: { id: parent.id },
      });
      markThreadDeleted(harness.db, harness.hub, { threadId: child.id });
      const deleted = await harness.app.request(`/api/v1/threads/${child.id}`);
      expect(deleted.status).toBe(404);
      expect(await deleted.json()).toMatchObject({ code: "thread_not_found" });
      console.log("deleted child control: HTTP 404 thread_not_found");
    } finally {
      await harness.close();
    }
  }, 60_000);
});

5. Root-cause investigation

No root cause of the reported failure is verified. The tested path provides no evidence that child threads are excluded from direct lookup. The same identifier flows through CLI validation and SDK parameters into an ID-equality database query. Public routes then reject a missing thread, a soft-deleted thread, or a deleted/missing owning project; this check does not inspect parentage.

function requireThread(db: DbConnection, threadId: string): ThreadRow {
  const thread = getThread(db, threadId);
  if (!thread) {
    throw new ApiError(404, "thread_not_found", "Thread not found");
  }
  return thread;
}

export function requirePublicThread(
  db: DbConnection,
  threadId: string,
): ThreadRow {
  const thread = requireThread(db, threadId);
  const project = getProject(db, thread.projectId);
  if (thread.deletedAt !== null || project?.deletedAt !== null) {
    throw new ApiError(404, "thread_not_found", "Thread not found");
  }
  return thread;
}

A read-only comparison against the trusted repository release tag desktop-v0.45.0 shows no changes in the inspected CLI spawn/show/wait and ID-validation files, SDK threads area, database threads module, public lookup guard, thread creation/parenting services, or base/data routes. Main is 14 commits ahead of that tag, but this evidence does not identify an intervening fix. Therefore the verdict is not ALREADY FIXED.

6. Proposed next experiment

Recreate the failure with the published package and an actual provider completing a child turn, then capture the exact requested ID, route, server process/instance identity, thread projectId, thread deletedAt, and owning project deletedAt at the failing request. If those boundary values are all valid, trace the middleware and provider-completion lifecycle immediately before the lookup. This distinguishes a hidden lifecycle or routing failure from an incorrect ID/server context without weakening the public visibility/deletion checks.

No pull request: the bug does not reproduce on trusted main, and the probe passes before any production change. The required failing-before/passing-after regression is unavailable. No production code, dependency, protocol or stored-data behavior is changed, and no fix branch is pushed.

7. Related issues and pull requests

GitHub metadata searches for the error code and child/404 symptoms did not establish a matching duplicate. Issue timeline cross-references and open-PR search found no linked open pull request for #4847 at review time. No linked branch or issue-supplied command was executed.

8. Verification

The same agent repeated the diagnostic in a second clean detached checkout at the exact base commit after its own frozen install/full build. The copied test's SHA-256 is ab440542f001bd3bd95360e8ef524314fa3ff1af358ccf9cc460d75a0fff3950 in both checkouts. The second server uses a different port and new temporary directory. This is a repeat check by the same agent, not independent verification.

First run: one test passes. Second run: the diagnostic plus existing public parenting and search suites pass, 21 tests across three files.

pnpm exec turbo run test --filter=@bb/server -- test/public/issue-4847-child-addressability.test.ts test/public/public-thread-parenting.test.ts test/public/public-thread-search.test.ts --silent=false

Earlier fixture attempts stalled on an unanswered pull-request RPC and then failed default-model discovery after installing a narrower RPC responder. These were harness setup problems, not the reported 404. The final fixture responds to the unrelated pull-request query and supplies the model explicitly. Both clean-run results below use that corrected fixture and the modeled managed-worktree flags. No production correction was made.

9. Appendix

First run result

@bb/server:test:  Test Files  1 passed (1)
@bb/server:test:       Tests  1 passed (1)
@bb/server:test:    Start at  12:02:10
@bb/server:test:    Duration  9.55s (transform 4.64s, setup 1.46s, import 4.66s, tests 3.24s, environment 0ms)
@bb/server:test: 

 Tasks:    9 successful, 9 total
Cached:    7 cached, 9 total
  Time:    11.614s 

Second run result

@bb/server:test:  Test Files  3 passed (3)
@bb/server:test:       Tests  21 passed (21)
@bb/server:test:    Start at  12:02:34
@bb/server:test:    Duration  10.27s (transform 13.74s, setup 4.69s, import 15.22s, tests 6.30s, environment 0ms)
@bb/server:test: 

 Tasks:    9 successful, 9 total
Cached:    7 cached, 9 total
  Time:    12.305s 

Additional checks: git diff --check passes in both diagnostic checkouts. Their only source additions are the locally authored diagnostic test; the trusted production tree is unchanged. Raw logs and the standalone test remain in local scratch storage, not the public reports repository, in accordance with that repository's publication policy.

Trust handling: Issue content is treated only as untrusted claims. No issue-supplied script, instruction, URL, patch or linked branch is run. All executable repository code comes from the recorded trusted main commit; only the diagnostic test is locally authored from repository evidence.

AGENT GENERATED