Reproduced on main fef851bee0 with real Codex on Linux: a deferred run interrupted before provider start made the next Continue fail at a zero handoff allowance. The existing fix #19414 now includes this regression. At cdc28c82a5, the same sequence completes and preserves the missed message as deferred history. Runtime evidence. CI is running.
[Bug]: Codex turns fail with "Insufficient context allowance" after a run is interrupted before provider start#23275
Before submitting
- I searched existing issues and did not find a duplicate. Related: #17952 had the same zero-budget failure, but its trigger was a Claude Fast Mode refusal; it was closed after that trigger was fixed, with the broader oversized-handoff handling left to #19414. #19875 is OpenCode with no usage reports, where
/compactcannot recover. #19768 was a Codex thread with a pending delegated-result handoff. Here the provider is Codex with a known window, nothing switches provider, model, or instance, and the replayed run never reached the provider. - I included enough detail to reproduce or investigate the problem.
Fixed by #19414, not yet merged. The test below fails on main and passes on the PR head. This report documents a separate trigger: a run interrupted before provider start, on a Codex thread with a known window. It can serve as a regression case for #19414, or be closed once that merges.
Area
apps/server
Steps to reproduce
- On a Codex thread, work until the provider thread's
contextUsage.usedTokensis above 75% of the reported window. WithmaxTokens258,400 that is about 193,800. - Create a run that is interrupted before the provider starts. I did this through the orchestration WebSocket API:
message.dispatchwith start modedefer_starton the idle thread, thenrun.interrupton that run. The run ends asinterruptedwith "Run interrupted before provider start", and Codex never sees its user message. - Send any message, for example "Continue".
A deterministic version of these steps fails on main @ aa0158d0d3. The test below goes through the real orchestrator and ProviderTurnStartService, using the fake Codex adapter in ProviderSwitch.integration.test.ts. Run 1 completes and reports {usedTokens: 196345, maxTokens: 258400}. Run 2 is sent with defer_start and interrupted. Run 3 sends "Continue". The control case skips run 2.
| Code | Unstarted interrupted run | Control |
|---|---|---|
main @ aa0158d0d3 (3 runs) | run 3 fails with ContextHandoffBudgetError | passes |
| #19414 @ 2741518550 (3 runs) | passes, with the handoff stored as deferred | passes |
| 1 | cd apps/server && vp test run src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts -t "near-full same-thread Codex" |
On main:
| 1 | + "failure": "Insufficient context allowance for the provider handoff. Compact the target conversation or use a larger-context model; the current request has not been truncated.", |
| 2 | + "status": "failed", |
Test diff against main
| 1 | diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts |
| 2 | index 72aae60c0d..16dd1f044f 100644 |
| 3 | --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts |
| 4 | +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts |
| 5 | @@ -1,5 +1,8 @@ |
| 6 | import { vi } from "vite-plus/test"; |
| 7 | -import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; |
| 8 | +import { |
| 9 | + historyResponseItems, |
| 10 | + latestNativeContextUsage, |
| 11 | +} from "@t3tools/provider-core/server/handoffBudget"; |
| 12 | import { assert, describe, it } from "@effect/vitest"; |
| 13 | import { |
| 14 | CommandId, |
| 15 | @@ -1465,6 +1468,142 @@ describe("orchestration v2 provider switching", () => { |
| 16 | ), |
| 17 | ); |
| 18 | |
| 19 | + it.live.each(["unstarted-interrupt", "control"] as const)( |
| 20 | + "starts a near-full same-thread Codex turn after %s", |
| 21 | + (scenario) => |
| 22 | + Effect.scoped( |
| 23 | + Effect.gen(function* () { |
| 24 | + const cwd = yield* checkpointWorkspace(`handoff-near-full-${scenario}`); |
| 25 | + const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]); |
| 26 | + const injectedHistory = yield* Ref.make<ReadonlyArray<unknown>>([]); |
| 27 | + // Real incident numbers: Codex reported 196,345 of 258,400 tokens used, |
| 28 | + // which leaves no handoff budget once the reserve is subtracted |
| 29 | + const usage = { usedTokens: 196_345, maxTokens: 258_400 }; |
| 30 | + const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([ |
| 31 | + makeTestAdapter({ |
| 32 | + instanceId: CODEX_MODEL_SELECTION.instanceId, |
| 33 | + driver: CODEX_DRIVER, |
| 34 | + capabilities: CodexProviderCapabilitiesV2, |
| 35 | + modelSelection: CODEX_MODEL_SELECTION, |
| 36 | + responseByRunOrdinal: {}, |
| 37 | + capturedTurns, |
| 38 | + injectedHistory, |
| 39 | + canReuseContextUsage: canReuseCodexContextUsage, |
| 40 | + tokenUsageByRunOrdinal: { 1: usage }, |
| 41 | + }), |
| 42 | + ]); |
| 43 | + yield* Effect.gen(function* () { |
| 44 | + const orchestrator = yield* Orchestrator.OrchestratorV2; |
| 45 | + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; |
| 46 | + const dispatch = ( |
| 47 | + key: string, |
| 48 | + text: string, |
| 49 | + dispatchMode: { readonly type: "start_immediately" | "defer_start" }, |
| 50 | + ) => |
| 51 | + orchestrator.dispatch({ |
| 52 | + type: "message.dispatch", |
| 53 | + commandId: CommandId.make(`command:near-full:${key}`), |
| 54 | + threadId, |
| 55 | + messageId: MessageId.make(`message:near-full:${key}`), |
| 56 | + createdBy: "user", |
| 57 | + creationSource: "web", |
| 58 | + text, |
| 59 | + attachments: [], |
| 60 | + modelSelection: CODEX_MODEL_SELECTION, |
| 61 | + dispatchMode, |
| 62 | + }); |
| 63 | + const waitForTerminal = (ordinal: number) => |
| 64 | + orchestrator.streamStoredEvents.pipe( |
| 65 | + Stream.filter( |
| 66 | + ({ event }) => |
| 67 | + event.type === "run.updated" && |
| 68 | + event.threadId === threadId && |
| 69 | + event.payload.ordinal === ordinal && |
| 70 | + (event.payload.status === "completed" || event.payload.status === "failed"), |
| 71 | + ), |
| 72 | + Stream.runHead, |
| 73 | + Effect.andThen(worker.drain()), |
| 74 | + ); |
| 75 | + yield* orchestrator.dispatch({ |
| 76 | + type: "thread.create", |
| 77 | + commandId: CommandId.make("command:near-full:create"), |
| 78 | + threadId, |
| 79 | + projectId, |
| 80 | + createdBy: "user", |
| 81 | + creationSource: "web", |
| 82 | + title: "Near-full Codex thread", |
| 83 | + modelSelection: CODEX_MODEL_SELECTION, |
| 84 | + runtimeMode: "full-access", |
| 85 | + interactionMode: "default", |
| 86 | + branch: null, |
| 87 | + worktreePath: null, |
| 88 | + }); |
| 89 | + yield* dispatch("first", "Earlier completed request.", { type: "start_immediately" }); |
| 90 | + yield* waitForTerminal(1); |
| 91 | + const afterFirst = yield* orchestrator.getThreadProjection(threadId); |
| 92 | + assert.deepEqual( |
| 93 | + latestNativeContextUsage(afterFirst, afterFirst.providerThreads[0]!)?.usage, |
| 94 | + usage, |
| 95 | + ); |
| 96 | + |
| 97 | + if (scenario === "unstarted-interrupt") { |
| 98 | + yield* dispatch("deferred", "Never sent to Codex.", { type: "defer_start" }); |
| 99 | + const deferred = (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)!; |
| 100 | + assert.equal(deferred.status, "preparing"); |
| 101 | + yield* orchestrator.dispatch({ |
| 102 | + type: "run.interrupt", |
| 103 | + commandId: CommandId.make("command:near-full:interrupt"), |
| 104 | + threadId, |
| 105 | + runId: deferred.id, |
| 106 | + }); |
| 107 | + yield* worker.drain(); |
| 108 | + const interrupted = yield* orchestrator.getThreadProjection(threadId); |
| 109 | + const interruptedRun = interrupted.runs.at(-1)!; |
| 110 | + assert.equal(interruptedRun.status, "interrupted"); |
| 111 | + assert.equal(interruptedRun.providerThreadId, afterFirst.providerThreads[0]?.id); |
| 112 | + assert.isFalse( |
| 113 | + interrupted.providerTurns.some( |
| 114 | + (turn) => turn.runAttemptId === interruptedRun.activeAttemptId, |
| 115 | + ), |
| 116 | + ); |
| 117 | + } |
| 118 | + |
| 119 | + const targetOrdinal = scenario === "unstarted-interrupt" ? 3 : 2; |
| 120 | + yield* dispatch("target", "Continue", { type: "start_immediately" }); |
| 121 | + yield* waitForTerminal(targetOrdinal); |
| 122 | + const projection = yield* orchestrator.getThreadProjection(threadId); |
| 123 | + const target = projection.runs.at(-1)!; |
| 124 | + const failure = projection.turnItems.find( |
| 125 | + (item) => item.type === "error" && item.runId === target.id, |
| 126 | + ); |
| 127 | + assert.deepEqual( |
| 128 | + { |
| 129 | + status: target.status, |
| 130 | + failure: failure?.type === "error" ? failure.failure.message : null, |
| 131 | + }, |
| 132 | + { status: "completed", failure: null }, |
| 133 | + ); |
| 134 | + assert.equal((yield* Ref.get(capturedTurns)).at(-1)?.text, "Continue"); |
| 135 | + assert.isEmpty(yield* Ref.get(injectedHistory)); |
| 136 | + }).pipe( |
| 137 | + Effect.provide( |
| 138 | + ProviderReplayHarness.layerWithRegistry( |
| 139 | + { |
| 140 | + name: `handoff-near-full-${scenario}`, |
| 141 | + runtimePolicyOverride: { |
| 142 | + cwd, |
| 143 | + approvalPolicy: "never", |
| 144 | + sandboxPolicy: { type: "readOnly" }, |
| 145 | + }, |
| 146 | + }, |
| 147 | + layerRegistry, |
| 148 | + ), |
| 149 | + ), |
| 150 | + ); |
| 151 | + }), |
| 152 | + ), |
| 153 | + ); |
| 154 | + |
| 155 | it.live("checks the queued provider's capability while the current provider stays running", () => |
| 156 | Effect.scoped( |
| 157 | Effect.gen(function* () { |
Expected behavior
The next turn starts. A run that never reached the provider leaves only a short user message to replay, and there is room for it: the provider still has about 60k tokens free and Codex had not reached its own auto-compaction limit. If the replay cannot fit, it should be deferred or replaced with a pointer, not fail the user's turn.
Actual behavior
The next run fails within a second with:
Insufficient context allowance for the provider handoff. Compact the target conversation or use a larger-context model; the current request has not been truncated.
Every later message on the thread fails the same way. Each failed run becomes another missed run, so the replay only grows. A manual /compact brings usage down and the next turn succeeds.
Traced through statev2.sqlite and the source on main (aa0158d0d3):
A run interrupted before provider start counts as a missed run.
ProviderTurnStartService.ts:1144selects every earlierfailedorinterruptedrun on the provider thread whose attempt was not delivered. That includes runs that never started, so the next turn builds adelta_since_target_last_seenhandoff to replay their user messages.The budget is 0 once usage passes 75% of the window.
handoffBudgetreservesmax(16000, ceil(window / 4))= 64,600 for a 258,400 window:text1 window (Codex maxTokens) 258,400 2 used after last turn 196,345 3 reserve 64,600 4 left before user message -2,545 → budget 0 At budget 0 even the coverage pointer does not fit, so
ContextHandoffDelivery.ts:79throwsContextHandoffBudgetError.
The same thread shows the boundary. An earlier run 6 also carried a replay of an unstarted interrupted run (run 5), but it started at 183,582 used tokens, which left a budget of about 10k, and it completed. After /compact (18,514 used) the same replay was delivered and the turn completed.
This happened 5 times across 3 Codex threads in one evening. Every failure came right after a run interrupted before provider start, with usage above the 75% line:
| Thread | Used / max before failure | Run just before the failure |
|---|---|---|
| A | 207,885 / 258,400 | interrupted, never started |
| B | 207,991 / 258,400 | interrupted, never started |
| C (3 failures) | 196,345 / 258,400 | interrupted, never started |
Codex was configured to auto-compact at 244,800 tokens, so T3 blocked the replay about 50k tokens before Codex would have compacted on its own.
Impact
Major degradation or frequent failure
Version or commit
Observed on T3 Code (Beta) 0.1.1 desktop. Reproduced by the test above on main @ aa0158d0d3; source links point there.
Environment
macOS 27.0.1 (arm64), T3 Code (Beta) 0.1.1 desktop, codex-cli 0.163.0, model gpt-6.1-sol (reasoning effort high), full-access runtime. Codex config at the time: model_context_window = 272000, model_auto_compact_token_limit = 244800. Codex reported maxTokens 258,400.
Logs or stack traces
| 1 | # runs on the affected thread (orchestration_v2_projection_runs + provider_turns) |
| 2 | run 4 completed usedTokens 183,582 / 258,400 |
| 3 | run 5 interrupted startedAt null "Run interrupted before provider start" |
| 4 | run 6 completed usedTokens 196,345 / 258,400 (replay of run 5 fit: budget ~10k) |
| 5 | run 7 interrupted startedAt null "Run interrupted before provider start" |
| 6 | run 8 failed ContextHandoffBudgetError, 0.8s after start |
| 7 | run 9 interrupted startedAt null "Run interrupted before provider start" |
| 8 | run 10 failed ContextHandoffBudgetError, 0.8s after start |
| 9 | run 11 failed ContextHandoffBudgetError, 0.8s after start (user typed "Continue") |
| 10 | run 12 completed /compact → usedTokens 18,514 |
| 11 | run 13 completed usedTokens 51,283 |
Workaround
Run /compact on the thread, then send the message again.
To keep Codex threads from reaching the line, set model_context_window in ~/.codex/config.toml well above model_auto_compact_token_limit, on a model whose real window allows it (gpt-6.1-sol supports 1M). Codex reports 95% of model_context_window as maxTokens, and the handoff budget reaches 0 at 75% of that, so the window needs to be more than about 1.4× the auto-compact limit. Codex then compacts before T3 blocks the replay. With the auto-compact limit unchanged at 244,800:
| 1 | model_context_window = 500000 |
Codex then reports maxTokens 475,000, and the budget only reaches 0 above about 356k used tokens.