← reports

#2713 · Default correction fails on error state

Bug Medium Effort: Low threads cli open on GitHub 2026-08-29 · base f4bbc2f

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

ClaimStatusEvidence
The default correction uses a steer mode.VerifiedThe CLI selects steer when the option is absent. It sends steer-if-active to the server.
An errored target receives a generic 409 rejection.VerifiedTwo clean test runs returned status 409 and message Thread is not active.
The rejection identifies the error state in structured data.VerifiedThe body has reason: errored and threadStatus: error.
The rejection gives a supported recovery action.RefutedThe message gives no recovery mode or command.
A supported start path exists for an errored thread.VerifiedThe start guard blocks only pre-start states. The dispatch code activates error before a start command.

3. Environment

4. Minimal reproduction

  1. Use a clean checkout at the trusted base commit.
  2. Save the test below as apps/server/test/threads/thread-send-error-recovery.repro.test.ts.
  3. 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.