#3353·Archon

bug(workflows): Codex "You've hit your usage limit" never triggers quota auto-resume

Author: arno-ockermanCreated Sep 17, 2026Updated Sep 17, 2026

Problem

With workflows.autoResumeOnQuotaReset: true, a run that dies on Codex's account-wide usage limit is never resumed. It goes terminal with no quota_resume_scheduled event — not even quota_resume_skipped — even though the provider's own message names a reset instant that falls well inside quotaDeadlineMs.

Observed on Archon 0.10.1 (commit e237584d), Docker, SQLite, defaultAssistant: codex:

  • workflow archon-ship, tier largecodex/gpt-5.6-sol
  • node deliver__corrections.fix__implement failed on loop iteration 1 after ~60 min
  • run status: failed
  • the message's own reset clause was ~3h20m in the future, inside the default 24h deadline

Verbatim stored node_failed error from the run:

Loop iteration 1 failed: Loop 'fix__implement' iteration 1 failed: SDK returned
codex_turn_failed — You've hit your usage limit. Upgrade to Pro
(https://chatgpt.com/explore/pro), visit
https://chatgpt.com/codex/settings/usage to purchase more credits or try again at
Sep 18th, 2026 12:04 AM.

Nothing else in the run failed. Failing there discarded the remaining review/correction nodes.

Why

The opt-in auto-resume (#2182) exists so a run survives a provider reset without a human babysitting it. Codex is both the default assistant and the large tier, so the runs where this matters most are exactly the ones not covered: in a measured archon-ship run the large-tier implement node was ~69% of the run's 20.7M input tokens. A single unwatched reset ends a multi-hour run that the feature was built to rescue.

The severity is also asymmetric in a way that hides the gap: the user-facing formatter already reports this correctly. error-formatter.ts (:48-65) matches the broad substrings rate limit / hit your limit / usage limit / session limit and renders "⚠️ AI usage limit reached (resets …)". So the operator is told the limit was reached, while the engine's own detector — fed the same string — sees nothing. The two lists disagree about what a usage cap looks like.

Desired outcome

A Codex usage-limit failure carrying a reset instant is treated exactly like the Claude wording already is: it satisfies isQuotaExhaustionError, and under autoResumeOnQuotaReset: true a durable resume is scheduled under the existing policy (attempts, deadline, quotaFallbackDelayMs), emitting quota_resume_scheduled.

Acceptance

  • isQuotaExhaustionError returns true for the verbatim Codex string above
  • A run whose only failure is that string emits quota_resume_scheduled (not a silent terminal failed)
  • With quotaFallbackDelayMs set, Codex resumes at now + delay when the reset clause is not parseable, rather than skipping
  • A Codex verbatim string is pinned in executor-shared.test.ts, and the existing drift guard is extended to cover it (see Additional notes)
  • Existing Claude cases (:959-971, :1000-1010, :1016) still pass

Evidence

Reproduction

  1. Configure workflows.autoResumeOnQuotaReset: true and run a Codex-tier workflow on an account near its 5-hour window cap (assistants.codex / tier largecodex/gpt-5.6-sol).
  2. Let the account cap be reached mid-node (a ~20M-token run gets there in one window).
  3. Observe: the node fails with codex_turn_failed; the run goes failed; no quota_resume_* event is written.

No test harness was used — this is an observed production run plus a predicate-by-predicate reading of 0.10.1 source (below), not a reproduction in-tree.

Environment

  • Archon version or commit: 0.10.1 (e237584d9c332fc492125bf9e0cb4756895f4da2)
  • Platform or adapter: Docker (execContext.kind === 'host', host worktree, not container)
  • Database: SQLite
  • OS: Linux (TrueNAS SCALE host, container image)
  • Provider/assistant: codex; model gpt-5.6-sol; tier large

Logs or screenshots

The node_failed payload quoted above. No secrets in it; the only URLs are OpenAI's own upgrade/settings links and the reset timestamp.

Verified cause (evidence, not a solution design)

Mirroring the shipped predicates from packages/workflows/src/executor-shared.ts and packages/providers/src/codex/provider.ts and feeding each string through them:

input isQuotaExhaustionError classifyError extractQuotaResetAt classifyCodexError error-formatter
Codex provider text (verbatim) false UNKNOWN null unknown true
Codex stored node error (with loop wrappers) false UNKNOWN null unknown true
Claude session-limit text (pinned in tests) true FATAL null unknown true
usage limit reached|1787569200 (pinned) true FATAL parsed unknown true

Three separate lists miss this string:

  1. QUOTA_EXHAUSTION_PATTERNSexecutor-shared.ts:38-43: 'session limit', 'usage limit reached', 'credit exhaustion', 'credit balance'. Codex says "You've hit your usage limit"; the list only contains the usage limit **reached** variant. isQuotaExhaustionError (:152) therefore never fires, and scheduleQuotaResume (dag-executor.ts:11574-11578) finds no isQuotaExhaustionError-qualified failure, so it returns undefined — which is why there is not even a quota_resume_skipped event.

  2. classifyError (executor-shared.ts:106) derives FATAL_PATTERNS from the same list (:54), so the string also lands as UNKNOWN rather than FATAL, unlike its pinned Claude sibling at :959-971. FATAL_PATTERNS is not the blocker for resume, but the same one-word mismatch produces two divergent classifications for two messages that mean the same thing.

  3. classifyCodexErrorproviders/src/codex/provider.ts:327-336: RATE_LIMIT_PATTERNS (:314) is ['rate limit', 'too many requests', 'overloaded'] and AUTH_PATTERNS (:323) is ['credit balance', 'unauthorized', 'authentication', 'invalid token']. Neither contains a usage-cap phrase, and isModelAccessError (:281) requires model plus an availability phrase, so the provider's own classifier returns 'unknown' too.

Second finding: the pattern fix alone is not enough for Codex

extractQuotaResetAt (executor-shared.ts:158-174) intentionally parses only two forms — the comment says "Parse only provider reset forms that carry an unambiguous instant/duration":

  • usage limit reached|<epoch 10-13 digits>
  • resets in <N> min|hour

Codex's clause is neither: "try again at Sep 18th, 2026 12:04 AM" — ordinal day, month name, 12-hour clock, and no timezone. The test at :1000-1010 deliberately pins a null for a non-parseable quota string, so returning null here is by design; the designed answer is quotaFallbackDelayMs.

But quotaFallbackDelayMs has no default (config-loader.ts:425-427 sets autoResumeOnQuotaReset: false, quotaMaxAttempts: 1, quotaDeadlineMs: 24h; the delay is optional). When it is unset and the reset clause is not parseable, scheduleQuotaResume logs quota_resume_skipped{reason: 'reset_unavailable'} and schedules nothing (:11594-11605). So adding the pattern alone would move Codex from "silent terminal failure" to "explicit skip" — better, but not a resume. Either the fallback needs a default when autoResumeOnQuotaReset is enabled, or the docs for the setting need to say the delay is required in practice for prose-style reset clauses (which covers Claude's resets 3:20pm (UTC) form as well — that one is pinned FATAL but also returns null from extractQuotaResetAt, so it depends on the same fallback).

That is a small design decision, so this issue is about the detection gap; flagging the delay as a related decision rather than prescribing an answer.

Constraints and related work

  • Must remain true: extractQuotaResetAt keeps returning null for ambiguous prose (the :1000-1010 intent), and quota exhaustion stays FATAL / non-retryable (the :1012 intent) — this is a resume-scheduling gap, not a retry one.
  • Known prerequisites or blockers: none observed. The failure path itself works; only the detector misses.
  • Related issues or PRs: #2182 (the opt-in auto-resume feature), #2177 (Claude session-limit classification, the precedent that made the Claude path work), #2425 (same class of gap: a provider message missing from a substring list).
  • Solution steering: Hint — add the Codex wording to QUOTA_EXHAUSTION_PATTERNS; whether to broaden to the bare usage limit that error-formatter.ts already accepts is a maintainer call (it is a wider net but would put both lists back in agreement).

Additional notes

  • Why existing tests did not catch it: the drift guard at executor-shared.test.ts:1016 asserts that "every detectCreditExhaustion output string classifies FATAL" — but detectCreditExhaustion only produces the Claude wordings (:354-369). Codex failures arrive as raw SDK error text, so no Codex string is ever fed through the guard. A second guard pinning a verbatim Codex usage-limit string would cover the family.
  • Possible structural fix, for consideration: the string suggests Codex distinguishes this from a generic text error via the structured codex_turn_failed subtype (dag-executor.ts:2656 / :6272). If the Codex SDK exposes the reset instant as data rather than prose, reading it there would make extractQuotaResetAt's prose-parsing question moot for this provider. I have not confirmed the SDK's error shape, so this is a hint, not an assumption about what is available.
  • Separate observation, possibly intentional: scheduleQuotaResume (dag-executor.ts:11563-11570) returns early with quota_resume_skipped{reason: 'container_unsupported'} when execContext.kind === 'container'. Host-worktree runs are unaffected (the reproductions above all use the default {kind: 'host'}), so this is out of scope here — mentioning it only in case container-mode resume is meant to be in scope for the feature.