@caddis/cli 0.4.56 → 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/guard_agy.py +700 -68
- package/bundles/antigravity-plugin/hooks.json +2 -1
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +63 -12
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +21 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +35 -0
- package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +67 -22
- package/bundles/antigravity-plugin/scripts/caddis_review.py +59 -9
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +120 -24
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +80 -7
- package/bundles/antigravity-plugin/scripts/oss_review.py +300 -79
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +4 -0
- package/bundles/antigravity-plugin/session_end_agy.py +13 -10
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +53 -8
- 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 +9 -5
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +18 -11
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +44 -4
- package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +13 -4
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +26 -7
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +31 -0
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +42 -4
- package/bundles/antigravity-plugin/warm_start_agy.py +26 -15
- 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/codex/.codex/skills/devops/gh-cli/SKILL.md +9 -5
- package/bundles/manifest.json +3 -3
- package/package.json +1 -1
|
@@ -19,6 +19,7 @@ from the files is escaped: a plan, a relay or a hook message can hold any text.
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
21
|
import datetime
|
|
22
|
+
import functools
|
|
22
23
|
import hashlib
|
|
23
24
|
import html
|
|
24
25
|
import json
|
|
@@ -452,11 +453,13 @@ def default_page_path(root: Path, slug: str, home: Path | None = None) -> Path:
|
|
|
452
453
|
return pages_root(home) / f"{main.name}-{tag}" / f"{slug}.html"
|
|
453
454
|
|
|
454
455
|
|
|
455
|
-
def write_page(root: Path, slug: str, out: Path | None, roadmap_mod) -> Path:
|
|
456
|
+
def write_page(root: Path, slug: str, out: Path | None, roadmap_mod) -> Path | None:
|
|
456
457
|
"""The page. At the default path it also writes `<slug>.json`, the one-row summary the hub is built from; an
|
|
457
|
-
`--out` page is a one-off copy and stays out of the hub."""
|
|
458
|
-
model = build(root, slug, roadmap_mod)
|
|
458
|
+
`--out` page is a one-off copy and stays out of the hub. None: a newer caddis wrote this page, so it is kept."""
|
|
459
459
|
path = Path(out) if out else default_page_path(root, slug)
|
|
460
|
+
if out is None and _owned_by_newer(slug, path.with_suffix(".json")): # before build: a skip does no work
|
|
461
|
+
return None
|
|
462
|
+
model = build(root, slug, roadmap_mod)
|
|
460
463
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
461
464
|
_write_atomic(path, render_html(model))
|
|
462
465
|
if out is None:
|
|
@@ -468,6 +471,66 @@ def write_page(root: Path, slug: str, out: Path | None, roadmap_mod) -> Path:
|
|
|
468
471
|
return path
|
|
469
472
|
|
|
470
473
|
|
|
474
|
+
# ---- the version gate: a session on an old plugin never overwrites what a newer caddis wrote (robustness #8) -------
|
|
475
|
+
|
|
476
|
+
@functools.lru_cache(maxsize=8)
|
|
477
|
+
def generator_version(here: Path | None = None) -> str:
|
|
478
|
+
"""This caddis's version: the Claude plugin's `.claude-plugin/plugin.json` or the agy plugin's root
|
|
479
|
+
`plugin.json` (both beside `scripts/`), else the source checkout's `caddis` entry in
|
|
480
|
+
`.github/runtime-targets.json`, else "dev"."""
|
|
481
|
+
base = Path(here or Path(__file__).resolve().parent).parent
|
|
482
|
+
for manifest in (base / ".claude-plugin" / "plugin.json", base / "plugin.json"):
|
|
483
|
+
data, _ = _read_json(manifest)
|
|
484
|
+
if data and data.get("version"):
|
|
485
|
+
return str(data["version"])
|
|
486
|
+
data, _ = _read_json(base / ".github" / "runtime-targets.json")
|
|
487
|
+
for target in (data or {}).get("targets") or []:
|
|
488
|
+
plugin = target.get("plugin") if isinstance(target, dict) else None
|
|
489
|
+
if isinstance(plugin, dict) and plugin.get("name") == "caddis" and plugin.get("version"):
|
|
490
|
+
return str(plugin["version"])
|
|
491
|
+
return "dev"
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _version_key(text) -> tuple[int, ...] | None:
|
|
495
|
+
parts = str(text or "").strip().split(".")
|
|
496
|
+
return tuple(int(p) for p in parts) if all(p.isdigit() for p in parts) else None
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def is_newer(theirs, mine) -> bool:
|
|
500
|
+
"""True only when both are numeric versions and `theirs` is the higher one; "dev" or "" never blocks."""
|
|
501
|
+
a, b = _version_key(theirs), _version_key(mine)
|
|
502
|
+
return a is not None and b is not None and a > b
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
GENERATOR_OWNERSHIP_S = 1800 # a newer caddis owns a page only while it keeps writing it (review of #71)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _newer_owner(summary: Path) -> tuple[str, bool]:
|
|
509
|
+
"""(version, live): the newer caddis that wrote `summary` ("" when it is not newer), and whether it wrote it in
|
|
510
|
+
the last GENERATOR_OWNERSHIP_S. An unreadable time is not live: a downgrade must never freeze a page."""
|
|
511
|
+
data, _ = _read_json(summary)
|
|
512
|
+
theirs = str((data or {}).get("generator") or "")
|
|
513
|
+
if not is_newer(theirs, generator_version()):
|
|
514
|
+
return "", False
|
|
515
|
+
age_h = _age_h(str((data or {}).get("generated_utc") or ""), datetime.datetime.now(datetime.timezone.utc))
|
|
516
|
+
return theirs, age_h is not None and age_h * 3600 <= GENERATOR_OWNERSHIP_S
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _gate(label: str, theirs: str, live: bool) -> bool:
|
|
520
|
+
"""True: skip the write. Prints one line when a newer caddis owns it, or when this one takes it over."""
|
|
521
|
+
if theirs and live:
|
|
522
|
+
print(f"[ROADMAP] {label}: a newer caddis ({theirs}) owns this page; skipped")
|
|
523
|
+
return True
|
|
524
|
+
if theirs:
|
|
525
|
+
print(f"[ROADMAP] {label}: taking over from caddis {theirs} (it has not written for "
|
|
526
|
+
f"{GENERATOR_OWNERSHIP_S // 60} min)")
|
|
527
|
+
return False
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _owned_by_newer(slug: str, summary: Path) -> bool:
|
|
531
|
+
return _gate(slug, *_newer_owner(summary))
|
|
532
|
+
|
|
533
|
+
|
|
471
534
|
# ---- the hub: one page over every roadmap page on this machine, grouped per repo ---------------------------------
|
|
472
535
|
|
|
473
536
|
HUB_STALE_H = 24 # a summary older than this is marked "not refreshed"
|
|
@@ -507,7 +570,7 @@ def page_summary(model: dict, main: Path, path: Path) -> dict:
|
|
|
507
570
|
else f"stopped: {stop.get('reason')}" if stop.get("reason") else "")
|
|
508
571
|
nxt = model.get("next") or {}
|
|
509
572
|
return {
|
|
510
|
-
"version": 1, "repo": main.name, "checkout": str(main), "folder": path.parent.name, "slug": model["roadmap"],
|
|
573
|
+
"version": 1, "generator": generator_version(), "repo": main.name, "checkout": str(main), "folder": path.parent.name, "slug": model["roadmap"],
|
|
511
574
|
"branch": model["branch"], "origin_commit": model.get("origin_commit", ""),
|
|
512
575
|
"generated_utc": model["generated"],
|
|
513
576
|
"milestones_done": model["done"], "milestones_total": model["total"],
|
|
@@ -530,7 +593,7 @@ def prune_folder(folder: Path, keep: set[str]) -> int:
|
|
|
530
593
|
if not folder.is_dir():
|
|
531
594
|
return removed
|
|
532
595
|
for js in folder.glob("*.json"):
|
|
533
|
-
if js.stem in keep:
|
|
596
|
+
if js.stem in keep or _newer_owner(js)[1]: # a live newer caddis may know a roadmap this one cannot list
|
|
534
597
|
continue
|
|
535
598
|
for f in (js, js.with_suffix(".html")):
|
|
536
599
|
try:
|
|
@@ -667,11 +730,21 @@ def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime) -> s
|
|
|
667
730
|
return "".join(out)
|
|
668
731
|
|
|
669
732
|
|
|
670
|
-
def write_hub(home: Path | None = None, now: datetime.datetime | None = None) -> Path:
|
|
671
|
-
"""`~/.caddis/roadmap-pages/index.html`: every roadmap page on this machine, grouped per repo.
|
|
733
|
+
def write_hub(home: Path | None = None, now: datetime.datetime | None = None) -> Path | None:
|
|
734
|
+
"""`~/.caddis/roadmap-pages/index.html`: every roadmap page on this machine, grouped per repo. None: a newer
|
|
735
|
+
caddis wrote the hub (its version is in `.hub-generator`), so it is kept."""
|
|
672
736
|
root_dir = pages_root(home)
|
|
673
737
|
root_dir.mkdir(parents=True, exist_ok=True)
|
|
738
|
+
stamp = root_dir / ".hub-generator"
|
|
739
|
+
try:
|
|
740
|
+
theirs = stamp.read_text(encoding="utf-8").strip()
|
|
741
|
+
live = time.time() - stamp.stat().st_mtime <= GENERATOR_OWNERSHIP_S
|
|
742
|
+
except (OSError, UnicodeDecodeError):
|
|
743
|
+
theirs, live = "", False
|
|
744
|
+
if _gate("hub", theirs if is_newer(theirs, generator_version()) else "", live):
|
|
745
|
+
return None
|
|
674
746
|
rows, errors = read_summaries(root_dir)
|
|
675
747
|
path = root_dir / "index.html"
|
|
676
748
|
_write_atomic(path, render_hub(rows, errors, now or datetime.datetime.now(datetime.timezone.utc)))
|
|
749
|
+
_write_atomic(stamp, generator_version() + "\n")
|
|
677
750
|
return path
|
|
@@ -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,
|
|
@@ -623,11 +804,29 @@ def main(argv: list[str] | None = None, env: dict[str, str] | None = None) -> in
|
|
|
623
804
|
return EXIT_CONFIG
|
|
624
805
|
diff_text = filter_result.kept_diff
|
|
625
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
|
+
|
|
626
822
|
try:
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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))]
|
|
631
830
|
except ConfigError as exc:
|
|
632
831
|
sys.stderr.write(f"{exc}\n")
|
|
633
832
|
return EXIT_CONFIG
|
|
@@ -698,85 +897,100 @@ def main(argv: list[str] | None = None, env: dict[str, str] | None = None) -> in
|
|
|
698
897
|
|
|
699
898
|
branch = current_branch(args.cwd)
|
|
700
899
|
|
|
701
|
-
#
|
|
702
|
-
#
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
or args.base_url or env.get("REVIEW_BASE_URL")
|
|
711
|
-
or args.model or env.get("REVIEW_MODEL")
|
|
712
|
-
)
|
|
713
|
-
chain: list[tuple[str, str, str, str]] = [(primary_provider, base_url, api_key, model)]
|
|
714
|
-
if not explicit:
|
|
715
|
-
for name in FALLBACK_PROVIDERS:
|
|
716
|
-
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))
|
|
717
909
|
continue
|
|
718
910
|
try:
|
|
719
|
-
|
|
911
|
+
chain.append(_http_entry(name, *resolve_config(args, env, provider=name, use_overrides=False)))
|
|
720
912
|
except ConfigError:
|
|
721
|
-
continue # no key for the spare — not an error, just nothing to fall back to
|
|
722
|
-
chain.append((name, fb_url, fb_key, fb_model))
|
|
723
|
-
|
|
724
|
-
def _try_chain(prompt: str, label: str) -> str | None:
|
|
725
|
-
"""One payload against every provider in the chain. None when all of them failed."""
|
|
726
|
-
for idx, (name, url, key, mdl) in enumerate(chain):
|
|
727
|
-
try:
|
|
728
|
-
review = call_llm(url, key, mdl, prompt)
|
|
729
|
-
except TRANSPORT_ERRORS as exc:
|
|
730
|
-
remaining = len(chain) - idx - 1
|
|
731
|
-
sys.stderr.write(
|
|
732
|
-
f"review request failed{label} ({mdl} @ {url}): {exc}\n"
|
|
733
|
-
+ (
|
|
734
|
-
f" falling back to {chain[idx + 1][0]}...\n"
|
|
735
|
-
if remaining
|
|
736
|
-
else " If the model id was renamed, set REVIEW_MODEL to the current id "
|
|
737
|
-
"(env overrides the preset).\n"
|
|
738
|
-
)
|
|
739
|
-
)
|
|
740
913
|
continue
|
|
741
|
-
if idx:
|
|
742
|
-
# Loud on purpose. A verdict from a different vendor than the one requested is
|
|
743
|
-
# a material fact about the review, not an implementation detail — it belongs
|
|
744
|
-
# in the transcript the phase report quotes.
|
|
745
|
-
print(f"[cross-review] {chain[0][0]} unavailable; reviewed by {name} ({mdl}).")
|
|
746
|
-
return review
|
|
747
|
-
return None
|
|
748
|
-
|
|
749
|
-
def _review_chunk(chunk: str, label: str, depth: int = 0) -> list[str] | None:
|
|
750
|
-
"""Review `chunk`, halving it and retrying once per level if every provider failed.
|
|
751
914
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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.
|
|
758
973
|
|
|
759
974
|
Bounded at RETRY_SPLIT_DEPTH levels and never splits a single file, so it cannot loop and
|
|
760
|
-
cannot silently shrink a diff to nothing. Returns None when
|
|
761
|
-
any further still fails — the abort path stays reachable.
|
|
975
|
+
cannot silently shrink a diff to nothing. Returns (None, kinds) when the chunk failed.
|
|
762
976
|
"""
|
|
763
|
-
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)
|
|
764
978
|
if review is not None:
|
|
765
|
-
return [review]
|
|
979
|
+
return [review], []
|
|
766
980
|
files = split_diff_by_file(chunk)
|
|
767
|
-
if depth >= RETRY_SPLIT_DEPTH or len(files) < 2:
|
|
768
|
-
return None
|
|
981
|
+
if "truncation" not in kinds or depth >= RETRY_SPLIT_DEPTH or len(files) < 2:
|
|
982
|
+
return None, kinds
|
|
769
983
|
half = len(files) // 2
|
|
770
984
|
sys.stderr.write(
|
|
771
985
|
f"[cross-review] retrying{label} as 2 smaller chunks ({half} + {len(files) - half} "
|
|
772
|
-
"file(s)) —
|
|
986
|
+
"file(s)) — a truncated reply is size-correlated.\n")
|
|
773
987
|
out: list[str] = []
|
|
774
988
|
for part_no, part in enumerate(("".join(files[:half]), "".join(files[half:])), 1):
|
|
775
|
-
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)
|
|
776
990
|
if got is None:
|
|
777
|
-
return None
|
|
991
|
+
return None, kinds + sub_kinds
|
|
778
992
|
out += got
|
|
779
|
-
return out
|
|
993
|
+
return out, []
|
|
780
994
|
|
|
781
995
|
whole_change = changed_paths(diff_text)
|
|
782
996
|
verdicts: list[bool | None] = []
|
|
@@ -787,13 +1001,20 @@ def main(argv: list[str] | None = None, env: dict[str, str] | None = None) -> in
|
|
|
787
1001
|
print(f"\n===== batch {batch_no}/{len(batches)} — {files} file(s), "
|
|
788
1002
|
f"{len(batch):,} chars =====")
|
|
789
1003
|
|
|
790
|
-
reviews = _review_chunk(batch, label)
|
|
1004
|
+
reviews, kinds = _review_chunk(batch, label)
|
|
791
1005
|
if reviews is None:
|
|
792
1006
|
# Every provider failed, and splitting did not rescue it. Do NOT continue to the next
|
|
793
1007
|
# batch: a partial sweep that still prints a verdict is the failure mode this whole
|
|
794
|
-
# tool guards against.
|
|
795
|
-
|
|
796
|
-
|
|
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
|
|
797
1018
|
|
|
798
1019
|
for review in reviews:
|
|
799
1020
|
print(review)
|
|
@@ -1206,6 +1206,10 @@ relay/
|
|
|
1206
1206
|
session-state.md
|
|
1207
1207
|
session-state/
|
|
1208
1208
|
context-window.json
|
|
1209
|
+
context-window.json.tmp
|
|
1210
|
+
.session-state-*.tmp
|
|
1211
|
+
hook-errors.jsonl
|
|
1212
|
+
.relay-injected/
|
|
1209
1213
|
PROJECT-FACTS.md
|
|
1210
1214
|
# lock files beside locked documents, and migration backups (never commit these)
|
|
1211
1215
|
*.lock
|