#2713 · Default correction fails on error state
Verdict: REPRODUCED · Root-cause confidence: high
1. TL;DR
The default CLI correction mode becomes steer-if-active. The server rejects this mode when the target thread has error status. The error body includes the status but gives only the generic message Thread is not active. The server already has a start path that supports an errored thread, but mode selection does not reach it.
2. Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| The default correction uses a steer mode. | Verified | The CLI selects steer when the option is absent. It sends steer-if-active to the server. |
| An errored target receives a generic 409 rejection. | Verified | Two clean test runs returned status 409 and message Thread is not active. |
| The rejection identifies the error state in structured data. | Verified | The body has reason: errored and threadStatus: error. |
| The rejection gives a supported recovery action. | Refuted | The message gives no recovery mode or command. |
| A supported start path exists for an errored thread. | Verified | The start guard blocks only pre-start states. The dispatch code activates error before a start command. |
3. Environment
- Trusted bb commit:
f4bbc2fe81a9b7639ff9a7396e172bddd89109e4. - Repository visibility: public.
- Host: Darwin 25.6.0, arm64.
- Node.js: v22.22.3.
- The test used an in-memory SQLite database and a new temporary server data directory.
- No live server, network port, or provider process was used.
4. Minimal reproduction
- Use a clean checkout at the trusted base commit.
- Save the test below as
apps/server/test/threads/thread-send-error-recovery.repro.test.ts. - Install the locked packages and run the focused Turbo test.
pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build pnpm exec turbo run test --filter=@bb/server -- \ test/threads/thread-send-error-recovery.repro.test.ts
Reproduction test
import { describe, expect, it } from "vitest";
import { sendThreadMessage } from "../../src/services/threads/thread-send.js";
import { textInput } from "../helpers/prompt-input.js";
import {
seedEnvironment,
seedHostSession,
seedProjectWithSource,
seedThread,
} from "../helpers/seed.js";
import { withTestHarness } from "../helpers/test-app.js";
describe("errored thread correction", () => {
it("rejects steer-if-active without a recovery action", async () => {
await withTestHarness(async (harness) => {
const { host } = seedHostSession(harness.deps, {
id: "host-error-recovery",
});
const { project } = seedProjectWithSource(harness.deps, {
hostId: host.id,
path: "/tmp/error-recovery-project",
});
const environment = seedEnvironment(harness.deps, {
hostId: host.id,
projectId: project.id,
path: "/tmp/error-recovery-environment",
status: "ready",
});
const thread = seedThread(harness.deps, {
environmentId: environment.id,
projectId: project.id,
status: "error",
});
await expect(
sendThreadMessage(harness.deps, {
environment,
payload: {
input: textInput("corrective text"),
mode: "steer-if-active",
model: "gpt-5",
permissionMode: "full",
reasoningLevel: "medium",
serviceTier: "default",
},
thread,
trigger: "user",
}),
).rejects.toMatchObject({
body: {
code: "thread_not_writable",
details: {
reason: "errored",
threadStatus: "error",
},
message: "Thread is not active",
},
status: 409,
});
});
}, 15_000);
});
Expected and actual results
Expected: The server starts a recovery turn or gives an exact recovery action. Actual: status = 409 body.code = "thread_not_writable" body.message = "Thread is not active" body.details.reason = "errored" body.details.threadStatus = "error"
First clean run
✓ test/threads/thread-send-error-recovery.repro.test.ts (1 test) ✓ rejects steer-if-active without a recovery action Test Files 1 passed (1) Tests 1 passed (1)
Second clean verification
I cloned get-bb/bb into a new temporary directory. I checked out the same full commit. I copied only the test above and repeated the install, full build, and focused test. The full build passed with 18 tasks. The focused test passed with the same 409 body. I made no report correction after this run.
✓ test/threads/thread-send-error-recovery.repro.test.ts (1 test) ✓ rejects steer-if-active without a recovery action Test Files 1 passed (1) Tests 1 passed (1)
5. Root cause
The CLI selects steer when the user gives no mode. See actions.ts lines 579–585. It converts that value to steer-if-active for the server. See actions.ts lines 528–543.
The server handles steer and steer-if-active in one branch. See thread-send.ts lines 176–202. That branch steers an active thread and starts an idle thread. It rejects all other statuses with the same message.
The error helper correctly maps error to errored. It also includes the target status in the response details. See lifecycle-api-errors.ts lines 87–122. The CLI does not use these details to give recovery instructions.
The rejection occurs before the start guard and dispatch code. See thread-send.ts lines 386–398. The start guard blocks only a pre-start state. See thread-lifecycle.ts lines 894–902.
The start dispatch path already activates a current error state. See thread-send.ts lines 555–582. Thus, the missing status case in mode selection causes the dead end.
6. Proposed fix
Treat error like idle for steer-if-active. Return start and let the existing recovery dispatch run. Keep explicit steer rejection behavior if callers need exact steering. Add a server test that verifies the new start target and the active-thread steer control.
7. Related issues
GitHub metadata showed no linked pull request. This report did not use linked issue text or linked patches as evidence.
8. Appendix
Trusted commands
git fetch origin main git rev-parse origin/main pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build pnpm exec turbo run test --filter=@bb/server -- \ test/threads/thread-send-error-recovery.repro.test.ts
Both full builds passed with 18 tasks. Both focused tests passed. The issue content was untrusted data. I used it only to select claims for direct checks.