fix: a Codex usage limit is quota_exhausted, a plain 429 is rate_limited, and only Codex's own error lines count (#3051 rework)

The probe's quota signature used to treat any rate limit, "too many
requests" or 429 as quota, and read the whole output, which echoes the
prompt. Now only Codex's trailing ERROR: / stream error lines are read.
A usage-limit or insufficient_quota line is MODEL_QUOTA_EXHAUSTED (exit 4,
cached 15 minutes; quota wins over a 429 that names insufficient_quota); a
plain 429 is MODEL_PROBE_RATE_LIMITED (exit 0, state rate_limited, never
cached), reported as CODEX_MODE: unverified (rate_limited) so the review
still runs and decides.

The quota HINT says how many minutes gstack skips Codex and how to retry
now (GSTACK_CODEX_PROBE_RETRY=1 skips the negative cache for that probe, or
delete <state root>/.codex-model-probe), with the full troubleshooting URL.
classifyOutsideReview reads the same trailing lines: a mid-review quota is
unavailable (quota_exhausted), a mid-review 429 unavailable (rate_limited).
New gate row rate_limited and docs anchor codex-rate-limited; the quota row
documents the retry. Tests cover each line shape, the echoed prompt, exit
0, timeout, retry recovery, probe/classifier parity, and two fresh-shell
preflight blocks where the second makes no paid Codex call.

Co-authored-by: venkatareddyg <venkatareddyg@users.noreply.github.com>
This commit is contained in:
garrytanandvenkatareddyg committed 2026-10-06 20:43:44 +00:00
1 parent 5884117c02
commit 3542322819
17 files changed
+314 -71

No files matched your search

