@caddis/cli 0.4.55 → 0.4.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/bundles/antigravity-plugin/caddis_statusline.py +7 -3
  2. package/bundles/antigravity-plugin/guard_agy.py +1056 -83
  3. package/bundles/antigravity-plugin/hooks.json +2 -1
  4. package/bundles/antigravity-plugin/plugin.json +1 -1
  5. package/bundles/antigravity-plugin/repo_freshness.py +182 -4
  6. package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +20 -12
  7. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +63 -12
  8. package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +14 -5
  9. package/bundles/antigravity-plugin/scripts/caddis_gate.py +27 -1
  10. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +93 -6
  11. package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +67 -22
  12. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +8 -2
  13. package/bundles/antigravity-plugin/scripts/caddis_review.py +290 -176
  14. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +346 -50
  15. package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +82 -8
  16. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +89 -24
  17. package/bundles/antigravity-plugin/scripts/caddis_statusline.py +7 -3
  18. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +10 -6
  19. package/bundles/antigravity-plugin/scripts/caddis_todo.py +7 -1
  20. package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +12 -5
  21. package/bundles/antigravity-plugin/scripts/caddis_worktree_safety.py +182 -0
  22. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +2 -0
  23. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +6 -2
  24. package/bundles/antigravity-plugin/scripts/no_window.py +49 -0
  25. package/bundles/antigravity-plugin/scripts/oss_review.py +303 -79
  26. package/bundles/antigravity-plugin/scripts/secret_filter.py +190 -22
  27. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +6 -1
  28. package/bundles/antigravity-plugin/session_end_agy.py +27 -11
  29. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +55 -9
  30. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +11 -8
  31. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +1 -1
  32. package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +10 -6
  33. package/bundles/antigravity-plugin/skills/git-commit/SKILL.md +2 -3
  34. package/bundles/antigravity-plugin/skills/implement/SKILL.md +31 -17
  35. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +1 -1
  36. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +45 -5
  37. package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +4 -2
  38. package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +13 -4
  39. package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +28 -6
  40. package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +16 -13
  41. package/bundles/antigravity-plugin/skills/ship/SKILL.md +32 -10
  42. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +32 -1
  43. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +44 -6
  44. package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +1 -1
  45. package/bundles/antigravity-plugin/warm_start_agy.py +40 -16
  46. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  47. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
  48. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +1 -1
  49. package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +2 -1
  50. package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
  51. package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +4 -2
  52. package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +10 -6
  53. package/bundles/codex/.codex/skills/devops/git-commit/SKILL.md +2 -3
  54. package/bundles/codex/.codex/skills/docs/writing-plans/SKILL.md +1 -1
  55. package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +28 -6
  56. package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +16 -13
  57. package/bundles/manifest.json +3 -3
  58. package/package.json +1 -1
@@ -29,12 +29,22 @@ Exit codes (fail-closed):
29
29
  0 REVIEW: CLEAN — no blocking issues
30
30
  1 REVIEW: BLOCKING — one or more blocking issues
31
31
  2 unused — advisory is not a review verdict
32
- 3 misconfigured — REVIEW_API_KEY missing, OR the pre-send secret filter refused (a
33
- denylisted path / possible secret; see secret_filter.py) — actionable
34
- message on stderr/stdout either way
35
- 4 error — no diff verdict parsed, git failure, endpoint/parse failure, or the diff
32
+ 3 could not run — REVIEW_API_KEY missing, OR the pre-send secret filter refused (a
33
+ denylisted path / possible secret; see secret_filter.py), OR every
34
+ provider failed with a PROVIDER failure: HTTP 429, any 5xx, a timeout, a
35
+ connection error, an empty reply, an agy error status, or the agy spend
36
+ cap refused — actionable message on stderr/stdout either way
37
+ 4 error — no diff verdict parsed, git failure, other endpoint/parse failure, or the diff
36
38
  exceeds REVIEW_MAX_DIFF_CHARS (see below — never silently downgraded to CLEAN)
