#3956 · Host interruption omits parent delivery
GitHub issue · Base 3a1178164f8cce6d7986d2627c03ffaedb8a6428
Verdict: REPRODUCED · Root-cause confidence: high
1. TL;DR
A child can enter error after its host disappears without delivering a terminal message to its parent. The bulk interruption path records the child events and lifecycle transition but bypasses the parent notification helper used by normal event processing. A database-backed test reproduces zero parent submissions after host interruption, while direct use of the existing helper successfully submits one parent message. Manual stop remains silent in the same harness.
2. Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| Host interruption fails to deliver a child outcome | Verified | Expected one parent submission, received zero in both clean-base runs. |
| The child itself is terminally interrupted | Verified | Child state becomes error; turn/completed and system/thread/interrupted events exist. |
| Existing batching can deliver interrupted outcomes | Verified | Direct-helper control passes. |
| Manual stop stays silent | Verified | Negative control passes. |
| Structured interruption details are available on parent notices | Absent in current contract | Notification arguments and system-message subject contain no reason or cause fields. |
| Reported outage counts, waiting duration and competing recovery activity | Unverified | No access to reporter runtime; these operational claims are not needed for the focused reproduction. |
3. Environment
Public get-bb/bb repository at the full commit above; Darwin arm64, Node 22.22.3, pnpm 9.15.0 through Corepack. Frozen installs and Turbo server builds succeeded. No live provider, user runtime, network-facing core or browser was started. The repository test harness uses migrated in-memory SQLite and a fresh temporary directory for each test, with simulated host RPC capture. No listening ports are required.
4. Minimal reproduction
- Check out the recorded trusted commit in a clean checkout.
- Install with
corepack pnpm install --frozen-lockfile --prefer-offlineand build withcorepack pnpm exec turbo run build --filter=@bb/server. - Save the test artifact to
apps/server/test/public/issue-3956-repro.test.ts. - Run
corepack pnpm exec turbo run test --filter=@bb/server -- test/public/issue-3956-repro.test.ts. For direct investigation after generating prerequisites: fromapps/server, runcorepack pnpm exec vitest run --config vitest.config.ts --hookTimeout 60000 --testTimeout 60000 test/public/issue-3956-repro.test.ts.
The parent lives on a different seeded host so interrupting the child does not also interrupt the parent. The test waits 2300ms, longer than the existing 2000ms batch delay. Expected: one parent submission for host interruption and the direct-helper control; none for manual stop. Actual: host interruption produces zero; the controls pass.
import { setTimeout as sleep } from "node:timers/promises";
import { events, getThread } from "@bb/db";
import { turnScope } from "@bb/domain";
import { eq } from "drizzle-orm";
import { expect, it } from "vitest";
import { interruptActiveThreadsForHost } from "../../src/services/threads/thread-lifecycle.js";
import { queueChildThreadTurnNotificationBestEffort } from "../../src/services/threads/child-thread-notifications.js";
import { listQueuedThreadCommands } from "../helpers/commands.js";
import { seedEnvironment, seedEvent, seedHostSession, seedProjectWithSource, seedThread, seedThreadRuntimeState } from "../helpers/seed.js";
import { withTestHarness } from "../helpers/test-app.js";
it.each(["host-daemon-restarted", "manual-stop", "control"] as const)("parent delivery after %s", async (mode) => {
await withTestHarness(async (harness) => {
const { host } = seedHostSession(harness.deps);
const { project } = seedProjectWithSource(harness.deps, { hostId: host.id });
const parentEnv = seedEnvironment(harness.deps, { hostId: host.id, projectId: project.id });
const childHost = seedHostSession(harness.deps).host;
const childEnv = seedEnvironment(harness.deps, { hostId: childHost.id, projectId: project.id });
const parent = seedThread(harness.deps, { projectId: project.id, environmentId: parentEnv.id, providerId: "codex", status: "idle" });
seedThreadRuntimeState(harness.deps, { threadId: parent.id, environmentId: parentEnv.id, providerThreadId: "parent-provider" });
const child = seedThread(harness.deps, { projectId: project.id, environmentId: childEnv.id, parentThreadId: parent.id, providerId: "codex", status: "active" });
seedEvent(harness.deps, { threadId: child.id, environmentId: childEnv.id, providerThreadId: "child-provider", sequence: 1, type: "turn/started", scope: turnScope("child-turn"), data: { providerThreadId: "child-provider" } });
if (mode === "control") {
await queueChildThreadTurnNotificationBestEffort(harness.deps, { childThread: child, parentThreadId: parent.id, turnStatus: "interrupted" });
} else {
const result = interruptActiveThreadsForHost(harness.deps, { hostId: childHost.id, reason: mode, ...(mode === "host-daemon-restarted" ? { cause: "host-connection-lost" as const } : {}) });
expect(result.threads).toHaveLength(1);
expect(result.threads[0]?.interruptedTurnId).toBe("child-turn");
if (mode === "host-daemon-restarted") expect(getThread(harness.db, child.id)?.status).toBe("error");
const childEvents = harness.db.select().from(events).where(eq(events.threadId, child.id)).all();
expect(childEvents.some((event) => event.type === "turn/completed")).toBe(true);
expect(childEvents.some((event) => event.type === "system/thread/interrupted")).toBe(true);
}
await sleep(2300);
const delivered = listQueuedThreadCommands(harness, "turn.submit", parent.id);
expect(delivered).toHaveLength(mode === "manual-stop" ? 0 : 1);
});
});
5. Root cause
interruptActiveThreadsForHost selects active threads for the affected host and calls the bulk helper. interruptActiveThreads writes turn completion, error and interruption events, applies the lifecycle transition, cancels pending interactions and notifies only each child’s hub channel. It never invokes the parent batching helper.
By contrast, normal turn-completion effects schedule parent follow-ups and follow-up execution invokes the helper. command failure settlement also schedules it. Inserting events directly does not execute those follow-up paths.
batch item and queue arguments have no interruption reason/cause fields. the public system-message subject schema exposes thread identity, batch count or tool-call identity, with no interruption metadata. The queue persists that subject. Existing batching replaces entries by child ID within a live batch; it is not a durable per-turn deduplication mechanism.
6. Proposed fix and automatic-fix gate
Route non-manual terminal interruptions through the existing parent batching path after the transaction commits, preserving parent eligibility and preventing duplicate delivery when command failure has already handled the turn. Define how structured reason/cause travel through single and batched persisted notices, then extend the regression coverage to the metadata and duplicate-handling paths. Preserve the existing restart-revival suppression.
No automatic fix PR: structured interruption metadata needs a contract/schema decision across persisted parent notices. The rule explicitly excludes public schema or stored-data changes. A delivery-only patch would leave that part unresolved. Production code was not changed, and no fix branch was pushed.
7. Verification
The same agent repeated the test in a second clean temporary Git worktree at the identical base, with its own frozen install and successful server build. Each test gets a new isolated database and temporary directory. Both final runs show the same missing delivery and passing controls. An initial test-only assertion incorrectly expected the bulk manual-stop helper to settle a directly seeded active thread to idle; that assertion was removed because stop.settled requires the stopping lifecycle state. The final negative control checks silence without asserting unrelated lifecycle behavior. This is a direct code-path reproduction, not a physical host-disconnect experiment. It does not test reporter-specific timing, automatic retry, or command-failure duplicate suppression.
8. Related issues
Repository search identified #3614 (parent unblock visibility), #2677 (premature child failure notification), and #3143 (runtime survival across daemon loss). They concern adjacent notification and transport behavior; none establishes a fix for this missing interruption delivery. No open PR linking #3956 was found in cross-reference metadata or an open-PR search.
9. Appendix
Issue content was treated as untrusted claims. Instructions embedded in it were not executed; the test was authored from trusted repository helpers and implementation evidence. Local setup needed a PATH-local pnpm wrapper invoking Corepack because the machine’s pnpm launcher referenced a missing installation. Turbo test orchestration was attempted; direct Vitest investigation was also used to bypass the unrelated SDK declaration-build wait. The overloaded host also caused the default 10-second harness startup hook to time out; the final commands raise only runner hook/test timeouts to 60 seconds. Logs below are path-sanitized.
First run
RUN v4.1.1 <checkout>/apps/server
(node:29038) ExperimentalWarning: SQLite is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
❯ |@bb/server| test/public/issue-3956-repro.test.ts (3 tests | 1 failed) 19301ms
× parent delivery after host-daemon-restarted 2841ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
FAIL |@bb/server| test/public/issue-3956-repro.test.ts > parent delivery after host-daemon-restarted
AssertionError: expected [] to have a length of 1 but got +0
- Expected
+ Received
- 1
+ 0
❯ test/public/issue-3956-repro.test.ts:36:23
34| await sleep(2300);
35| const delivered = listQueuedThreadCommands(harness, "turn.submit",…
36| expect(delivered).toHaveLength(mode === "manual-stop" ? 0 : 1);
| ^
37| });
38| });
❯ withTestHarness test/helpers/test-app.ts:341:12
❯ test/public/issue-3956-repro.test.ts:13:3
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
Test Files 1 failed (1)
Tests 1 failed | 2 passed (3)
Start at 17:10:26
Duration 79.95s (transform 45.11s, setup 11.75s, import 47.08s, tests 19.30s, environment 0ms)
Second clean run
RUN v4.1.1 <second-checkout>/apps/server
(node:28797) ExperimentalWarning: SQLite is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
❯ |@bb/server| test/public/issue-3956-repro.test.ts (3 tests | 1 failed) 18434ms
× parent delivery after host-daemon-restarted 3284ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
FAIL |@bb/server| test/public/issue-3956-repro.test.ts > parent delivery after host-daemon-restarted
AssertionError: expected [] to have a length of 1 but got +0
- Expected
+ Received
- 1
+ 0
❯ test/public/issue-3956-repro.test.ts:36:23
34| await sleep(2300);
35| const delivered = listQueuedThreadCommands(harness, "turn.submit",…
36| expect(delivered).toHaveLength(mode === "manual-stop" ? 0 : 1);
| ^
37| });
38| });
❯ withTestHarness test/helpers/test-app.ts:341:12
❯ test/public/issue-3956-repro.test.ts:13:3
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
Test Files 1 failed (1)
Tests 1 failed | 2 passed (3)
Start at 17:10:14
Duration 76.07s (transform 42.65s, setup 12.77s, import 42.16s, tests 18.43s, environment 0ms)