2026-09-30 verification: typed busy settlement and bounded resubmission

Verdict: REPRODUCED for the bridge-level typed-busy failure. Confidence: high within this synthetic bridge scope. Two clean current-main runs confirm that a typed busy refusal fails its turn. Synthetic idle metadata does not cause an automatic resubmission during the bounded observation window. A subsequent explicit turn succeeds once the synthetic refusal budget is exhausted. This does not demonstrate a server retry storm, queue recovery policy, or real OMP timing. Historical evidence is preserved below.

Eligibility: open native Bug, High priority, Low effort; labels providers, provider-acp and confirmed-repro. All comments and paginated timeline entries were refreshed. PR #3004 remains closed and unmerged; its metadata alone was rechecked, and no branch or diff was executed. No linked open PR or open PR mentioning #3009 was found. Public activity showed no overlapping investigation. Private SlopCop running-job state remains unavailable under the no-runtime constraint. Both repositories are public.

Trusted base and two personal runs

Fetched get-bb/bb origin/main: d7a6d74e87f55b80243667c67f68644b4737e77a. Linux 6.18.44 x86_64, Node 22.19.0, pnpm 9.15.0, Vitest 4.1.1. Two separate clean checkouts at this exact SHA received independent frozen installations and normal full Turbo builds: 62/62 successful tasks in each, with four cache hits each. A preliminary scoped build found no build tasks for this source-only package; the normal full builds supplied the required build verification. Before setup: 1,596,141 free inodes; after verification: approximately 1,484,579. Prior investigation evidence remains intact.

The same agent personally ran the identical final fixture twice. Run A began at 18:43:27 UTC; Run B, in the second clean checkout, began at 18:43:51 UTC on September 30. Each passed 5/5 selected tests, with 102 unrelated tests skipped by the name filter. Both forced Turbo runs completed 2/2 tasks with zero cache hits. Earlier exploratory runs passed the same behavior assertions but their optional capture variable was filtered by Turbo; their logs are retained and excluded from the final two-run evidence. The final fixture writes captures directly to fresh local directories.

Production bridge code, the agent connection, delta translation, and event assembly ran unchanged. The repository's fake ACP subprocess received a small opt-in synthetic response hook; the existing bridge suite received five investigation tests. These are the only tracked-file differences in either source checkout. The trusted suite creates fresh temporary workspaces and isolated local resources, then stops its sessions and removes the workspaces. Retained captures use unique issue-3009-evidence-* directories. No live bb app, server, daemon, real ACP provider, credential, external plugin or dependency was used.

Expected and actual

Desired behavior under a defined busy-recovery contract: a known temporary busy refusal should remain distinguishable from a generic failure, with bounded waiting/resubmission or an explicit queue disposition. Actual: the tested refusal emits a provider error and settles the turn as failed. The next caller-driven submission is a new turn; it is not a retry generated by an idle notification.

RESULT {"scenario":"typed-after-bounded","statuses":["failed","failed","failed","completed"],"promptRequests":4,"providerErrors":3,"idleNotifications":3,"automaticResubmissionsObserved":0,"recovery":"explicit-next-turn"}
RESULT {"scenario":"typed-before","statuses":["failed","completed"],"promptRequests":2,"providerErrors":1,"idleNotifications":1,"automaticResubmissionsObserved":0,"recovery":"explicit-next-turn"}
RESULT {"scenario":"typed-without-idle","statuses":["failed","completed"],"promptRequests":2,"providerErrors":1,"idleNotifications":0,"automaticResubmissionsObserved":0,"recovery":"explicit-next-turn"}
RESULT {"scenario":"generic-after","statuses":["failed","completed"],"promptRequests":2,"providerErrors":1,"idleNotifications":1,"automaticResubmissionsObserved":0,"recovery":"explicit-next-turn"}
RESULT {"scenario":"success-control","statuses":["completed"],"promptRequests":1,"providerErrors":0,"idleNotifications":0,"automaticResubmissionsObserved":0,"recovery":"explicit-next-turn"}

After each refusal, the test waits 150 ms of real time and checks that the outbound prompt count has not increased. In the idle cases it first waits for the child to record that it sent its notification. This bounds the observation; it does not prove that no delayed or higher-level retry can ever occur. All repeated attempts in the test are explicitly supplied by its caller. Persisted request traces, assembled completion/error events, and final structured summaries match between the two runs.

