← reports

#1768 · Child-thread notifications are unfindable after the fact: search misses them and thread log defaults to 100 events

Bug / UX Low Effort: Small threads cli open on GitHub 2026-08-18 base 16ceb3a540f81c1189efaffb27a39b1d9443abf5 (main)

Verdict: PARTIALLY REPRODUCED · root-cause confidence: high · linked open PRs: none

TL;DR

Plain-language framing. When a child thread finishes, bb writes a message that starts with [bb system] into its parent thread (stored as an ordinary client/turn/requested event with initiator: "system"; when two or more children settle inside a 2 s window they are merged into one Child thread updates: list). The reporter, an orchestrator, later wanted to check "was I told my worker died?" using two CLI tools: bb thread search (full-text search over threads) and bb thread log (dump a thread's history).

Claim 1 (search cannot find them) is refuted on main. The [bb system] messages are written to the FTS index at insert time (as user_message segments — a segment is one indexed row of text, here one user/system message) and bb thread search "Child thread updates" --json found both of my orchestrator's batched notifications live (active.total 1, matches at seq 19 and 144). The only ways to get zero hits that I found: the parent thread is hidden (visibility: hidden, e.g. inherited from a hidden orchestrator; search filters t.visibility = 'visible'), or the message text simply never contained the phrase (single-child notifications say @thread:X failed., not "Child thread updates:"). The reporter's hits: 0 output does not correspond to any string the CLI prints, so their exact command/output is unverifiable.

Claim 2 (log silently truncates) is verified, but the mechanism is different from what the issue says, and worse. The human-readable bb thread log (default) is not "the last 100 events": it is the timeline API's default page — the newest 20 user-message segments (capped further by a 1500-event budget) — and the CLI ignores the timelinePage.hasOlderRows flag the server returns, so nothing marks the cut. bb thread log --json is capped at 100 events, but the query is ascending by sequence with LIMIT, so it returns the oldest 100 events and silently drops the newest — on my 180-event orchestrator the default JSON stopped at seq 129 and did not contain the two newer notifications at seq 144 and 183. --limit on the human formats is rejected outright. Additionally the minimal format collapses every notification that was delivered while a turn was active (steer) inside the "Worked for" block; only --format verbose shows them (in my run 2 of 3 [bb system] messages appear in minimal, 3 of 3 in verbose; the verifier's longer run showed 2 vs 5).

Net: the "did my worker die" question is answerable today with bb thread search <child id> or bb thread log --json --limit <big>, but the defaults of bb thread log make a negative grep look authoritative when it is a window (and, for JSON, the wrong end of the window).

Claims vs findings

ClaimStatusEvidence
Parent receives batched [bb system] Child thread updates: message reporting both a failed and a completed childVerifiedLive: parent thr_kjmpu6p3i2 received exactly the issue's wording at seq 19 — - @thread:thr_v3x33nrebs failed. / - @thread:thr_feiwytz7k8 completed. (systemMessageKind: child-outcome-batch) — after one bad-model child and one ok child spawned back-to-back; a second batch (two bad-model children spawned in parallel) at seq 144; a single-child @thread:thr_va35e367eg failed. at seq 183 delivered as a steer during an active turn. verbose log, all events. Note batching is timing-dependent (2 s window): the verifier's sequential spawns produced two single-child messages instead.
bb thread search "Child thread updates" --json returns 0 hits even for a parent that received itRefuted on main (visible thread)Live search found the batched messages: active.total = 1, matches sourceKind: user_message at sourceSeq 19 and 144 (09-search-variants-after-batch.txt; verifier reproduced independently: total 1, seq 310). Unit test at the DB layer also passes (test). Indexing happens in appendStoredThreadEventsInTransactionupsertThreadSearchSegments for every client/turn/requested regardless of initiator (packages/db/src/data/events.ts:467-478, packages/db/src/data/events.ts:718-723). Zero hits happens if the parent is hidden (packages/db/src/data/threads.ts:950; second unit test) or if the parent only ever received single-child messages (no such phrase). The literal hits: 0 output does not exist in the CLI (it prints {active:{total,results},archived:…}).
"…including for the parent thread that had received the message minutes earlier, and whose own bb thread log shows it 23 times"Unverified / refuted with caveatIf the human log really printed the phrase 23 times, the message was stored as client/turn/requested and therefore indexed (there is no other write path: packages/db/src/data/events.ts:668-724). The only code path that then still yields total 0 is the parent being non-visible (t.visibility = 'visible' filter, packages/db/src/data/threads.ts:950) — e.g. an orchestrator spawned with --visibility hidden, whose children inherit that. I could not confirm the reporter's thread visibility. On a visible parent I could not make search miss a message the log shows.
"Whatever search indexes, it is not these"RefutedSegments table for the parent contains all three [bb system] messages as user_message rows; searching bb system, failed, thr_v3x33nrebs, completed all hit them (07, 09).
bb thread log --help documents --limit as "json format only (default 100)"Verified17-thread-log-help.txt; apps/cli/src/commands/thread/show.ts:441-448.
Default human-readable output is capped at 100 events with no indication and no paging flagPartly wrong mechanism, right conclusionHuman formats call GET /threads/:id/timeline with the default page = latest 20 user-message segments / 1500-event budget, not 100 events (apps/cli/src/commands/thread/show.ts:470-482, apps/server/src/routes/threads/data.ts:144-180, apps/server/src/services/threads/timeline.ts:169). The response carries timelinePage.hasOlderRows (packages/server-contract/src/api/threads.ts:713-721) which the CLI never prints. --limit in human formats errors: --limit and --after-seq are only supported with --format json. Test issue-1768-thread-log-truncation.test.ts: 25-turn thread → page has 20 segments, hasOlderRows: true, first turn (the notification) absent from the text, no marker.
Grepping the log checks "a recent window while looking like it is checking history"Verified for human formats; inverted for --json--json default returns the oldest 100 events (ascending + LIMIT, packages/db/src/data/events.ts:1095-1113, apps/server/src/routes/threads/data.ts:479-488). Live: default JSON returned seq 1–129 of a 180-event thread whose max seq is 236; the notifications at seq 144 and 183 were not in it (14-log-checks.txt).
Three parents with 201/147/3 children returned 0 matches (ambiguous)UnverifiableReporter explicitly does not claim non-delivery. Consistent with either window above. Note also that queueParentSystemMessage silently returns false (no event, no log line) when the parent has a pending interaction or is archived (apps/server/src/services/threads/parent-system-messages.ts:414-424), so a truly-never-delivered case is possible and would be indistinguishable after the fact.

