bug(workflows): Codex "You've hit your usage limit" never triggers quota auto-resume
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, tierlarge→codex/gpt-5.6-sol - node
deliver__corrections.fix__implementfailed 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
-
isQuotaExhaustionErrorreturnstruefor the verbatim Codex string above - A run whose only failure is that string emits
quota_resume_scheduled(not a silent terminalfailed) - With
quotaFallbackDelayMsset, Codex resumes atnow + delaywhen 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
- Configure
workflows.autoResumeOnQuotaReset: trueand run a Codex-tier workflow on an account near its 5-hour window cap (assistants.codex/ tierlarge→codex/gpt-5.6-sol). - Let the account cap be reached mid-node (a ~20M-token run gets there in one window).
- Observe: the node fails with
codex_turn_failed; the run goesfailed; noquota_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; modelgpt-5.6-sol; tierlarge
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:
QUOTA_EXHAUSTION_PATTERNS—executor-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 theusage limit **reached**variant.isQuotaExhaustionError(:152) therefore never fires, andscheduleQuotaResume(dag-executor.ts:11574-11578) finds noisQuotaExhaustionError-qualified failure, so it returnsundefined— which is why there is not even aquota_resume_skippedevent.classifyError(executor-shared.ts:106) derivesFATAL_PATTERNSfrom the same list (:54), so the string also lands asUNKNOWNrather thanFATAL, unlike its pinned Claude sibling at:959-971.FATAL_PATTERNSis not the blocker for resume, but the same one-word mismatch produces two divergent classifications for two messages that mean the same thing.classifyCodexError—providers/src/codex/provider.ts:327-336:RATE_LIMIT_PATTERNS(:314) is['rate limit', 'too many requests', 'overloaded']andAUTH_PATTERNS(:323) is['credit balance', 'unauthorized', 'authentication', 'invalid token']. Neither contains a usage-cap phrase, andisModelAccessError(:281) requiresmodelplus 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:
extractQuotaResetAtkeeps returning null for ambiguous prose (the:1000-1010intent), and quota exhaustion staysFATAL/ non-retryable (the:1012intent) — 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 bareusage limitthaterror-formatter.tsalready 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:1016asserts that "everydetectCreditExhaustionoutput string classifies FATAL" — butdetectCreditExhaustiononly 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_failedsubtype (dag-executor.ts:2656/:6272). If the Codex SDK exposes the reset instant as data rather than prose, reading it there would makeextractQuotaResetAt'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 withquota_resume_skipped{reason: 'container_unsupported'}whenexecContext.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.
Source: coleam00/Archon