Idle-shape limit: the fixture sends session_info_update with a synthetic title and isBusy: false. Current repository wire schemas accept arbitrary session-update extension fields, and its translation tests classify session_info_update as noise. This is a deliberately synthetic idle advertisement, not a verified OMP release-specific extension contract. It establishes current handling of that accepted notification shape; it cannot establish real provider readiness.

Current root cause

Small fix proposal, not implemented: preserve typed error data, establish an explicit provider-supported busy/idle contract, and handle only that known temporary refusal with a bounded wait and at most one resubmission or a typed queue disposition. Preserve generic-error failure behavior and input-acceptance uniqueness. Next tests: repeated busy after retry, absent/invalid idle, timeout, cancellation and coalesced updates. A separately scoped server-dispatch test with synthetic persistence must establish any retry-loop or queue claim; these bridge-only results do not do so.

The existing confirmed-repro label remains appropriate for this scoped reproduction. PR #3004's historical static assessment remains historical; its current closed/unmerged state is not proof that a repair shipped. No production fix, PR, workflow or routine issue comment was created.

Exact commands and complete investigation fixture

The store path is the writable shared store used here; choose a writable equivalent elsewhere. Save each block with its displayed filename beside the clean checkouts. The installer only adds the opt-in hook and tests to repository-owned test files; it does not change production files. Raw captures stay outside the reports repository. The earlier report's three existing raw artifacts remain unchanged for historical continuity.

# Save the three complete fixture files below in a fresh parent directory.
git clone https://github.com/get-bb/bb.git run-a
cd run-a
git checkout --detach d7a6d74e87f55b80243667c67f68644b4737e77a
# Use Node 22.19.0 and pnpm 9.15.0.
pnpm install --frozen-lockfile --store-dir /workspace/.pnpm-store
pnpm exec turbo run build
python3 ../install-fixture.py .
pnpm exec turbo run test --filter=@bb/provider-bridge-acp --force -- src/bridge/bridge.test.ts -t 'issue 3009' --silent=false
# Repeat the same steps in a second clean clone named run-b, at the same SHA.
# Copy only the three fixture files; never reuse prior synthetic state.
fixture-hook.txt
  const budget = Number(process.env.ISSUE3009_FAILURES ?? "0");
  if (budget > 0) {
    process.env.ISSUE3009_FAILURES = String(budget - 1);
    activePromptId = null;
    const notifyIdle = () => {
      const update = { sessionUpdate: "session_info_update", title: "synthetic idle", isBusy: false };
      notifyUpdate(update);
      appendFileSync(process.env.ISSUE3009_IDLE_LOG, JSON.stringify(update) + "\n");
    };
    if (process.env.ISSUE3009_IDLE === "before") notifyIdle();
    send({ jsonrpc: "2.0", id: message.id, error: {
      code: -32003, message: "Synthetic prompt refusal",
      ...(process.env.ISSUE3009_TYPED === "1" ? { data: { reason: "session_busy", hint: "wait" } } : {}),
    } });
    if (process.env.ISSUE3009_IDLE === "after") setTimeout(notifyIdle, 10);
    return;
  }

