#3462 · Retained turns survive idle stop
Verdict: REPRODUCED · Root-cause confidence: high · component-level reproduction
1. TL;DR
When the server thinks a thread is idle but its daemon retains a turn, stopping it can return success without clearing that turn. The server selects a release command, whose daemon handler deliberately protects active turns. Its response is indistinguishable from a completed release. A deterministic test demonstrates three consecutive no-op releases, followed by an interrupt that clears the fixture's turn.
2. Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| Idle server stop requests release and returns success. | Verified | Existing public stop-route suite; server source. |
| An active daemon turn survives repeated release requests. | Verified | New diagnostic test executes the production dispatcher three times. |
| Later sends reject while that turn remains. | Verified in source | Runtime admission guard rejects a non-null active-turn ID. |
| The original provider failed to report completion. | Unverified | No live incident trace; the divergent state is seeded. |
| Retry and tell acknowledge delivery before rejection. | Unverified | Outside this focused stop reproduction. |
3. Environment
Darwin arm64, Node v22.22.3, frozen workspace dependencies. Both checkouts use the recorded trusted main commit. Corepack supplies the pinned pnpm because the machine's normal pnpm shim points to a missing file. No provider account, live BB instance, imported store, or listener was used. The test creates and removes its own temporary workspace; the server harness uses its isolated test database.
4. Minimal reproduction
- Check out trusted main at the recorded commit in a clean directory.
- Install and build:
corepack pnpm install --frozen-lockfile --prefer-offline corepack pnpm exec turbo run build
- Append the diagnostic below to apps/host-daemon/src/command-dispatch.test.ts; it uses the existing trusted suite helpers.
- Run:
corepack pnpm exec turbo run test --filter=@bb/host-daemon -- src/command-dispatch.test.ts corepack pnpm exec turbo run test --filter=@bb/server -- test/public/public-thread-stop-runtime.test.ts
Expected product behavior: an explicit stop must either clear the retained work or communicate that it did not stop. Actual dispatcher behavior: the retained-turn release resolves to the same result as an idle release, and all three attempts leave the active-turn ID unchanged. Only the subsequent interrupt calls stop for that thread. This diagnostic asserts the defective behavior, so a passing diagnostic confirms the defect; it is not a red/green fix regression.
Appended diagnostic test:
it("issue 3462: repeated releases hide a retained turn in a success result", async () => {
const root = await makeTempDir("bb-3462-");
const runtime = createRuntime();
const manager = new RuntimeManager({
createRuntime: () => runtime,
provisionWorkspace: async () => createWorkspace(root),
});
await manager.ensureEnvironment({ environmentId: "repro", workspacePath: root });
const options = makeDispatchOptions({ runtimeManager: manager, dataDir: root });
runtime.setActiveTurn("retained", "unfinished");
runtime.setIdle("idle");
const release = (threadId: string) => dispatchCommand({
type: "thread.stop", intent: "release", environmentId: "repro", threadId,
}, options);
const idleResult = await release("idle");
for (let attempt = 0; attempt < 3; attempt++) {
expect(await release("retained")).toEqual(idleResult);
expect(runtime.getActiveTurnId("retained")).toBe("unfinished");
}
expect(runtime.stopThread.mock.calls.map(([args]) => args.threadId)).toEqual(["idle"]);
await dispatchCommand({
type: "thread.stop", intent: "interrupt", environmentId: "repro", threadId: "retained",
}, options);
expect(runtime.getActiveTurnId("retained")).toBeNull();
});
5. Root cause
apps/server/src/services/threads/thread-lifecycle.ts:1400-L1420 sends release for an idle thread. apps/host-daemon/src/command-dispatch.ts:347-L390 returns early when the runtime has an active turn. That branch and a successful stop share the same checkpoint response; packages/host-daemon-contract/src/commands.ts:1205-L1210 has no disposition field and is strict.
apps/server/src/routes/threads/actions.ts:367-L375 returns success after the lifecycle call. apps/cli/src/commands/thread/actions.ts:522-L533 prints success when the SDK call resolves. packages/agent-runtime/src/runtime.ts:758-L767 refuses new turns while an active ID or pending start exists. The active-turn guard itself protects legitimate races; removing it globally would be unsafe. The deeper origin of server/daemon divergence remains unproven.
6. Proposed fix and automation limit
Distinguish release outcomes across the daemon/server boundary and define explicit-stop reconciliation while preserving automatic-release race protection. Test protocol compatibility and concurrent stop/start behavior, including deduplicated requests. No automatic PR: this is a cross-subsystem protocol/behavior decision, outside the simple-fix rule. No production change was attempted. No linked open PR was found.
7. Related issues
Search found #2538 with the same admission-error symptom. It was not reproduced here and is not established as the same root cause.
8. Verification
The same investigator repeated the diagnostic in a second clean temporary checkout at the identical commit with a separate frozen install and a fresh temporary fixture directory. The second checkout ran both suites with --only --force after dependency preparation, avoiding cached test results. Dispatcher: 41/41; server: 8/8, in both checkouts. See the run excerpts below. This is a repeat check by the same agent, not an independent review. No correction to the mechanism was needed. The original provider failure and full live CLI/send sequence remain unverified.
9. Appendix
Issue content, including its proposed patch, was treated as untrusted evidence. No issue-supplied code or instructions were executed. The test above was authored from trusted repository fixtures and implementation. Build and test logs have local paths and terminal formatting removed. No screenshots apply to this nonvisual dispatcher defect.
First dispatcher run
@bb/host-daemon:test: @bb/host-daemon:test: @bb/host-daemon:test: RUN v4.1.1 CHECKOUT @bb/host-daemon:test: @bb/host-daemon:test: ✓ |@bb/host-daemon| src/command-dispatch.test.ts (41 tests) 389ms @bb/host-daemon:test: @bb/host-daemon:test: Test Files 1 passed (1) @bb/host-daemon:test: Tests 41 passed (41) @bb/host-daemon:test: Start at 20:34:33 @bb/host-daemon:test: Duration 4.58s (transform 3.00s, setup 0ms, import 3.88s, tests 389ms, environment 0ms) @bb/host-daemon:test: Tasks: 7 successful, 7 total Cached: 0 cached, 7 total Time: 3m19.834s
Second dispatcher run, cache bypassed
@bb/host-daemon:test: @bb/host-daemon:test: @bb/host-daemon:test: RUN v4.1.1 CHECKOUT @bb/host-daemon:test: @bb/host-daemon:test: ✓ |@bb/host-daemon| src/command-dispatch.test.ts (41 tests) 353ms @bb/host-daemon:test: @bb/host-daemon:test: Test Files 1 passed (1) @bb/host-daemon:test: Tests 41 passed (41) @bb/host-daemon:test: Start at 20:36:19 @bb/host-daemon:test: Duration 3.35s (transform 1.88s, setup 0ms, import 2.69s, tests 353ms, environment 0ms) @bb/host-daemon:test: Tasks: 1 successful, 1 total Cached: 0 cached, 1 total Time: 5.854s
First server run
@bb/server:test: @bb/server:test: (node:43001) ExperimentalWarning: SQLite is an experimental feature and might change at any time @bb/server:test: (Use `node --trace-warnings ...` to show where the warning was created) @bb/server:test: ✓ |@bb/server| test/public/public-thread-stop-runtime.test.ts (8 tests) 2055ms @bb/server:test: ✓ releases an idle runtime without changing thread state 1267ms @bb/server:test: @bb/server:test: Test Files 1 passed (1) @bb/server:test: Tests 8 passed (8) @bb/server:test: Start at 20:34:57 @bb/server:test: Duration 12.97s (transform 7.22s, setup 0ms, import 10.68s, tests 2.06s, environment 0ms) @bb/server:test: Tasks: 8 successful, 8 total Cached: 0 cached, 8 total Time: 3m32.303s
Second server run, cache bypassed
@bb/server:test: @bb/server:test: (node:43747) ExperimentalWarning: SQLite is an experimental feature and might change at any time @bb/server:test: (Use `node --trace-warnings ...` to show where the warning was created) @bb/server:test: ✓ |@bb/server| test/public/public-thread-stop-runtime.test.ts (8 tests) 2176ms @bb/server:test: ✓ releases an idle runtime without changing thread state 1493ms @bb/server:test: @bb/server:test: Test Files 1 passed (1) @bb/server:test: Tests 8 passed (8) @bb/server:test: Start at 20:36:06 @bb/server:test: Duration 13.88s (transform 8.06s, setup 0ms, import 11.47s, tests 2.18s, environment 0ms) @bb/server:test: Tasks: 1 successful, 1 total Cached: 0 cached, 1 total Time: 17.106s