#2654 · Plugin command timeout state
Verdict: NOT REPRODUCED · Root-cause confidence: high
1. TL;DR
The CLI prints a command-state statement after plugin contribution discovery times out. A direct test reproduced that text on trusted main. However, both clean runs recorded only three discovery GET requests and no plugin command POST request. The CLI exits before it can send a task mutation in this path. The reported mutation and this exact timeout message cannot come from the same main-branch invocation.
The issue content was untrusted data. The investigation did not run any issue script, patch, branch, attachment, or external link.
2. Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| The plugin command path prints a state statement after a response timeout. | Verified | Both clean runs printed the same message after three contribution-discovery timeouts. |
| A task mutation can complete during the same invocation that prints this exact message. | Refuted on main | Both runs recorded three GET requests and zero POST requests. The source exits at the failed discovery step. |
| Retries of this exact failure path create duplicate tasks. | Not reproduced | No command request reached the test server in either clean run. |
| Other write verbs did not duplicate during the historical event. | Unverified | The historical runtime data was not available. It was not necessary for the main-branch control-flow check. |
3. Environment
- Trusted commit:
a76407a7e8ba5265126c1f85a71820ef45aa59a2. - Source package version: bb-app 0.40.0.
- Linux 7.0.0-30-generic x86_64, Node.js v24.18.0, pnpm 9.15.0.
- First ephemeral HTTP port: 45661. Second ephemeral HTTP port: 46429.
- No bb development instance, provider, account, or persistent data directory was used.
4. Minimal reproduction
- Install and build the trusted checkout.
pnpm install --frozen-lockfile --prefer-offline TURBO_DAEMON=false pnpm exec turbo run build --force
- Copy the reproduction test into
apps/cli/src/__tests__/. - Run the focused test.
TURBO_DAEMON=false pnpm exec turbo run test --filter=@bb/cli --force -- --run src/__tests__/issue-2654-repro.test.ts
Reproduction test source
import { spawn } from "node:child_process";
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it } from "vitest";
interface CliResult {
code: number | null;
stderr: string;
}
function runCli(baseUrl: string): Promise<CliResult> {
return new Promise((resolve) => {
const child = spawn(
process.execPath,
["apps/cli/dist/index.js", "tasks", "create", "--title", "probe"],
{
cwd: new URL("../../../..", import.meta.url),
env: { ...process.env, BB_CLI: "", BB_SERVER_URL: baseUrl },
},
);
let stderr = "";
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.on("close", (code) => resolve({ code, stderr }));
});
}
describe("plugin CLI timeout command state", () => {
let server: Server | undefined;
afterEach(async () => {
if (server !== undefined) {
await new Promise<void>((resolve) => server?.close(() => resolve()));
}
});
it("does not send the command request when contribution discovery times out", async () => {
const requests: string[] = [];
server = createServer((request) => {
requests.push(`${request.method} ${request.url}`);
});
await new Promise<void>((resolve) =>
server?.listen(0, "127.0.0.1", resolve),
);
const address = server.address() as AddressInfo;
const result = await runCli(`http://127.0.0.1:${address.port}`);
process.stdout.write(
JSON.stringify({ ...result, requests }, null, 2) + "\n",
);
expect(result.code).toBe(1);
expect(result.stderr).toContain("No server response was received");
expect(requests).toEqual([
"GET /api/v1/plugins/contributions",
"GET /api/v1/plugins/contributions",
"GET /api/v1/plugins/contributions",
]);
}, 20_000);
});
Expected if the report reproduces: at least one POST /api/v1/plugins/tasks/cli request appears before the timeout message.
Actual first run:
{
"code": 1,
"stderr": "bb did not respond at http://127.0.0.1:45661 after 3 attempts (last window 4000ms) — it may be busy or temporarily unreachable. No server response was received and your command did not run; re-run it.\n",
"requests": [
"GET /api/v1/plugins/contributions",
"GET /api/v1/plugins/contributions",
"GET /api/v1/plugins/contributions"
]
}
The focused test passed in 10.886 seconds. It showed that the timeout statement matched the request sequence.
Verification
A second clean checkout used the same trusted commit. It used a new HTTP port and a new install. The same test passed in 10.888 seconds. It again recorded three contribution GET requests and no command POST request. No report claim required a correction after the second run.
{
"code": 1,
"stderr": "bb did not respond at http://127.0.0.1:46429 after 3 attempts (last window 4000ms) — it may be busy or temporarily unreachable. No server response was received and your command did not run; re-run it.\n",
"requests": [
"GET /api/v1/plugins/contributions",
"GET /api/v1/plugins/contributions",
"GET /api/v1/plugins/contributions"
]
}
5. Root cause
The message belongs to contribution discovery, not command execution. The discovery function sends only GET /api/v1/plugins/contributions. It returns an unreachable result after three retry windows.
The caller prints the message and exits at lines 48–57. It can call the plugin command POST only at line 84, after successful discovery. The command function sends the mutation request through a separate POST path.
The task plugin creates a row only after that POST reaches the task creation handler. Therefore, the main-branch path that prints this exact message cannot create a task in the same invocation.
The historical duplicate source remains unknown. Raw per-attempt stderr and server request logs could show whether a different POST timeout occurred.
6. Proposed fix
No source fix is supported by the direct result. The current statement matches the verified control flow on main. A next investigation should correlate each historical CLI process with server request logs before it changes the message or adds idempotency.
7. Related issues
No related issue was used as evidence.
8. Appendix
Commands used:
git fetch origin main git rev-parse origin/main pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build TURBO_DAEMON=false pnpm exec turbo run test --filter=@bb/cli --force -- --run src/__tests__/issue-2654-repro.test.ts git log -S'describeUnreachableServer' -- apps/cli/src/plugin-cli-proxy.ts apps/cli/src/index.ts git blame -L 119,145 -- apps/cli/src/plugin-cli-proxy.ts git blame -L 42,87 -- apps/cli/src/index.ts
The second checkout required a forced frozen reinstall because its first package link step left no module directory. The forced install and uncached build then completed successfully.
2026-09-30 verification: discovery versus response loss
Verdict: NOT REPRODUCED. High confidence in the tested CLI branch distinction; the cause of the historical duplicate task writes remains unknown. The earlier report and its artifacts above are retained as historical evidence. Its broad statements about the historical incident should be read subject to this scope: current synthetic CLI behavior does not reconstruct bb 0.39.0 on an overloaded macOS machine.
Fresh eligibility read: open native Bug, Priority High, Effort Low. All four comments and paginated timeline metadata were read; no linked open PR or current overlapping SlopCop investigation was found. The external bot's August report is historical evidence, not this agent's new verification. Later reporter corrections say raw retry receipts were discarded and the matcher accepted both “did not respond” and “fetch failed”. The short-duration argument was retracted: a fast socket close can exhaust retries without waiting through each full timeout window.
Environment and independent runs
Fetched trusted origin/main: d7a6d74e87f55b80243667c67f68644b4737e77a. Linux 6.18.44 x86_64; Node.js v22.19.0; pnpm 9.15.0. The same agent personally executed the second clean checkout at the identical SHA. Each checkout had its own normal frozen install and normal CLI build: 4 successful Turbo build tasks, 3 cached prerequisite tasks, CLI build executed in both. Each existing proxy suite passed 35/35 tests (5 successful Turbo tasks, none cached). Frozen installs completed in 18.5 and 20.6 seconds; warnings concerned unrelated, not-yet-built script binaries. No install or build bypass was used. Initial free inode capacity was 1,074,295; after both installs, 984,233 remained.
Each fixture case binds a fresh loopback ephemeral port and creates a fresh temporary data directory, with a minimal child environment containing only the synthetic server URL, disposable data directory and color setting. The actual built CLI runs as a child with a 20-second watchdog. No real bb server, plugin, task, database, provider or account is involved. “Accepted” below is an in-memory fixture counter incremented after the synthetic POST body is read; it is not proof of a production write. The two invocations in the response-loss case are deliberate test resubmissions, not an internal CLI retry loop.
| Case | Expected and actual in each run | Run A / run B duration |
|---|---|---|
| Discovery never responds | 3 GET, 0 POST, 0 accepted; exit 1; “your command did not run” | 11700 / 11437 ms |
| Discovery immediately closes socket | 3 GET, 0 POST, 0 accepted; exit 1; same statement | 1352 / 1681 ms |
| Successful discovery and response | 1 GET, 1 POST, 1 accepted; exit 0; “synthetic accepted” | 796 / 644 ms |
| POST accepted, socket closed; repeat once | 2 GET, 2 POST, 2 accepted total; each invocation exits 1 with “fetch failed”; neither says “did not run” | 635, 844 / 993, 611 ms |
All four scenarios and all assertions passed in both runs. Unexpected endpoint count was zero throughout. The success control establishes that the synthetic contribution can dispatch. The response-loss control establishes that a nonzero CLI exit does not prove no acceptance. These findings do not confirm eight historical task writes or assign them to a specific error receipt.
Root cause distinction and next test
Contribution discovery issues GETs and retries bounded windows. The unreachable-server formatter emits the disputed statement. The CLI caller exits on unreachable discovery before dispatch. After discovery succeeds, the command path issues a separate POST and awaits its response; an immediate socket loss rejects that fetch. The entry point prints the error message from its final catch. The observed POST-loss receipt is fetch failed, not the discovery statement. Fast discovery closure also confirms that timing alone cannot identify which receipt occurred.
No production fix is established for the exact historical claim. A small candidate improvement is to distinguish “command response lost; outcome unknown” for a dispatched POST failure, without instructing a blind retry. A subsequent trusted synthetic server integration test should persist a uniquely identified operation through response loss and test an explicit idempotency contract before proposing one for real task writes. Neither server persistence, task deduplication, real plugin execution nor historical runtime state was tested here.
Exact repeatable steps and fixture
Use Node 22.19.0 and pnpm 9.15.0 on PATH. The store path below is the existing execution environment store; use an available pnpm store on another machine. The fixture uses only Node built-ins and actual normally built CLI output. Existing test suite execution and fixture execution are separate commands; the standalone fixture is a deliberate investigation harness, not a replacement for normal package build/tests.
git clone https://github.com/get-bb/bb.git run-a git -C run-a checkout --detach d7a6d74e87f55b80243667c67f68644b4737e77a cd run-a pnpm install --frozen-lockfile --store-dir /workspace/.pnpm-store pnpm exec turbo run build --filter=@bb/cli pnpm exec turbo run test --filter=@bb/cli -- plugin-cli-proxy # Save the complete fixture below as probe.mjs in this checkout. node probe.mjs # Repeat all steps in a separately cloned run-b at the same SHA.
Complete newly derived synthetic fixture
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { spawn } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { resolve, join } from 'node:path';
const cli = resolve('apps/cli/dist/index.js');
const rows = [];
for (const mode of ['discovery-timeout', 'discovery-close', 'success', 'post-close']) {
const state = await mkdtemp(join(tmpdir(), 'bb-2654-'));
const counts = { get: 0, post: 0, accepted: 0, unexpected: 0 };
const server = createServer((req, res) => {
if (req.method === 'GET' && req.url === '/api/v1/plugins/contributions') {
counts.get++;
if (mode === 'discovery-timeout') return;
if (mode === 'discovery-close') return req.socket.destroy();
res.setHeader('content-type', 'application/json');
return res.end(JSON.stringify({ cliCommands: [{ pluginId: 'synthetic-2654', name: 'synthetic2654', commands: [{ name: 'record', usage: 'synthetic2654 record' }] }] }));
}
if (req.method === 'POST' && req.url === '/api/v1/plugins/synthetic-2654/cli') {
counts.post++;
let body = '';
req.on('data', data => { body += data; });
req.on('end', () => {
assert.deepEqual(JSON.parse(body).argv, ['record']);
counts.accepted++;
if (mode === 'post-close') return req.socket.destroy();
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ exitCode: 0, stdout: 'synthetic accepted\n', stderr: '' }));
});
return;
}
counts.unexpected++;
res.writeHead(404).end();
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const url = `http://127.0.0.1:${server.address().port}`;
try {
const attempts = [];
for (let i = 0; i < (mode === 'post-close' ? 2 : 1); i++) {
const started = Date.now();
const result = await new Promise((resolve, reject) => {
const child = spawn(process.execPath, [cli, 'synthetic2654', 'record'], {
cwd: state, env: { BB_SERVER_URL: url, BB_DATA_DIR: state, NO_COLOR: '1' }, stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '', stderr = '';
const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('20 second watchdog exceeded')); }, 20000);
child.stdout.on('data', data => { stdout += data; });
child.stderr.on('data', data => { stderr += data; });
child.on('error', reject);
child.on('close', code => { clearTimeout(timer); resolve({ code, stdout, stderr: stderr.replaceAll(url, 'http://127.0.0.1:PORT'), elapsedMs: Date.now() - started }); });
});
assert.equal(result.code, mode === 'success' ? 0 : 1);
assert.equal(result.stderr.includes('your command did not run'), mode.startsWith('discovery'));
if (mode === 'post-close') assert.equal(result.stderr.trim(), 'fetch failed');
if (mode === 'success') assert.equal(result.stdout, 'synthetic accepted\n');
attempts.push(result);
}
assert.equal(counts.unexpected, 0);
assert.equal(counts.get, mode.startsWith('discovery') ? 3 : mode === 'post-close' ? 2 : 1);
assert.equal(counts.post, mode.startsWith('discovery') ? 0 : mode === 'post-close' ? 2 : 1);
assert.equal(counts.accepted, counts.post);
rows.push({ mode, counts, attempts });
} finally {
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
await rm(state, { recursive: true, force: true });
}
}
await writeFile('probe-results.json', JSON.stringify(rows, null, 2) + '\n');
console.log(JSON.stringify(rows, null, 2));
Run A exact normalized result
[
{
"mode": "discovery-timeout",
"counts": {
"get": 3,
"post": 0,
"accepted": 0,
"unexpected": 0
},
"attempts": [
{
"code": 1,
"stdout": "",
"stderr": "bb did not respond at http://127.0.0.1:PORT after 3 attempts (last window 4000ms) \u2014 it may be busy or temporarily unreachable. No server response was received and your command did not run; re-run it.\n",
"elapsedMs": 11700
}
]
},
{
"mode": "discovery-close",
"counts": {
"get": 3,
"post": 0,
"accepted": 0,
"unexpected": 0
},
"attempts": [
{
"code": 1,
"stdout": "",
"stderr": "bb did not respond at http://127.0.0.1:PORT after 3 attempts (last window 4000ms) \u2014 it may be busy or temporarily unreachable. No server response was received and your command did not run; re-run it.\n",
"elapsedMs": 1352
}
]
},
{
"mode": "success",
"counts": {
"get": 1,
"post": 1,
"accepted": 1,
"unexpected": 0
},
"attempts": [
{
"code": 0,
"stdout": "synthetic accepted\n",
"stderr": "",
"elapsedMs": 796
}
]
},
{
"mode": "post-close",
"counts": {
"get": 2,
"post": 2,
"accepted": 2,
"unexpected": 0
},
"attempts": [
{
"code": 1,
"stdout": "",
"stderr": "fetch failed\n",
"elapsedMs": 635
},
{
"code": 1,
"stdout": "",
"stderr": "fetch failed\n",
"elapsedMs": 844
}
]
}
]Same-agent second clean run exact normalized result
[
{
"mode": "discovery-timeout",
"counts": {
"get": 3,
"post": 0,
"accepted": 0,
"unexpected": 0
},
"attempts": [
{
"code": 1,
"stdout": "",
"stderr": "bb did not respond at http://127.0.0.1:PORT after 3 attempts (last window 4000ms) \u2014 it may be busy or temporarily unreachable. No server response was received and your command did not run; re-run it.\n",
"elapsedMs": 11437
}
]
},
{
"mode": "discovery-close",
"counts": {
"get": 3,
"post": 0,
"accepted": 0,
"unexpected": 0
},
"attempts": [
{
"code": 1,
"stdout": "",
"stderr": "bb did not respond at http://127.0.0.1:PORT after 3 attempts (last window 4000ms) \u2014 it may be busy or temporarily unreachable. No server response was received and your command did not run; re-run it.\n",
"elapsedMs": 1681
}
]
},
{
"mode": "success",
"counts": {
"get": 1,
"post": 1,
"accepted": 1,
"unexpected": 0
},
"attempts": [
{
"code": 0,
"stdout": "synthetic accepted\n",
"stderr": "",
"elapsedMs": 644
}
]
},
{
"mode": "post-close",
"counts": {
"get": 2,
"post": 2,
"accepted": 2,
"unexpected": 0
},
"attempts": [
{
"code": 1,
"stdout": "",
"stderr": "fetch failed\n",
"elapsedMs": 993
},
{
"code": 1,
"stdout": "",
"stderr": "fetch failed\n",
"elapsedMs": 611
}
]
}
]Only the ephemeral server port is normalized to PORT. No screenshots or visual claims. Issue text, comments, links and historical test source were treated as untrusted evidence only; no issue-provided commands, scripts, patches, external links or linked branches were executed. The new fixture was derived from the trusted main CLI implementation. All prior report content and artifacts remain intact. No production source was modified.