Skip to content

orchestrator: codex is quota-walled until 2026-08-20, so both reviewers are one claude-opus-5 — restore review independence by model when the latch is armed #197

Description

@melihucar

Gate: bootstrap
Agent: claude

Found by retrospective 34 (orchestrator/logs/retro-34.md, Finding 1).

This is not #126. #126 asks for the quota latch to be re-probeable and
expiring, on the evidence that a previous firing was transient. Today's firing
is not transient and a re-probe would correctly re-latch. This issue is about
what the loop should do while the latch is right.

Evidence

codex hit a real quota wall at 2026-08-15T09:17:00Z, on #13's impl.1.
orchestrator/logs/13/codex.impl.1.log ends:

error: you've hit your usage limit. upgrade to pro (...), visit
https://chatgpt.com/codex/settings/usage to purchase more credits or
try again at aug 20th, 2026 11:42 am.

Five days out. review_diversity_lost has fired four times over the run
(2026-08-13T02:31:03Z, 2026-08-14T10:08:35Z, 2026-08-14T19:06:57Z,
2026-08-15T09:17:00Z).

REVIEWER_AGENTS = {1: "claude", 2: "codex"} (orchestrator/loop.py:990).
Once codex_ok() is false, resolve() (orchestrator/loop.py:232-237) sends
slot 2 down the identical final line as slot 1:

def resolve(agent: str, role: str, escalate: bool = False) -> tuple[str, str | None]:
    if agent == "codex" and codex_ok() and not escalate:
        return "codex", None
    if escalate:
        return "claude", ESCALATED_MODEL          # == "claude-opus-5"
    return "claude", MODELS.get(role, MODELS["implementer"])

MODELS["reviewer"] is claude-opus-5 and ESCALATED_MODEL is
claude-opus-5, so there is no code path that gives the two reviewers
different weights.
review_stage() states the cost itself
(orchestrator/loop.py:1271-1273):

Cross-model on purpose: two instances of one model reviewing a diff behave
closer to one reviewer than to two. Once codex is out of quota both become
Opus, losing vendor diversity but keeping independent contexts.

Every issue merged since the latch armed was reviewed by two claude-opus-5
instances. Verified on disk, not inferred:

$ for i in 13 166 163 168 165; do ls orchestrator/logs/$i/ | grep -E 'review2\.'; done
# claude.review2.*.log for all five; no codex.review2.* anywhere

$ ls orchestrator/logs/*/codex.review2.*.log
# 11 issues in the entire run ever had a codex reviewer:
# 5, 8, 9, 10, 11, 20, 39, 72, 79, 80, 106

$ ls orchestrator/logs/*/[a-z]*.review2.*.log | sed 's|.*/||; s|\..*||' | sort | uniq -c
  69 claude
  48 codex

Blast radius on the queue, not just the reviewers

$ gh issue list --state open --limit 300 --json body \
    --jq '[.[] | .body // "" | capture("(?im)^\\s*Agent:\\s*(?<a>codex|claude|duel)").a]
          | group_by(.) | map({a:.[0], n:length}) | .[] | "\(.a)\t\(.n)"'
claude  72
codex   47
duel     5
$ # plus 13 open issues with no Agent line, which default to codex

65 of 137 open issues (47%) name an agent that cannot run for the rest of the
run.
And because resolve("codex", role) and resolve("claude", role) now
return the same ("claude", MODELS[role]) pair, Agent: codex and
Agent: claude are indistinguishable — the planner's routing decision, the
retrospective's routing decision, and the whole duel alternation carry zero
information while the latch is armed. Nothing says so anywhere; the issue bodies
still read as if a vendor was chosen.

What to change

orchestrator/loop.py only. One behaviour: when the codex slot falls back, give
it a different model rather than a second copy of reviewer 1.

  1. Add a constant next to MODELS (orchestrator/loop.py:99-107):

    # The stand-in for codex once the quota latch is armed. A DIFFERENT
    # generation, not the same weights twice: review_stage()'s own comment is
    # that two instances of one model behave closer to one reviewer than to two,
    # and that is the check the merge path rests on.
    FALLBACK_MODEL = os.environ.get("FR_MODEL_FALLBACK", "claude-opus-4-8")
  2. In resolve(), return it when standing in for codex and not escalating:

    if agent == "codex":            # reached only when codex_ok() is False
        return "claude", FALLBACK_MODEL

    Keep the escalate branch ahead of it — escalation is a deliberate reach for
    the strongest model and must not be downgraded.

  3. disable_codex() (orchestrator/loop.py:216-228) already records
    review_diversity_lost. Add the model actually substituted to that record —
    record("review_diversity_lost", reason=reason, fallback=FALLBACK_MODEL)
    so a later retrospective can tell "lost vendor diversity, kept model
    diversity" from "lost both".

Acceptance criteria

  • A test in orchestrator/check_orchestrator.py that pins the invariant
    directly: with _codex_disabled = True, resolve("claude", "reviewer") and
    resolve("codex", "reviewer") return different model strings. It must
    fail if either the constant is set equal to MODELS["reviewer"] or the new
    branch is deleted.
  • A test that resolve("codex", "reviewer", escalate=True) still returns
    ESCALATED_MODEL — escalation outranks the fallback.
  • bash scripts/gate.sh bootstrap passes, including orchestrator-runnable.

Out of scope

Metadata

Metadata

Assignees

No one assigned

    Labels

    fr:metaImprovement to the loop itselffr:p2Normal -- the default when untriagedfr:readyClaimable by an agent

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions