← reports

#2370 · Auto input can fail during a slow turn start

Bug Priority: High Effort: Unset providers threads cli ui open on GitHub 2026-08-27 · base ad79bbb5ec90

Verdict: PARTIALLY REPRODUCED · Root-cause confidence: high

1. TL;DR

PR #2379 fixed the reported active-turn injection before the tested base commit. Two focused daemon tests confirm that result. A third test confirms a separate slow-start case. After five seconds, the daemon returns a generic error for input sent before the first turn ID exists. The server then drops that input, adds an error event, and changes a healthy thread to error. The host does not retry one command in a loop.

2. Claims vs findings

Claim from the issueStatusEvidence
--mode auto injects input into an existing busy turn. Fixed at base PR #2379 is in the base. The active-turn and stale-target tests both pass and select steer.
A slow start can reject the second input after five seconds. Verified The focused daemon test passes because it expects the exact refusal after the fixed wait.
The refusal adds an error and changes a healthy thread to error. Verified The server test finds system/error. It also receives error instead of active.
The refused input is lost. Verified for the slow-start case The server records client/turn/rejected. The failure path does not put the input in a queue.
The host retries one logical command in a loop. Refuted runLiveHostCommand makes one host call. Separate sends can create separate failures.
The issue appeared after stable 0.39.0. Partly verified PR #2242 added the five-second refusal. The archived production transcripts were not available.
The two production starts took 11.3 and 28.4 seconds. Unverified The production data was not available. The shared timeout path exists at the base commit.

3. Environment

4. Minimal reproduction

  1. Prepare the base checkout.
    git checkout ad79bbb5ec909524f8f281e62d860c588a86f332
    pnpm install --frozen-lockfile --prefer-offline
    pnpm exec turbo run build
  2. Run the two fixed-path tests and the residual test.
    pnpm exec turbo run test --filter=@bb/host-daemon --force -- \
      src/command-dispatch.test.ts \
      -t "steers an auto submit when the active turn appears after the server snapshot|rebases auto input onto the daemon's newer active turn|rejects auto input when a pending turn still has no id after the wait"

    Actual test result:

    Test Files  1 passed (1)
    Tests       3 passed | 37 skipped (40)

    The first two tests prove that active-turn auto input uses steer. The third test expects this residual refusal:

    Refusing to start a competing turn while thread-1 is still starting
  3. Download the server test patch to an exact local path.
    curl --fail --location \
      https://get-bb.github.io/reports/issues/2370/repro/server-failure-repro.patch \
      --output /tmp/bb-2370-server-failure-repro.patch
    git apply --check /tmp/bb-2370-server-failure-repro.patch
    git apply /tmp/bb-2370-server-failure-repro.patch
  4. Run the server test.
    cd apps/server
    pnpm exec vitest run test/threads/thread-send-dispatch.test.ts \
      -t "issue 2370 keeps a live thread active after a pending-start refusal"

    Expected:

    No system/error event
    Thread status: active

    Actual:

    AssertionError: expected [ 'thread/identity', …(5) ] to not include 'system/error'
    
    Expected: "active"
    Received: "error"
    
    Test Files  1 failed (1)
    Tests       1 failed | 10 skipped (11)

Reproduction test

The complete patch is server-failure-repro.patch.

The daemon test creates the exact refusal. This server test isolates the server response to that refusal.

it("issue 2370 keeps a live thread active after a pending-start refusal", async () => {
  await withTestHarness(async (harness) => {
    const { environment, thread } = seedProviderThreadFixture({
      harness,
      status: "active",
      value: 2370,
    });
    seedTurnStarted(harness.deps, {
      environmentId: environment.id,
      providerThreadId: "provider-send-dispatch-2370",
      sequence: 3,
      threadId: thread.id,
      turnId: "turn-still-healthy",
    });
    const activeThread = getThread(harness.db, thread.id);
    if (!activeThread) throw new Error("Expected an active thread");

    await sendThreadMessage(harness.deps, {
      environment,
      payload: {
        input: textInput("send while the first turn is still starting"),
        mode: "auto",
        model: "gpt-5",
        permissionMode: "full",
        reasoningLevel: "medium",
        serviceTier: "default",
      },
      thread: activeThread,
      trigger: "user",
    });
    const queued = await waitForQueuedCommand(
      harness,
      (candidate) =>
        candidate.command.type === "turn.submit" &&
        candidate.command.threadId === thread.id,
    );
    await reportQueuedCommandError(harness, queued, {
      errorCode: "command_failed",
      errorMessage:
        `Refusing to start a competing turn while ${thread.id} is still starting`,
    });

    const eventTypes = listEvents(harness.db, {
      threadId: thread.id,
    }).map((event) => event.type);
    expect.soft(eventTypes).not.toContain("system/error");
    expect.soft(getThread(harness.db, thread.id)?.status).toBe("active");
  });
});