+4 -2
View File
@@ -784,6 +784,8 @@ else
_CODEX_MODE="model_unusable"
elif [ "${_GSTACK_CODEX_PROBE_STATE:-}" = inconclusive ]; then
_CODEX_MODE="unverified"
elif [ "${_GSTACK_CODEX_PROBE_STATE:-}" = rate_limited ]; then
_CODEX_MODE="unverified (rate_limited)"
else
_CODEX_MODE="ready"; _gstack_codex_version_check 2>/dev/null || true
fi
@@ -799,9 +801,9 @@ Branch on the echoed `CODEX_MODE`:
- **`not_authed`** — installed but no credentials. Print: "Codex not authenticated; outside coverage unavailable. Run `codex login` or set `$CODEX_API_KEY`." Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`broken_install`** — the CLI is on PATH but cannot execute (spawn ENOENT, non-executable binary, missing vendor payload). Print: "Codex is installed but its binary cannot run — Codex passes skipped. Reinstall: `npm install -g @openai/codex`." Relay the probe's HINT lines. Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`model_unusable`** — the selected model (see `CODEX_MODEL:`) is invalid or unavailable to the account (HTTP 400 on every call). Relay the probe's HINT lines and the fix (`GSTACK_CODEX_MODEL=<supported-model>` or config.toml `model`); never substitute a model. Keep the required Claude adversarial pass; do not dispatch a duplicate. The ~10s round trip is cached for 1h.
- **`quota_exhausted`** — Codex usage/rate limit: relay its line verbatim (reset time), no more Codex calls. Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`quota_exhausted`** — Codex usage limit: relay the probe's lines verbatim (reset time, retry); no more Codex calls this run. Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`sandbox_unavailable`** — Codex's sandbox cannot start here (containers without user namespaces); the probe printed the reason and fix. No paid call ran; outside coverage is unavailable. Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`ready`** or **`unverified`** — run the Codex pass below. `unverified` means the model check timed out; say so, and let the pass's own verdict decide.
- **`ready`** or **`unverified`** — run the Codex pass below. `unverified` means the model check timed out or, with `(rate_limited)`, hit a 429; say so, and let the pass's own verdict decide.
Disabled/unavailable retains applicable native passes. Recheck each outside dispatch.
Record provider and completed/unavailable/disabled/skipped per phase; CEO covers
+45 -14
View File
@@ -9,7 +9,7 @@
# _gstack_codex_sandbox_preflight — free `codex sandbox` check before paid calls (Linux)
# _gstack_codex_first_use_notice — once per machine: provider, account, how to turn off
# _gstack_codex_model_probe — round-trip probe of the selected model (#2477);
# exit 4 = the account's Codex usage limit (#3051)
# exit 4 = the account's Codex usage limit
# _gstack_codex_version_check — warn on known-bad Codex CLI versions
# _gstack_codex_timeout_wrapper — gtimeout -> timeout -> bash-native watchdog
# _gstack_codex_log_event — telemetry emission to ~/.gstack/analytics/
@@ -224,11 +224,24 @@ _gstack_codex_model_probe() {
# ENOENT, non-executable binary, missing vendor payload). Deterministic,
# so fail-open is wrong: retrying never helps. Never cached — a reinstall
# fixes it and must be picked up on the very next probe (#2742).
# MODEL_QUOTA_EXHAUSTED (exit 4) — Codex's usage-limit or insufficient_quota
# error on a failed, non-timeout call: the account, not the model. Codex's
# own line (with its reset time) is relayed verbatim; cached 15 min under
# the same signature as MODEL_UNUSABLE, so later fresh-shell blocks in the
# run make no Codex call. GSTACK_CODEX_PROBE_RETRY=1 skips both negative
# cache entries for that probe.
# MODEL_PROBE_RATE_LIMITED (exit 0) — a plain 429 / rate-limit error: often
# gone in seconds, so the review still runs, like the inconclusive case
# (_GSTACK_CODEX_PROBE_STATE=rate_limited, CODEX_MODE: unverified
# (rate_limited)). Codex's line is relayed verbatim; never cached.
# MODEL_PROBE_INCONCLUSIVE (exit 0) — timeout/transient; the review still
# runs so a slow network never wedges codex mode, but readiness is
# unverified (_GSTACK_CODEX_PROBE_STATE=inconclusive, CODEX_MODE:
# unverified) and the review's own classification decides. Never cached.
#
# Quota and rate-limit signatures read only Codex's own trailing error lines
# (ERROR: / stream error), never the whole output: it echoes the prompt.
#
# Only call this AFTER _gstack_codex_auth_probe passes — probing without
# auth just measures the auth failure again.
local _kind="${1:-exec}"
@@ -272,6 +285,7 @@ _gstack_codex_model_probe() {
_sig=$(printf '%s\n' "$_model" "$_codex_home" "$_cfg_m" "$_auth_mode" "$_auth_m" "$_bin" "$_bin_m" | cksum | tr -d ' ')
local _now
_now=$(date +%s 2>/dev/null || echo 0)
local _docs="https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md"
if [ -n "$_cache" ] && [ -f "$_cache" ]; then
local _c_line _c_status _c_ts _c_sig
_c_line=$(head -1 "$_cache" 2>/dev/null)
@@ -283,10 +297,11 @@ _gstack_codex_model_probe() {
echo "MODEL_OK (cached)"
return 0
fi
[ "${GSTACK_CODEX_PROBE_RETRY:-}" = "1" ] && _c_sig="retry"
if [ "$_c_status" = "MODEL_QUOTA_EXHAUSTED" ] && [ "$_c_sig" = "$_sig" ] && [ $((_now - _c_ts)) -lt 900 ]; then
echo "MODEL_QUOTA_EXHAUSTED (cached)"
sed -n '2,4p' "$_cache" 2>/dev/null
echo "HINT: Codex refused this account's calls for its usage limit within the last 15 minutes; outside coverage is unavailable until the reset time above. No Codex call was made."
echo "HINT: Codex refused this account's calls for its usage limit (Codex's line above names the reset time); the model choice is fine. No Codex call was made: gstack skips Codex for $(( (_c_ts + 900 - _now + 59) / 60 )) more minute(s). Retry now: GSTACK_CODEX_PROBE_RETRY=1, or delete $_cache. $_docs#codex-quota-exhausted"
return 4
fi
if [ "$_c_status" = "MODEL_UNUSABLE" ] && [ "$_c_sig" = "$_sig" ] && [ $((_now - _c_ts)) -lt 900 ]; then
@@ -304,23 +319,38 @@ _gstack_codex_model_probe() {
echo "MODEL_OK"
return 0
fi
# A usage limit or rate limit is the account, not the model or the network
# (#3051): Codex says so plainly ("You've hit your usage limit ... try again
# at <time>"). Falling through to the inconclusive branch dropped that line,
# re-probed for 30 s in every skill, and pointed users at model config.
# Only a failed, non-timeout call counts; Codex's own line (it carries the
# reset time and the purchase link) is relayed verbatim and cached briefly.
_QUOTA_SIG="usage limit|rate limit|rate_limit_exceeded|insufficient_quota|exceeded your current quota|too many requests|\"status\":[[:space:]]*429|status:?[[:space:]]*429"
if [ "$_code" -ne 124 ] && printf '%s' "$_out" | grep -qiE "$_QUOTA_SIG"; then
local _q_lines
_q_lines=$(printf '%s\n' "$_out" | grep -iE "$_QUOTA_SIG" | head -3)
# A usage limit is the account, not the model or the network: Codex says so
# plainly ("You've hit your usage limit ... try again at <time>"), and the
# inconclusive branch used to drop that line and re-probe in every skill.
# Only a failed, non-timeout call counts, and only Codex's own trailing error
# lines are read. Quota wins over rate limit: a 429 body that names
# insufficient_quota is the quota.
local _err _q_lines
if [ "$_code" -ne 124 ]; then
_err=$(printf '%s\n' "$_out" | awk '
/^[[:space:]]*(\[[^]]*\][[:space:]]*)?(ERROR:|stream error)/ { buf = buf $0 "\n"; next }
/^[[:space:]]*$/ { next }
{ buf = "" }
END { printf "%s", buf }')
fi
_q_lines=$(printf '%s' "$_err" | grep -iE "usage limit|insufficient_quota|exceeded your current quota|quota exceeded" | head -3)
if [ -n "$_q_lines" ]; then
[ -n "$_cache" ] && { mkdir -p "$_gstack_home" 2>/dev/null; printf 'MODEL_QUOTA_EXHAUSTED %s %s\n%s\n' "$_now" "$_sig" "$_q_lines" > "$_cache" 2>/dev/null; }
echo "MODEL_QUOTA_EXHAUSTED"
printf '%s\n' "$_q_lines"
echo "HINT: Codex refused the call for this account's usage or rate limit (the line above is Codex's own message, with its reset time). Outside coverage is unavailable until then; the model choice is fine."
echo "HINT: Codex refused the call for this account's usage limit (Codex's line above names the reset time); the model choice is fine. Outside coverage is unavailable: gstack skips Codex for 15 minutes. Retry now: GSTACK_CODEX_PROBE_RETRY=1${_cache:+, or delete $_cache}. $_docs#codex-quota-exhausted"
_gstack_codex_log_event "codex_quota_exhausted" 2>/dev/null || true
return 4
fi
_q_lines=$(printf '%s' "$_err" | grep -iE "rate.?limit|too many requests|(^|[^0-9])429([^0-9]|$)" | head -3)
if [ -n "$_q_lines" ]; then
_GSTACK_CODEX_PROBE_STATE="rate_limited"
echo "MODEL_PROBE_RATE_LIMITED — CODEX_MODE: unverified (rate_limited); proceeding, and the review's own result still decides."
printf '%s\n' "$_q_lines"
echo "HINT: Codex rate-limited the check (Codex's line above). Not cached; if the review itself is rate-limited, it reports missing coverage. $_docs#codex-rate-limited"
_gstack_codex_log_event "codex_rate_limited" 2>/dev/null || true
return 0
fi
# A model rejection is deterministic: a 400 ("is not supported", "requires a
# newer version of Codex") or a 404. A retired model answers 404 "does not
# exist or you do not have access to it", and a custom provider with a wrong
@@ -470,7 +500,8 @@ _gstack_codex_log_event() {
# Gated on $_TEL != "off" (caller sets this from gstack-config).
# Event types: codex_timeout, codex_auth_failed, codex_cli_missing,
# codex_version_warning, codex_model_unusable, codex_sandbox_unavailable,
# codex_model_retired, codex_cli_outdated.
# codex_model_retired, codex_cli_outdated, codex_quota_exhausted,
# codex_rate_limited.
# Payload schema: {skill, event, duration_s, ts}. NEVER includes prompt
# content, env var values, or auth tokens.
local _event="$1"
+7 -3
View File
@@ -514,9 +514,13 @@ follow the "Model not supported (HTTP 400 or 404)" recovery steps in
invocations on the same 400.
If the output contains `MODEL_QUOTA_EXHAUSTED`, stop: the account hit its Codex
usage or rate limit. Relay Codex's own line under the marker verbatim (it names the
reset time); the model is fine, so do not change it. Running the modes anyway fails
the same way.
usage limit. Relay Codex's own line under the marker verbatim (it names the reset
time) and the HINT line (how long gstack skips Codex, and how to retry now); the model
is fine, so do not change it. Running the modes anyway fails the same way.
`MODEL_PROBE_RATE_LIMITED` is non-blocking: Codex answered 429. Report
`CODEX_MODE: unverified (rate_limited)`, relay Codex's line and continue; a
rate-limited mode run is missing coverage, never a pass.
If the output contains `CODEX_SANDBOX: unavailable`, stop: Codex's sandbox cannot
start here, so every command it runs would fail and its review would read nothing.
+7 -3
View File
@@ -106,9 +106,13 @@ follow the "Model not supported (HTTP 400 or 404)" recovery steps in
invocations on the same 400.
If the output contains `MODEL_QUOTA_EXHAUSTED`, stop: the account hit its Codex
usage or rate limit. Relay Codex's own line under the marker verbatim (it names the
reset time); the model is fine, so do not change it. Running the modes anyway fails
the same way.
usage limit. Relay Codex's own line under the marker verbatim (it names the reset
time) and the HINT line (how long gstack skips Codex, and how to retry now); the model
is fine, so do not change it. Running the modes anyway fails the same way.
`MODEL_PROBE_RATE_LIMITED` is non-blocking: Codex answered 429. Report
`CODEX_MODE: unverified (rate_limited)`, relay Codex's line and continue; a
rate-limited mode run is missing coverage, never a pass.
If the output contains `CODEX_SANDBOX: unavailable`, stop: Codex's sandbox cannot
start here, so every command it runs would fail and its review would read nothing.
+29 -5
View File
@@ -179,11 +179,12 @@ export GSTACK_CODEX_MODEL=<supported-model>
### `CODEX_MODE: quota_exhausted` / `MODEL_QUOTA_EXHAUSTED`
**Meaning.** Codex refused the call because the account behind it hit its
usage limit or rate limit. The line under the marker is Codex's own message,
with its reset time and where to buy more. The model choice is fine. gstack
skips the remaining Codex calls in that run and reports outside coverage as
unavailable; it never counts as a pass. The result is cached for 15 minutes,
so later skills do not spend another 30 seconds learning the same thing.
usage limit (`You've hit your usage limit`, or `insufficient_quota`). The line
under the marker is Codex's own message, with its reset time and where to buy
more. The model choice is fine. gstack reports outside coverage as
unavailable, never as a pass, and caches the result for 15 minutes, so the
rest of the run (and other skills) make no Codex call. The HINT line says how
many minutes remain.
**Fix.** Wait for the reset time in Codex's message, or add credits or a
higher plan for that account. To use a different account, sign in again:
@@ -192,9 +193,32 @@ higher plan for that account. To use a different account, sign in again:
codex login
```
To re-check before gstack's 15-minute cache expires (for example, right after
buying credits), skip the cached result for one check, or delete it:
```bash
export GSTACK_CODEX_PROBE_RETRY=1 # unset it again afterwards
# or
rm -f ~/.gstack/.codex-model-probe # <state root>/.codex-model-probe
```
**Expected result.** After the reset (or after `codex login`, which changes
the auth signature and re-probes at once), `CODEX_MODE: ready`.
<a id="codex-rate-limited"></a>
### `CODEX_MODE: unverified (rate_limited)` / `MODEL_PROBE_RATE_LIMITED` / `unavailable: Codex rate-limited the review`
**Meaning.** Codex answered HTTP 429 (too many requests), which usually clears
within seconds. It is a different state from `quota_exhausted` and is never
cached. At probe time the review still runs, and its own result decides. A 429
during the review itself means that review failed, so coverage is missing,
never a pass.
**Fix.** Re-run the review in a minute. If 429s persist, check the rate limits
for the account or API key on the provider's dashboard.
**Expected result.** `CODEX_MODE: ready`, and the review completes.
<a id="codex-mode-unverified"></a>
### `CODEX_MODE: unverified` / `MODEL_PROBE_INCONCLUSIVE`
+4 -2
View File
@@ -252,6 +252,8 @@ else
_CODEX_MODE="model_unusable"
elif [ "${_GSTACK_CODEX_PROBE_STATE:-}" = inconclusive ]; then
_CODEX_MODE="unverified"
elif [ "${_GSTACK_CODEX_PROBE_STATE:-}" = rate_limited ]; then
_CODEX_MODE="unverified (rate_limited)"
else
_CODEX_MODE="ready"; _gstack_codex_version_check 2>/dev/null || true
fi
@@ -267,9 +269,9 @@ Branch on the echoed `CODEX_MODE`:
- **`not_authed`** — installed but no credentials. Print: "Codex not authenticated; outside coverage unavailable. Run `codex login` or set `$CODEX_API_KEY`." Fall back to the Claude subagent path.
- **`broken_install`** — the CLI is on PATH but cannot execute (spawn ENOENT, non-executable binary, missing vendor payload). Print: "Codex is installed but its binary cannot run — Codex passes skipped. Reinstall: `npm install -g @openai/codex`." Relay the probe's HINT lines. Fall back to the Claude subagent path.
- **`model_unusable`** — the selected model (see `CODEX_MODEL:`) is invalid or unavailable to the account (HTTP 400 on every call). Relay the probe's HINT lines and the fix (`GSTACK_CODEX_MODEL=<supported-model>` or config.toml `model`); never substitute a model. Fall back to the Claude subagent path. The ~10s round trip is cached for 1h.
- **`quota_exhausted`** — Codex usage/rate limit: relay its line verbatim (reset time), no more Codex calls. Fall back to the Claude subagent path.
- **`quota_exhausted`** — Codex usage limit: relay the probe's lines verbatim (reset time, retry); no more Codex calls this run. Fall back to the Claude subagent path.
- **`sandbox_unavailable`** — Codex's sandbox cannot start here (containers without user namespaces); the probe printed the reason and fix. No paid call ran; outside coverage is unavailable. Fall back to the Claude subagent path.
- **`ready`** or **`unverified`** — run the Codex pass below. `unverified` means the model check timed out; say so, and let the pass's own verdict decide.
- **`ready`** or **`unverified`** — run the Codex pass below. `unverified` means the model check timed out or, with `(rate_limited)`, hit a 429; say so, and let the pass's own verdict decide.
**Disabled is a terminal branch for this section.** If the preflight prints
`CODEX_MODE: disabled`, persist `outside_status: disabled` with the guarded
+7 -2
View File
@@ -65,8 +65,13 @@ export const GATE_OUTCOMES = {
},
quota_exhausted: {
state: 'unavailable', anchor: 'codex-quota-exhausted',
summary: "Codex refused the call for this account's usage or rate limit",
fix: "wait for the reset time in Codex's message above, or add credits or a higher plan for that account; the model choice is fine",
summary: "Codex refused the call for this account's usage limit",
fix: "wait for the reset time in Codex's message above, or add credits or a higher plan for that account; the model choice is fine. To re-check before gstack's 15-minute cache expires, set GSTACK_CODEX_PROBE_RETRY=1 (https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#codex-quota-exhausted)",
},
rate_limited: {
state: 'unavailable', anchor: 'codex-rate-limited',
summary: 'Codex rate-limited the review (HTTP 429), so it did not complete',
fix: 're-run the review in a minute; if 429s persist, check the account rate limits on the provider dashboard (https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#codex-rate-limited)',
},
helper_unavailable: {
state: 'unavailable', anchor: 'sourced-helper-location',
+21 -6
View File
@@ -44,11 +44,23 @@ const SANDBOX_FAILURE = /^\s*bwrap: \S.*$|^.*\bbubblewrap is unavailable\b.*$|^.
/** Exact phrases a reviewer uses when it could not execute; the last fallback, used only without positive evidence. */
const EXECUTION_FAILURE_PHRASE = /\b(?:commands? (?:could not|couldn't|cannot|can't) (?:be )?run|(?:could not|couldn't|was unable to|am unable to|unable to) (?:run (?:any )?(?:shell )?commands|execute (?:any )?commands|inspect the diff|read the diff|access the diff)|the diff could not be (?:read|inspected|accessed)|every (?:shell )?(?:command|invocation) failed)\b/i;
/**
* An account usage or rate limit (#3051). Codex states it plainly ("You've hit
* your usage limit ... try again at <time>"); the line carries the reset time,
* so it is relayed verbatim. Mirrors _QUOTA_SIG in bin/gstack-codex-probe.
* Codex's own trailing error lines (`ERROR: ...`, `stream error: ...`), never
* the whole stderr: it echoes the session prompt, which may quote any of the
* phrases below. Mirrors the awk filter in bin/gstack-codex-probe.
*/
export const QUOTA_FAILURE = /^.*(?:usage limit|rate limit|rate_limit_exceeded|insufficient_quota|exceeded your current quota|too many requests|"status":\s*429|status:?\s*429).*$/im;
const CODEX_ERROR_LINE = /^\s*(?:\[[^\]]*\]\s*)?(?:ERROR:|stream error)/;
function codexErrorLines(stderr: string): string[] {
let block: string[] = [];
for (const line of stderr.split(/\r?\n/)) {
if (CODEX_ERROR_LINE.test(line)) block.push(line.trim());
else if (line.trim()) block = [];
}
return block;
}
/** The account's usage limit: Codex's line carries the reset time, so it is relayed verbatim. Same signature as the probe. */
export const QUOTA_FAILURE = /usage limit|insufficient_quota|exceeded your current quota|quota exceeded/i;
/** A plain HTTP 429 or rate limit: transient, so a separate reason from the quota. */
export const RATE_LIMIT_FAILURE = /rate.?limit|too many requests|(?:^|[^0-9])429(?:[^0-9]|$)/i;
/** `codex review` transcript on stderr: a command that ran prints " succeeded in Nms:". */
const TRANSCRIPT_SUCCESS = /^\s*succeeded in \d+(?:\.\d+)?m?s:?\s*$/m;
@@ -78,8 +90,11 @@ function execution(input: OutsideReviewInput): OutsideReviewClassification['exec
if (exit !== 0) {
const detail = sandbox(stderr);
if (detail) return { state: 'unavailable', reason: 'sandbox_unavailable', detail };
const quota = exit !== 124 ? stderr.match(QUOTA_FAILURE)?.[0].trim().slice(0, 240) : undefined;
if (quota) return { state: 'unavailable', reason: 'quota_exhausted', detail: quota };
const errors = exit !== 124 ? codexErrorLines(stderr) : [];
const quota = errors.find(line => QUOTA_FAILURE.test(line));
if (quota) return { state: 'unavailable', reason: 'quota_exhausted', detail: quota.slice(0, 240) };
const rateLimit = errors.find(line => RATE_LIMIT_FAILURE.test(line));
if (rateLimit) return { state: 'unavailable', reason: 'rate_limited', detail: rateLimit.slice(0, 240) };
const head = stderrHead(stderr);
return { state: 'unavailable', reason: exit === 124 ? 'timeout' : 'execution_failed', detail: head ? `exit ${exit}: ${head}` : `exit ${exit}` };
}
+4 -2
View File
@@ -387,6 +387,8 @@ else
_CODEX_MODE="model_unusable"
elif [ "${_GSTACK_CODEX_PROBE_STATE:-}" = inconclusive ]; then
_CODEX_MODE="unverified"
elif [ "${_GSTACK_CODEX_PROBE_STATE:-}" = rate_limited ]; then
_CODEX_MODE="unverified (rate_limited)"
else
_CODEX_MODE="ready"; _gstack_codex_version_check 2>/dev/null || true
fi
@@ -402,9 +404,9 @@ Branch on the echoed `CODEX_MODE`:
- **`not_authed`** — installed but no credentials. Print: "Codex not authenticated; outside coverage unavailable. Run `codex login` or set `$CODEX_API_KEY`." Fall back to the Claude subagent path.
- **`broken_install`** — the CLI is on PATH but cannot execute (spawn ENOENT, non-executable binary, missing vendor payload). Print: "Codex is installed but its binary cannot run — Codex passes skipped. Reinstall: `npm install -g @openai/codex`." Relay the probe's HINT lines. Fall back to the Claude subagent path.
- **`model_unusable`** — the selected model (see `CODEX_MODEL:`) is invalid or unavailable to the account (HTTP 400 on every call). Relay the probe's HINT lines and the fix (`GSTACK_CODEX_MODEL=<supported-model>` or config.toml `model`); never substitute a model. Fall back to the Claude subagent path. The ~10s round trip is cached for 1h.
- **`quota_exhausted`** — Codex usage/rate limit: relay its line verbatim (reset time), no more Codex calls. Fall back to the Claude subagent path.
- **`quota_exhausted`** — Codex usage limit: relay the probe's lines verbatim (reset time, retry); no more Codex calls this run. Fall back to the Claude subagent path.
- **`sandbox_unavailable`** — Codex's sandbox cannot start here (containers without user namespaces); the probe printed the reason and fix. No paid call ran; outside coverage is unavailable. Fall back to the Claude subagent path.
- **`ready`** or **`unverified`** — run the Codex pass below. `unverified` means the model check timed out; say so, and let the pass's own verdict decide.
- **`ready`** or **`unverified`** — run the Codex pass below. `unverified` means the model check timed out or, with `(rate_limited)`, hit a 429; say so, and let the pass's own verdict decide.
**Outcome routing:** Follow the row for the current result. After an invocation, route its result
again. Leave only after recording disabled/unavailable coverage, or after
@@ -326,6 +326,8 @@ else
_CODEX_MODE="model_unusable"
elif [ "${_GSTACK_CODEX_PROBE_STATE:-}" = inconclusive ]; then
_CODEX_MODE="unverified"
elif [ "${_GSTACK_CODEX_PROBE_STATE:-}" = rate_limited ]; then
_CODEX_MODE="unverified (rate_limited)"
else
_CODEX_MODE="ready"; _gstack_codex_version_check 2>/dev/null || true
fi
@@ -341,9 +343,9 @@ Branch on the echoed `CODEX_MODE`:
- **`not_authed`** — installed but no credentials. Print: "Codex not authenticated; outside coverage unavailable. Run `codex login` or set `$CODEX_API_KEY`." Fall back to the Claude subagent path.
- **`broken_install`** — the CLI is on PATH but cannot execute (spawn ENOENT, non-executable binary, missing vendor payload). Print: "Codex is installed but its binary cannot run — Codex passes skipped. Reinstall: `npm install -g @openai/codex`." Relay the probe's HINT lines. Fall back to the Claude subagent path.
- **`model_unusable`** — the selected model (see `CODEX_MODEL:`) is invalid or unavailable to the account (HTTP 400 on every call). Relay the probe's HINT lines and the fix (`GSTACK_CODEX_MODEL=<supported-model>` or config.toml `model`); never substitute a model. Fall back to the Claude subagent path. The ~10s round trip is cached for 1h.
- **`quota_exhausted`** — Codex usage/rate limit: relay its line verbatim (reset time), no more Codex calls. Fall back to the Claude subagent path.
- **`quota_exhausted`** — Codex usage limit: relay the probe's lines verbatim (reset time, retry); no more Codex calls this run. Fall back to the Claude subagent path.
- **`sandbox_unavailable`** — Codex's sandbox cannot start here (containers without user namespaces); the probe printed the reason and fix. No paid call ran; outside coverage is unavailable. Fall back to the Claude subagent path.
- **`ready`** or **`unverified`** — run the Codex pass below. `unverified` means the model check timed out; say so, and let the pass's own verdict decide.
- **`ready`** or **`unverified`** — run the Codex pass below. `unverified` means the model check timed out or, with `(rate_limited)`, hit a 429; say so, and let the pass's own verdict decide.
**Disabled is a terminal branch for this section.** If the preflight prints
`CODEX_MODE: disabled`, persist `outside_status: disabled` with the guarded
+4 -2
View File
@@ -832,6 +832,8 @@ else
_CODEX_MODE="model_unusable"
elif [ "${_GSTACK_CODEX_PROBE_STATE:-}" = inconclusive ]; then
_CODEX_MODE="unverified"
elif [ "${_GSTACK_CODEX_PROBE_STATE:-}" = rate_limited ]; then
_CODEX_MODE="unverified (rate_limited)"
else
_CODEX_MODE="ready"; _gstack_codex_version_check 2>/dev/null || true
fi
@@ -847,9 +849,9 @@ Branch on the echoed `CODEX_MODE`:
- **`not_authed`** — installed but no credentials. Print: "Codex not authenticated; outside coverage unavailable. Run `codex login` or set `$CODEX_API_KEY`." Fall back to the Claude subagent path.
- **`broken_install`** — the CLI is on PATH but cannot execute (spawn ENOENT, non-executable binary, missing vendor payload). Print: "Codex is installed but its binary cannot run — Codex passes skipped. Reinstall: `npm install -g @openai/codex`." Relay the probe's HINT lines. Fall back to the Claude subagent path.
- **`model_unusable`** — the selected model (see `CODEX_MODEL:`) is invalid or unavailable to the account (HTTP 400 on every call). Relay the probe's HINT lines and the fix (`GSTACK_CODEX_MODEL=<supported-model>` or config.toml `model`); never substitute a model. Fall back to the Claude subagent path. The ~10s round trip is cached for 1h.
- **`quota_exhausted`** — Codex usage/rate limit: relay its line verbatim (reset time), no more Codex calls. Fall back to the Claude subagent path.
- **`quota_exhausted`** — Codex usage limit: relay the probe's lines verbatim (reset time, retry); no more Codex calls this run. Fall back to the Claude subagent path.
- **`sandbox_unavailable`** — Codex's sandbox cannot start here (containers without user namespaces); the probe printed the reason and fix. No paid call ran; outside coverage is unavailable. Fall back to the Claude subagent path.
- **`ready`** or **`unverified`** — run the Codex pass below. `unverified` means the model check timed out; say so, and let the pass's own verdict decide.
- **`ready`** or **`unverified`** — run the Codex pass below. `unverified` means the model check timed out or, with `(rate_limited)`, hit a 429; say so, and let the pass's own verdict decide.
**Outcome routing:** Pick exactly one row from this table, finish that row's
steps, then leave Outside Voice. Missing reviewer coverage is non-blocking;
+4 -2
View File
@@ -49,6 +49,8 @@ else
_CODEX_MODE="model_unusable"
elif [ "${_GSTACK_CODEX_PROBE_STATE:-}" = inconclusive ]; then
_CODEX_MODE="unverified"
elif [ "${_GSTACK_CODEX_PROBE_STATE:-}" = rate_limited ]; then
_CODEX_MODE="unverified (rate_limited)"
else
_CODEX_MODE="ready"; _gstack_codex_version_check 2>/dev/null || true
fi
@@ -64,9 +66,9 @@ Branch on the echoed `CODEX_MODE`:
- **`not_authed`** — installed but no credentials. Print: "Codex not authenticated; outside coverage unavailable. Run `codex login` or set `$CODEX_API_KEY`." Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`broken_install`** — the CLI is on PATH but cannot execute (spawn ENOENT, non-executable binary, missing vendor payload). Print: "Codex is installed but its binary cannot run — Codex passes skipped. Reinstall: `npm install -g @openai/codex`." Relay the probe's HINT lines. Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`model_unusable`** — the selected model (see `CODEX_MODEL:`) is invalid or unavailable to the account (HTTP 400 on every call). Relay the probe's HINT lines and the fix (`GSTACK_CODEX_MODEL=<supported-model>` or config.toml `model`); never substitute a model. Keep the required Claude adversarial pass; do not dispatch a duplicate. The ~10s round trip is cached for 1h.
- **`quota_exhausted`** — Codex usage/rate limit: relay its line verbatim (reset time), no more Codex calls. Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`quota_exhausted`** — Codex usage limit: relay the probe's lines verbatim (reset time, retry); no more Codex calls this run. Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`sandbox_unavailable`** — Codex's sandbox cannot start here (containers without user namespaces); the probe printed the reason and fix. No paid call ran; outside coverage is unavailable. Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`ready`** or **`unverified`** — run the Codex pass below. `unverified` means the model check timed out; say so, and let the pass's own verdict decide.
- **`ready`** or **`unverified`** — run the Codex pass below. `unverified` means the model check timed out or, with `(rate_limited)`, hit a 429; say so, and let the pass's own verdict decide.
`CODEX_MODE: disabled` means skip the Codex passes ONLY.
`ready` and `unverified` run them; every other mode skips them with the printed reason.
+5 -3
View File
@@ -114,7 +114,7 @@ On any error: continue — ${feature} is informational, not a gate.`;
* Mode values: `disabled` (config off) | `helper_unavailable` (the probe could
* not be sourced; its own diagnostic is echoed) | `not_installed` | `not_authed` |
* `broken_install` | `sandbox_unavailable` | `model_unusable` | `quota_exhausted` |
* `unverified` | `ready`.
* `unverified` (echoed as `unverified (rate_limited)` after a probe-time 429) | `ready`.
* The path is host-rewritten at gen-skill-docs time (pathRewrites), so the
* literal `~/.claude/skills/gstack` is correct here and becomes `$GSTACK_ROOT`
* etc. for non-Claude hosts.
@@ -166,6 +166,8 @@ else
${m}="model_unusable"
elif [ "\${_GSTACK_CODEX_PROBE_STATE:-}" = inconclusive ]; then
${m}="unverified"
elif [ "\${_GSTACK_CODEX_PROBE_STATE:-}" = rate_limited ]; then
${m}="unverified (rate_limited)"
else
${m}="ready"; _gstack_codex_version_check 2>/dev/null || true
fi
@@ -181,9 +183,9 @@ Branch on the echoed \`CODEX_MODE\`:
- **\`not_authed\`** — installed but no credentials. Print: "Codex not authenticated; outside coverage unavailable. Run \`codex login\` or set \`$CODEX_API_KEY\`." ${nativeRoute}
- **\`broken_install\`** — the CLI is on PATH but cannot execute (spawn ENOENT, non-executable binary, missing vendor payload). Print: "Codex is installed but its binary cannot run — Codex passes skipped. Reinstall: \`npm install -g @openai/codex\`." Relay the probe's HINT lines. ${nativeRoute}
- **\`model_unusable\`** — the selected model (see \`CODEX_MODEL:\`) is invalid or unavailable to the account (HTTP 400 on every call). Relay the probe's HINT lines and the fix (\`GSTACK_CODEX_MODEL=<supported-model>\` or config.toml \`model\`); never substitute a model. ${nativeRoute} The ~10s round trip is cached for 1h.
- **\`quota_exhausted\`** — Codex usage/rate limit: relay its line verbatim (reset time), no more Codex calls. ${nativeRoute}
- **\`quota_exhausted\`** — Codex usage limit: relay the probe's lines verbatim (reset time, retry); no more Codex calls this run. ${nativeRoute}
- **\`sandbox_unavailable\`** — Codex's sandbox cannot start here (containers without user namespaces); the probe printed the reason and fix. No paid call ran; outside coverage is unavailable. ${nativeRoute}
- **\`ready\`** or **\`unverified\`** — run the Codex pass below. \`unverified\` means the model check timed out; say so, and let the pass's own verdict decide.`;
- **\`ready\`** or **\`unverified\`** — run the Codex pass below. \`unverified\` means the model check timed out or, with \`(rate_limited)\`, hit a 429; say so, and let the pass's own verdict decide.`;
}
/**
+4 -2
View File
@@ -49,6 +49,8 @@ else
_CODEX_MODE="model_unusable"
elif [ "${_GSTACK_CODEX_PROBE_STATE:-}" = inconclusive ]; then
_CODEX_MODE="unverified"
elif [ "${_GSTACK_CODEX_PROBE_STATE:-}" = rate_limited ]; then
_CODEX_MODE="unverified (rate_limited)"
else
_CODEX_MODE="ready"; _gstack_codex_version_check 2>/dev/null || true
fi
@@ -64,9 +66,9 @@ Branch on the echoed `CODEX_MODE`:
- **`not_authed`** — installed but no credentials. Print: "Codex not authenticated; outside coverage unavailable. Run `codex login` or set `$CODEX_API_KEY`." Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`broken_install`** — the CLI is on PATH but cannot execute (spawn ENOENT, non-executable binary, missing vendor payload). Print: "Codex is installed but its binary cannot run — Codex passes skipped. Reinstall: `npm install -g @openai/codex`." Relay the probe's HINT lines. Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`model_unusable`** — the selected model (see `CODEX_MODEL:`) is invalid or unavailable to the account (HTTP 400 on every call). Relay the probe's HINT lines and the fix (`GSTACK_CODEX_MODEL=<supported-model>` or config.toml `model`); never substitute a model. Keep the required Claude adversarial pass; do not dispatch a duplicate. The ~10s round trip is cached for 1h.
- **`quota_exhausted`** — Codex usage/rate limit: relay its line verbatim (reset time), no more Codex calls. Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`quota_exhausted`** — Codex usage limit: relay the probe's lines verbatim (reset time, retry); no more Codex calls this run. Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`sandbox_unavailable`** — Codex's sandbox cannot start here (containers without user namespaces); the probe printed the reason and fix. No paid call ran; outside coverage is unavailable. Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`ready`** or **`unverified`** — run the Codex pass below. `unverified` means the model check timed out; say so, and let the pass's own verdict decide.
- **`ready`** or **`unverified`** — run the Codex pass below. `unverified` means the model check timed out or, with `(rate_limited)`, hit a 429; say so, and let the pass's own verdict decide.
`CODEX_MODE: disabled` means skip the Codex passes ONLY.
`ready` and `unverified` run them; every other mode skips them with the printed reason.
+105 -6
View File
@@ -15,6 +15,11 @@
* affected user 30s + real tokens per section)
* - transient failure -> MODEL_PROBE_INCONCLUSIVE, FAIL-OPEN (exit 0),
* never cached
* - usage limit / insufficient_quota on a failed call -> MODEL_QUOTA_EXHAUSTED
* (exit 4), cached 15 min; GSTACK_CODEX_PROBE_RETRY=1
* skips the cache. A plain 429 -> MODEL_PROBE_RATE_LIMITED
* (exit 0, state rate_limited), never cached. Only
* Codex's trailing ERROR / stream error lines count.
* - config.toml mtime change invalidates a cached MODEL_OK and a cached
* MODEL_UNUSABLE (editing the pin IS the fix)
* - the probed model is the runtime selection (#2914): explicit request,
@@ -57,7 +62,18 @@ case "\${STUB_MODE:-ok}" in
echo "ERROR: 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 Oct 10th, 2026 2:55 AM." >&2
exit 1 ;;
ratelimit429) echo 'ERROR: {"type":"error","status":429,"error":{"type":"rate_limit_exceeded","message":"Rate limit reached"}}' >&2; exit 1 ;;
insufficientquota) echo 'ERROR: Quota exceeded. Check your plan and billing details. (insufficient_quota)' >&2; exit 1 ;;
quota429)
echo 'stream error: exceeded retry limit, last status: 429 Too Many Requests; retrying 1/5' >&2
echo 'ERROR: {"type":"error","status":429,"error":{"type":"insufficient_quota","message":"You exceeded your current quota"}}' >&2
exit 1 ;;
echoedquota)
printf 'user\nexplain why the usage limit and insufficient_quota (429) paths differ\n' >&2
echo 'ERROR: unexpected status 500 Internal Server Error' >&2
exit 1 ;;
quotaok) echo "OK (note: you are close to your usage limit)"; exit 0 ;;
ratelimitok) echo "OK"; echo "ERROR: rate limit warning from a retried request" >&2; exit 0 ;;
quotatimeout) echo "ERROR: You've hit your usage limit. Try again at 3 AM." >&2; exit 124 ;;
retired404)
echo 'ERROR: unexpected status 404 Not Found: The model \`gpt-5.2-codex\` does not exist or you do not have access to it., url: https://chatgpt.com/backend-api/codex/responses' >&2
exit 1 ;;
@@ -444,6 +460,7 @@ describe('B1: codex sandbox preflight and unverified readiness', () => {
['ok', 'ok', 'ready', 1],
['ok', 'model400', 'model_unusable', 1],
['ok', 'quota', 'quota_exhausted', 1],
['ok', 'ratelimit429', 'unverified (rate_limited)', 1],
] as const) {
const f = makeFixture();
try {
@@ -460,8 +477,11 @@ describe('B1: codex sandbox preflight and unverified readiness', () => {
});
});
describe('#3051: a Codex usage or rate limit is its own state, never "inconclusive; proceeding"', () => {
for (const mode of ['quota', 'ratelimit429'] as const) {
const QUOTA_LINE = "try again at Oct 10th, 2026 2:55 AM";
const DOCS = 'https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md';
describe('a Codex usage limit is quota_exhausted; a plain 429 is rate_limited', () => {
for (const mode of ['quota', 'insufficientquota', 'quota429'] as const) {
test(`${mode} -> MODEL_QUOTA_EXHAUSTED, exit 4, Codex's own line relayed, cached 15 min, event logged`, () => {
const f = makeFixture();
try {
@@ -469,24 +489,84 @@ describe('#3051: a Codex usage or rate limit is its own state, never "inconclusi
expect(r.stdout).toContain('MODEL_QUOTA_EXHAUSTED');
expect(r.stdout).toContain('rc=4');
expect(r.stdout).not.toContain('MODEL_PROBE_INCONCLUSIVE');
expect(r.stdout).not.toContain('RATE_LIMITED');
expect(r.stdout).not.toContain('MODEL_UNUSABLE');
if (mode === 'quota') expect(r.stdout).toContain('try again at Oct 10th, 2026 2:55 AM');
if (mode === 'quota') expect(r.stdout).toContain(QUOTA_LINE);
expect(r.stdout).toContain('gstack skips Codex for 15 minutes. Retry now: GSTACK_CODEX_PROBE_RETRY=1');
expect(r.stdout).toContain(`${DOCS}#codex-quota-exhausted`);
expect(fs.readFileSync(path.join(f.gstackHome, 'analytics', 'skill-usage.jsonl'), 'utf-8')).toContain('"event":"codex_quota_exhausted"');
const again = runProbe(f, 'ok', {}, '_gstack_codex_model_probe; echo "rc=$?"');
expect(again.stdout).toContain('MODEL_QUOTA_EXHAUSTED (cached)');
expect(again.stdout).toContain('rc=4');
if (mode === 'quota') expect(again.stdout).toContain('try again at Oct 10th, 2026 2:55 AM');
expect(again.stdout).toMatch(/gstack skips Codex for 1[45] more minute\(s\)\. Retry now: GSTACK_CODEX_PROBE_RETRY=1, or delete \S+\.codex-model-probe\./);
if (mode === 'quota') expect(again.stdout).toContain(QUOTA_LINE);
expect(invocations(f)).toBe(1);
} finally { fs.rmSync(f.home, { recursive: true, force: true }); }
});
}
test('a successful call that mentions a usage limit stays MODEL_OK', () => {
test('a plain 429 -> MODEL_PROBE_RATE_LIMITED: exit 0, state rate_limited, line relayed, never cached', () => {
const f = makeFixture();
try {
const r = runProbe(f, 'quotaok', {}, '_gstack_codex_model_probe; echo "rc=$?"');
const r = runProbe(f, 'ratelimit429', { _TEL: 'community' }, '_gstack_codex_model_probe; echo "rc=$? state=${_GSTACK_CODEX_PROBE_STATE:-}"');
expect(r.stdout).toContain('MODEL_PROBE_RATE_LIMITED — CODEX_MODE: unverified (rate_limited)');
expect(r.stdout).toContain('"status":429');
expect(r.stdout).toContain(`${DOCS}#codex-rate-limited`);
expect(r.stdout).toContain('rc=0 state=rate_limited');
expect(r.stdout).not.toContain('QUOTA');
expect(fs.readFileSync(path.join(f.gstackHome, 'analytics', 'skill-usage.jsonl'), 'utf-8')).toContain('"event":"codex_rate_limited"');
const again = runProbe(f, 'ok', {}, '_gstack_codex_model_probe; echo "rc=$?"');
expect(again.stdout).toContain('MODEL_OK');
expect(again.stdout).not.toContain('cached');
expect(invocations(f)).toBe(2);
} finally { fs.rmSync(f.home, { recursive: true, force: true }); }
});
test("an echoed prompt that names usage limit, insufficient_quota and 429 is not Codex's error", () => {
const f = makeFixture();
try {
const r = runProbe(f, 'echoedquota', {}, '_gstack_codex_model_probe; echo "rc=$? state=${_GSTACK_CODEX_PROBE_STATE:-}"');
expect(r.stdout).toContain('MODEL_PROBE_INCONCLUSIVE');
expect(r.stdout).toContain('rc=0 state=inconclusive');
expect(r.stdout).not.toContain('QUOTA');
expect(r.stdout).not.toContain('RATE_LIMITED');
} finally { fs.rmSync(f.home, { recursive: true, force: true }); }
});
for (const mode of ['quotaok', 'ratelimitok'] as const) {
test(`a successful call that mentions a limit (${mode}) stays MODEL_OK`, () => {
const f = makeFixture();
try {
const r = runProbe(f, mode, {}, '_gstack_codex_model_probe; echo "rc=$?"');
expect(r.stdout).toContain('MODEL_OK');
expect(r.stdout).toContain('rc=0');
} finally { fs.rmSync(f.home, { recursive: true, force: true }); }
});
}
test('a timeout that printed a usage-limit line keeps the inconclusive contract', () => {
const f = makeFixture();
try {
const r = runProbe(f, 'quotatimeout', {}, '_gstack_codex_model_probe; echo "rc=$? state=${_GSTACK_CODEX_PROBE_STATE:-}"');
expect(r.stdout).toContain('MODEL_PROBE_INCONCLUSIVE (exit 124)');
expect(r.stdout).toContain('rc=0 state=inconclusive');
expect(r.stdout).not.toContain('QUOTA');
} finally { fs.rmSync(f.home, { recursive: true, force: true }); }
});
test('GSTACK_CODEX_PROBE_RETRY=1 re-checks past a cached quota result without touching model, config or credentials', () => {
const f = makeFixture();
try {
runProbe(f, 'quota');
const before = ['config.toml', 'auth.json'].map(name => fs.statSync(path.join(f.codexHome, name)).mtimeMs);
expect(runProbe(f, 'ok', {}, '_gstack_codex_model_probe; echo "rc=$?"').stdout).toContain('MODEL_QUOTA_EXHAUSTED (cached)');
const r = runProbe(f, 'ok', { GSTACK_CODEX_PROBE_RETRY: '1' }, '_gstack_codex_model_probe; echo "rc=$?"');
expect(r.stdout).toContain('MODEL_OK');
expect(r.stdout).toContain('rc=0');
expect(invocations(f)).toBe(2);
expect(lastArgs(f)).toContain('model="gpt-5.4"');
expect(['config.toml', 'auth.json'].map(name => fs.statSync(path.join(f.codexHome, name)).mtimeMs)).toEqual(before);
expect(runProbe(f, 'ok', {}, '_gstack_codex_model_probe').stdout).toContain('MODEL_OK (cached)');
} finally { fs.rmSync(f.home, { recursive: true, force: true }); }
});
@@ -501,6 +581,25 @@ describe('#3051: a Codex usage or rate limit is its own state, never "inconclusi
expect(invocations(f)).toBe(2);
} finally { fs.rmSync(f.home, { recursive: true, force: true }); }
});
test('two fresh-shell preflight blocks in one run: the second makes no paid Codex call after a quota refusal', () => {
const block = codexPreflight({ disabledBehavior: 'skip-all' }).match(/```bash\n([\s\S]*?)\n```/)![1]!;
const f = makeFixture();
try {
fs.mkdirSync(path.join(f.home, '.claude', 'skills'), { recursive: true });
fs.symlinkSync(ROOT, path.join(f.home, '.claude', 'skills', 'gstack'));
const env = { PATH: `${f.stubDir}:${process.env.PATH ?? ''}`, HOME: f.home, CODEX_HOME: f.codexHome, GSTACK_HOME: f.gstackHome,
STUB_LOG: f.stubLog, STUB_ARGS_LOG: f.stubArgsLog, SANDBOX_FIXTURES };
const first = spawnSync('bash', ['-c', block], { encoding: 'utf8', timeout: 20000, env: { ...env, STUB_MODE: 'quota' } });
expect(first.stdout).toContain('CODEX_MODE: quota_exhausted');
expect(invocations(f)).toBe(1);
const second = spawnSync('bash', ['-c', block], { encoding: 'utf8', timeout: 20000, env: { ...env, STUB_MODE: 'ok' } });
expect(second.stdout).toContain('MODEL_QUOTA_EXHAUSTED (cached)');
expect(second.stdout).toContain(QUOTA_LINE);
expect(second.stdout).toContain('CODEX_MODE: quota_exhausted');
expect(invocations(f)).toBe(1);
} finally { fs.rmSync(f.home, { recursive: true, force: true }); }
});
});
describe('B2: a 404 model rejection is unusable, never ready (#2843)', () => {
+3 -3
View File
@@ -166,7 +166,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// wave's headline capability) grows the union to 1.195x. Deliberate:
// the section is on-demand (loads only for Apple store targets), so
// per-invocation cost for non-iOS ships is one manifest line.
maxSizeRatio: 1.434, // Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + A1 rework: the preflight's helper_unavailable branch (the sourced helper's own diagnostic with its doc URL); measured 269,141 / 187,706 = 1.4338 (2026-10-06).
maxSizeRatio: 1.435, // Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + A1 rework: the preflight's helper_unavailable branch (the sourced helper's own diagnostic with its doc URL); measured 269,141 / 187,706 = 1.4338 (2026-10-06). + A2 rework: the rate_limited preflight branch and the quota line's retry wording; measured 269,307 / 187,706 = 1.4347 (2026-10-06).
},
'plan-ceo-review': {
skill: 'plan-ceo-review',
@@ -221,7 +221,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// 1.08 → 1.10: the scope-gate exceptions block (+ its adversarial-review
// hardening: host-anchored mode signal, precedence, passing-mention
// guards) and the plan-mode preamble reword land the union at 1.092.
maxSizeRatio: 1.176, // + clarity rules for saved decisions/setup gates + the Aside probe's failure reason; measured 1.1504. + test value bar and Tests to Retire in the lazy Test review section (~2.6KB); measured 1.168 + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.173 (2026-09-30). + v1.91.12.0 merge of #2999 (headless rule: a disallowed question tool never qualifies) with #3002; measured 1.1741 (2026-10-01). + Oct 6 fix wave: quota_exhausted and helper_unavailable preflight branches; measured 146,404 / 124,597 = 1.1750 (2026-10-06).
maxSizeRatio: 1.177, // + clarity rules for saved decisions/setup gates + the Aside probe's failure reason; measured 1.1504. + test value bar and Tests to Retire in the lazy Test review section (~2.6KB); measured 1.168 + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.173 (2026-09-30). + v1.91.12.0 merge of #2999 (headless rule: a disallowed question tool never qualifies) with #3002; measured 1.1741 (2026-10-01). + Oct 6 fix wave: quota_exhausted and helper_unavailable preflight branches; measured 146,404 / 124,597 = 1.1750 (2026-10-06). + the rate_limited preflight branch; measured 146,570 / 124,597 = 1.1764 (2026-10-06).
},
'plan-design-review': {
skill: 'plan-design-review',
@@ -485,7 +485,7 @@ do not launch the downstream skill or open a browser.`,
maxSkeletonBytes: 74_881, // Shared-code identity/skip/action rules + critical-severity validation; measured 74,493 (2026-09-17). + v1.91.12.0 merge of #2999 (review clarity repairs: await reads, research alongside dispatch, /review deadline and setup authority, findings sources) with #3002 (guarded state-root lines, plan-check checkpoints); each fit alone; measured 74,881 (2026-10-01).
minUnionBytes: 89_000, // Phase 4 wave 1; measured union 93,357
mustContain: ['confidence', 'P1', 'P2', 'Review Army', 'adversarial'],
maxSizeRatio: 1.206, // Shared-code feature + critical-severity validation: 128,042 union bytes / 108,523 baseline = 1.1799; preserves content floors. + v1.91.12.0 merge of #2999 (above, plus plan-completion fallback intent and specialist checklist-by-path) with #3002; measured 1.1843 (2026-10-01). + v1.91.19.0 wave: verdict-form Codex callers with sandbox preflight (INV-1/B1), P0/P1 structured gate (B1b), bound-plan discovery and data-not-instructions guard (F1/D3), visible LEARNINGS failures (B5); measured 129,941 / 108,523 = 1.1974 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 130,025 / 108,523 = 1.1981 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051); measured 130,359 / 108,523 = 1.2012 (2026-10-06). + A1 rework: helper_unavailable preflight branch; measured 130,845 / 108,523 = 1.2057 (2026-10-06).
maxSizeRatio: 1.208, // Shared-code feature + critical-severity validation: 128,042 union bytes / 108,523 baseline = 1.1799; preserves content floors. + v1.91.12.0 merge of #2999 (above, plus plan-completion fallback intent and specialist checklist-by-path) with #3002; measured 1.1843 (2026-10-01). + v1.91.19.0 wave: verdict-form Codex callers with sandbox preflight (INV-1/B1), P0/P1 structured gate (B1b), bound-plan discovery and data-not-instructions guard (F1/D3), visible LEARNINGS failures (B5); measured 129,941 / 108,523 = 1.1974 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 130,025 / 108,523 = 1.1981 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051); measured 130,359 / 108,523 = 1.2012 (2026-10-06). + A1 rework: helper_unavailable preflight branch; measured 130,845 / 108,523 = 1.2057 (2026-10-06). + A2 rework: rate_limited preflight branch; measured 131,011 / 108,523 = 1.2072 (2026-10-06).
},
codex: {
skill: 'codex',
+57 -12
View File
@@ -9,6 +9,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { classifyOutsideReview, validateOutsideReview, type OutsideGate } from '../lib/outside-review-result';
import { gateOutcomeLine, type GateReason } from '../lib/gate-outcomes';
const ROOT = path.resolve(import.meta.dir, '..');
const LIB = path.join(ROOT, 'lib', 'outside-review-result.ts');
@@ -197,8 +198,11 @@ describe('B1: a review whose sandbox could not start is unavailable, not clean',
});
});
describe('#3051: a mid-run Codex usage limit reads as unavailable (quota), with Codex\'s own line', () => {
describe('a mid-run Codex usage limit is unavailable (quota_exhausted); a mid-run 429 is unavailable (rate_limited)', () => {
const LINE = "ERROR: 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 Oct 10th, 2026 2:55 AM.";
const RATE_429 = 'ERROR: {"type":"error","status":429,"error":{"type":"rate_limit_exceeded","message":"Rate limit reached"}}';
const QUOTA_429 = 'ERROR: {"type":"error","status":429,"error":{"type":"insufficient_quota","message":"You exceeded your current quota"}}';
const ECHOED = 'user\nCheck the usage limit banner and the insufficient_quota (429) retry path in this diff\n';
test('failed call with the usage-limit line -> unavailable, reason quota_exhausted, detail is the line', () => {
const result = classifyOutsideReview({ text: '', stderr: `Reading prompt from stdin...\n${LINE}\n`, exit: 1, gate: 'review' });
@@ -206,23 +210,64 @@ describe('#3051: a mid-run Codex usage limit reads as unavailable (quota), with
expect(result.detail).toBe(LINE);
});
test('a timeout whose partial stderr mentions a rate limit keeps the timeout reason', () => {
const result = classifyOutsideReview({ text: '', stderr: 'retrying after rate limit\n', exit: 124, gate: 'review' });
expect(result.reason).toBe('timeout');
test('failed call with a bare 429 -> unavailable, reason rate_limited, detail is the line', () => {
const result = classifyOutsideReview({ text: '', stderr: `${ECHOED}${RATE_429}\n`, exit: 1, gate: 'review' });
expect([result.verdict, result.reason, result.detail]).toEqual(['unavailable', 'rate_limited', RATE_429]);
expect(gateOutcomeLine('Codex outside review', 'rate_limited', result.detail)).toContain('https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#codex-rate-limited');
});
test('a 429 body that also names insufficient_quota is the quota (quota wins over rate limit)', () => {
const stderr = `stream error: exceeded retry limit, last status: 429 Too Many Requests; retrying 1/5\n${QUOTA_429}\n`;
expect(classifyOutsideReview({ text: '', stderr, exit: 1, gate: 'review' }).reason).toBe('quota_exhausted');
});
test('an echoed prompt that names usage limit, insufficient_quota and 429 stays execution_failed', () => {
const stderr = `${ECHOED}ERROR: unexpected status 500 Internal Server Error\n`;
expect(classifyOutsideReview({ text: '', stderr, exit: 1, gate: 'review' }).reason).toBe('execution_failed');
expect(classifyOutsideReview({ text: '', stderr: `ERROR: ${LINE.slice(7)}\nthen the session continued and failed\n`, exit: 1, gate: 'review' }).reason).toBe('execution_failed');
});
test('a timeout whose partial stderr has a usage-limit or rate-limit line keeps the timeout reason', () => {
for (const line of [LINE, RATE_429]) {
expect(classifyOutsideReview({ text: '', stderr: `${line}\n`, exit: 124, gate: 'review' }).reason).toBe('timeout');
}
});
test('a completed review that discusses rate limits is not unavailable', () => {
const text = '[P2] the client ignores the API rate limit header\nRecommendation: fix the retry loop because it ignores Retry-After';
const result = classifyOutsideReview({ text, stderr: '', exit: 0, gate: 'review' });
const result = classifyOutsideReview({ text, stderr: `${ECHOED}${RATE_429}\n`, exit: 0, gate: 'review' });
expect(result.execution.state).toBe('ran');
});
test('the probe and the classifier recognise the same Codex lines', () => {
const probe = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-codex-probe'), 'utf8').match(/_QUOTA_SIG="([^\n]+)"\n/)![1]!;
for (const line of [LINE, 'ERROR: {"type":"error","status":429,"error":{"type":"rate_limit_exceeded"}}', 'insufficient_quota: You exceeded your current quota']) {
const bash = spawnSync('bash', ['-c', `printf '%s' "$1" | grep -qiE "$2"`, '_', line, probe.replace(/\\"/g, '"')], { timeout: 5000 });
expect({ line, bash: bash.status }).toEqual({ line, bash: 0 });
expect(classifyOutsideReview({ text: '', stderr: line, exit: 1, gate: 'review' }).reason).toBe('quota_exhausted');
}
test('the probe and the classifier give the same outcome for the same Codex error output', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-quota-parity-'));
try {
const stubDir = path.join(home, 'bin');
const codexHome = path.join(home, '.codex');
fs.mkdirSync(stubDir);
fs.mkdirSync(codexHome);
fs.writeFileSync(path.join(codexHome, 'auth.json'), '{}');
fs.writeFileSync(path.join(stubDir, 'codex'), '#!/usr/bin/env bash\nprintf \'%s\' "$STUB_STDERR" >&2\nexit 1\n', { mode: 0o755 });
const cases: Array<[string, GateReason]> = [
[`${LINE}\n`, 'quota_exhausted'],
['ERROR: Quota exceeded. Check your plan and billing details.\n', 'quota_exhausted'],
[`${QUOTA_429}\n`, 'quota_exhausted'],
[`${RATE_429}\n`, 'rate_limited'],
['stream error: exceeded retry limit, last status: 429 Too Many Requests\n', 'rate_limited'],
[`${ECHOED}ERROR: unexpected status 500 Internal Server Error\n`, 'execution_failed'],
];
for (const [stderr, reason] of cases) {
fs.rmSync(path.join(home, '.gstack'), { recursive: true, force: true });
const probe = spawnSync('bash', ['-c', `source "${path.join(ROOT, 'bin', 'gstack-codex-probe')}" && _gstack_codex_model_probe`], {
encoding: 'utf8', timeout: 20_000,
env: { PATH: `${stubDir}:${process.env.PATH ?? ''}`, HOME: home, CODEX_HOME: codexHome, GSTACK_HOME: path.join(home, '.gstack'), STUB_STDERR: stderr, _TEL: 'off' },
});
const probeReason = probe.stdout.includes('MODEL_QUOTA_EXHAUSTED') ? 'quota_exhausted'
: probe.stdout.includes('MODEL_PROBE_RATE_LIMITED') ? 'rate_limited'
: probe.stdout.includes('MODEL_PROBE_INCONCLUSIVE') ? 'execution_failed' : `other: ${probe.stdout}`;
expect({ stderr, probe: probeReason }).toEqual({ stderr, probe: reason });
expect({ stderr, classifier: classifyOutsideReview({ text: '', stderr, exit: 1, gate: 'review' }).reason }).toEqual({ stderr, classifier: reason });
}
} finally { fs.rmSync(home, { recursive: true, force: true }); }
});
});