#7092 · Stop loses cancellation before delayed Codex activation
November 5, 2026 · trusted main 2d912ea09011708b6a331c1ee7a15bea6ad32970
Verdict: REPRODUCED at the provider-bridge boundary · Root-cause confidence: high · reproduction label: confirmed-repro.
1. TL;DR
Stop can fail after an accepted Codex turn has not yet become natively interruptible. The bridge waits five seconds for native activation, returns the early interrupt rejection, and leaves the session alive without a deferred cancellation. A repository-owned fake app-server delayed by 6.5 seconds demonstrates the failure, while its 350 ms control stops normally. A second Stop after delayed activation succeeds. This report does not establish how frequently real Codex activation is delayed and does not reproduce the full UI remaining at “stopping.”
2. Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| Stop is lost when activation exceeds five seconds. | Verified | Regression fails in two clean checkouts at 6,500 ms, with the early interrupt error; 350 ms passes. |
| The delayed native turn remains stoppable after the first failure. | Verified | Additional bridge probe has zero terminal boundaries before retry and a successful second Stop after activation. |
| The live app stays at “stopping.” | Unverified live | Server source explains this outcome, but no real-provider/UI instance was used. |
| The reported historical incident had exactly this cause. | Unverified | No reporter runtime or private incident data was read. The deterministic defect stands independently. |
3. Environment
- Trusted public get-bb/bb main at the full commit above; GitHub main was checked again before preparing the fix.
- Linux x86_64, kernel 4.19.0-gvisor; Node v22.19.0; pnpm 9.15.0.
- Only the repository fake Codex app-server was executed. No real Codex executable, model, credentials, or API calls were needed.
- No app server, ports, production runtime, or persistent user data was used. Each test creates and removes a temporary workspace.
- Frozen installs passed in both checkouts. The initial full build needed the SDK build first; the subsequent full Turbo build passed all 73 tasks. SDK build and Turbo test dependencies also passed in the second checkout.
4. Minimal reproduction
The patch changes only the test and its repository fixture: it makes the fixture delay configurable and adds a case past the existing timeout. Production bridge code remains unchanged. It was authored from the trusted repository, not copied from the issue or its linked scripts.
git clone https://github.com/get-bb/bb.git bb-stop-check cd bb-stop-check git checkout --detach 2d912ea09011708b6a331c1ee7a15bea6ad32970 pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build:types --filter=@get-bb/plugin-sdk # Save the test-only patch printed below as /tmp/regression.patch. git apply /tmp/regression.patch pnpm exec turbo run test --force --filter=bb-plugin-provider-codex -- src/bridge/bridge.zero-work-turn.test.ts -t 'interrupts a response-opened turn'
Expected: both delay cases return a successful Stop and exactly one interrupted terminal boundary. Actual on unchanged main:
FAIL src/bridge/bridge.zero-work-turn.test.ts > interrupts a response-opened turn with native activation delayed 6500 ms
AssertionError: expected { code: -32000, …(1) } to be undefined
Received: { "code": -32000, "message": "no active turn to interrupt" }
Tests 1 failed | 1 passed | 22 skipped (24)
Complete test-only regression patch (save as /tmp/regression.patch)
diff --git a/plugins/provider-codex/src/bridge/bridge.zero-work-turn.test.ts b/plugins/provider-codex/src/bridge/bridge.zero-work-turn.test.ts
index 4b856d71b3..af6431b102 100644
--- a/plugins/provider-codex/src/bridge/bridge.zero-work-turn.test.ts
+++ b/plugins/provider-codex/src/bridge/bridge.zero-work-turn.test.ts
@@ -397,39 +397,55 @@ it("does not resurrect a response-opened turn settled before its turn/started ar
]);
}, 30_000);
-it("interrupts a response-opened turn once codex reports it started", async () => {
- const providerThreadId = await startSession();
- harness.sendRequest(2, "turn/start", {
- threadId: THREAD_ID,
- providerThreadId,
- input: [{ type: "text", text: "/late-start-interruptible", mentions: [] }],
- clientRequestId: "creq_ntrptate23",
- options: { ...FULL_ACCESS_SESSION_OPTIONS },
- });
- await harness.waitForResponse(2);
- await waitForEvents((events) =>
- events.some((event) => event.type === "turn/started"),
- );
+it.each([350, 6_500])(
+ "interrupts a response-opened turn with native activation delayed %i ms",
+ async (lateStartDelayMs) => {
+ const scriptPath = join(workspaceDir, "script.json");
+ writeFileSync(scriptPath, JSON.stringify({ lateStartDelayMs }));
+ stubFakeCodexAppServer(scriptPath);
+ const providerThreadId = await startSession();
+ const dispatchedAt = Date.now();
+ harness.sendRequest(2, "turn/start", {
+ threadId: THREAD_ID,
+ providerThreadId,
+ input: [
+ { type: "text", text: "/late-start-interruptible", mentions: [] },
+ ],
+ clientRequestId: "creq_ntrptate23",
+ options: { ...FULL_ACCESS_SESSION_OPTIONS },
+ });
+ await harness.waitForResponse(2);
+ await waitForEvents((events) =>
+ events.some((event) => event.type === "turn/started"),
+ );
- harness.sendRequest(3, "thread/stop", {
- threadId: THREAD_ID,
- providerThreadId,
- intent: "interrupt",
- activeTurnId: "turn-fx-1",
- });
- const stopped = await harness.waitForResponse(3);
- expect(stopped.error).toBeUndefined();
+ harness.sendRequest(3, "thread/stop", {
+ threadId: THREAD_ID,
+ providerThreadId,
+ intent: "interrupt",
+ activeTurnId: "turn-fx-1",
+ });
+ const stopped = await harness.waitForResponse(3);
+ await new Promise((resolve) =>
+ setTimeout(
+ resolve,
+ Math.max(0, lateStartDelayMs + 200 - (Date.now() - dispatchedAt)),
+ ),
+ );
+ expect(stopped.error).toBeUndefined();
- const events = await waitForEvents((all) =>
- all.some((event) => event.type === "turn/completed"),
- );
- expect(events.filter((event) => event.type === "turn/started")).toHaveLength(
- 1,
- );
- expect(events.filter((event) => event.type === "turn/completed")).toEqual([
- expect.objectContaining({ status: "interrupted" }),
- ]);
-}, 30_000);
+ const events = await waitForEvents((all) =>
+ all.some((event) => event.type === "turn/completed"),
+ );
+ expect(
+ events.filter((event) => event.type === "turn/started"),
+ ).toHaveLength(1);
+ expect(events.filter((event) => event.type === "turn/completed")).toEqual([
+ expect.objectContaining({ status: "interrupted" }),
+ ]);
+ },
+ 30_000,
+);
async function compactAndWaitForCompletion(
clientRequestId: string,
diff --git a/plugins/provider-codex/src/bridge/fake-codex-app-server.mjs b/plugins/provider-codex/src/bridge/fake-codex-app-server.mjs
index e9ff7631cc..c460d90cf9 100644
--- a/plugins/provider-codex/src/bridge/fake-codex-app-server.mjs
+++ b/plugins/provider-codex/src/bridge/fake-codex-app-server.mjs
@@ -635,7 +635,7 @@ async function handleRequest(message) {
threadId: params.threadId,
turn: { id: turnId, status: "inProgress" },
});
- }, LATE_TURN_START_DELAY_MS);
+ }, script?.lateStartDelayMs ?? LATE_TURN_START_DELAY_MS);
return;
}
if (firstInputText(params.input) === RESPOND_THEN_EXIT_PROMPT_TEXT) {
For a direct observation of the native activation race, also save the complete test below to plugins/provider-codex/src/bridge/bridge.issue-7092.test.ts after applying that patch, then run:
pnpm exec turbo run test --force --filter=bb-plugin-provider-codex -- src/bridge/bridge.issue-7092.test.ts
This characterization probe passes on the buggy baseline because it asserts the observed failure, then verifies that the second Stop recovers it. It is not the fix regression test. Actual output:
{"lateStartDelayMs":350,"firstStopAfterMs":359,"firstStopError":null,"terminalsBeforeRetry":1,"secondStopError":null}
{"lateStartDelayMs":6500,"firstStopAfterMs":5005,"firstStopError":{"code":-32000,"message":"no active turn to interrupt"},"terminalsBeforeRetry":0,"secondStopError":null}
At 6,500 ms the first Stop fails after roughly five seconds; there are still zero terminal boundaries after activation. The successful second Stop and final interrupted boundary show that the session was still live, rather than merely reporting a stale error.
Complete characterization probe
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import {
experimental_assembleCapturedThreadEvents as assembleEvents,
experimental_createBridgeJsonRpcTestHarness as createHarness,
} from "@get-bb/plugin-sdk/provider-bridge/testing";
import { handleLine } from "./bridge.js";
import {
FULL_ACCESS_SESSION_OPTIONS,
stubFakeCodexAppServer,
} from "./fake-codex-app-server-harness.js";
it.each([350, 6_500])(
"observes startup cancellation at %i ms",
async (lateStartDelayMs) => {
const directory = mkdtempSync(join(tmpdir(), "bb-stop-probe-"));
const scriptPath = join(directory, "fixture.json");
writeFileSync(scriptPath, JSON.stringify({ lateStartDelayMs }));
stubFakeCodexAppServer(scriptPath);
const harness = createHarness(handleLine);
const threadId = "thr_autopilot_7092";
try {
harness.sendRequest(1, "thread/start", {
threadId,
cwd: directory,
instructionMode: "append",
options: { ...FULL_ACCESS_SESSION_OPTIONS },
});
const started = await harness.waitForResponse(1);
const result = started.result as { providerThreadId: string };
const providerThreadId = result.providerThreadId;
const dispatchedAt = performance.now();
harness.sendRequest(2, "turn/start", {
threadId,
providerThreadId,
input: [
{ type: "text", text: "/late-start-interruptible", mentions: [] },
],
clientRequestId: "creq_ntrptate23",
options: { ...FULL_ACCESS_SESSION_OPTIONS },
});
expect((await harness.waitForResponse(2)).error).toBeUndefined();
const stopParams = {
threadId,
providerThreadId,
intent: "interrupt",
activeTurnId: "turn-fx-1",
};
harness.sendRequest(3, "thread/stop", stopParams);
const firstStop = await harness.waitForResponse(3);
const firstStopAfterMs = Math.round(performance.now() - dispatchedAt);
await new Promise((resolve) =>
setTimeout(
resolve,
Math.max(
0,
lateStartDelayMs + 200 - (performance.now() - dispatchedAt),
),
),
);
const terminalsBeforeRetry = assembleEvents(
harness.messages,
"codex",
).filter((event) => event.type === "turn/completed").length;
let secondStopError = null;
if (firstStop.error !== undefined) {
harness.sendRequest(4, "thread/stop", stopParams);
const secondStop = await harness.waitForResponse(4);
secondStopError = secondStop.error ?? null;
expect(secondStop.error).toBeUndefined();
}
process.stderr.write(
`${JSON.stringify({ lateStartDelayMs, firstStopAfterMs, firstStopError: firstStop.error ?? null, terminalsBeforeRetry, secondStopError })}\n`,
);
if (lateStartDelayMs > 5_000) {
expect(firstStop.error).toMatchObject({
message: "no active turn to interrupt",
});
expect(terminalsBeforeRetry).toBe(0);
} else {
expect(firstStop.error).toBeUndefined();
expect(terminalsBeforeRetry).toBe(1);
}
expect(
assembleEvents(harness.messages, "codex").filter(
(event) => event.type === "turn/completed",
),
).toEqual([expect.objectContaining({ status: "interrupted" })]);
} finally {
harness.sendRequest(99, "thread/stop", {
threadId,
providerThreadId: "cleanup",
intent: "release",
activeTurnId: null,
});
await harness.waitForResponse(99);
harness.restore();
vi.unstubAllEnvs();
rmSync(directory, { recursive: true, force: true });
}
},
20_000,
);
5. Root cause
- Accepted start-response handling opens a bb turn from the accepted native response and tracks it as not yet natively started.
- interruptCodexTurn recognizes the specific premature interrupt rejection and waits only
INTERRUPT_SETTLEMENT_TIMEOUT_MS = 5_000. At timeout it returns the original failure. - waitForNativeTurnStart removes its waiter at timeout. There is no retained cancellation intent.
- handleThreadStop returns the error before reaching interrupted settlement or
releaseSession, leaving the app-server alive to activate later. - Server stop settlement returns an interrupt-command failure while a thread is still stopping; the public stop entry point does not throw that failure unless a stopped result was required. This explains the reported UI symptom as a source-supported inference, not a fresh UI observation.
The defect is not a long-running interrupt after native activation. It is an accepted turn whose activation arrives after a bounded retry has been discarded.
6. Proposed fix and validation
For explicit Stop only, recognize the exact premature-interrupt error while the response-opened turn is still awaiting native activation. Once its five-second wait expires, use the already-existing interrupted-boundary and session-release path instead of returning early. Awaiting release kills the session before returning Stop success, preventing later native work. This deliberately avoids an unbounded retry or a new background-cancellation lifecycle. Permission-change steering retains its existing failure semantics; unrelated interrupt errors and post-activation errors still propagate.
The local fix changes 102 text lines (63 additions + 39 deletions) across three files in the existing Codex bridge subsystem. There are no dependencies, protocol/schema changes, persistent-data changes, generated files, or packaging changes.
- The focused regression was added first: unchanged production code failed the 6,500 ms case and passed 350 ms in both clean checkouts.
- With the fix, all 24 zero-work/stop tests pass, including permanent non-activation, second rejection, unrelated rejection, completion races, and native-start timing.
- Full plugin suite: 45 files, 562 tests pass. Turbo lint and typecheck pass.
- Codex recording parity: a stale pre-existing subagent allowlist entry causes one failure with the repository allowlist. A baseline-versus-itself run reproduces the same failure. Strict comparison with an empty external allowlist checks actual old/new differences without stale allowances; results are recorded below.
git diff --checkpasses. No binary changes.
7. Verification
The same agent repeated the regression in a second, newly created detached worktree at 2d912ea09011708b6a331c1ee7a15bea6ad32970, with a fresh frozen install, built SDK, and fresh test workspaces. Only the authored test/fixture patch was applied there; the production bridge was untouched. The second command was:
pnpm exec turbo run test --force --filter=bb-plugin-provider-codex -- src/bridge/bridge.zero-work-turn.test.ts -t 'interrupts a response-opened turn'
Result: the same 6,500 ms error and 350 ms success (one failed, one passed, 22 skipped). The --force run bypassed Turbo result caching. The separate characterization probe then confirmed no terminal boundary before the successful second Stop. No correction to the bridge verdict was required. These are two runs by the same agent, not independent reviewers.
8. Related work
No open pull request linked to #7092 was found through issue timeline/closing metadata or an open-PR body search before the fix. Nearby merged #7031 handles turn-start request timeout ownership, not cancellation after accepted but delayed native activation.
9. Appendix and limits
Issue content, comments, links, and embedded commands were treated solely as untrusted claims. No linked external script, branch, patch, or test was fetched or executed. Only trusted main and locally authored changes were run. Synthetic IDs in the test are fixture data. No screenshots are supplied because the verified reproduction is a bridge contract, not a visual test.
Full live UI/status reproduction and real-provider frequency remain unverified. Build bootstrap and stale parity-allowlist failures are environment/baseline limitations, not evidence against the stop-race reproduction. Raw local artifacts are retained outside public git; the complete repeatable tests and relevant exact outputs appear inline above.
Validation output
Test Files 45 passed (45) Tests 562 passed (562) Tasks: 10 successful, 10 total (plugin test, lint, typecheck and dependencies)
Strict recording parity, with an empty external allowlist:
16 passed, 0 failed, 1 skipped (17 cells)