feat(routing): add provider cooldown failover and network recovery - #7
Draft
yury-procoders wants to merge 62 commits into
Draft
feat(routing): add provider cooldown failover and network recovery#7yury-procoders wants to merge 62 commits into
yury-procoders wants to merge 62 commits into
Conversation
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
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
marked this pull request as ready for review
August 4, 2026 12:56
yury-procoders
marked this pull request as draft
August 4, 2026 16:00
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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.
next_retry_atis returnedno_responseevidenceno_responseinside 60 secondshalt / no next_retry_atdeadlockTemporary 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
Verification
--selftestentrypoints passed on the final commit.124 ok, 0 fail.75 checked, 0 failed.8 ok, 0 fail.39 ok, 0 fail.git diff --checkpassed.Non-goals