@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.
- package/bundles/antigravity-plugin/caddis_statusline.py +7 -3
- package/bundles/antigravity-plugin/guard_agy.py +1056 -83
- package/bundles/antigravity-plugin/hooks.json +2 -1
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +182 -4
- package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +20 -12
- package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +63 -12
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +14 -5
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +27 -1
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +93 -6
- package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +67 -22
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +8 -2
- package/bundles/antigravity-plugin/scripts/caddis_review.py +290 -176
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +346 -50
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +82 -8
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +89 -24
- package/bundles/antigravity-plugin/scripts/caddis_statusline.py +7 -3
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +10 -6
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +7 -1
- package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +12 -5
- package/bundles/antigravity-plugin/scripts/caddis_worktree_safety.py +182 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +2 -0
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +6 -2
- package/bundles/antigravity-plugin/scripts/no_window.py +49 -0
- package/bundles/antigravity-plugin/scripts/oss_review.py +303 -79
- package/bundles/antigravity-plugin/scripts/secret_filter.py +190 -22
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +6 -1
- package/bundles/antigravity-plugin/session_end_agy.py +27 -11
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +55 -9
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +11 -8
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +10 -6
- package/bundles/antigravity-plugin/skills/git-commit/SKILL.md +2 -3
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +31 -17
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +45 -5
- package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +4 -2
- package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +13 -4
- package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +28 -6
- package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +16 -13
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +32 -10
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +32 -1
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +44 -6
- package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +1 -1
- package/bundles/antigravity-plugin/warm_start_agy.py +40 -16
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +2 -1
- package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +4 -2
- package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +10 -6
- package/bundles/codex/.codex/skills/devops/git-commit/SKILL.md +2 -3
- package/bundles/codex/.codex/skills/docs/writing-plans/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +28 -6
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +16 -13
- package/bundles/manifest.json +3 -3
- 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
|
|
33
|
-
denylisted path / possible secret; see secret_filter.py)
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
#
|
|
92
|
-
#
|
|
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
|
-
|
|
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
|
|
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))}
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
-
#
|
|
699
|
-
#
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
|
|
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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
|
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)) —
|
|
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
|
-
|
|
793
|
-
|
|
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)
|