37
39
 
40
+ Provider chain (owner policy, 2026-10-16): deepseek -> agy-argon -> glm. A named --provider goes first,
41
+ then the rest in policy order; a provider with no key (or no agy on PATH) is skipped. A provider failure
42
+ moves to the next provider and never splits the diff. On HTTP 429 a `Retry-After` is honoured ONCE per
43
+ provider (capped at 60 s), then the next provider is tried. A provider that failed gets no more calls in
44
+ the same run. agy runs through `caddis_spend.py run` (the EUR spend cap), in plan mode, with the diff in
45
+ the prompt (no tools needed). The secret filter runs once, before any provider, agy included. The
46
+ provider that gave each verdict is printed.
47
+
38
48
  Diff-size ceiling: an oversized diff sent to a chat-completions endpoint has been observed, live, to
39
49
  come back two different unsafe ways — an empty `content` field on an HTTP 200 (at least fails closed:
40
50
  no verdict line ⇒ exit 4), and, worse, a `REVIEW: CLEAN` verdict with no substantive engagement (a
@@ -54,8 +64,11 @@ import argparse
54
64
  import http.client
55
65
  import json
56
66
  import os
67
+ import shutil
68
+ import signal
57
69
  import subprocess
58
70
  import sys
71
+ import time
59
72
  import urllib.error
60
73
  import urllib.request
61
74
  from pathlib import Path
@@ -88,8 +101,9 @@ PROVIDERS: dict[str, dict[str, str]] = {
88
101
  }
89
102
  DEFAULT_PROVIDER = "deepseek"
90
103
 
91
- # Tried, in order, ONLY when the primary provider fails at transport level (timeout, HTTP
92
- # error, unparseable response) AND the user did not name a provider explicitly.
104
+ # The review chain, in owner-policy order (2026-10-16): DeepSeek, then agy Argon, then GLM. A named
105
+ # provider goes first and the rest follow in this order; a custom endpoint (REVIEW_BASE_URL /
106
+ # REVIEW_MODEL) never falls back, because its owner named exactly who reviews.
93
107
  #
94
108
  # Why a fallback rather than just picking one vendor: the whole point of this tool is that the
95
109
  # reviewer is a DIFFERENT vendor from the author, so collapsing to a single provider means an
@@ -102,7 +116,20 @@ DEFAULT_PROVIDER = "deepseek"
102
116
  # two same-vendor passes missed, an anchor bug, an orphaned-permission-rows bug). GLM is the
103
117
  # spare tyre, not a co-equal — an alternation policy that sends half the phases to a provider
104
118
  # that times out buys variety it never actually collects.
105
- FALLBACK_PROVIDERS: tuple[str, ...] = ("glm",)
119
+ AGY_PROVIDER = "agy-argon"
120
+ REVIEW_ORDER: tuple[str, ...] = ("deepseek", AGY_PROVIDER, "glm")
121
+
122
+ # agy runs headless through the spend cap: plan mode (it needs no tools, the diff is in the prompt).
123
+ AGY_MODEL = "gemini-4-argon-high"
124
+ AGY_TIMEOUT_S = 15 * 60
125
+ # The prompt is one command-line argument (`--print=<prompt>`). A Windows command line holds at most
126
+ # 32,767 characters, so a larger prompt skips agy instead of failing to start.
127
+ AGY_MAX_PROMPT_CHARS = 28_000
128
+ # `caddis_spend.py run` refusal codes: 75 over the cap or no lock, 78 cap not configured. Never agy's own.
129
+ SPEND_REFUSED = (75, 78)
130
+
131
+ # On HTTP 429, wait for `Retry-After` once per provider per run, at most this long.
132
+ RETRY_AFTER_CAP_S = 60.0
106
133
 
107
134
  # Fail-closed diff-size ceiling (see the module docstring). Observed live: an oversized diff got back
108
135
  # an empty `content` field (safe, but wasteful) on one occasion, and — separately, a bulk multi-document
@@ -149,6 +176,56 @@ class ConfigError(Exception):
149
176
  """Raised when configuration can't be resolved (unknown provider, or missing API key)."""
150
177
 
151
178
 
179
+ class ProviderFailure(RuntimeError):
180
+ """The provider could not answer: HTTP 429, a 5xx, a timeout, a connection error, an empty reply, an agy
181
+ error status. NOT_RUN (exit 3), never a verdict, never a reason to split the diff."""
182
+
183
+ def __init__(self, message: str, retry_after: float | None = None):
184
+ super().__init__(message)
185
+ self.retry_after = retry_after
186
+
187
+
188
+ class ProviderSkipped(Exception):
189
+ """The provider is not available for this run (the spend cap refused agy, agy is missing, the prompt
190
+ is too long for the agy command line). The chain moves on; nothing was reviewed. `for_run=False`: skip only
191
+ this prompt (it was too long), so a smaller batch may still use the provider."""
192
+
193
+ def __init__(self, message: str, for_run: bool = True):
194
+ super().__init__(message)
195
+ self.for_run = for_run
196
+
197
+
198
+ def _retry_after(exc: BaseException) -> float | None:
199
+ """Seconds from a 429's `Retry-After` header (delta-seconds form only), else None."""
200
+ if isinstance(exc, ProviderFailure):
201
+ return exc.retry_after
202
+ if isinstance(exc, urllib.error.HTTPError) and exc.code == 429 and exc.headers is not None:
203
+ try:
204
+ value = float((exc.headers.get("Retry-After") or "").strip())
205
+ except ValueError:
206
+ return None
207
+ return value if value >= 0 else None
208
+ return None
209
+
210
+
211
+ def failure_kind(exc: BaseException) -> str:
212
+ """'provider' (NOT_RUN: next provider, no split), 'truncation' (size signal: may split), or 'other'.
213
+
214
+ Order matters: HTTPError is a URLError, and RemoteDisconnected is both a ConnectionError and an
215
+ HTTPException (a dropped connection, not a truncated body)."""
216
+ if isinstance(exc, (ProviderFailure, ProviderSkipped)):
217
+ return "provider"
218
+ if isinstance(exc, urllib.error.HTTPError):
219
+ return "provider" if exc.code == 429 or exc.code >= 500 else "other"
220
+ if isinstance(exc, (urllib.error.URLError, TimeoutError, ConnectionError)):
221
+ return "provider"
222
+ if isinstance(exc, http.client.HTTPException):
223
+ return "truncation"
224
+ if isinstance(exc, OSError):
225
+ return "provider"
226
+ return "other"
227
+
228
+
152
229
  DEFAULT_KEYS_FILE = "~/.caddis/keys.env"
153
230
 
154
231
 
@@ -490,14 +567,116 @@ def call_llm(base_url: str, api_key: str, model: str, prompt: str, timeout: int
490
567
  # which is EXIT_BLOCKING. A crash was being reported to scripted callers as "the
491
568
  # reviewer found blocking issues."
492
569
  # Raising here routes both into the existing retry/failover path and kills the crash.
570
+ # Since 2026-10-19 this is a ProviderFailure: NOT_RUN and the next provider, never a split. An empty
571
+ # reply from a throttled DeepSeek is not size-correlated, and halving it sent MORE calls to it.
493
572
  if not isinstance(content, str) or not content.strip():
494
- raise RuntimeError(
573
+ raise ProviderFailure(
495
574
  "endpoint returned HTTP 200 with empty or absent message content "
496
575
  f"(type={type(content).__name__}) — treating as a provider failure"
497
576
  )
498
577
  return content
499
578
 
500
579
 
580
+ def _spend_script() -> Path | None:
581
+ """caddis_spend.py: beside this file in a bundle, else the source repo's scripts/ folder."""
582
+ here = Path(__file__).resolve().parent
583
+ for candidate in (here / "caddis_spend.py", here.parents[1] / "scripts" / "caddis_spend.py"):
584
+ if candidate.is_file():
585
+ return candidate
586
+ return None
587
+
588
+
589
+ def agy_launcher() -> list[str] | None:
590
+ """The command prefix that runs agy under the spend cap, or None when agy or caddis_spend is missing.
591
+ Every agy call goes through `caddis_spend.py run` (owner policy: agy only through the spend cap)."""
592
+ agy = shutil.which("agy")
593
+ spend = _spend_script()
594
+ if not agy or spend is None:
595
+ return None
596
+ return [sys.executable, str(spend), "run", "--caller", "oss-review", "--", agy]
597
+
598
+
599
+ def _kill_tree(proc) -> None:
600
+ """Kill the child and everything it started (it runs in its own process group / session)."""
601
+ if os.name == "nt":
602
+ subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)], capture_output=True)
603
+ else:
604
+ try:
605
+ os.killpg(proc.pid, signal.SIGKILL)
606
+ except (ProcessLookupError, PermissionError):
607
+ proc.kill()
608
+
609
+
610
+ def _run_capped(argv: list[str], timeout: float) -> tuple[int, str, str]:
611
+ """Run `argv` with stdin closed; (exit, stdout, stderr). On timeout kill the whole tree and raise
612
+ TimeoutError: a killed caddis_spend must not leave agy running on."""
613
+ group = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if os.name == "nt"
614
+ else {"start_new_session": True})
615
+ env = {**os.environ, "CADDIS_HEADLESS": "1"}
616
+ proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
617
+ text=True, encoding="utf-8", errors="replace", env=env, **group)
618
+ try:
619
+ stdout, stderr = proc.communicate(timeout=timeout)
620
+ except subprocess.TimeoutExpired:
621
+ _kill_tree(proc)
622
+ try:
623
+ proc.communicate(timeout=30)
624
+ except subprocess.TimeoutExpired:
625
+ pass
626
+ raise TimeoutError(f"agy timed out after {int(timeout)} s; process tree killed") from None
627
+ except BaseException:
628
+ _kill_tree(proc)
629
+ raise
630
+ return proc.returncode, stdout or "", stderr or ""
631
+
632
+
633
+ def _agy_document(stdout: str) -> dict | None:
634
+ """The last agy JSON document (keys status/response) that starts a line of `stdout`.
635
+
636
+ Mirror of scripts/caddis_spend.py::_agy_document, duplicated so this tool stays one stdlib-only file.
637
+ A `{` inside the model reply never starts a line of the raw output (it is inside a JSON string)."""
638
+ decoder = json.JSONDecoder()
639
+ text = stdout or ""
640
+ starts = [0] if text.startswith("{") else []
641
+ starts += [i + 1 for i, ch in enumerate(text) if ch == "\n" and text[i + 1:i + 2] == "{"]
642
+ found = None
643
+ for start in starts:
644
+ try:
645
+ obj, _end = decoder.raw_decode(text, start)
646
+ except json.JSONDecodeError:
647
+ continue
648
+ if isinstance(obj, dict) and {"status", "response"} <= obj.keys():
649
+ found = obj
650
+ return found
651
+
652
+
653
+ def call_agy(launcher: list[str], prompt: str, timeout: float = AGY_TIMEOUT_S) -> str:
654
+ """One review by agy Argon through the spend cap -> the reply text.
655
+
656
+ ProviderSkipped: the cap refused, agy is missing, or the prompt is too long for one argument.
657
+ ProviderFailure: a non-zero exit, a status other than SUCCESS (ERROR, RESOURCE_EXHAUSTED), no JSON
658
+ document, or an empty reply."""
659
+ if len(prompt) > AGY_MAX_PROMPT_CHARS:
660
+ raise ProviderSkipped(f"prompt is {len(prompt):,} chars, over the {AGY_MAX_PROMPT_CHARS:,}-char "
661
+ "agy command-line limit", for_run=False)
662
+ argv = [*launcher, "--model", AGY_MODEL, "--mode", "plan", "--output-format", "json",
663
+ f"--print={prompt}"]
664
+ code, stdout, stderr = _run_capped(argv, timeout)
665
+ if code in SPEND_REFUSED:
666
+ raise ProviderSkipped(f"the agy spend cap refused the call (caddis_spend exit {code}): "
667
+ f"{stderr.strip()[-300:]}")
668
+ if code == 127:
669
+ raise ProviderSkipped(f"agy could not start: {stderr.strip()[-300:]}")
670
+ doc = _agy_document(stdout)
671
+ status = str(doc.get("status")) if doc else "no JSON document"
672
+ if code != 0 or status != "SUCCESS":
673
+ raise ProviderFailure(f"agy exit {code}, status {status}: {(stderr or stdout).strip()[-300:]}")
674
+ response = doc.get("response")
675
+ if not isinstance(response, str) or not response.strip():
676
+ raise ProviderFailure("agy returned an empty response")
677
+ return response
678
+
679
+
501
680
  def _force_utf8_stdio() -> None:
