@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
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
`shared` (handoff-integrity Phase 5, the design of fleet-handoff Phase 2): a checkout is shared when its root
|
|
4
4
|
`AGENTS.md` says `Shared checkout: yes`, or when `git status` lists a path this session did not change. In a
|
|
5
5
|
shared checkout handoff writes no tracked file there; it uses `caddis_docs_pr.py` instead.
|
|
6
|
+
|
|
7
|
+
`checkout_is_shared` and `foreign_changes` (robustness #11) are the two halves of that test. `caddis_lanes.py accept`
|
|
8
|
+
calls them before it fast-forwards the session checkout, and the commands that switch, rebase or pull run
|
|
9
|
+
`shared` first (pr-merge, ship-pr, ship-merge, cloud-phases). A linked worktree is never shared: AGENTS.md is
|
|
10
|
+
tracked, so a worktree carries the line too, but the worktree is the safe place the rule sends the work to.
|
|
6
11
|
"""
|
|
7
12
|
|
|
8
13
|
from __future__ import annotations
|
|
@@ -79,28 +84,74 @@ def _norm(path: str) -> str:
|
|
|
79
84
|
return path.strip().replace("\\", "/")
|
|
80
85
|
|
|
81
86
|
|
|
82
|
-
|
|
83
|
-
"""
|
|
87
|
+
class CheckoutStatusError(OSError):
|
|
88
|
+
"""`git status` could not run in the checkout, so foreign changes cannot be ruled out."""
|
|
84
89
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
"""
|
|
88
|
-
|
|
90
|
+
|
|
91
|
+
def _repo_top(start: Path) -> Path:
|
|
92
|
+
"""The nearest folder at or above `start` that holds `.git` (a folder or a gitfile); `start` when none does."""
|
|
93
|
+
here = Path(start).resolve()
|
|
94
|
+
for folder in (here, *here.parents):
|
|
95
|
+
if (folder / ".git").exists():
|
|
96
|
+
return folder
|
|
97
|
+
return here
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def is_linked_worktree(top: Path) -> bool:
|
|
101
|
+
"""True when `top/.git` is a gitfile that points into `<repo>/.git/worktrees/` (a `git worktree add` tree)."""
|
|
102
|
+
gitfile = Path(top) / ".git"
|
|
103
|
+
if not gitfile.is_file():
|
|
104
|
+
return False
|
|
89
105
|
try:
|
|
90
|
-
|
|
106
|
+
line = gitfile.read_text(encoding="utf-8", errors="replace").strip()
|
|
91
107
|
except OSError:
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
108
|
+
return False
|
|
109
|
+
return line.lower().startswith("gitdir:") and "/worktrees/" in line.replace("\\", "/").lower()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def checkout_is_shared(root: Path) -> bool:
|
|
113
|
+
"""True when the repo that holds `root` says `Shared checkout: yes` in its top `AGENTS.md` (any case).
|
|
114
|
+
|
|
115
|
+
A linked worktree is never shared (see the module note). A missing or unreadable AGENTS.md means no."""
|
|
116
|
+
top = _repo_top(Path(root))
|
|
117
|
+
if is_linked_worktree(top):
|
|
118
|
+
return False
|
|
119
|
+
try:
|
|
120
|
+
text = (top / "AGENTS.md").read_text(encoding="utf-8", errors="replace")
|
|
121
|
+
except OSError:
|
|
122
|
+
return False
|
|
123
|
+
return bool(SHARED_RE.search(text))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def foreign_changes(root: Path, own_files: set[str] | None = None) -> list[str]:
|
|
127
|
+
"""The dirty paths in `root` that are not in `own_files`, in `git status` order. A rename counts both paths.
|
|
128
|
+
|
|
129
|
+
Without session tracking (`own_files` None or empty), every dirty path counts. Raises CheckoutStatusError
|
|
130
|
+
when `git status` fails: the caller must then treat the checkout as unsafe."""
|
|
95
131
|
ret, lines = get_porcelain_lines(Path(root))
|
|
96
132
|
if ret != 0:
|
|
97
|
-
|
|
98
|
-
own = {_norm(p) for p in own_files}
|
|
133
|
+
raise CheckoutStatusError(f"git status failed in {root}")
|
|
134
|
+
own = {_norm(p) for p in (own_files or set())}
|
|
99
135
|
foreign: list[str] = []
|
|
100
136
|
for entry in lines:
|
|
101
137
|
for path in entry_paths(entry):
|
|
102
138
|
if _norm(path) not in own and path not in foreign:
|
|
103
139
|
foreign.append(path)
|
|
140
|
+
return foreign
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def is_shared_checkout(root: Path, own_files: set[str]) -> tuple[bool, str]:
|
|
144
|
+
"""(True, reason) when handoff must not write tracked files in `root`; else (False, "").
|
|
145
|
+
|
|
146
|
+
`Shared checkout: no` in AGENTS.md never hides a change this session did not make. When git status cannot
|
|
147
|
+
run, the checkout counts as shared: a wrong "no" could commit another session's files.
|
|
148
|
+
"""
|
|
149
|
+
if checkout_is_shared(Path(root)):
|
|
150
|
+
return True, "AGENTS.md says Shared checkout: yes"
|
|
151
|
+
try:
|
|
152
|
+
foreign = foreign_changes(Path(root), own_files)
|
|
153
|
+
except CheckoutStatusError:
|
|
154
|
+
return True, "git status failed, so changes by other sessions cannot be ruled out"
|
|
104
155
|
if foreign:
|
|
105
156
|
return True, "changes not made by this session: " + ", ".join(foreign[:3])
|
|
106
157
|
return False, ""
|
|
@@ -602,7 +602,26 @@ def is_test_file(path: str) -> bool:
|
|
|
602
602
|
return False
|
|
603
603
|
|
|
604
604
|
|
|
605
|
+
_STEERING_FILENAMES = {"conftest.py", "pytest.ini", "pyproject.toml", "setup.cfg", "tox.ini"}
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def is_review_steering_file(path: str) -> bool:
|
|
609
|
+
"""True for config that steers reviews, merges or tests: never tier 0, and never a 0-reviewer "1-small".
|
|
610
|
+
|
|
611
|
+
`.caddis/*.toml` (review/implement routing, merge-chain checks, spend cap), any `conftest.py`, the pytest and
|
|
612
|
+
packaging config, and `.github/workflows/**`. A fix diff that changes these can turn a gate off, so it is reviewed.
|
|
613
|
+
"""
|
|
614
|
+
norm = path.replace("\\", "/").lower().removeprefix("./")
|
|
615
|
+
if norm.startswith(".caddis/") and norm.endswith(".toml"):
|
|
616
|
+
return True
|
|
617
|
+
if norm.startswith(".github/workflows/"):
|
|
618
|
+
return True
|
|
619
|
+
return norm.rsplit("/", 1)[-1] in _STEERING_FILENAMES
|
|
620
|
+
|
|
621
|
+
|
|
605
622
|
def is_tier0_file(path: str) -> bool:
|
|
623
|
+
if is_review_steering_file(path):
|
|
624
|
+
return False
|
|
606
625
|
norm = path.replace("\\", "/").lower()
|
|
607
626
|
if norm.startswith("./"):
|
|
608
627
|
norm = norm[2:]
|
|
@@ -685,6 +704,8 @@ def review_tier(files: list[tuple[str, int]], plan_risk: str = "normal") -> str:
|
|
|
685
704
|
non_tier0_lines = sum(lines for _, lines in non_tier0)
|
|
686
705
|
if review_triggers(non_tier0_paths, non_tier0_lines):
|
|
687
706
|
return "2"
|
|
707
|
+
if any(is_review_steering_file(p) for p in non_tier0_paths):
|
|
708
|
+
return "1"
|
|
688
709
|
has_test = any(is_test_file(p) for p, _ in files)
|
|
689
710
|
if non_tier0_lines <= 40 and has_test:
|
|
690
711
|
return "1-small"
|
|
@@ -33,6 +33,7 @@ except ModuleNotFoundError as exc:
|
|
|
33
33
|
import caddis_exit
|
|
34
34
|
from caddis_heavy_slot import HeavySlotBusy, heavy_slot
|
|
35
35
|
|
|
36
|
+
import caddis_checkout_guard
|
|
36
37
|
import caddis_routing
|
|
37
38
|
import caddis_redact
|
|
38
39
|
import caddis_spend
|
|
@@ -2165,6 +2166,28 @@ def _printable(value: object) -> str:
|
|
|
2165
2166
|
return _CONTROL_CHARS.sub("", text)
|
|
2166
2167
|
|
|
2167
2168
|
|
|
2169
|
+
# Accept fast-forwards the SESSION checkout. In a checkout a dev service serves from, or one that holds a peer's
|
|
2170
|
+
# uncommitted edits, that moves files under them (robustness #11). The lane's own scaffolding is not a peer's change.
|
|
2171
|
+
_LANE_OWN_PREFIX = ".caddis/orchestrator/"
|
|
2172
|
+
|
|
2173
|
+
|
|
2174
|
+
def shared_checkout_reason(repo: Path) -> str:
|
|
2175
|
+
"""Why accept must not fast-forward `repo`, or ''. Shared (AGENTS.md) and/or foreign changes (git status):
|
|
2176
|
+
every reason that holds, joined with "; ", so an --allow-shared audit records all of them."""
|
|
2177
|
+
reasons = []
|
|
2178
|
+
if caddis_checkout_guard.checkout_is_shared(repo):
|
|
2179
|
+
reasons.append("AGENTS.md says Shared checkout: yes")
|
|
2180
|
+
try:
|
|
2181
|
+
dirty = caddis_checkout_guard.foreign_changes(repo)
|
|
2182
|
+
except caddis_checkout_guard.CheckoutStatusError:
|
|
2183
|
+
reasons.append("git status failed, so changes by other sessions cannot be ruled out")
|
|
2184
|
+
dirty = []
|
|
2185
|
+
foreign = [path for path in dirty if not path.replace("\\", "/").startswith(_LANE_OWN_PREFIX)]
|
|
2186
|
+
if foreign:
|
|
2187
|
+
reasons.append("changes not made by this lane: " + ", ".join(foreign[:3]))
|
|
2188
|
+
return "; ".join(reasons)
|
|
2189
|
+
|
|
2190
|
+
|
|
2168
2191
|
def _accept_command(
|
|
2169
2192
|
args,
|
|
2170
2193
|
repo: Path,
|
|
@@ -2221,6 +2244,16 @@ def _accept_command(
|
|
|
2221
2244
|
or packet["worktree_digest"] != worktree_digest(wt.path, files)):
|
|
2222
2245
|
print("refusing: worktree changed since check")
|
|
2223
2246
|
return EXIT_FAIL
|
|
2247
|
+
unsafe = shared_checkout_reason(repo)
|
|
2248
|
+
if unsafe and not getattr(args, "allow_shared", False):
|
|
2249
|
+
print(f"refusing: the session checkout is not safe to fast-forward ({_printable(unsafe)}). "
|
|
2250
|
+
"Run the programme from a worktree of origin/<default> under %TEMP% (git worktree add), "
|
|
2251
|
+
"or pass --allow-shared to fast-forward here anyway")
|
|
2252
|
+
return EXIT_USAGE
|
|
2253
|
+
if unsafe:
|
|
2254
|
+
packet["allow_shared"] = unsafe
|
|
2255
|
+
write_packet(packet_path, packet)
|
|
2256
|
+
print(f"ALLOW SHARED: {_printable(unsafe)}")
|
|
2224
2257
|
if args.override_review is not None:
|
|
2225
2258
|
packet["override_review"] = args.override_review
|
|
2226
2259
|
write_packet(packet_path, packet)
|
|
@@ -2458,6 +2491,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
2458
2491
|
_add_phase_arguments(accept)
|
|
2459
2492
|
accept.add_argument("--message", required=True)
|
|
2460
2493
|
accept.add_argument("--override-review")
|
|
2494
|
+
accept.add_argument("--allow-shared", action="store_true",
|
|
2495
|
+
help="fast-forward a shared checkout or one with foreign changes anyway (recorded)")
|
|
2461
2496
|
abandon = subparsers.add_parser("abandon")
|
|
2462
2497
|
_add_phase_arguments(abandon)
|
|
2463
2498
|
finish = subparsers.add_parser("finish")
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
"""Merge pull requests one at a time, from green CI to a verified deploy, and stop the line at the first problem.
|
|
2
2
|
|
|
3
3
|
For each PR (design section 11): when BEHIND, update the branch and cancel the old head's runs (force-cancel after
|
|
4
|
-
2 minutes); a cancelled run on the head is re-run, never merged on; "
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
2 minutes); a cancelled run on the head is re-run, never merged on; "checks green" is ci-watch's verdict on the
|
|
5
|
+
`statusCheckRollup` of the PR's `headRefOid` (never the `gh pr checks` table, which can show an older head); "no
|
|
6
|
+
checks reported" waits, then stops; every check skipped stops (no job ran), both unless --allow-no-checks; a failed
|
|
7
|
+
check stops; no merge while a push run is in progress on the default branch; squash merge with --match-head-commit;
|
|
8
|
+
then the merge SHA must get a push run, and `caddis_prod_check.py` must pass before the next PR starts. A STOP file is
|
|
9
|
+
checked before each update-branch and each merge. --dry-run makes read-only `gh` calls and prints what it would do.
|
|
8
10
|
|
|
9
11
|
Exit 0 when every PR merged and verified; 1 at the first stop; 3 when the profile cannot be read.
|
|
10
12
|
|
|
11
|
-
Usage: caddis_merge_chain.py [--repo PATH] [--gh-repo OWNER/NAME] [--docs-only] [--dry-run]
|
|
13
|
+
Usage: caddis_merge_chain.py [--repo PATH] [--gh-repo OWNER/NAME] [--docs-only] [--dry-run] [--allow-no-checks]
|
|
12
14
|
[--stop-file PATH ...] <pr> [<pr> ...]
|
|
13
15
|
"""
|
|
14
16
|
|
|
@@ -21,13 +23,15 @@ from pathlib import Path
|
|
|
21
23
|
|
|
22
24
|
try:
|
|
23
25
|
import caddis_exit
|
|
26
|
+
import caddis_lanes
|
|
24
27
|
import caddis_prod_check as prod
|
|
25
28
|
except ModuleNotFoundError as exc:
|
|
26
|
-
if exc.name not in {"caddis_exit", "caddis_prod_check"}:
|
|
29
|
+
if exc.name not in {"caddis_exit", "caddis_lanes", "caddis_prod_check", "caddis_heavy_slot", "caddis_routing"}:
|
|
27
30
|
raise
|
|
28
31
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
29
32
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
|
|
30
33
|
import caddis_exit
|
|
34
|
+
import caddis_lanes
|
|
31
35
|
import caddis_prod_check as prod
|
|
32
36
|
|
|
33
37
|
MAX_ATTEMPTS = 8
|
|
@@ -45,6 +49,7 @@ class Stop(Exception):
|
|
|
45
49
|
class ChainContext(prod.Context):
|
|
46
50
|
stop_files: list[Path] = field(default_factory=list)
|
|
47
51
|
docs_only: bool = False
|
|
52
|
+
allow_no_checks: bool = False
|
|
48
53
|
|
|
49
54
|
|
|
50
55
|
def _stop_check(ctx: ChainContext, before: str) -> None:
|
|
@@ -90,26 +95,63 @@ def _head_run(ctx: ChainContext, branch: str, head: str) -> dict | None:
|
|
|
90
95
|
return next((r for r in runs if r.get("headSha") == head), None) # type: ignore[union-attr]
|
|
91
96
|
|
|
92
97
|
|
|
93
|
-
def
|
|
94
|
-
"""
|
|
98
|
+
def _rollup_state(rollup: object, allow_no_checks: bool) -> tuple[str, str]:
|
|
99
|
+
"""(state, detail) from the head's `statusCheckRollup`, by ci-watch's own verdict (`caddis_lanes._rollup_verdict`).
|
|
100
|
+
|
|
101
|
+
state is 'pass', 'fail', 'cancel', 'pending', 'none' (no check reported) or 'skipped' (every check skipped).
|
|
102
|
+
A check that only SKIPPED or NEUTRAL ran no job, so a rollup with no SUCCESS proves nothing: 'skipped', unless
|
|
103
|
+
the owner passed --allow-no-checks for a repo that has no CI at all.
|
|
104
|
+
"""
|
|
105
|
+
verdict = caddis_lanes._rollup_verdict(rollup)
|
|
106
|
+
if verdict is None:
|
|
107
|
+
return ("pass", "no checks reported (--allow-no-checks)") if allow_no_checks else ("none", "")
|
|
108
|
+
_green, failed, unfinished = verdict
|
|
109
|
+
if failed:
|
|
110
|
+
cancelled = {str(i.get("name") or i.get("context") or "?") for i in rollup # type: ignore[union-attr]
|
|
111
|
+
if isinstance(i, dict) and str(i.get("conclusion") or "").upper() == "CANCELLED"}
|
|
112
|
+
return ("cancel" if set(failed) <= cancelled else "fail"), ", ".join(failed)
|
|
113
|
+
if unfinished:
|
|
114
|
+
return "pending", ", ".join(unfinished)
|
|
115
|
+
ran = sum(1 for i in rollup if isinstance(i, dict) # type: ignore[union-attr]
|
|
116
|
+
and str(i.get("conclusion") or i.get("state") or "").upper() == "SUCCESS")
|
|
117
|
+
if not ran:
|
|
118
|
+
return ("pass", "every check skipped (--allow-no-checks)") if allow_no_checks else ("skipped", "")
|
|
119
|
+
return "pass", ""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _wait_checks(ctx: ChainContext, pr: int, head: str) -> str:
|
|
123
|
+
"""Return 'pass', 'fail', 'cancel' or 'moved' for the commit `head`. Raise Stop when no proof comes in time.
|
|
124
|
+
|
|
125
|
+
The verdict is ci-watch's: the `statusCheckRollup` of the PR's `headRefOid`, read in one `gh pr view`. The
|
|
126
|
+
`gh pr checks` table is never read: it can still show an older head's checks.
|
|
127
|
+
"""
|
|
95
128
|
deadline = ctx.clock.now() + CHECKS_WAIT_MINUTES * 60
|
|
96
129
|
while True:
|
|
97
|
-
proc = ctx.gh("pr", "
|
|
98
|
-
if proc.rc != 0
|
|
130
|
+
proc = ctx.gh("pr", "view", "--json", "headRefOid,statusCheckRollup", str(pr))
|
|
131
|
+
if proc.rc != 0:
|
|
99
132
|
# Auth or network failure: say so now instead of waiting 30 minutes as if no check had started.
|
|
100
|
-
raise Stop(f"#{pr}: gh pr
|
|
101
|
-
buckets: list[str] = []
|
|
133
|
+
raise Stop(f"#{pr}: gh pr view failed: {(proc.err or proc.out).strip()[:300]}")
|
|
102
134
|
try:
|
|
103
|
-
|
|
135
|
+
data = prod.json.loads(proc.out) if proc.out.strip() else {}
|
|
104
136
|
except ValueError:
|
|
105
|
-
|
|
106
|
-
if
|
|
107
|
-
|
|
108
|
-
if "
|
|
109
|
-
|
|
110
|
-
|
|
137
|
+
data = {}
|
|
138
|
+
if not isinstance(data, dict):
|
|
139
|
+
data = {}
|
|
140
|
+
if data.get("headRefOid") != head:
|
|
141
|
+
ctx.out(f" #{pr}: the head is now {str(data.get('headRefOid'))[:12]}, not {head[:12]}; reading again")
|
|
142
|
+
return "moved"
|
|
143
|
+
state, detail = _rollup_state(data.get("statusCheckRollup"), ctx.allow_no_checks)
|
|
144
|
+
if state == "pass":
|
|
145
|
+
if detail:
|
|
146
|
+
ctx.out(f" #{pr}: WARNING: {detail} on head {head[:12]}")
|
|
111
147
|
return "pass"
|
|
112
|
-
|
|
148
|
+
if state in ("fail", "cancel"):
|
|
149
|
+
return state
|
|
150
|
+
if state == "skipped":
|
|
151
|
+
raise Stop(f"#{pr}: every check on head {head[:12]} was skipped: no job ran, so this is not a pass. "
|
|
152
|
+
"If this repo has no CI at all, run again with --allow-no-checks")
|
|
153
|
+
waiting = (f"checks pending ({detail})" if state == "pending" else
|
|
154
|
+
"no checks reported yet (if this repo has no CI at all, run again with --allow-no-checks)")
|
|
113
155
|
if ctx.clock.now() >= deadline:
|
|
114
156
|
raise Stop(f"#{pr}: {waiting} after {CHECKS_WAIT_MINUTES} min")
|
|
115
157
|
if ctx.dry_run:
|
|
@@ -159,10 +201,10 @@ def merge_one(ctx: ChainContext, pr: int) -> str | None:
|
|
|
159
201
|
ctx.out(f" WARNING: re-run of {run['databaseId']} refused: {(rerun.err or rerun.out).strip()[:200]}")
|
|
160
202
|
ctx.clock.sleep(60)
|
|
161
203
|
continue
|
|
162
|
-
result = _wait_checks(ctx, pr)
|
|
204
|
+
result = _wait_checks(ctx, pr, head)
|
|
163
205
|
if result == "fail":
|
|
164
206
|
raise Stop(f"#{pr}: CI failed on head {head[:12]}")
|
|
165
|
-
if result
|
|
207
|
+
if result in ("cancel", "moved"):
|
|
166
208
|
continue
|
|
167
209
|
if ctx.dry_run:
|
|
168
210
|
ctx.out(f"#{pr} ({merge_state}): checks green; would wait for idle push runs, then squash-merge {head[:12]}")
|
|
@@ -219,6 +261,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
219
261
|
parser.add_argument("--profile", type=Path, help="default: <repo>/.caddis/prod-check.toml")
|
|
220
262
|
parser.add_argument("--docs-only", action="store_true", help="the PRs change docs only: expect no deploy")
|
|
221
263
|
parser.add_argument("--dry-run", action="store_true", help="read-only gh calls; print the steps")
|
|
264
|
+
parser.add_argument("--allow-no-checks", action="store_true",
|
|
265
|
+
help="the repo has no CI: accept a head with no check, or with every check skipped")
|
|
222
266
|
parser.add_argument("--stop-file", type=Path, action="append", default=[],
|
|
223
267
|
help="extra STOP file (~/.caddis/STOP is always checked)")
|
|
224
268
|
args = parser.parse_args(argv)
|
|
@@ -229,6 +273,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
229
273
|
return caddis_exit.NOT_RUN
|
|
230
274
|
ctx = ChainContext(run=prod.real_runner(args.repo), clock=prod.Clock(), profile=profile, repo=args.repo,
|
|
231
275
|
gh_repo=args.gh_repo, dry_run=args.dry_run, docs_only=args.docs_only,
|
|
276
|
+
allow_no_checks=args.allow_no_checks,
|
|
232
277
|
stop_files=[Path.home() / ".caddis" / "STOP", *args.stop_file])
|
|
233
278
|
return merge_chain(ctx, args.prs)
|
|
234
279
|
|
|
@@ -77,6 +77,33 @@ USAGE_LIMIT_RE = re.compile(
|
|
|
77
77
|
r"Usage limit reached for|Limit Exhausted|Insufficient balance|You.ve hit your .*limit|RESOURCE_EXHAUSTED",
|
|
78
78
|
re.IGNORECASE,
|
|
79
79
|
)
|
|
80
|
+
# A provider failure, not a review: HTTP 429, any 5xx, a rate-limit or overload error. Counts only when the run gave no
|
|
81
|
+
# verdict line (a review may quote such text from the code). 18-19 Oct 2026: a GLM 429 was reported as
|
|
82
|
+
# "FAILED no verdict line" = BLOCKED, which can start a fix session for nothing.
|
|
83
|
+
PROVIDER_FAILURE_RE = re.compile(
|
|
84
|
+
r"API Error: (?:429|5\d\d)\b|\bHTTP (?:429|5\d\d)\b|Too Many Requests|rate_limit_error|overloaded_error"
|
|
85
|
+
r"|Service Unavailable|Bad Gateway|Gateway Time-?out|Internal Server Error",
|
|
86
|
+
re.IGNORECASE,
|
|
87
|
+
)
|
|
88
|
+
CARD_TIMEOUT_SECS = 30 * 60 # one card review; at the limit the launcher tree is killed and the run is NOT_RUN
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _provider_failure(output: str, review_text: str) -> str | None:
|
|
92
|
+
"""Why a run with no verdict line is a provider failure (NOT_RUN), or None when it is a real review."""
|
|
93
|
+
if _verdicts(review_text):
|
|
94
|
+
return None # any verdict line, even two that conflict, means a model wrote a review: BLOCKED, never NOT_RUN
|
|
95
|
+
if not review_text.strip():
|
|
96
|
+
return "empty reply"
|
|
97
|
+
match = PROVIDER_FAILURE_RE.search(output)
|
|
98
|
+
if match:
|
|
99
|
+
return f"provider error ({match.group(0)})"
|
|
100
|
+
try:
|
|
101
|
+
data = _leading_json(output)
|
|
102
|
+
except ValueError: # not JSON (JSONDecodeError is a ValueError)
|
|
103
|
+
data = None
|
|
104
|
+
if isinstance(data, dict) and data.get("is_error") is True:
|
|
105
|
+
return "the run reported is_error"
|
|
106
|
+
return None
|
|
80
107
|
|
|
81
108
|
|
|
82
109
|
def _review_role(repo: Path) -> list[str]:
|
|
@@ -120,12 +147,30 @@ def launch(provider: str, prompt: str, cwd: Path) -> tuple[int, str]:
|
|
|
120
147
|
# and the verdict is lost (seen with DeepSeek on 10 Oct 2026). claude-oss.ps1 sets this for GLM only.
|
|
121
148
|
env = {**os.environ, "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS": "1"}
|
|
122
149
|
# stdin closed: started from a background shell with an open stdin, the launcher hung 40 minutes (10 Oct 2026).
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
150
|
+
# Its own process group (Windows) or session (POSIX), so the timeout kills the whole tree (pwsh, claude), not only
|
|
151
|
+
# the top process. A timeout is NOT_RUN (3): the run switches provider, never BLOCKED.
|
|
152
|
+
group = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if os.name == "nt"
|
|
153
|
+
else {"start_new_session": True})
|
|
154
|
+
proc = subprocess.Popen(cmd, cwd=cwd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
155
|
+
text=True, encoding="utf-8", errors="replace", env=env, **group)
|
|
156
|
+
try:
|
|
157
|
+
stdout, stderr = proc.communicate(timeout=CARD_TIMEOUT_SECS)
|
|
158
|
+
except subprocess.TimeoutExpired:
|
|
159
|
+
_kill_tree(proc)
|
|
160
|
+
try:
|
|
161
|
+
stdout, _stderr = proc.communicate(timeout=60)
|
|
162
|
+
except subprocess.TimeoutExpired:
|
|
163
|
+
stdout = ""
|
|
164
|
+
return caddis_exit.NOT_RUN, (f"caddis_review: card review timed out after {CARD_TIMEOUT_SECS} s; process tree "
|
|
165
|
+
f"killed\n{stdout or ''}")
|
|
166
|
+
except BaseException:
|
|
167
|
+
_kill_tree(proc)
|
|
168
|
+
raise
|
|
169
|
+
stdout, stderr = stdout or "", stderr or ""
|
|
170
|
+
if proc.returncode != 0 and stderr:
|
|
126
171
|
# A limit or key message often comes on stderr; keep it for the switch decision.
|
|
127
|
-
return
|
|
128
|
-
return
|
|
172
|
+
return proc.returncode, (stdout + "\n" + stderr).strip() + "\n"
|
|
173
|
+
return proc.returncode, stdout
|
|
129
174
|
|
|
130
175
|
|
|
131
176
|
def _leading_json(output: str) -> object:
|
|
@@ -261,12 +306,15 @@ def run_review(
|
|
|
261
306
|
break
|
|
262
307
|
|
|
263
308
|
# Limit text counts only when the run gave no verdict: a review may quote such text from the code.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
309
|
+
extracted = _extract_review_text(output)
|
|
310
|
+
has_usage_limit = bool(USAGE_LIMIT_RE.search(output)) and not (code == 0 and _verdicts(extracted))
|
|
311
|
+
# A 429, a 5xx or an empty reply is the provider failing, not the reviewer finding nothing: switch, and
|
|
312
|
+
# NOT_RUN when no provider is left. Never "FAILED no verdict line" (BLOCKED).
|
|
313
|
+
provider_failed = _provider_failure(output, extracted)
|
|
314
|
+
needs_switch = (code == 3) or (code == 2) or has_usage_limit or provider_failed is not None
|
|
267
315
|
|
|
268
316
|
# Again after the JSON decode: an escaped key form that survived in the raw JSON is plain text now.
|
|
269
|
-
review_text = caddis_redact.redact(
|
|
317
|
+
review_text = caddis_redact.redact(extracted, masks)
|
|
270
318
|
review_file = out_dir / f"{card.stem}-{provider}.txt"
|
|
271
319
|
content = review_text.rstrip("\r\n") + f"\nEXIT {code} secs {duration}\n"
|
|
272
320
|
|
|
@@ -277,6 +325,8 @@ def run_review(
|
|
|
277
325
|
reason = "exit 2"
|
|
278
326
|
elif has_usage_limit:
|
|
279
327
|
reason = "usage limit reached"
|
|
328
|
+
elif provider_failed is not None:
|
|
329
|
+
reason = provider_failed
|
|
280
330
|
else:
|
|
281
331
|
reason = f"exit {code}"
|
|
282
332
|
|
|
@@ -241,9 +241,13 @@ def default_branch(root: Path) -> str:
|
|
|
241
241
|
caddis_exit.NOT_RUN)
|
|
242
242
|
|
|
243
243
|
|
|
244
|
+
FETCH = True # `render --no-fetch` (the waiting hook) turns it off for one command: read the origin refs it has
|
|
245
|
+
|
|
246
|
+
|
|
244
247
|
def fetch(root: Path) -> str:
|
|
245
248
|
"""Fetch origin and return the default branch name."""
|
|
246
|
-
|
|
249
|
+
if FETCH:
|
|
250
|
+
_git(root, "fetch", "--quiet", "origin")
|
|
247
251
|
return default_branch(root)
|
|
248
252
|
|
|
249
253
|
|
|
@@ -2218,9 +2222,93 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
2218
2222
|
which.add_argument("--all", action="store_true", help="every roadmap on origin (used by hooks and commands)")
|
|
2219
2223
|
p.add_argument("--root", default=".")
|
|
2220
2224
|
p.add_argument("--out", default=None, help="the HTML file (default ~/.caddis/roadmap-pages/<repo>/<slug>.html)")
|
|
2225
|
+
p.add_argument("--no-fetch", action="store_true",
|
|
2226
|
+
help="use the origin refs this checkout has; no git fetch (the waiting hook's background render)")
|
|
2227
|
+
p.add_argument("--delay", type=int, default=0,
|
|
2228
|
+
help="wait this many seconds (at most 60) first: the waiting hook's follow-up render")
|
|
2221
2229
|
return parser
|
|
2222
2230
|
|
|
2223
2231
|
|
|
2232
|
+
RENDER_LOCK_STALE_S = 600 # a render lock older than this is left by a killed render
|
|
2233
|
+
PENDING_NAME = ".roadmap-render.pending" # session_waiting.py writes it beside its debounce stamp
|
|
2234
|
+
|
|
2235
|
+
|
|
2236
|
+
def _clear(path: Path | None) -> bool:
|
|
2237
|
+
"""Delete `path`; True when it was there."""
|
|
2238
|
+
if path is None:
|
|
2239
|
+
return False
|
|
2240
|
+
try:
|
|
2241
|
+
path.unlink()
|
|
2242
|
+
return True
|
|
2243
|
+
except FileNotFoundError:
|
|
2244
|
+
return False
|
|
2245
|
+
except OSError:
|
|
2246
|
+
return path.exists()
|
|
2247
|
+
|
|
2248
|
+
|
|
2249
|
+
def _take_render_lock(lock: Path) -> bool:
|
|
2250
|
+
"""One render per repo at a time (robustness #8): create `lock` with O_EXCL, never wait. A lock older than
|
|
2251
|
+
RENDER_LOCK_STALE_S is taken over once. False: another render holds it."""
|
|
2252
|
+
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
2253
|
+
for attempt in range(2):
|
|
2254
|
+
try:
|
|
2255
|
+
fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
2256
|
+
except FileExistsError:
|
|
2257
|
+
pass
|
|
2258
|
+
except PermissionError: # Windows: a deleted lock whose delete is still pending
|
|
2259
|
+
return False
|
|
2260
|
+
else:
|
|
2261
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
2262
|
+
fh.write(str(os.getpid()))
|
|
2263
|
+
return True
|
|
2264
|
+
try:
|
|
2265
|
+
stale = time.time() - lock.stat().st_mtime > RENDER_LOCK_STALE_S
|
|
2266
|
+
except FileNotFoundError:
|
|
2267
|
+
stale = True # released between the create and the stat: try again
|
|
2268
|
+
except OSError: # Windows: a lock being deleted cannot be read either
|
|
2269
|
+
return False
|
|
2270
|
+
if attempt or not stale:
|
|
2271
|
+
return False
|
|
2272
|
+
try:
|
|
2273
|
+
lock.unlink()
|
|
2274
|
+
except FileNotFoundError:
|
|
2275
|
+
pass
|
|
2276
|
+
except OSError:
|
|
2277
|
+
return False
|
|
2278
|
+
return False
|
|
2279
|
+
|
|
2280
|
+
|
|
2281
|
+
def _render(root: Path, args: argparse.Namespace) -> int:
|
|
2282
|
+
"""`render`: the pages, the prune and the hub. The caller holds the repo's render lock."""
|
|
2283
|
+
import caddis_roadmap_page
|
|
2284
|
+
slugs, on_origin = list_roadmaps_checked(root) if args.all else ([args.roadmap], None)
|
|
2285
|
+
failed = written = pruned = 0
|
|
2286
|
+
for slug in slugs: # --all: one bad roadmap must not stop the others
|
|
2287
|
+
try:
|
|
2288
|
+
path = caddis_roadmap_page.write_page(root, slug, Path(args.out) if args.out else None,
|
|
2289
|
+
sys.modules[__name__])
|
|
2290
|
+
if path is None: # a newer caddis owns this page: kept, and neither written nor failed
|
|
2291
|
+
continue
|
|
2292
|
+
written += 1
|
|
2293
|
+
print(f"[ROADMAP] page written: {path.resolve()}")
|
|
2294
|
+
except Exception as exc: # --all: one roadmap must never stop the others
|
|
2295
|
+
if not args.all:
|
|
2296
|
+
raise
|
|
2297
|
+
failed += 1
|
|
2298
|
+
print(f"[ROADMAP] {slug}: page not written: {exc}", file=sys.stderr)
|
|
2299
|
+
try: # the hub is a convenience: nothing below ever fails a render or changes its exit code
|
|
2300
|
+
if args.all and on_origin is not None: # a roadmap removed from origin leaves the hub too; a listing
|
|
2301
|
+
# that failed removes nothing, and a folder still on origin keeps its page even if its render failed
|
|
2302
|
+
pruned = caddis_roadmap_page.prune_folder(
|
|
2303
|
+
caddis_roadmap_page.default_page_path(root, "_").parent, on_origin | set(slugs))
|
|
2304
|
+
hub = caddis_roadmap_page.write_hub() if not args.out and (written or pruned) else None
|
|
2305
|
+
if hub is not None: # None: a newer caddis owns the hub (write_hub said so)
|
|
2306
|
+
print(f"[ROADMAP] hub written: {hub.resolve()}")
|
|
2307
|
+
except Exception as exc:
|
|
2308
|
+
print(f"[ROADMAP] hub not written: {exc}", file=sys.stderr)
|
|
2309
|
+
return caddis_exit.BLOCKED if failed else caddis_exit.CLEAN
|
|
2310
|
+
|
|
2311
|
+
|
|
2224
2312
|
def _dispatch(args: argparse.Namespace) -> int:
|
|
2225
2313
|
root = _repo_root(args.root)
|
|
2226
2314
|
if args.cmd == "list":
|
|
@@ -2266,29 +2354,37 @@ def _dispatch(args: argparse.Namespace) -> int:
|
|
|
2266
2354
|
import caddis_roadmap_page # the page is separate; only `render` needs it
|
|
2267
2355
|
if args.all and args.out:
|
|
2268
2356
|
raise RoadmapError("--out works with one --roadmap, not with --all", caddis_exit.ERROR)
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2357
|
+
if args.delay > 0:
|
|
2358
|
+
time.sleep(min(args.delay, 60))
|
|
2359
|
+
folder = caddis_roadmap_page.default_page_path(root, "_").parent.name
|
|
2360
|
+
lock = caddis_roadmap_page.pages_root() / ".locks" / f"{folder}.lock"
|
|
2361
|
+
# The waiting hook's debounce (and a render that finds the lock held) leaves PENDING_NAME: the render that
|
|
2362
|
+
# holds the lock renders once more at its end, so the last mark change always reaches the page.
|
|
2363
|
+
state = caddis_roadmap_page.main_checkout(root) / ".caddis" / "session-state"
|
|
2364
|
+
pending = state / PENDING_NAME if args.all and not args.out else None
|
|
2365
|
+
if not _take_render_lock(lock):
|
|
2366
|
+
if pending is not None and state.is_dir():
|
|
2367
|
+
try:
|
|
2368
|
+
pending.write_text(str(os.getpid()), encoding="utf-8")
|
|
2369
|
+
except OSError:
|
|
2370
|
+
pass
|
|
2371
|
+
print("[ROADMAP] another render of this repo is running; skipped")
|
|
2372
|
+
return caddis_exit.CLEAN
|
|
2373
|
+
global FETCH
|
|
2374
|
+
FETCH = not args.no_fetch
|
|
2375
|
+
try:
|
|
2376
|
+
_clear(pending) # this render reads the marks as they are now
|
|
2377
|
+
code = _render(root, args)
|
|
2378
|
+
if _clear(pending): # a mark changed while it ran: once more, under the same lock
|
|
2379
|
+
code = _render(root, args)
|
|
2380
|
+
return code
|
|
2381
|
+
finally:
|
|
2382
|
+
FETCH = True
|
|
2383
|
+
try: # only our own lock: a render that ran past the stale age may have been taken over
|
|
2384
|
+
if lock.read_text(encoding="utf-8").strip() == str(os.getpid()):
|
|
2385
|
+
lock.unlink()
|
|
2386
|
+
except (OSError, UnicodeDecodeError):
|
|
2387
|
+
pass
|
|
2292
2388
|
return caddis_exit.ERROR
|
|
2293
2389
|
|
|
2294
2390
|
|