See the commands, daemon log, and server log.

5. Root cause

PR #2379 fixed the original active-turn path

The daemon now reads the live turn target. It also rebases a stale target once.

const liveTurnId = await resolveLiveSubmittedTurnTarget(command, entry);
...
targetTurnId = liveTurnId;
...
return (
  (await resolveLiveSubmittedTurnTarget(command, entry)) ??
  command.target.expectedTurnId
);

Source: stale-target rebase and live target selection.

The merge commit 2ddd1b8e is an ancestor of the tested base.

The pending-start path still has a fixed wait

A provider can take more than five seconds to publish its first turn ID. The daemon then throws a generic error.

const TURN_SUBMIT_ACTIVE_TURN_WAIT_MS = 5_000;

const awaitedTurnId = await entry.runtime.waitForActiveTurn(
  command.threadId,
  { timeoutMs: TURN_SUBMIT_ACTIVE_TURN_WAIT_MS },
);
...
if (entry.runtime.getLiveThreadIds().includes(command.threadId)) {
  throw new Error(
    `Refusing to start a competing turn while ${command.threadId} is still starting`,
  );
}

Source: wait constant and refusal path.

The server changes the refusal into a run failure

The live command code makes one host call. It does not retry that call.

Source: single host call.

The settlement code treats each failed turn.submit as terminal. It rejects the request, adds system/error, and applies run.failed.

type: "client/turn/rejected",
...
appendSystemErrorEventInTransaction(...);
...
event: { type: "run.failed" },

Source: failure settlement.

This path has no queue operation. Therefore, the refused input has no later delivery path.

The original pi-provider refusal was not reproduced at this base. The prior report joined that claim to the separate slow-start defect.

6. Proposed fix (first principles)

  1. Add a typed pending-start busy error to the host daemon contract.
  2. Increase HOST_DAEMON_PROTOCOL_VERSION because the error meaning crosses the daemon wire.
  3. Keep the live thread status when the server receives this error.
  4. Do not add system/error for this temporary refusal.
  5. Put refused direct input into the thread queue.
  6. Restore a consumed queued group at the queue head.
  7. Test late turn IDs, thread completion, duplicate delivery, and restart recovery.

Closed PR #2417 used this design. It did not merge.

7. Related issues

8. Appendix

Commands

gh issue view 2370 --comments -R get-bb/bb
git fetch origin main
git merge-base --is-ancestor 2ddd1b8e75ce4e01a9b75ba94d827ac1c74fb202 ad79bbb5ec909524f8f281e62d860c588a86f332
git diff --name-only ad79bbb5ec90..origin/main -- <affected paths>
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
pnpm exec turbo run test --filter=@bb/host-daemon --force -- src/command-dispatch.test.ts -t "steers an auto submit when the active turn appears after the server snapshot|rebases auto input onto the daemon's newer active turn|rejects auto input when a pending turn still has no id after the wait"
curl --fail --location https://get-bb.github.io/reports/issues/2370/repro/server-failure-repro.patch --output /tmp/bb-2370-server-failure-repro.patch
git apply --check /tmp/bb-2370-server-failure-repro.patch
git apply /tmp/bb-2370-server-failure-repro.patch
cd apps/server
pnpm exec vitest run test/threads/thread-send-dispatch.test.ts -t "issue 2370 keeps a live thread active after a pending-start refusal"

Raw evidence

No open pull request links to this issue. Therefore, this report has no PR review section.

Verification

The verifier ran the daemon test and the linked server patch. The verifier confirmed both residual assertions.

This revision adds an exact patch path and changes the verdict. It separates the fixed active-turn path from the residual slow-start path.

The revision also records current origin/main. No affected source file changed after the base.