Environment

Minimal reproduction

A. Live: notifications are searchable; the default logs hide them

All scripts live in 1768/repro/, take the CLI from $CLI (default node packages/scripts/dist/commands/run-cli.js, i.e. run them from the repo root) and ids from PARENT/PROJECT/CHILD, and write outputs to $OUT_DIR (default: the script's own directory).

  1. Build + start: pnpm install --frozen-lockfile --prefer-offline && pnpm exec turbo run build && scripts/bb-dev-app current; then eval "$(scripts/bb-dev-app env)". Create a project (see Appendix) and note its id.
  2. Spawn an idle parent and wait for it:
    bb thread spawn --project proj_jyevf2557d --provider codex --permission-mode accept-edits --title "1768 orchestrator" --prompt "Reply only with ok." --json | grep '"id"'
      "id": "thr_kjmpu6p3i2",
    bb thread wait thr_kjmpu6p3i2 --status idle
  3. Spawn one child that fails (nonexistent model) and one that completes, as the parent (--parent-self reads BB_THREAD_ID; step3-spawn-children.sh):
    export BB_THREAD_ID=thr_kjmpu6p3i2
    bb thread spawn --project proj_jyevf2557d --provider codex --permission-mode accept-edits --parent-self --title "1768 child bad-model" --model does-not-exist-model --prompt "Reply only with ok." --json | grep '"id"'
      "id": "thr_v3x33nrebs",
    bb thread spawn --project proj_jyevf2557d --provider codex --permission-mode accept-edits --parent-self --title "1768 child ok" --prompt "Reply only with ok." --json | grep '"id"'
      "id": "thr_feiwytz7k8",
    unset BB_THREAD_ID; bb thread wait thr_kjmpu6p3i2 --status idle
    In my run both settled inside the 2 s batch window and the parent got exactly the issue's message (03-parent-log-minimal.txt):
    ── User ────────────────────────────────────────────────────
    [bb system]
    
    Child thread updates:
    
    - @thread:thr_v3x33nrebs failed.
    - @thread:thr_feiwytz7k8 completed.
    
    ── Assistant ───────────────────────────────────────────────
    Batching is timing-dependent (CHILD_THREAD_TURN_NOTIFICATION_BATCH_DELAY_MS = 2_000, apps/server/src/services/threads/child-thread-notifications.ts:77): the verifier's sequential spawns produced two single-child @thread:X failed. messages instead, and then search "Child thread updates" legitimately returned 0 because the phrase never existed. Check what you actually got before searching:
    bb thread log thr_kjmpu6p3i2 --json --limit 100000 | grep -c 'Child thread updates'
    If that prints 0, spawn two bad-model children in parallel (spawn-batch-children.sh, which backgrounds both spawns and waits) — that reliably lands both failures inside the window (my run: 08-spawn-batch-children.txtthr_3x4b52fnah, thr_vzm6xr4wyu, batched at seq 144).
  4. Grow the parent past 100 events so the JSON window matters (filler-turns.sh: three small tool-using prompts via POST /threads/:id/send; my run: 62 → 88 → 114 events), then run spawn-batch-children.sh for the second batch and, optionally, midturn-child.sh (starts a 75 s filler turn, then spawns a bad-model child so its notification is delivered as a steer while the parent is busy; my run: thr_va35e367eg, seq 183, target.kind: "auto"). Wait for bb thread wait thr_kjmpu6p3i2 --status idle.
  5. Search (CHILD=thr_v3x33nrebs bash search-variants.sh, script). Expected per issue: 0 hits. Actual (given the phrase exists on a visible thread): every [bb system] message is found as a user_message segment.
    == bb thread search "Child thread updates" --json
      active total 1
         thr_kjmpu6p3i2 user_message seq 19 '[bb system]\n\nChild thread updates:\n\n- @thread:thr_v3x33nrebs failed.\n-'
         thr_kjmpu6p3i2 user_message seq 144 '[bb system]\n\nChild thread updates:\n\n- @thread:thr_vzm6xr4wyu failed.\n-'
         thr_kjmpu6p3i2 user_message seq 183 '[bb system]\n\n@thread:thr_va35e367eg failed.\n\nReview the thread before '
      archived total 0
    == bb thread search "bb system" --json
      active total 1
         thr_kjmpu6p3i2 user_message seq 19 '[bb system]\n\nChild thread updates:\n\n- @thread:thr_v3x33nrebs failed.\n-'
         thr_kjmpu6p3i2 user_message seq 144 '[bb system]\n\nChild thread updates:\n\n- @thread:thr_vzm6xr4wyu failed.\n-'
         thr_kjmpu6p3i2 user_message seq 183 '[bb system]\n\n@thread:thr_va35e367eg failed.\n\nReview the thread before '
      archived total 0
    == bb thread search "failed" --json
      active total 1
         thr_kjmpu6p3i2 user_message seq 144 '[bb system]\n\nChild thread updates:\n\n- @thread:thr_vzm6xr4wyu failed.\n-'
         thr_kjmpu6p3i2 user_message seq 19 '[bb system]\n\nChild thread updates:\n\n- @thread:thr_v3x33nrebs failed.\n-'
         thr_kjmpu6p3i2 user_message seq 183 '[bb system]\n\n@thread:thr_va35e367eg failed.\n\nReview the thread before '
      archived total 0
    == bb thread search "thr_v3x33nrebs" --json
      active total 1
         thr_kjmpu6p3i2 user_message seq 19 '[bb system]\n\nChild thread updates:\n\n- @thread:thr_v3x33nrebs failed.\n-'
      archived total 0
    == bb thread search "completed" --json
      active total 1
         thr_kjmpu6p3i2 user_message seq 19 '[bb system]\n\nChild thread updates:\n\n- @thread:thr_v3x33nrebs failed.\n-'
      archived total 0
    
  6. Log windows (PARENT=thr_kjmpu6p3i2 bash log-checks.sh, script). Expected per issue: default = last 100 events. Actual: JSON default = oldest 100 (seq 1–129 of max 236; the notifications at seq 144 and 183 are not in it, only the old seq 19 one is); human --limit rejected; minimal format shows 2 of the 3 [bb system] messages (the mid-turn steer one is collapsed):
    == bb thread log thr_kjmpu6p3i2 --json   (CLI default limit=100)
      events returned: 100 min seq 1 max seq 129
      seqs of [bb system] client/turn/requested IN this window: [19]
    == bb thread log thr_kjmpu6p3i2 --json --limit 100000
      total events on thread: 180 max seq 236 (seq gaps are normal: delta/streaming rows are pruned)
      seqs of events containing 'Child thread updates': [19, 144]
      seqs of ALL [bb system] client/turn/requested: [19, 144, 183]
      => [bb system] messages NOT in the default --json window (seq > 129 ): [144, 183]
    == bb thread log thr_kjmpu6p3i2 --limit 500   (human format + --limit)
    Error: --limit and --after-seq are only supported with --format json
    == bb thread log thr_kjmpu6p3i2   (minimal, default) | grep -c 'bb system'
    2
    == bb thread log thr_kjmpu6p3i2 --format verbose | grep -c 'bb system'
    3
    
  7. Human log, minimal (full) — note there is no line saying how many rows/segments were returned or whether older rows exist, and the seq-183 @thread:thr_va35e367eg failed. message is missing (it appears only in verbose, indented under "Worked for", see Appendix):
    ── User ────────────────────────────────────────────────────
    Reply only with ok.
    
    ── Provisioned thread ──────────────────────────────────────
      Preparing workspace
      Using workspace: /tmp/bb-1768-scratch
      Using branch: main (725bf4c)
    
    ── Assistant ───────────────────────────────────────────────
    ok
    
    ── Provider session was replaced. ──────────────────────────
      Execution settings changed; the codex session was rebuilt to apply them.
    
    ── User ────────────────────────────────────────────────────
    [bb system]
    
    Child thread updates:
    
    - @thread:thr_v3x33nrebs failed.
    - @thread:thr_feiwytz7k8 completed.
    
    ── Assistant ───────────────────────────────────────────────
    Noted.
    
    ── Provider session was replaced. ──────────────────────────
      Execution settings changed; the codex session was rebuilt to apply them.
    
    ── User ────────────────────────────────────────────────────
    Filler turn 1: run these shell commands one at a time (ls -la /tmp/bb-1768-scratch ; git -C /tmp/bb-1768-scratch log --oneline ; date ; uname -a ; echo done) and then reply only with ok.
    
    ── Worked for (11s) ────────────────────────────────────────
    
    ── Assistant ───────────────────────────────────────────────
    ok
    
    ── User ────────────────────────────────────────────────────
    Filler turn 2: run these shell commands one at a time (ls -la /tmp/bb-1768-scratch ; git -C /tmp/bb-1768-scratch log --oneline ; date ; uname -a ; echo done) and then reply only with ok.
    
    ── Worked for (8s) ─────────────────────────────────────────
    … (36 more lines, see repro/15-parent-log-minimal-after-batch.txt)

B. Unit-level: the timeline page and the JSON limit both silently drop the notification

apps/server/test/public/issue-1768-thread-log-truncation.test.ts — run from apps/server: pnpm exec vitest run test/public/issue-1768-thread-log-truncation.test.ts. Test 1 is a characterization test (passes on main): 25 turns where turn 1 is a [bb system] Child thread updates: message → default timeline page has segmentLimit 20 / returnedSegmentCount 20 / hasOlderRows true, formatted text contains prompts 6–25 only, no "older"/"truncated" marker; /events?limit=100 on a 104-event thread returns seq 1–100 (newest turn dropped). Test 2 asserts the desired behavior (the human text mentions omitted rows) and fails on main — that is the assertion expect(text.toLowerCase()).toMatch(/older|omitted|truncat/). Output: 10-vitest-thread-log-truncation.txt.

import { encodeClientTurnRequestIdNumber, threadScope, turnScope } from "@bb/domain";
import { threadTimelineResponseSchema } from "@bb/server-contract";
import { z } from "zod";
import { formatThreadTimelineText } from "@bb/thread-view";
import { describe, expect, it } from "vitest";
import { readJson } from "../helpers/json.js";
import { seedEvent, seedThreadFixture } from "../helpers/seed.js";
import { withTestHarness } from "../helpers/test-app.js";

// Issue #1768 (claim 2): `bb thread log <id>` (human formats) reads
// GET /threads/:id/timeline with the default page (latest 20 user-message
// segments) and prints the rows without any "older rows exist" marker.
// `bb thread log --json` reads GET /threads/:id/events with limit=100.
// This test seeds an orchestrator-like thread whose FIRST turn is a
// "[bb system] Child thread updates:" notification followed by 24 ordinary
// turns, and shows that both default views silently drop that notification.
describe("issue 1768: bb thread log silently truncates", () => {
  it("default timeline page drops the oldest [bb system] notification without any marker in the text output", async () => {
    await withTestHarness(async (harness) => {
      const { environment, thread } = seedThreadFixture(harness);

      const seedMessageTurn = (args: {
        requestId: number;
        startSequence: number;
        text: string;
        turnId: string;
        initiator: "user" | "system";
      }) => {
        seedEvent(harness.deps, {
          threadId: thread.id,
          environmentId: environment.id,
          sequence: args.startSequence,
          type: "client/turn/requested",
          scope: threadScope(),
          data: {
            direction: "outbound",
            requestId: encodeClientTurnRequestIdNumber({ value: args.requestId }),
            input: [{ type: "text", text: args.text }],
            target: { kind: "new-turn" },
            execution: {
              model: "gpt-5",
              reasoningLevel: "medium",
              permissionMode: "full",
              serviceTier: "default",
              source: "client/turn/requested",
            },
            initiator: args.initiator,
            senderThreadId: null,
            request: { method: "turn/start", params: {} },
            source: "tell",
          },
        });
        seedEvent(harness.deps, {
          threadId: thread.id,
          environmentId: environment.id,
          providerThreadId: "provider-thread-1",
          scope: turnScope(args.turnId),
          sequence: args.startSequence + 1,
          type: "turn/started",
          data: {},
        });
        seedEvent(harness.deps, {
          threadId: thread.id,
          environmentId: environment.id,
          providerThreadId: "provider-thread-1",
          scope: turnScope(args.turnId),
          sequence: args.startSequence + 2,
          type: "item/completed",
          data: {
            item: {
              type: "agentMessage",
              id: `${args.turnId}-assistant`,
              text: `${args.turnId} answered.`,
            },
          },
        });
        seedEvent(harness.deps, {
          threadId: thread.id,
          environmentId: environment.id,
          providerThreadId: "provider-thread-1",
          scope: turnScope(args.turnId),
          sequence: args.startSequence + 3,
          type: "turn/completed",
          data: { status: "completed" },
        });
      };

      const notification =
        "[bb system]\n\nChild thread updates:\n\n- @thread:thr_worker1 failed.\n- @thread:thr_worker2 completed.";
      // Turn 1 is the child-thread notification the orchestrator wants to find later.
      seedMessageTurn({
        requestId: 1,
        startSequence: 1,
        text: notification,
        turnId: "turn-1",
        initiator: "system",
      });
      // 24 more ordinary turns => 25 user-message segments, 100 events total.
      for (let i = 2; i <= 25; i += 1) {
        seedMessageTurn({
          requestId: i,
          startSequence: (i - 1) * 4 + 1,
          text: `Ordinary prompt ${i}`,
          turnId: `turn-${i}`,
          initiator: "user",
        });
      }

      // What `bb thread log <id>` (minimal format) requests: the default page.
      const timelineResponse = await harness.app.request(
        `/api/v1/threads/${thread.id}/timeline`,
      );
      expect(timelineResponse.status).toBe(200);
      const timeline = threadTimelineResponseSchema.parse(
        await readJson(timelineResponse),
      );
      // The server knows the page is truncated...
      expect(timeline.timelinePage.segmentLimit).toBe(20);
      expect(timeline.timelinePage.returnedSegmentCount).toBe(20);
      expect(timeline.timelinePage.hasOlderRows).toBe(true);

      // ...but the text the CLI prints carries no trace of that.
      const text = formatThreadTimelineText(timeline.rows, {
        verbose: false,
        color: false,
      });
      expect(text).toContain("Ordinary prompt 25");
      expect(text).toContain("Ordinary prompt 6");
      expect(text).not.toContain("Ordinary prompt 5");
      // The notification (turn 1) is outside the window and simply absent.
      expect(text).not.toContain("Child thread updates");
      // No hint of truncation anywhere in the human output.
      expect(text.toLowerCase()).not.toMatch(/older|truncat|more events|showing/);

      // `bb thread log --json` requests /events with limit=100 (the CLI default).
      const eventsResponse = await harness.app.request(
        `/api/v1/threads/${thread.id}/events?limit=100`,
      );
      expect(eventsResponse.status).toBe(200);
      const eventRowSchema = z.object({ seq: z.number(), type: z.string(), data: z.unknown() });
      const events = z.array(eventRowSchema).parse(await readJson(eventsResponse));
      // 100 events exactly fit; add one more turn and the NEWEST events fall
      // off: /events lists ascending by sequence and applies LIMIT, so the
      // JSON default is the OLDEST 100 events, not the last 100 the issue
      // assumes.
      expect(events).toHaveLength(100);
      seedMessageTurn({
        requestId: 26,
        startSequence: 101,
        text: "Ordinary prompt 26",
        turnId: "turn-26",
        initiator: "user",
      });
      const eventsResponse2 = await harness.app.request(
        `/api/v1/threads/${thread.id}/events?limit=100`,
      );
      const events2 = z.array(eventRowSchema).parse(await readJson(eventsResponse2));
      expect(events2).toHaveLength(100);
      const seqs = events2.map((event) => event.seq);
      expect(Math.min(...seqs)).toBe(1);
      expect(Math.max(...seqs)).toBe(100); // seq 101-104 (newest turn) dropped silently
      expect(
        events2.some(
          (event) =>
            event.type === "client/turn/requested" &&
            JSON.stringify(event.data).includes("Ordinary prompt 26"),
        ),
      ).toBe(false);
    });
  });

  // Desired behavior (FAILS on main): a windowed default page must say so.
  // Kept separate so the characterization test above stays green.
  it("EXPECTED TO FAIL ON MAIN: the human text should announce that older rows were omitted", async () => {
    await withTestHarness(async (harness) => {
      const { environment, thread } = seedThreadFixture(harness);
      for (let i = 1; i <= 25; i += 1) {
        seedEvent(harness.deps, {
          threadId: thread.id,
          environmentId: environment.id,
          sequence: i,
          type: "client/turn/requested",
          scope: threadScope(),
          data: {
            direction: "outbound",
            requestId: encodeClientTurnRequestIdNumber({ value: i }),
            input: [{ type: "text", text: `Prompt ${i}` }],
            target: { kind: "new-turn" },
            execution: {
              model: "gpt-5",
              reasoningLevel: "medium",
              permissionMode: "full",
              serviceTier: "default",
              source: "client/turn/requested",
            },
            initiator: "user",
            senderThreadId: null,
            request: { method: "turn/start", params: {} },
            source: "tell",
          },
        });
      }
      const timeline = threadTimelineResponseSchema.parse(
        await readJson(
          await harness.app.request(`/api/v1/threads/${thread.id}/timeline`),
        ),
      );
      expect(timeline.timelinePage.hasOlderRows).toBe(true);
      const text = formatThreadTimelineText(timeline.rows, {
        verbose: false,
        color: false,
      });
      // This is what a reader needs and what the CLI does not print today.
      expect(text.toLowerCase()).toMatch(/older|omitted|truncat/);
    });
  });
});

packages/db/test/data/issue-1768-repro.test.ts — run from packages/db: pnpm exec vitest run test/data/issue-1768-repro.test.ts. Both pass on main: a system-initiated client/turn/requested with the batched text is indexed and found for a visible thread; the identical message on a visibility: "hidden" thread is indexed but returns total 0. Output: 11-vitest-search-repro.txt.

import { describe, expect, it } from "vitest";
import {
  encodeClientTurnRequestIdNumber,
  threadScope,
  type PromptInput,
} from "@bb/domain";
import { createConnection } from "../../src/connection.js";
import { migrate } from "../../src/migrate.js";
import { noopNotifier } from "../../src/notifier.js";
import { appendStoredThreadEvent } from "../../src/data/events.js";
import { upsertHost } from "../../src/data/hosts.js";
import { createProject } from "../../src/data/projects.js";
import {
  createThread,
  searchThreadsWithPendingInteractionState,
} from "../../src/data/threads.js";

// Issue #1768: "[bb system] Child thread updates" notifications are stored as
// system-initiated client/turn/requested events on the parent thread. Verify
// whether thread search indexes/finds them.
describe("issue 1768: child-thread notification searchability", () => {
  it("finds a system-initiated child-thread outcome batch message via search", () => {
    const db = createConnection(":memory:");
    migrate(db);
    const host = upsertHost(db, noopNotifier, { name: "h", type: "persistent" });
    const { project } = createProject(db, noopNotifier, {
      name: "p",
      source: { type: "local_path", hostId: host.id, path: "/tmp/p" },
    });
    const parent = createThread(db, noopNotifier, {
      projectId: project.id,
      providerId: "codex",
      title: "orchestrator",
    });
    const input: PromptInput[] = [
      {
        type: "text",
        text: "[bb system]\n\nChild thread updates:\n\n- @thread:thr_a failed.\n- @thread:thr_b completed.",
        mentions: [],
      },
    ];
    appendStoredThreadEvent(db, noopNotifier, {
      threadId: parent.id,
      environmentId: null,
      type: "client/turn/requested",
      scope: threadScope(),
      data: {
        direction: "outbound",
        requestId: encodeClientTurnRequestIdNumber({ value: 1 }),
        source: "tell",
        initiator: "system",
        senderThreadId: null,
        systemMessageKind: "child-outcome-batch",
        systemMessageSubject: { kind: "thread-batch", count: 2 },
        input,
        target: { kind: "new-turn" },
        request: { method: "turn/start", params: {} },
        execution: {
          model: "gpt-5",
          serviceTier: "default",
          reasoningLevel: "medium",
          permissionMode: "full",
          source: "client/turn/requested",
        },
      },
    });

    const segments = db.$client
      .prepare(
        "SELECT source_kind, text FROM thread_search_segments WHERE thread_id = ?",
      )
      .all(parent.id);
    console.log("indexed segments:", JSON.stringify(segments, null, 2));

    const result = searchThreadsWithPendingInteractionState(db, {
      query: "Child thread updates",
      limitPerGroup: 10,
    });
    console.log("search result:", JSON.stringify(result.active, null, 2));
    expect(result.active.total).toBe(1);
    expect(result.active.results[0]?.matches[0]?.text).toContain(
      "Child thread updates",
    );
    db.$client.close();
  });

  it("does NOT find the same message when the parent thread is hidden (visibility filter)", () => {
    const db = createConnection(":memory:");
    migrate(db);
    const host = upsertHost(db, noopNotifier, { name: "h", type: "persistent" });
    const { project } = createProject(db, noopNotifier, {
      name: "p",
      source: { type: "local_path", hostId: host.id, path: "/tmp/p" },
    });
    const parent = createThread(db, noopNotifier, {
      projectId: project.id,
      providerId: "codex",
      title: "hidden orchestrator",
      visibility: "hidden",
    });
    appendStoredThreadEvent(db, noopNotifier, {
      threadId: parent.id,
      environmentId: null,
      type: "client/turn/requested",
      scope: threadScope(),
      data: {
        direction: "outbound",
        requestId: encodeClientTurnRequestIdNumber({ value: 1 }),
        source: "tell",
        initiator: "system",
        senderThreadId: null,
        systemMessageKind: "child-outcome-batch",
        systemMessageSubject: { kind: "thread-batch", count: 2 },
        input: [
          {
            type: "text",
            text: "[bb system]\n\nChild thread updates:\n\n- @thread:thr_a failed.",
            mentions: [],
          },
        ],
        target: { kind: "new-turn" },
        request: { method: "turn/start", params: {} },
        execution: {
          model: "gpt-5",
          serviceTier: "default",
          reasoningLevel: "medium",
          permissionMode: "full",
          source: "client/turn/requested",
        },
      },
    });
    const indexed = db.$client
      .prepare("SELECT count(*) AS n FROM thread_search_segments WHERE thread_id = ? AND source_kind = 'user_message'")
      .get(parent.id) as { n: number };
    expect(indexed.n).toBe(1); // it IS indexed...
    const result = searchThreadsWithPendingInteractionState(db, {
      query: "Child thread updates",
      limitPerGroup: 10,
    });
    expect(result.active.total).toBe(0); // ...but hidden threads are filtered out of results
    db.$client.close();
  });
});

Root cause

1. Search does index the notifications (claim refuted)

Every stored event passes through appendStoredThreadEventsInTransaction (packages/db/src/data/events.ts:668-724), which after the INSERT INTO events calls upsertThreadSearchSegments with the segments produced by listThreadSearchSegmentsForStoredEventArgs: client/turn/requested → a user_message segment from the visible prompt text (any initiator), item/completed agent messages → assistant_message, legacy system/manager/user_messagesystem_message:

  467  function listThreadSearchSegmentsForStoredEventArgs(args: {
  468    eventArgs: AppendStoredThreadEventArgs;
  469    sequence: number;
  470  }): UpsertThreadSearchSegmentInput[] {
  471    switch (args.eventArgs.type) {
  472      case "client/turn/requested":
  473        return buildThreadEventSearchSegment({
  474          threadId: args.eventArgs.threadId,
  475          sequence: args.sequence,
  476          sourceKind: "user_message",
  477          text: extractVisiblePromptText(args.eventArgs.data.input),
  478        });
  479      case "item/completed":
  480        if (args.eventArgs.data.item.type !== "agentMessage") {
  481          return [];
  482        }
  483        return buildThreadEventSearchSegment({
  484          threadId: args.eventArgs.threadId,
  485          sequence: args.sequence,
  486          sourceKind: "assistant_message",
  487          text: args.eventArgs.data.item.text,
  488        });
  489      case "system/manager/user_message":
  490        return buildThreadEventSearchSegment({
  491          threadId: args.eventArgs.threadId,
  492          sequence: args.sequence,
  493          sourceKind: "system_message",
  494          text: args.eventArgs.data.text,
  495        });
  496      default:
  497        return [];
  716          )`,
  717      );
  718      upsertThreadSearchSegments(db, {
  719        updatedAt: now,
  720        segments: listThreadSearchSegmentsForStoredEventArgs({
  721          eventArgs: args,
  722          sequence,
  723        }),
  724      });

Parent system messages are built as plain {type:"text", text, mentions} input with no agent-only visibility (apps/server/src/services/threads/parent-system-messages.ts:143-168) and appended as client/turn/requested with initiator: "system" (apps/server/src/services/threads/parent-system-messages.ts:226-245), so they land in the index as user_message segments — which is exactly what the live search returned. The FTS query is an OR of prefix tokens per segment, and threads must match every token in some segment. The one filter that can hide a real hit is thread visibility:

  940        ${sql.join(tokenMatchSelects, sql` UNION ALL `)}
  941      ),
  942      ranked_threads AS (
  943        SELECT
  944          token_matches.threadId AS threadId,
  945          MIN(token_matches.tokenRank) AS bestRank,
  946          MAX(t.updated_at) AS threadUpdatedAt
  947        FROM token_matches
  948        JOIN threads AS t ON t.id = token_matches.threadId
  949        WHERE t.deleted_at IS NULL
  950          AND t.visibility = 'visible'
  951          AND ${archiveFilter}
  952        GROUP BY threadId
  953        HAVING COUNT(DISTINCT token_matches.tokenIndex) = ${args.tokenMatchQueries.length}

Hidden threads (created with --visibility hidden, or children inheriting a hidden parent) are never returned. Also, only 3 matches per thread are surfaced (THREAD_SEARCH_MATCHES_PER_THREAD = 3, packages/db/src/data/threads.ts:54), so search answers "which threads got such a message", not "how many times".

2. bb thread log windows differ by format and neither is announced

The CLI has two completely different backends behind one command:

  426      );
  427  
  428    parent
  429      .command("log [id]")
  430      .description("Show thread event log")
  431      .option("--self", "Target the current thread (from BB_THREAD_ID)")
  432      .option(
  433        "--json",
  434        "Print machine-readable JSON output (alias for --format json)",
  435      )
  436      .option(
  437        "--format <format>",
  438        "Output format: json (raw events), minimal (compact timeline), verbose (expanded timeline)",
  439        "minimal",
  440      )
  441      .option(
  442        "--limit <count>",
  443        "Maximum number of events to return; json format only (default 100)",
  444      )
  445      .option(
  446        "--after-seq <seq>",
  447        "Return events after this sequence number; json format only",
  448      )
  449      .action(
  450        action(async (id: string | undefined, opts: ThreadLogCommandOptions) => {
  451          const threadId = requireThreadIdOrSelf(id, opts);
  452          const sdk = createCliBbSdk(getUrl());
  453          const format = resolveThreadTimelineTextFormat(opts);
  454  
  455          if (format !== "json" && (opts.limit || opts.afterSeq)) {
  456            throw new Error(
  457              "--limit and --after-seq are only supported with --format json",
  458            );
  459          }
  460  
  461          if (format === "json") {
  462            const events = await sdk.threads.events.list({
  463              threadId,
  464              limit: String(opts.limit ?? 100),
  465              ...(opts.afterSeq ? { afterSeq: opts.afterSeq } : {}),
  466            });
  467            console.log(JSON.stringify(events, null, 2));
  468            return;
  469          }
  470  
  471          const timeline: ThreadTimelineResponse = await sdk.threads.timeline({
  472            threadId,
  473            ...(format === "verbose" ? { includeNestedRows: "true" } : {}),
  474          });
  475          const color = process.stdout.isTTY === true && !process.env.NO_COLOR;
  476          const text = formatThreadTimelineText(timeline.rows, {
  477            verbose: format === "verbose",
  478            color,
  479          });
  480          console.log(text);
  481        }),
  482      );
  479    get(routes.events, (context, query) => {
  480      requirePublicThread(deps.db, context.req.param("id"));
  481      return context.json(
  482        listThreadEventRows(deps.db, {
  483          threadId: context.req.param("id"),
  484          afterSeq: parseOptionalInteger(query.afterSeq, "afterSeq"),
  485          limit: parseOptionalInteger(query.limit, "limit") ?? 100,
  486        }),
  487      );
  488    });
 1095  export function listStoredEventRows(
 1096    db: DbConnection,
 1097    args: ListStoredEventRowsArgs,
 1098  ): StoredEventRow[] {
 1099    return db
 1100      .select(storedEventRowFields)
 1101      .from(events)
 1102      .where(
 1103        args.afterSequence === undefined
 1104          ? eq(events.threadId, args.threadId)
 1105          : and(
 1106              eq(events.threadId, args.threadId),
 1107              gt(events.sequence, args.afterSequence),
 1108            ),
 1109      )
 1110      .orderBy(events.sequence)
 1111      .limit(args.limit ?? Number.MAX_SAFE_INTEGER)
 1112      .all();
 1113  }

ORDER BY sequence ASC LIMIT 100 with no afterSeq means the default JSON is the beginning of the thread. For an orchestrator with hundreds of children, bb thread log --json | grep "Child thread updates" checks the first 100 events and nothing else, which explains "0 matches on parents that also hit the event cap" without any indexing bug (live: seq 144 and 183 absent from the default window, 14-log-checks.txt). Nothing in the output says the list was cut.

Deeper issue

queueParentSystemMessage returns false silently — no event, no log line — when the parent has a pending interaction or is archived/deleted (apps/server/src/services/threads/parent-system-messages.ts:414-424); flushChildThreadTurnNotificationBatch only logs on thrown errors (apps/server/src/services/threads/child-thread-notifications.ts:412-442). So a genuinely undelivered outcome leaves no trace, and no read-side tool can ever distinguish it from a truncated view. Related: #1650.

Proposed fix (first principles)

  1. Make truncation visible in bb thread log. In apps/cli/src/commands/thread/show.ts, after formatThreadTimelineText, print a trailer when timeline.timelinePage.hasOlderRows is true, e.g. (showing newest 20 of more user-message segments; older rows omitted — use --segments N or --all). Add --segments <n> (maps to segmentLimit, max 100) and --all (loop on olderCursor via beforeAnchorSeq/beforeAnchorId until hasOlderRows is false, prepend pages). Nothing crosses the server/daemon boundary; the API already supports it. Risk: --all on huge threads is slow — print pages as they arrive.
  2. Fix the JSON default direction or say what it is. Either document in --help that --limit returns the oldest N events from --after-seq (and add --tail that fetches the newest N — needs an order=desc or beforeSeq on /events, server-side change in listStoredEventRows), or print to stderr returned 100 events (seq 1–180); more exist, pass --after-seq 180. The latter is a one-liner: events.length === limit ⇒ warn.
  3. Direct query. Add bb thread notifications <id> (or bb thread log --system) backed by a targeted DB query: events WHERE thread_id=? AND type='client/turn/requested' AND json_extract(data,'$.initiator')='system', exposing systemMessageKind/systemMessageSubject (already stamped: child-failed, child-completed, child-outcome-batch, subject thread id/count) — that answers "was I told worker X died" exactly and needs no text grep. Also make the silent return false paths in queueParentSystemMessage at least log at info with the parent id and reason.
  4. Search: no indexing change needed. Optionally document that hidden threads are excluded, or add --include-hidden to bb thread search.

PR review

No linked open PRs.

Related issues

Appendix

Commands run

git checkout --detach 16ceb3a54
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
scripts/bb-dev-app current                      # App :14672, Server :22672, daemon :30672
export BB_SERVER_URL=http://localhost:22672 BB_HOST_DAEMON_PORT=30672
unset BB_THREAD_ID BB_ENVIRONMENT_ID BB_THREAD_STORAGE BB_PROJECT_ID BB_CLI
bb machine list                                  # host_vvjjb88hue
mkdir -p /tmp/bb-1768-scratch && git -C /tmp/bb-1768-scratch init && git -C /tmp/bb-1768-scratch commit --allow-empty -m init
curl -s -X POST $BB_SERVER_URL/api/v1/projects -H 'content-type: application/json' \
  -d '{"name":"qa-1768","source":{"type":"local_path","path":"/tmp/bb-1768-scratch","hostId":"host_vvjjb88hue"}}'   # proj_jyevf2557d
bb thread spawn --project proj_jyevf2557d --provider codex --permission-mode accept-edits --title "1768 orchestrator" --prompt "Reply only with ok." --json   # thr_kjmpu6p3i2
bb thread wait thr_kjmpu6p3i2 --status idle
PARENT=thr_kjmpu6p3i2 PROJECT=proj_jyevf2557d bash repro/step3-spawn-children.sh   # thr_v3x33nrebs (bad model), thr_feiwytz7k8 (ok)
bb thread wait thr_kjmpu6p3i2 --status idle
bb thread log thr_kjmpu6p3i2 --json --limit 100000 > repro/02-parent-events.json ; bb thread log thr_kjmpu6p3i2 > repro/03-...; --format verbose > repro/04-...
bb thread search "Child thread updates" --json > repro/05-... ; bb thread search "bb system" --json > repro/06-...
CHILD=thr_v3x33nrebs bash repro/search-variants.sh > repro/07-search-variants.txt
PARENT=thr_kjmpu6p3i2 bash repro/filler-turns.sh                                    # 62 -> 88 -> 114 events
PARENT=thr_kjmpu6p3i2 PROJECT=proj_jyevf2557d bash repro/spawn-batch-children.sh   # thr_3x4b52fnah thr_vzm6xr4wyu (parallel) -> batch at seq 144
PARENT=thr_kjmpu6p3i2 PROJECT=proj_jyevf2557d bash repro/midturn-child.sh          # thr_va35e367eg -> steer notification at seq 183
CHILD=thr_v3x33nrebs bash repro/search-variants.sh > repro/09-search-variants-after-batch.txt
PARENT=thr_kjmpu6p3i2 bash repro/log-checks.sh > repro/14-log-checks.txt
PARENT=thr_kjmpu6p3i2 PROJECT=proj_jyevf2557d bash repro/save-artifacts.sh
(cd apps/server && pnpm exec vitest run test/public/issue-1768-thread-log-truncation.test.ts)
(cd packages/db && pnpm exec vitest run test/data/issue-1768-repro.test.ts)
pnpm dev:stop

Search right after step 3 (only the first batched notification existed)

== bb thread search "Child thread updates" --json
  active total 1
     thr_kjmpu6p3i2 user_message seq 19 '[bb system]\n\nChild thread updates:\n\n- @thread:thr_v3x33nrebs failed.\n-'
  archived total 0
== bb thread search "bb system" --json
  active total 1
     thr_kjmpu6p3i2 user_message seq 19 '[bb system]\n\nChild thread updates:\n\n- @thread:thr_v3x33nrebs failed.\n-'
  archived total 0
== bb thread search "failed" --json
  active total 1
     thr_kjmpu6p3i2 user_message seq 19 '[bb system]\n\nChild thread updates:\n\n- @thread:thr_v3x33nrebs failed.\n-'
  archived total 0
== bb thread search "thr_v3x33nrebs" --json
  active total 1
     thr_kjmpu6p3i2 user_message seq 19 '[bb system]\n\nChild thread updates:\n\n- @thread:thr_v3x33nrebs failed.\n-'
  archived total 0
== bb thread search "completed" --json
  active total 1
     thr_kjmpu6p3i2 user_message seq 19 '[bb system]\n\nChild thread updates:\n\n- @thread:thr_v3x33nrebs failed.\n-'
  archived total 0

Verbose log excerpt: the steer-delivered notification only shows nested

── User ────────────────────────────────────────────────────
Filler turn 4: run the shell command (sleep 75; echo done) exactly once, wait for it to finish, then reply only with ok.

── Worked for (1m 23s) ─────────────────────────────────────
  ── Assistant
  I’ll run it exactly once and wait for completion.

  ── Ran sleep 75; echo done (1m 15s)
    $ sleep 75; echo done
    done

  ── User
  [bb system]

  @thread:thr_va35e367eg failed.

  Review the thread before deciding next steps.
  steer

  ── Assistant
  The command is still running; I’m continuing to wait for that same invocation.

── Assistant ───────────────────────────────────────────────
ok

vitest output

 RUN  v4.1.1 /home/sawyer/projects/bb/.claude/worktrees/wf_570fde41-63f-3/apps/server

 ❯  @bb/server  test/public/issue-1768-thread-log-truncation.test.ts (2 tests | 1 failed) 241ms
     × EXPECTED TO FAIL ON MAIN: the human text should announce that older rows were omitted 80ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL   @bb/server  test/public/issue-1768-thread-log-truncation.test.ts > issue 1768: bb thread log silently truncates > EXPECTED TO FAIL ON MAIN: the human text should announce that older rows were omitted
AssertionError: expected '── user ─────────────────────────────…' to match /older|omitted|truncat/

- Expected:
/older|omitted|truncat/

+ Received:
"── user ────────────────────────────────────────────────────
prompt 6

── user ────────────────────────────────────────────────────
prompt 7

── user ────────────────────────────────────────────────────
prompt 8

── user ────────────────────────────────────────────────────
prompt 9

── user ────────────────────────────────────────────────────
prompt 10

── user ────────────────────────────────────────────────────
prompt 11

── user ────────────────────────────────────────────────────
prompt 12

── user ────────────────────────────────────────────────────
prompt 13

── user ────────────────────────────────────────────────────
prompt 14

── user ────────────────────────────────────────────────────
prompt 15

── user ────────────────────────────────────────────────────
prompt 16

── user ────────────────────────────────────────────────────
prompt 17

── user ────────────────────────────────────────────────────
prompt 18

── user ────────────────────────────────────────────────────
prompt 19

── user ────────────────────────────────────────────────────
prompt 20

… (33 more lines, see repro/10-vitest-thread-log-truncation.txt)
 RUN  v4.1.1 /home/sawyer/projects/bb/.claude/worktrees/wf_570fde41-63f-3/packages/db


 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  14:57:26
   Duration  1.07s (transform 337ms, setup 0ms, import 801ms, tests 188ms, environment 0ms)

Help text

Usage: bb thread log [options] [id]

Show thread event log

Options:
  --self             Target the current thread (from BB_THREAD_ID)
  --json             Print machine-readable JSON output (alias for --format
                     json)
  --format <format>  Output format: json (raw events), minimal (compact
                     timeline), verbose (expanded timeline) (default: "minimal")
  --limit <count>    Maximum number of events to return; json format only
                     (default 100)
  --after-seq <seq>  Return events after this sequence number; json format only
  -h, --help         display help for command

Usage: bb thread search [options] <query>

Search threads and messages

Options:
  --limit <count>  Maximum results per group
  --json           Print machine-readable JSON output
  -h, --help       display help for command

All artifacts

Verification

An independent verifier followed the reproduction on their own worktree/instance at 16ceb3a54. Steps 1–3 worked verbatim; their sequential bad-model spawns did not batch (two single-child messages, search "Child thread updates" → 0 because the phrase never existed), while spawning two children in parallel produced the batched text and search found it (total 1, seq 310) — confirming that [bb system] messages are indexed. The log checks reproduced exactly (default --json = oldest 100 events, human --limit rejected, minimal 2 vs verbose 5 [bb system] lines); both vitest files behaved as stated; not fixed on origin/main. Their artifacts are in 1768/verify/.

Changes made in this revision in response: (1) the FTS-indexing excerpt/permalink previously pointed at events.ts#L553-L585 (which is appendDaemonEventsInTransaction at the base commit; the numbers came from a later HEAD); it now shows listThreadSearchSegmentsForStoredEventArgs (L467–497) and the upsertThreadSearchSegments call in appendStoredThreadEventsInTransaction (L716–724), re-extracted from 16ceb3a54. (2) The batch step now spawns the two children in parallel, states that batching is a 2 s race, and tells the reader to confirm the phrase exists before searching; the whole live run was redone from scratch on a fresh instance (parent thr_kjmpu6p3i2) and every artifact under 1768/repro/ was regenerated in place, including new filler-turns.sh (to push the thread past 100 events) and midturn-child.sh (steer-delivered notification). (3) All scripts now take $CLI/PARENT/PROJECT/CHILD/OUT_DIR instead of hardcoded worktree paths and ids. (4) Added the "log shows it 23 times" claim row. (5) Explained sparse seq numbers and defined "segment" on first use. (6) Rephrased the minimal-vs-verbose finding as "every mid-turn notification is collapsed" with both my (2/3) and the verifier's (2/5) counts.