test-addition.txt
for (const scenario of [
  { name: "typed-after-bounded", failures: 3, typed: true, idle: "after" },
  { name: "typed-before", failures: 1, typed: true, idle: "before" },
  { name: "typed-without-idle", failures: 1, typed: true, idle: "none" },
  { name: "generic-after", failures: 1, typed: false, idle: "after" },
  { name: "success-control", failures: 0, typed: false, idle: "none" },
]) {
  it("issue 3009 " + scenario.name, async () => {
    const requestLog = join(workspaceDir, "requests.jsonl");
    const idleLog = join(workspaceDir, "idle.jsonl");
    const { providerThreadId } = await startThread({ envVars: {
      FAKE_ACP_REQUEST_LOG: requestLog,
      ISSUE3009_FAILURES: String(scenario.failures),
      ISSUE3009_TYPED: scenario.typed ? "1" : "0",
      ISSUE3009_IDLE: scenario.idle,
      ISSUE3009_IDLE_LOG: idleLog,
    } });
    const prompts = () => loggedAcpRequests(requestLog).filter(row => row.method === "session/prompt").length;
    const statuses: string[] = [];
    const submit = async () => {
      const before = threadEventsOfType("turn/completed").length;
      const id = sendTurnRequest("turn/start", providerThreadId, { input: [{ type: "text", text: "synthetic bounded request", mentions: [] }] });
      const response = await waitForResponse(id);
      expect(response.error).toBeUndefined();
      const event = await waitFor(() => {
        const rows = threadEventsOfType("turn/completed");
        return rows.length > before ? rows.at(-1) : undefined;
      }, "new completion");
      statuses.push(String(event.status));
    };
    for (let i = 0; i < scenario.failures; i++) {
      await submit();
      expect(statuses.at(-1)).toBe("failed");
      if (scenario.idle !== "none") {
        await waitFor(() => existsSync(idleLog) && readFileSync(idleLog, "utf8").trim().split("\n").length === i + 1 ? true : undefined, "synthetic idle sent");
      }
      await new Promise(resolve => realSetTimeout(resolve, 150));
      expect(prompts()).toBe(i + 1);
    }
    await submit();
    expect(statuses.at(-1)).toBe("completed");
    expect(prompts()).toBe(scenario.failures + 1);
    const errors = threadEventsOfType("provider/error");
    expect(errors).toHaveLength(scenario.failures);
    if (scenario.typed) expect(JSON.stringify(errors)).toContain("session_busy");
    const idleNotifications = existsSync(idleLog) ? readFileSync(idleLog, "utf8").trim().split("\n").length : 0;
    const result = { scenario: scenario.name, statuses, promptRequests: prompts(), providerErrors: errors.length, idleNotifications, automaticResubmissionsObserved: 0, recovery: "explicit-next-turn" };
    const evidenceDir = mkdtempSync(join(dirname(fileURLToPath(import.meta.url)), "issue-3009-evidence-"));
    writeFileSync(join(evidenceDir, scenario.name + ".json"), JSON.stringify({ result, requests: loggedAcpRequests(requestLog), messages: output.messages, events: threadEvents() }, null, 2));
    process.stderr.write("RESULT " + JSON.stringify(result) + "\n");
  });
}
install-fixture.py
from pathlib import Path
import sys
root=Path(sys.argv[1]);base=Path(__file__).parent
p=root/'packages/provider-bridge-acp/src/bridge/fake-acp-agent.mjs';s=p.read_text();needle='  if (process.env.FAKE_ACP_PROMPT_ERROR === "1") {';assert s.count(needle)==1;p.write_text(s.replace(needle,(base/'fixture-hook.txt').read_text()+needle))
p=root/'packages/provider-bridge-acp/src/bridge/bridge.test.ts';p.write_text(p.read_text()+(base/'test-addition.txt').read_text())

Trust and limits: issue bodies, comments, links, code and historical patches were untrusted evidence only. No issue-supplied command or code, external issue link, linked branch or historical patch was executed. Fixtures were authored from trusted current test helpers and wire contracts. The original completion-instant race, external provider readiness, live queue contents, client timeouts and server retry cadence remain unverified.

← reports

#3009 · ACP bridge fails a typed busy prompt

Bug High Effort: Low providers provider-acp open on GitHub 2026-09-03 · base fa778fe32

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

The ACP bridge sends a prompt while an agent can still report a busy session.

The agent returns a typed busy error and later sends an idle update.

The bridge treats the response as a general provider error and fails the turn.

A deterministic test reproduced this result in two clean checkouts of the same trusted commit.

2. Claims vs findings

ClaimStatusEvidence
A typed busy response fails the turn.VerifiedBoth clean test runs received status: "failed".
The bridge has no special path for the typed reason.VerifiedThe response error stores only the message and code. The turn catch path handles all errors alike.
An idle update after the rejection lets the prompt run again.Refuted for current bb behaviorThe fake agent sent an idle update. The bridge did not send a second prompt.
Repeated dispatch can cause a retry storm.UnverifiedThe focused test covers one bridge turn. It does not run the server dispatch loop.
The failure occurs with one external ACP agent at a turn boundary.UnverifiedThis report did not run external code or use a real provider account.

3. Environment

