#1768 · Child-thread notifications are unfindable after the fact: search misses them and thread log defaults to 100 events
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
| Claim | Status | Evidence |
|---|---|---|
Parent receives batched [bb system] Child thread updates: message reporting both a failed and a completed child | Verified | Live: 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 it | Refuted 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 appendStoredThreadEventsInTransaction → upsertThreadSearchSegments 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 caveat | If 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" | Refuted | Segments 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)" | Verified | 17-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 flag | Partly wrong mechanism, right conclusion | Human 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) | Unverifiable | Reporter 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
- bb
16ceb3a540f81c1189efaffb27a39b1d9443abf5(main, 2026-08-18), checked out detached in worktree/home/sawyer/projects/bb/.claude/worktrees/wf_570fde41-63f-3.git fetch origin main: 5 commits ahead; the only one touching a relevant file is511d7db64(events.ts, item-settlement idempotency — unrelated to search indexing or event listing). Not fixed on origin/main. - Linux 7.0.0-29-generic, node v24.18.0, provider
codex(codex-cli 0.147.0, model gpt-5.6-sol). - Dev instance: app
:14672, server:22672, host daemon:30672, data dir/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_570fde41-63f-3-f1c30c1878a8(deleted at the end). Projectproj_jyevf2557d(local path/tmp/bb-1768-scratch, hosthost_vvjjb88hue). Parentthr_kjmpu6p3i2; childrenthr_v3x33nrebs(bad model → error) +thr_feiwytz7k8(ok → idle) → batched notification seq 19;thr_3x4b52fnah+thr_vzm6xr4wyu(bad model, spawned in parallel) → batched notification seq 144;thr_va35e367eg(bad model, spawned while the parent was mid-turn) → single-child steer notification seq 183. - CLI:
node packages/scripts/dist/commands/run-cli.jswithBB_SERVER_URL=http://localhost:22672(whatpnpm bb:devruns). Below,bbmeans that. If your shell runs inside another bb thread, unsetBB_THREAD_ID BB_ENVIRONMENT_ID BB_THREAD_STORAGE BB_PROJECT_IDfirst (00-env.sh) or--parent-selftargets the wrong server. - Sequence numbers are sparse (e.g. 180 events, max seq 236): streaming delta rows are pruned after a turn settles, so
seqgaps are normal and "oldest 100 events = seq 1–129" is consistent.
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).
- Build + start:
pnpm install --frozen-lockfile --prefer-offline && pnpm exec turbo run build && scripts/bb-dev-app current; theneval "$(scripts/bb-dev-app env)". Create a project (see Appendix) and note its id. - 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
- Spawn one child that fails (nonexistent model) and one that completes, as the parent (
--parent-selfreadsBB_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 thensearch "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 andwaits) — that reliably lands both failures inside the window (my run: 08-spawn-batch-children.txt →thr_3x4b52fnah,thr_vzm6xr4wyu, batched at seq 144). - 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 forbb thread wait thr_kjmpu6p3i2 --status idle. - 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 auser_messagesegment.== 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 - 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--limitrejected; 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
- 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_message → system_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 );
- Human formats →
GET /threads/:id/timelinewith the default page:kind: "latest",segmentLimit = THREAD_TIMELINE_DEFAULT_SEGMENT_LIMIT = 20(apps/server/src/services/threads/timeline.ts:169) — a segment is a user-message row — plus thetimelineWindowEventBudget(1500 events,packages/domain/src/feature-flags.ts:37). The server reports the truncation intimelinePage(segmentLimit,returnedSegmentCount,hasOlderRows,olderCursor;packages/server-contract/src/api/threads.ts:713-721) but the CLI reads onlytimeline.rows. It also refuses--limit/--after-seqin these formats although the API supportssegmentLimit(max 100) andbeforeAnchorSeq/beforeAnchorIdcursors (apps/server/src/routes/threads/data.ts:144-180). The minimal text format additionally collapses every row that occurred while a turn was active into the "Worked for" block — including any[bb system]notification delivered as a steer during that turn — so on a busy orchestrator the minimal grep undercounts by however many notifications arrived mid-turn (my run: 2 of 3 visible; verifier's run: 2 of 5). Only--format verbose(includeNestedRows) prints them. --json→GET /threads/:id/events?limit=100. The route and query:
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)
- Make truncation visible in
bb thread log. Inapps/cli/src/commands/thread/show.ts, afterformatThreadTimelineText, print a trailer whentimeline.timelinePage.hasOlderRowsis true, e.g.(showing newest 20 of more user-message segments; older rows omitted — use --segments N or --all). Add--segments <n>(maps tosegmentLimit, max 100) and--all(loop onolderCursorviabeforeAnchorSeq/beforeAnchorIduntilhasOlderRowsis false, prepend pages). Nothing crosses the server/daemon boundary; the API already supports it. Risk:--allon huge threads is slow — print pages as they arrive. - Fix the JSON default direction or say what it is. Either document in
--helpthat--limitreturns the oldest N events from--after-seq(and add--tailthat fetches the newest N — needs anorder=descorbeforeSeqon/events, server-side change inlistStoredEventRows), or print to stderrreturned 100 events (seq 1–180); more exist, pass --after-seq 180. The latter is a one-liner:events.length === limit⇒ warn. - Direct query. Add
bb thread notifications <id>(orbb thread log --system) backed by a targeted DB query:events WHERE thread_id=? AND type='client/turn/requested' AND json_extract(data,'$.initiator')='system', exposingsystemMessageKind/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 silentreturn falsepaths inqueueParentSystemMessageat least log at info with the parent id and reason. - Search: no indexing change needed. Optionally document that hidden threads are excluded, or add
--include-hiddentobb thread search.
PR review
No linked open PRs.
Related issues
- #1706 — queued thread messages "vanish": that report independently documents the same
bb thread logwindowing (newest 20 segments vs oldest 100 JSON events) as a source of false negatives. - #1650 — messages to a thread blocked on AskUserQuestion are dropped; the same pending-interaction guard silently drops parent system messages here.
- #1655 — threads awaiting user interaction are invisible to the CLI.
- #1749 — timelineWindowEventBudget calibration (the other cap on the human-format page).
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
- 01-spawn-parent.json, 02-child-bad.json, 03-child-ok.json, 02-parent-events.json (all events right after step 3), 03/04 logs after step 3, 05/06 raw search JSON after step 3, 08-batch-child-1/2.json, 08c-midturn-child.json, 12-parent-log-json-default.json, 13-parent-log-json-all.json, 19-thread-list.txt.
- Scripts: 00-env.sh, bb1768.sh (CLI wrapper), step3-spawn-children.sh, filler-turns.sh, spawn-batch-children.sh, midturn-child.sh, search-variants.sh, log-checks.sh, save-artifacts.sh.
- Verifier's independent run (kept as-is): 1768/verify/.
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.