502
681
  """Make stdout/stderr survive a review written by a model, on any console.
503
682
 
@@ -555,7 +734,9 @@ def main(argv: list[str] | None = None, env: dict[str, str] | None = None) -> in
555
734
  help="git range to review (e.g. origin/main..HEAD); default = working tree")
556
735
  parser.add_argument("--cwd", default=".", help="repo directory (default: cwd)")
557
736
  parser.add_argument("--provider", default=None,
558
- help=f"preset: {', '.join(sorted(PROVIDERS))} (default {DEFAULT_PROVIDER}); env REVIEW_PROVIDER")
737
+ help=f"preset: {', '.join(sorted(PROVIDERS))} or {AGY_PROVIDER} (default "
738
+ f"{DEFAULT_PROVIDER}); env REVIEW_PROVIDER. A provider failure falls back in "
739
+ f"the order {' -> '.join(REVIEW_ORDER)}")
559
740
  parser.add_argument("--base-url", dest="base_url", default=None, help="override REVIEW_BASE_URL / the preset")
560
741
  parser.add_argument("--model", default=None, help="override REVIEW_MODEL / the preset")
561
742
  parser.add_argument("--max-diff-chars", type=int, default=None,
@@ -608,6 +789,9 @@ def main(argv: list[str] | None = None, env: dict[str, str] | None = None) -> in
608
789
  secret_filter.DEFAULT_DENY_GLOBS + cfg_deny,
609
790
  cfg_allow + list(args.allow_path),
610
791
  )
792
+ for warn_path, warn_line, warn_rule in filter_result.warnings:
793
+ # Context lines are already in the repo: sending them adds no exposure. Name them anyway.
794
+ print(f"WARN {warn_path}:{warn_line} ({warn_rule}) — already in the repo, not refused")
611
795
  if filter_result.hits:
612
796
  print("REVIEW: REFUSED — possible secrets:")
613
797
  for hit_path, hit_line, hit_rule in filter_result.hits:
@@ -620,11 +804,29 @@ def main(argv: list[str] | None = None, env: dict[str, str] | None = None) -> in
620
804
  return EXIT_CONFIG
621
805
  diff_text = filter_result.kept_diff
622
806
 
807
+ primary_provider = (
808
+ args.provider or env.get("REVIEW_PROVIDER") or DEFAULT_PROVIDER
809
+ ).strip().lower()
810
+ # A custom ENDPOINT names exactly who reviews (REVIEW_BASE_URL/REVIEW_MODEL, the documented
811
+ # future-proofing path): it never falls back. A named preset falls back on a provider failure,
812
+ # and the switch is printed, so the reviewer is never misreported.
813
+ custom = bool(args.base_url or env.get("REVIEW_BASE_URL") or args.model or env.get("REVIEW_MODEL"))
814
+ agy_cmd = agy_launcher()
815
+
816
+ def _http_entry(name: str, url: str, key: str, mdl: str):
817
+ return (name, mdl, url, lambda prompt: call_llm(url, key, mdl, prompt))
818
+
819
+ def _agy_entry(launcher: list[str]):
820
+ return (AGY_PROVIDER, AGY_MODEL, "caddis_spend run", lambda prompt: call_agy(launcher, prompt))
821
+
623
822
  try:
624
- base_url, api_key, model = resolve_config(args, env)
625
- primary_provider = (
626
- args.provider or env.get("REVIEW_PROVIDER") or DEFAULT_PROVIDER
627
- ).strip().lower()
823
+ if primary_provider == AGY_PROVIDER and not custom:
824
+ if agy_cmd is None:
825
+ raise ConfigError(f"provider {AGY_PROVIDER!r} needs agy on PATH and caddis_spend.py "
826
+ "(beside this script or in the repo's scripts/ folder).")
827
+ chain = [_agy_entry(agy_cmd)]
828
+ else:
829
+ chain = [_http_entry(primary_provider, *resolve_config(args, env))]
628
830
  except ConfigError as exc:
629
831
  sys.stderr.write(f"{exc}\n")
630
832
  return EXIT_CONFIG
@@ -695,85 +897,100 @@ def main(argv: list[str] | None = None, env: dict[str, str] | None = None) -> in
695
897
 
696
898
  branch = current_branch(args.cwd)
697
899
 
698
- # Fall back to another vendor only when the user did NOT name one. If they asked for a
699
- # specific provider, silently reviewing with a different one would misreport who checked
700
- # the code — the single fact this tool exists to establish.
701
- # "Explicit" must include a custom ENDPOINT, not just a named preset. Someone who set
702
- # REVIEW_BASE_URL/REVIEW_MODEL (the documented future-proofing path) has named exactly who
703
- # should review their code; falling back to the GLM preset would misreport the reviewer —
704
- # the one fact this tool exists to establish.
705
- explicit = bool(
706
- args.provider or env.get("REVIEW_PROVIDER")
707
- or args.base_url or env.get("REVIEW_BASE_URL")
708
- or args.model or env.get("REVIEW_MODEL")
709
- )
710
- chain: list[tuple[str, str, str, str]] = [(primary_provider, base_url, api_key, model)]
711
- if not explicit:
712
- for name in FALLBACK_PROVIDERS:
713
- if name == primary_provider:
900
+ # The rest of the chain in policy order (DeepSeek -> agy Argon -> GLM). A provider with no key, or
901
+ # agy with no agy on PATH, is skipped: not an error, just nothing to fall back to.
902
+ if not custom:
903
+ for name in REVIEW_ORDER:
904
+ if name == chain[0][0]:
905
+ continue
906
+ if name == AGY_PROVIDER:
907
+ if agy_cmd is not None:
908
+ chain.append(_agy_entry(agy_cmd))
714
909
  continue
715
910
  try:
716
- fb_url, fb_key, fb_model = resolve_config(args, env, provider=name, use_overrides=False)
911
+ chain.append(_http_entry(name, *resolve_config(args, env, provider=name, use_overrides=False)))
717
912
  except ConfigError:
718
- continue # no key for the spare — not an error, just nothing to fall back to
719
- chain.append((name, fb_url, fb_key, fb_model))
720
-
721
- def _try_chain(prompt: str, label: str) -> str | None:
722
- """One payload against every provider in the chain. None when all of them failed."""
723
- for idx, (name, url, key, mdl) in enumerate(chain):
724
- try:
725
- review = call_llm(url, key, mdl, prompt)
726
- except TRANSPORT_ERRORS as exc:
727
- remaining = len(chain) - idx - 1
728
- sys.stderr.write(
729
- f"review request failed{label} ({mdl} @ {url}): {exc}\n"
730
- + (
731
- f" falling back to {chain[idx + 1][0]}...\n"
732
- if remaining
733
- else " If the model id was renamed, set REVIEW_MODEL to the current id "
734
- "(env overrides the preset).\n"
735
- )
736
- )
737
913
  continue
738
- if idx:
739
- # Loud on purpose. A verdict from a different vendor than the one requested is
740
- # a material fact about the review, not an implementation detail — it belongs
741
- # in the transcript the phase report quotes.
742
- print(f"[cross-review] {chain[0][0]} unavailable; reviewed by {name} ({mdl}).")
743
- return review
744
- return None
745
-
746
- def _review_chunk(chunk: str, label: str, depth: int = 0) -> list[str] | None:
747
- """Review `chunk`, halving it and retrying once per level if every provider failed.
748
914
 
