Skip to content

feat(routing): add provider cooldown failover and network recovery - #7

Draft
yury-procoders wants to merge 62 commits into
procoders:mainfrom
yury-procoders:feat/rate-limit-rerouting
Draft

feat(routing): add provider cooldown failover and network recovery#7
yury-procoders wants to merge 62 commits into
procoders:mainfrom
yury-procoders:feat/rate-limit-rerouting

Conversation

@yury-procoders

@yury-procoders yury-procoders commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds provider-aware failure classification and deterministic rerouting for the tier model pool. The dispatcher now distinguishes transient provider pressure, permanent provider failures, model-local failures, and correlated connectivity loss, then persists the resulting cooldown/circuit/probe state across resume.

This is a stacked PR and must merge after:

  1. feat(zai): a headless GLM worker backend #5 — z.ai backend
  2. feat(pool): deterministic weighted tier model pools #6 — tier/model pool
  3. this PR — rate-limit and failure rerouting

The implementation branch intentionally contains both prerequisites so the three-provider integration can be tested as one executable state machine. Please review/merge in the order above.

Three-provider failure scenarios covered

The default pool can involve Codex, Claude, and z.ai. The same rules apply to other explicitly configured backends.

Scenario Effect on the failed provider Effect on the rest of the pool Liveness guarantee
First rate-limit/overload/timeout Short bounded retry of the same assignment No provider is excluded yet Foreground delay is capped at 60 seconds
Second transient failure Persist backend cooldown and advance the frozen ring Healthy providers remain eligible No busy wait; an absolute next_retry_at is returned
Long provider reset window Cooldown keeps the provider out until its real reset timestamp Work reroutes to another healthy provider A multi-hour cooldown cannot block the process/thread
Cooldown expires One real job receives a half-open probe lease Competing jobs remain blocked from probing that provider Exactly one probe; lease expiry requires liveness reconciliation
Auth failure Permanent circuit opens for that backend Other providers may continue No automatic auth retry loop
Out of credits Permanent circuit opens; bounded ring advances Explicit healthy fallback may run after ring exhaustion Open circuits and active cooldowns cannot be bypassed by fallback
Model unavailable Excludes only the exact backend/model pair Another model on the same backend or another provider can run Does not unnecessarily disable the whole provider
One true connection/DNS/TLS/reset failure Records causal no_response evidence Other providers still run One provider cannot declare the internet down
Two distinct providers fail with no_response inside 60 seconds Opens a run-wide network pause All provider launches pause After expiry, exactly one real-job recovery probe is leased
A provider succeeds in the same 60-second window Success vetoes stale outage evidence Pool remains operational Prevents a false global pause during partial provider trouble
z.ai error 1234 / provider-reported network failure Treated as provider-local evidence, not proof of internet loss Other providers remain eligible Cannot trigger the correlated offline pause
Internet returns after a global pause Global recovery probe has priority over a simultaneous expired provider cooldown Successful probe clears the run pause; provider cooldown remains independently tracked Prevents the halt / no next_retry_at deadlock
Retry budget exhausted or state is replayed after a crash Atomic, attempt-id-based ledger rejects duplicate, unknown, malformed, and future charges No extra provider launch is minted Resume is idempotent and fails closed on poisoned state

Temporary internet loss

A transient local outage is deliberately handled more conservatively than a single provider outage. The run pauses only after two distinct providers produce real no-response failures in the same batch and 60-second correlation window. Provider-formatted errors do not count. A completed success within that window is a live veto. When the pause expires, one real job—not a synthetic request—is leased as the recovery probe. Probe failure renews the pause; probe success clears it. This prevents fan-out storms while ensuring the run cannot remain indefinitely halted without a retry timestamp or a leased recovery attempt.

Cooldown and fallback interaction

