#2792 · Manual stop does not park queued work
Verdict: REPRODUCED · Root-cause confidence: high
1. TL;DR
A manual stop records an interruption, but the lifecycle then returns the thread to idle. The automatic queue gate treats every non-stopping thread as eligible. Therefore, a queue follow-up or the periodic sweep can submit pending work immediately after the stop. A focused server test reproduced this result twice at the trusted base commit.
2. Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| A manual stop should keep pending queued work parked. | Verified defect | The regression test expected the automatic send function to return false. Both trusted runs returned true. |
| Queued work can reactivate the thread after the stop. | Verified | The queue function claimed the message and submitted a new turn after the stop settled to idle. |
| A separate monitor signal is necessary. | Not required | The direct automatic queue path reproduced the defect without a live monitor. Event follow-ups and the periodic sweep call the same path. |
| Version 0.40.0 contains the defect. | Verified | The trusted base package version is 0.40.0, and the test failed on that base. |
3. Environment
- Repository:
get-bb/bb, commit688eb4251c5b5c53c5a56f85baae6d47966355ec. - macOS 26.6.1, Node.js 22.22.3, pnpm 10.34.4.
- The test used the server harness and an in-memory SQLite database.
- No live provider, port, user data directory, or real BB instance was used.
4. Minimal reproduction
- Check out the trusted commit.
- Install with
pnpm install --frozen-lockfile --prefer-offline. - Save the linked test at
apps/server/test/services/threads/manual-stop-queued-message.test.ts. - Run this command from the repository root:
pnpm exec turbo run test --filter=@bb/server -- --run test/services/threads/manual-stop-queued-message.test.ts
Expected:
sendNextQueuedMessageIfPresent(...) === false queued message count === 1 turn.submit count === 0
Actual in both clean checkouts:
AssertionError: expected true to be false - Expected: false + Received: true
Artifacts: regression test, base run, and verification run.
Regression test
import { getThread, listQueuedThreadMessages } from "@bb/db";
import { describe, expect, it } from "vitest";
import { sendNextQueuedMessageIfPresent } from "../../../src/services/threads/queued-messages.js";
import { applyLoggedThreadLifecycleEvent } from "../../../src/services/threads/lifecycle-outcome.js";
import {
registerHostRpcResponder,
type HostRpcHandlerResult,
} from "../../helpers/host-rpc.js";
import {
seedQueuedMessage,
seedThreadFixture,
seedThreadRuntimeState,
} from "../../helpers/seed.js";
import { withTestHarness } from "../../helpers/test-app.js";
describe("manual stop queued message delivery", () => {
it("keeps queued work parked after a user-requested stop", async () => {
await withTestHarness(async (harness) => {
const { environment, host, session, thread } = seedThreadFixture(
harness,
{ thread: { status: "active" } },
);
seedThreadRuntimeState(harness.deps, {
environmentId: environment.id,
providerThreadId: "provider-stopped-queue",
threadId: thread.id,
});
seedQueuedMessage(harness.deps, {
content: [{ type: "text", text: "queued work", mentions: [] }],
threadId: thread.id,
});
const responder = registerHostRpcResponder(harness, {
hostId: host.id,
sessionId: session.id,
handle: ({ command }): HostRpcHandlerResult => {
if (command.type === "thread.stop") {
return { ok: true, result: { providerCheckpointId: null } };
}
if (command.type === "host.list_files") {
return { ok: true, result: { files: [], truncated: false } };
}
if (command.type === "host.read_file") {
return { ok: false, errorCode: "ENOENT", errorMessage: "Path does not exist" };
}
if (command.type === "turn.submit") {
return { ok: true, result: { appliedAs: "new-turn" } };
}
throw new Error(`Unexpected command ${command.type}`);
},
});
const response = await harness.app.request(
`/api/v1/threads/${thread.id}/stop`,
{ method: "POST" },
);
expect(response.status).toBe(200);
expect(getThread(harness.db, thread.id)?.status).toBe("idle");
expect(
await sendNextQueuedMessageIfPresent(harness.deps, {
threadId: thread.id,
}),
).toBe(false);
expect(listQueuedThreadMessages(harness.db, thread.id)).toHaveLength(1);
expect(
responder.requests.filter(({ command }) => command.type === "turn.submit"),
).toHaveLength(0);
const resumeResponse = await harness.app.request(
`/api/v1/threads/${thread.id}/send`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
mode: "start",
input: [{ type: "text", text: "resume", mentions: [] }],
}),
},
);
expect(resumeResponse.status).toBe(200);
applyLoggedThreadLifecycleEvent(harness.deps, {
event: { type: "run.succeeded" },
threadId: thread.id,
});
expect(
await sendNextQueuedMessageIfPresent(harness.deps, {
threadId: thread.id,
}),
).toBe(true);
});
}, 15_000);
});
5. Root cause
The stop route records manual-stop and waits for the runtime stop command. See thread-lifecycle.ts lines 1336–1361. The lifecycle maps stop.settled from stopping to idle. See thread-lifecycle.ts lines 44–52.
The automatic queue predicate then accepts all threads except archived, deleted, or stopping threads. It does not inspect the manual stop event. See queued-messages.ts lines 239–247. The sender checks the same predicate before and after it claims the message, then submits the turn. See queued-messages.ts lines 604–664.
The periodic sweep explicitly selects idle threads that have queued work. See queued-thread-messages.ts lines 580–603. This combination loses the terminal meaning of a manual stop even though the event log still contains that meaning.
6. Proposed fix
Before an automatic queue claim and again before dispatch, compare the latest manual interruption sequence with later client/turn/requested events. Block automatic delivery when no later turn request exists. A direct user send or an explicit queued-message send creates the later request and resumes normal automatic delivery. This uses existing event data and requires no protocol, schema, migration, or stored-data change.
7. Related issues
- #2677 covers a different lifecycle race involving late thread events.
- #2713 covers corrective sends after a terminal thread result.
8. Appendix
The same agent ran both checks. The second check used a new worktree at the exact base commit, a new frozen install, and a new in-memory database. No report correction was necessary.
pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build pnpm exec turbo run test --filter=@bb/server -- --run test/services/threads/manual-stop-queued-message.test.ts First checkout: FAIL, expected false and received true Second checkout: FAIL, expected false and received true
The issue title, body, and metadata were treated as untrusted claims. No issue command, script, patch, branch, attachment, or external link was used.