749
- WHY HALVE RATHER THAN JUST FAIL. Both observed failures were transport truncation on a
750
- long reply — an `http.client.IncompleteRead` on one provider and an empty HTTP 200 body on
751
- another — and both were SIZE-CORRELATED. Retrying the same payload against the same
752
- endpoint reproduces the same overflow; retrying a smaller one usually does not. The whole
753
- run used to abort here, which is safe but throws away a review the user has already
754
- waited minutes for.
915
+ # Per run: a provider that failed (429, 5xx, timeout, empty reply, cap refusal) gets no more calls,
916
+ # and each provider waits for a 429 Retry-After at most once. Hammering a throttled provider with
917
+ # more, smaller calls is the 18-19 Oct 2026 failure.
918
+ down: set[str] = set()
919
+ waited: set[str] = set()
920
+
921
+ def _try_chain(prompt: str, label: str) -> tuple[str | None, list[str]]:
922
+ """One payload against every provider still up. (review, []) or (None, the failure kinds)."""
923
+ kinds: list[str] = []
924
+ for idx, (name, mdl, where, call) in enumerate(chain):
925
+ if name in down:
926
+ continue
927
+ while True:
928
+ try:
929
+ review = call(prompt)
930
+ except ProviderSkipped as exc:
931
+ sys.stderr.write(f"[cross-review] {name} skipped{label}: {exc}\n")
932
+ if exc.for_run:
933
+ down.add(name)
934
+ kinds.append("provider")
935
+ break
936
+ except TRANSPORT_ERRORS as exc:
937
+ kind = failure_kind(exc)
938
+ retry = _retry_after(exc)
939
+ if kind == "provider" and retry is not None and name not in waited:
940
+ waited.add(name)
941
+ delay = min(retry, RETRY_AFTER_CAP_S)
942
+ sys.stderr.write(f"[cross-review] {name} is rate-limited (HTTP 429){label}; waiting "
943
+ f"{delay:g} s once (Retry-After), then one retry\n")
944
+ time.sleep(delay)
945
+ continue
946
+ kinds.append(kind)
947
+ if kind == "provider":
948
+ down.add(name)
949
+ left = [n for n, *_ in chain[idx + 1:] if n not in down]
950
+ sys.stderr.write(
951
+ f"review request failed{label} ({mdl} @ {where}): {exc}\n"
952
+ + (f" falling back to {left[0]}...\n" if left
953
+ else " If the model id was renamed, set REVIEW_MODEL to the current id "
954
+ "(env overrides the preset).\n" if kind == "other"
955
+ else " no provider left to try.\n")
956
+ )
957
+ break
958
+ # Loud on purpose, every time: which vendor gave the verdict is a material fact about
959
+ # the review, and it belongs in the transcript the phase report quotes.
960
+ if idx:
961
+ print(f"[cross-review] {chain[0][0]} unavailable; reviewed by {name} ({mdl}).")
962
+ else:
963
+ print(f"[cross-review] reviewed by {name} ({mdl}).")
964
+ return review, []
965
+ return None, kinds or ["provider"] # nothing tried: every provider was already down
966
+
967
+ def _review_chunk(chunk: str, label: str, depth: int = 0) -> tuple[list[str] | None, list[str]]:
968
+ """Review `chunk`; on a size-truncation failure, halve it and retry once per level.
969
+
970
+ WHY HALVE. The observed truncations (`http.client.IncompleteRead`, parking-lot 006) were
971
+ SIZE-CORRELATED: a smaller payload usually gets through. Only that signal splits. A provider
972
+ failure (429, 5xx, timeout, empty reply) does NOT: halving a throttled call sends it more calls.
755
973
 
756
974
  Bounded at RETRY_SPLIT_DEPTH levels and never splits a single file, so it cannot loop and
757
- cannot silently shrink a diff to nothing. Returns None when a chunk that cannot be split
758
- any further still fails — the abort path stays reachable.
975
+ cannot silently shrink a diff to nothing. Returns (None, kinds) when the chunk failed.
759
976
  """