Cooldowns are backend-wide by design: subscription/provider quota signals generally do not prove that a second model on the same account has independent capacity. This may be conservative, but it avoids multiplying requests against a shared quota. Model-unavailable remains exact-model scoped. Explicit fallback assignments are rechecked against the target backend's circuit and cooldown; an expired cooldown receives a single half-open lease. If global network recovery and provider recovery become eligible simultaneously, the global network probe wins and the provider cooldown probe stays idle.

Implementation

  • Adds strict UTC timestamp parsing and canonical persisted cooldowns.
  • Adds normalized failure classification for Codex, Claude, and z.ai, including exact official z.ai message variants.
  • Adds bounded retry/reroute policy with a run-level retry ledger.
  • Adds frozen-ring selection, circuit breakers, half-open leases, correlated network evidence, and crash-safe transition validation.
  • Wires result collection, status/dashboard rendering, resume behavior, schemas, routing docs, runbooks, spec, audits, plan, and execution manifest.
  • Keeps non-pool jobs on their existing backend; only foreground waiting is capped so a long reset cannot hang the orchestrator.

Verification

  • All 36 Python --selftest entrypoints passed on the final commit.
  • Pool state machine: 124 ok, 0 fail.
  • Dashboard: 75 checked, 0 failed.
  • z.ai wire smoke: 8 ok, 0 fail.
  • z.ai worker stub: 39 ok, 0 fail.
  • Advisor-worker and path-transport shell integration suites passed.
  • Shell syntax, execution manifest validation, and git diff --check passed.
  • Negative controls were exercised: mutations to exact z.ai codeless classification, retry cap, pool-state invariants, and dead-link validation were detected by their checks.
  • Independent final review: approved with no remaining Critical/Important findings.

Non-goals

  • No quota-aware load balancing or adaptive weights.
  • No resizing/reordering of a frozen pool during a run.
  • No automatic recovery from auth failures without explicit reconciliation.
  • No use of provider marketing/status text as proof of local internet loss.

Adds `zai` as a sixth dispatch backend so codex, claude and z.ai can run
concurrently on separate quotas. v1 role is worker + rate-limit fallback;
never a reviewer, never an arbiter seat.

Mechanism: Bash-spawned `claude -p --bare` in a git worktree, under the
process-group supervisor, with the dispatcher's provider credentials
scrubbed via `env -i` and z.ai's Anthropic-compatible endpoint injected.
Enforcement stays the caller's git-derived scope gate.

Grounded by live probes (claude 2.1.207, codex-cli 0.144.4, real Coding
Plan key): the redirect works, z.ai validates model names, the agentic
tool loop completes, `session_id` is a real UUID, and codex cannot reach
z.ai at all (it requires the Responses API, which z.ai does not expose).
The first draft's `--allowedTools` list did not do what it claimed. Measured
against the real binary: `--tools` decides which tools exist, `--allowedTools`
decides which run unprompted. With only the latter the worker had `Bash` (which
the draft said was withheld) and no `Write` at all — and under `--bare` `Write`
cannot be restored, so a bare worker cannot create a file.

Replaces `--bare` with HOME/CLAUDE_CONFIG_DIR redirection to a scratch dir plus
`--tools` + `--allowedTools` + `--exclude-dynamic-system-prompt-sections`. That
is the only measured configuration that both gives the worker `Write` and keeps
the cacheable prefix byte-identical across worktrees: 4 tools, ~3.3k tokens per
request, 2.30 credits per job.

Also corrected from the audits: real z.ai 429 codes replace "shapes unobserved";
an explicit classifier branch is mandatory or GLM errors silently get OpenAI's
needle set; `failure-policy.py` needs a FALLBACK entry or a credit wall halts the
run; `parallel-dispatcher.md`'s adapter table was missing from the file list; two
cited CI gates do not exist and must be added; `light` stays glm-5-turbo on a
head-to-head measurement (16% faster, and glm-4.7 only 7% cheaper, not 32%).

Adds the three pre-flight audits and their knowledge-base files.
light is glm-5-turbo on a head-to-head measurement, not on the multiplier
table: turbo 8.5s/2.56 credits vs glm-4.7 10.1s/2.38 — 16% faster for 7% more,
because glm-4.7 emits ~60% more output and eats its own lower multiplier.

