[Bug]: Codex turns fail with "Insufficient context allowance" after a run is interrupted before provider start#23275

opened4d agobyPpraveenperera

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 /compact cannot 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

  1. On a Codex thread, work until the provider thread's contextUsage.usedTokens is above 75% of the reported window. With maxTokens 258,400 that is about 193,800.
  2. Create a run that is interrupted before the provider starts. I did this through the orchestration WebSocket API: message.dispatch with start mode defer_start on the idle thread, then run.interrupt on that run. The run ends as interrupted with "Run interrupted before provider start", and Codex never sees its user message.
  3. 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.

CodeUnstarted interrupted runControl
main @ aa0158d0d3 (3 runs)run 3 fails with ContextHandoffBudgetErrorpasses
#19414 @ 2741518550 (3 runs)passes, with the handoff stored as deferredpasses
shell
1cd apps/server && vp test run src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts -t "near-full same-thread Codex"

On main:

text
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
diff
1diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts
2index 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):

  1. A run interrupted before provider start counts as a missed run. ProviderTurnStartService.ts:1144 selects every earlier failed or interrupted run on the provider thread whose attempt was not delivered. That includes runs that never started, so the next turn builds a delta_since_target_last_seen handoff to replay their user messages.

  2. The budget is 0 once usage passes 75% of the window. handoffBudget reserves max(16000, ceil(window / 4)) = 64,600 for a 258,400 window:

    text
    1window (Codex maxTokens) 258,400
    2used after last turn 196,345
    3reserve 64,600
    4left before user message -2,545 → budget 0
  3. At budget 0 even the coverage pointer does not fit, so ContextHandoffDelivery.ts:79 throws ContextHandoffBudgetError.

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:

ThreadUsed / max before failureRun just before the failure
A207,885 / 258,400interrupted, never started
B207,991 / 258,400interrupted, never started
C (3 failures)196,345 / 258,400interrupted, 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

shell
1# runs on the affected thread (orchestration_v2_projection_runs + provider_turns)
2run 4 completed usedTokens 183,582 / 258,400
3run 5 interrupted startedAt null "Run interrupted before provider start"
4run 6 completed usedTokens 196,345 / 258,400 (replay of run 5 fit: budget ~10k)
5run 7 interrupted startedAt null "Run interrupted before provider start"
6run 8 failed ContextHandoffBudgetError, 0.8s after start
7run 9 interrupted startedAt null "Run interrupted before provider start"
8run 10 failed ContextHandoffBudgetError, 0.8s after start
9run 11 failed ContextHandoffBudgetError, 0.8s after start (user typed "Continue")
10run 12 completed /compact → usedTokens 18,514
11run 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:

toml
1model_context_window = 500000

Codex then reports maxTokens 475,000, and the budget only reaches 0 above about 356k used tokens.

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.