760
- review = _try_chain(build_review_prompt(chunk, branch, args.range, whole_change), label)
977
+ review, kinds = _try_chain(build_review_prompt(chunk, branch, args.range, whole_change), label)
761
978
  if review is not None:
762
- return [review]
979
+ return [review], []
763
980
  files = split_diff_by_file(chunk)
764
- if depth >= RETRY_SPLIT_DEPTH or len(files) < 2:
765
- return None
981
+ if "truncation" not in kinds or depth >= RETRY_SPLIT_DEPTH or len(files) < 2:
982
+ return None, kinds
766
983
  half = len(files) // 2
767
984
  sys.stderr.write(
768
985
  f"[cross-review] retrying{label} as 2 smaller chunks ({half} + {len(files) - half} "
769
- "file(s)) — both observed failures were size-correlated truncation.\n")
986
+ "file(s)) — a truncated reply is size-correlated.\n")
770
987
  out: list[str] = []
771
988
  for part_no, part in enumerate(("".join(files[:half]), "".join(files[half:])), 1):
772
- got = _review_chunk(part, f"{label} part {part_no}", depth + 1)
989
+ got, sub_kinds = _review_chunk(part, f"{label} part {part_no}", depth + 1)
773
990
  if got is None:
774
- return None
991
+ return None, kinds + sub_kinds
775
992
  out += got