Also locks the property that registering a backend does not widen advisor
eligibility: zai has no consult adapter and must never be selected.
…o codex rules

classify() ends with `else: rules = _CODEX_RULES`, so registering zai without
its own branch would classify an unrecognised GLM error with OpenAI's needles —
verified: 'please run `codex login`' came back as auth. It is now 'other'.

The CLI's --backend choices still omit devin and opencode, which cannot call
this script at all. Pre-existing; deliberately untouched here.
…of halting

A missing FALLBACK key reads as None, which decide() turns into halt — the
first credit wall would have stopped the whole run. Verified before the fix:
action=halt.

devin and opencode are still missing from this table and still halt.
Pre-existing; left untouched here.
… cost

The counts come from z.ai's own response, so zai stays OUT of
UNMEASURED_BACKENDS. total_cost_usd and modelUsage[*].costUSD are computed from
Anthropic's price table for a model that never ran and are never carried —
job_result.usage has no cost field to hold one.
…e assertion

The stub test caught two real defects before either could ship:

  * the worker never entered the worktree. claude has no --cd equivalent
    (contrast codex exec --cd), so it would have edited files in the launcher's
    cwd while the gate diffed an untouched worktree — a job that changed nothing
    where it was supposed to, waved through as clean.

  * a BLOCKED job crashed the worker. The scope gate signals its verdict through
    the exit code (0 clean, 1 blocked, 2 gate fault) and the worker treated any
    non-zero as fatal, so every out-of-scope write died instead of returning a
    blocked job_result.

It also forced a fix in the usage extractor: --output-format json emits one JSON
DOCUMENT, and line-wise parsing silently yields nothing the moment that document
is pretty-printed. It now parses the document first and falls back to JSONL.
The stub test validates argv; the defect that broke the first draft passed every
argv assertion because the argv was exactly as intended. This runs the REAL
claude binary against a local stub HTTP server — no network, no key, no quota —
and asserts the tool array actually sent, the Bearer header, the absence of
x-api-key, that git status is out of the cached system block, and that the tools
and system blocks are byte-identical across two different worktrees (the
property that makes the prefix cacheable across parallel workers).