4. Minimal reproduction

  1. Clone the trusted repository and select the base commit.
    gh repo clone get-bb/bb bb-trusted -- --branch main --single-branch
    cd bb-trusted
    git checkout --detach fa778fe32934b3a2dde706bb6ec01ac18a8e3e28
  2. Install and build the trusted source.
    pnpm install --frozen-lockfile --prefer-offline
    pnpm exec turbo run build
  3. Download and apply the report's test-only patch.
    curl -fsS https://get-bb.github.io/reports/issues/3009/repro/repro.patch -o /tmp/issue-3009-repro.patch
    git apply /tmp/issue-3009-repro.patch

    The patch adds a fake agent response with code -32003 and reason session_busy.

    The fake agent sends a session_info_update after ten milliseconds.

  4. Run the package test through Turbo.
    pnpm exec turbo run test --filter=@bb/provider-bridge-acp --force

Expected:

{ "status": "completed" }
prompt log: ["after-busy", "after-busy"]
provider errors: []

Actual:

AssertionError: expected { type: 'turn/completed', …(4) }
to match object { status: 'completed' }

- Expected
+ Received

  {
-   "status": "completed",
+   "status": "failed",
  }

Test Files  1 failed | 17 passed (18)
Tests       1 failed | 311 passed (312)

Regression test

it("retries a busy prompt after the agent reports that the session is idle", async () => {
  const promptLog = join(workspaceDir, "busy-prompt-log.jsonl");
  const { providerThreadId } = await startThread({
    envVars: {
      FAKE_ACP_BUSY_RESPONSES: "1",
      FAKE_ACP_PROMPT_LOG: promptLog,
    },
  });
  const turnId = sendTurnRequest("turn/start", providerThreadId, {
    input: [{ type: "text", text: "after-busy", mentions: [] }],
  });
  await waitForResponse(turnId);

  const completed = await waitForTurnCompleted();
  expect(completed).toMatchObject({ status: "completed" });
  expect(loggedPrompts(promptLog)).toEqual(["after-busy", "after-busy"]);
  expect(threadEventsOfType("provider/error")).toEqual([]);
});

Reproduction files:

Verification

A second clean checkout used the same full base commit.

The second checkout used a new temporary directory and a new install.

The same Turbo command failed at the same assertion.

No report claim changed after the second run.

5. Root cause

The connection parser receives the JSON-RPC error data but drops it from AcpAgentResponseError.

See the error class.

See the response parser.

The turn loop sends session/prompt and catches all rejected responses with one general path.

That path emits a session error and clears the active prompt.

See the turn loop.

The error translator sets settlesTurn: true.

See the error translation.

The notification path forwards an idle update but does not connect it to prompt retry state.

See the notification path.

Thus, the bridge converts a temporary busy state into a final failed turn.

6. Proposed fix

Keep the parsed error data on AcpAgentResponseError.

Match only the known busy code and typed reason.

Wait for a valid idle update, then send the same prompt one more time.

Do not emit a second input acceptance event.

Limit the wait and retry count so an agent cannot hold a turn forever.

Cover normal, repeated-busy, wrong-reason, timeout, and coalesced-update cases.

7. PR review

PR #3004 · static review only

The pull request changes six files with 372 additions and four deletions.

Its diff keeps error data, matches the typed busy reason, waits for idle, and retries once.

Its tests cover a repeated rejection, a timeout, a wrong reason, and a coalesced update.

The approach addresses the verified root cause.

No blocking defect was visible in the static diff.

This report did not check out or run the pull request.

GitHub metadata showed all required checks as successful during this review.

8. Related issues

The issue metadata links other queue and agent-resume cases.

This report did not test those cases, so it makes no duplicate claim.

9. Appendix

The issue title, body, comments, links, logs, and code blocks were treated as untrusted data.

No external issue link, script, patch, binary, branch, or provider code was used.

Commands used:

gh issue view 3009 --repo get-bb/bb --comments
gh repo view get-bb/bb --json visibility
gh repo clone get-bb/bb <temporary-directory>/bb-trusted -- --branch main --single-branch
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
pnpm exec turbo run test --filter=@bb/provider-bridge-acp --force
gh pr view 3004 --repo get-bb/bb
gh pr diff 3004 --repo get-bb/bb