776
- return out
993
+ return out, []
777
994
 
778
995
  whole_change = changed_paths(diff_text)
779
996
  verdicts: list[bool | None] = []
@@ -784,13 +1001,20 @@ def main(argv: list[str] | None = None, env: dict[str, str] | None = None) -> in
784
1001
  print(f"\n===== batch {batch_no}/{len(batches)} — {files} file(s), "
785
1002
  f"{len(batch):,} chars =====")
786
1003
 
787
- reviews = _review_chunk(batch, label)
1004
+ reviews, kinds = _review_chunk(batch, label)
788
1005
  if reviews is None:
789
1006
  # Every provider failed, and splitting did not rescue it. Do NOT continue to the next
790
1007
  # batch: a partial sweep that still prints a verdict is the failure mode this whole
791
- # tool guards against.
792
- sys.stderr.write(f"batch {batch_no}/{len(batches)} could not be reviewed — aborting.\n")
793
- return EXIT_ERROR
1008
+ # tool guards against. Only provider failures: could not run (3). Anything else: 4.
1009
+ not_run = all(k == "provider" for k in kinds)
1010
+ sys.stderr.write(f"batch {batch_no}/{len(batches)} could not be reviewed — aborting"
1011
+ + (" (every provider failed: NOT_RUN).\n" if not_run else ".\n"))
1012
+ if any(v is False for v in verdicts):
1013
+ # An earlier batch already found a blocking issue. A later failure must not hide it behind
1014
+ # NOT_RUN (3), which reads as "nothing found, retry". Fail closed and report the finding.
1015
+ print("REVIEW: BLOCKING (an earlier batch blocked; the remaining batches were not reviewed)")
1016
+ return EXIT_BLOCKING
1017
+ return EXIT_CONFIG if not_run else EXIT_ERROR
794
1018
 
795
1019
  for review in reviews:
796
1020
  print(review)