SKIPs cleanly when claude is not on PATH.
The dispatcher's adapter table is load-bearing — without a row nothing maps
backend: zai to its worker script. devin and opencode are still absent from
that table; pre-existing, not fixed here.
Neither gate existed. shellcheck covered only hooks/*.sh, leaving the worker
scripts — the largest shell surface in the repo — with no static analysis at
all; and no step ran any scripts/test-*.sh, so every bash-level proof was
manual-only.

Turning both on found four issues, all in the new zai files, and none in the
pre-existing scripts. --read-only now does something real (it empties the
allow-file, so a read-only job that writes anything is BLOCKED) instead of being
accepted and forgotten.
The new shellcheck gate over scripts/*.sh failed on the runner: it has 0.9.0,
which reports SC2015 where 0.11.0 (used locally) stays quiet. Two of the three
sites are pre-existing files the gate had never looked at before — which is the
point of adding it.

Semantics are unchanged in all three: each meant 'if NOT (A and B) then C', which
is exactly what SC2015 warns may be misread.
…(PR 2 of 3)

Lets one tier name several (backend, model) pairs and hands successive jobs to
them in turn, so a run burns three providers' quotas evenly instead of draining
one. Purely additive: a config with no pools key behaves exactly as today.

Round-robin decides at FIRST dispatch and the choice is frozen into state.json,
so /v:resume re-dispatches to the recorded backend rather than re-deriving it —
otherwise an interrupted job could come back on a different backend with
different isolation and a different worktree. The one exception is a quota
failure, where moving the job is the point.

Independent of the zai backend; zai just becomes an eligible pool member.
The pool spec is cut from main and linked the zai design doc, which lives only
on feat/zai-backend. The gate is line-based and does not respect inline code
spans, so even quoting the path in backticks trips it — the branch dependency is
now stated in prose instead.

Adds the archaeology and library audits.
Four blocking defects in the first draft, two of them mine and inverted:

  * the justification for skipping quota-aware balancing named the wrong
    backend. z.ai is the MOST measurable of the three; the genuinely blind one
    is claude, which this repo already lists in UNMEASURED_BACKENDS. The real
    reason is architectural — Compound V never speaks HTTP to a provider, it
    reads a CLI's stdout, so no provider header ever reaches the dispatcher.

  * the draft would have introduced the policy regression its own Non-goals
    promised to avoid: the never-Haiku execution-layer gate fires on a job's
    explicit model, and a pool job carries none, so the gate became unreachable.
    It now runs on the RESOLVED model.

claude is excluded from pools by default: Anthropic shares usage limits between
Claude and Claude Code, so a claude pool member competes with the operator's own
session. Integer weights (default 1) ship now — they are the only way to express
a reduced share, and weighted rotation is what comparable routers actually do.

Assignment is computed from MANIFEST order, not dispatch order, and the member
list is frozen at run start; an unavailable member is skipped with the counter
still advancing, so a shrinking pool cannot shift later assignments.

Corrected: rate_limited retries the same backend and never reroutes; only
out_of_credits reroutes, and today always to claude — which under a pool would
dump an exhausted run onto the operator's subscription. Availability has no
mechanism in this repo at all (removed in v2.6.2), so the spec now defines it
narrowly instead of assuming it. PR 1 is a declared merge prerequisite.

Adds the domain audit and its knowledge-base file.
Six rounds between the archaeologist and the doc-validator; real errors found in
both directions. Two practices came out of it and are now in the knowledge base:
report a check's denominator and a negative control that can fail (a broken
harness and a clean repo both print zero), and re-run a gate after editing any
doc that describes it — explaining a construct requires reproducing it, which is
how a dead link landed inside the paragraph warning against dead links.
The allow-list used to be written to a predictable path next to the worktree
($WT.art/write_allowed.globs) before the child started and read back after it
exited. The child has Bash + Write and no kernel confinement, so it could
overwrite that file before the gate read it — reproduced end to end: an
out-of-scope write plus an appended `**` to the allow-file came back as a
clean success.

The list is now built as repeated --allow arguments in the parent shell and
passed directly to compound-v-scope-check.py, which already supported --allow
alongside --allow-file. No file on disk sits between the worker and the gate
for zai to tamper with. Every other worker still uses the file-based allow-
list; the pattern is inherited across all six, and fixing it repo-wide is a
separate, larger change.
Adds the review that drove the previous commit (CONFIRMED/PLAUSIBLE/REJECTED
per finding, merge verdict, and the applied-fixes table) plus the original
handoff brief it was run against.
@yury-procoders
yury-procoders marked this pull request as draft August 1, 2026 19:12
…s/ dir

- compound-v-pool-state.py: validate_state() compared job.get("backend")
  != "pool" directly instead of via _normalized_backend(), the third spot
  the prior review fix (199e794) missed alongside manifest_pool_ordinals
  and freeze_assignments. A job with backend "Pool" (mixed case) now hits
  the same resume-safety gate as the lowercase form.
- SKILL.md: list docs/superpowers/reviews/ in Output Directory
  Conventions — introduced by this branch's own adversarial self-review
  but never added to the canonical directory list.
This branch (PR procoders#7) forked from tier-model-pool (PR procoders#6) two commits
before PR procoders#6's own adversarial-review fixes landed, so PR procoders#7 was
missing them. Reconciles both branches:

- decide()'s earliest_reset_seconds now gates to the terminal halt
  action only (was leaking onto ordinary retry/reroute results),
  matching PR procoders#6's review fix ported onto the new pool-agnostic
  decide() signature (pool_routed bool instead of pool_members list).
- context_length tier-escalation now sets clear_assignment for
  pool-routed jobs, so a stale pool_index isn't left behind when a
  job leaves the ring — same fix, ported the same way.
- validate_state()'s "pool" backend comparison uses the shared
  _normalized_backend() helper (was comparing the raw string
  directly), the third spot the original review fix missed.
- skills/compound-v/failure-policy.md: PR procoders#7 rewrote this doc's
  cooldown/network-pause model; folded back PR procoders#6's escalation/
  non-pool-fallback facts and the anti-patterns section (including
  the pool retry-budget footgun) that the rewrite had dropped, since
  those aren't duplicated anywhere else.
- compound-v-dashboard.py: merged both branches' fixture/assertion
  additions (PR procoders#7's cooldown-state rendering checks, PR procoders#6's pool
  backend-cell check) into one fixture; had to add a matching
  pool_members ring entry for the added pool job, which PR procoders#7's
  now-stricter validate_state() call requires.
- compound-v-pool-state.py: updated a PR procoders#6 selftest ("an available
  member that fails concrete resolution") that used "zai" as a
  stand-in for an unresolvable backend — PR procoders#7 has since given zai a
  real resolver entry, so the scenario the test needs no longer
  reproduces with that name. Swapped in a synthetic backend name
  that stays genuinely unresolvable regardless of future backends.

All affected selftests pass: compound-v-pool-state.py (128/0),
compound-v-failure-policy.py (59/0), compound-v-dashboard.py
(77/0), compound-v-validate-manifest.py (145/0),
compound-v-classify-failure.py (114/0), compound-v-resolve-model.py
(PASSED).
… review

An independent Codex spec-compliance review (read-only, xhigh) flagged
merge residue and two functional gaps in the cooldown/failover layer.

Merge residue (no behavior change):
- agents/parallel-dispatcher.md: dropped the old "prerequisite PR 1"
  zai placeholder row now that the real one exists — two rows for the
  same backend, one of them outside the table.
- compound-v-pool-state.py: collapsed a dead `"complete" if changed
  else "complete"` conditional (both branches identical).
- Untracked 5 committed __pycache__/*.pyc files and added the same
  bytecode-ignore block already carried on feat/zai-backend.

Real gaps:
- compound-v-failure-policy.py: earliest_reset_seconds was gated on
  ANY halt action, not specifically an exhausted-pool/exhausted-backend
  one. auth, model_unavailable, and deepest-tier context_length halts
  are structural (no reset time is meaningful), and network-pause/
  usage-window/long-wait halts already carry their own next_retry_at.
  Only budget_exhausted(), the out_of_credits-no-fallback halt, and
  the per-class-exhaustion halt genuinely mean "retry in about this
  long" — narrowed to just those three call sites.
- compound-v-pool-state.py: decide()'s clear_assignment=True for a
  pool job escalating past context_length was emitted but never
  consumed. escalate_tier isn't advance_pool, so transition_state()
  took the no-launch path and returned state_updated while leaving
  the job's stale pool_index/assigned_backend/assigned_model in
  place — exactly the state pool freeze is supposed to prevent.
  transition_state() has no ring-selection or model-resolution
  authority of its own (by design — that's the dispatcher's
  compound-v-resolve-model.py call), so it now requires the caller to
  supply an already-resolved fallback_assignment alongside
  clear_assignment and applies it as an ordinary assignment_source:
  fallback pair; a clear_assignment with no fallback_assignment is now
  a loud ValueError instead of a silent no-op. Documented the
  fallback_assignment requirement in skills/compound-v/failure-policy.md
  next to the rest of the escalation contract.

All affected selftests pass: compound-v-pool-state.py (130/0),
compound-v-failure-policy.py (65/0), compound-v-dashboard.py (77/0),
compound-v-validate-manifest.py (145/0), compound-v-classify-failure.py
(114/0), compound-v-resolve-model.py (PASSED).
@yury-procoders
yury-procoders marked this pull request as ready for review August 4, 2026 12:56
@yury-procoders
yury-procoders marked this pull request as draft August 4, 2026 16:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant