#3462 · Retained turns survive idle stop

BugHighEffort: Mediumthreads, hostGitHub issue · 2026-09-11 · base 66bf09cd2265955118178fa8afcefc0a38aacd1f

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

ClaimFindingEvidence
Idle server stop requests release and returns success.VerifiedExisting public stop-route suite; server source.
An active daemon turn survives repeated release requests.VerifiedNew diagnostic test executes the production dispatcher three times.
Later sends reject while that turn remains.Verified in sourceRuntime admission guard rejects a non-null active-turn ID.
The original provider failed to report completion.UnverifiedNo live incident trace; the divergent state is seeded.
Retry and tell acknowledge delivery before rejection.UnverifiedOutside 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

  1. Check out trusted main at the recorded commit in a clean directory.
  2. Install and build:
    corepack pnpm install --frozen-lockfile --prefer-offline
    corepack pnpm exec turbo run build
  3. Append the diagnostic below to apps/host-daemon/src/command-dispatch.test.ts; it uses the existing trusted suite helpers.
  4. 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