@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.
Files changed (31) hide show
  1. package/bundles/antigravity-plugin/guard_agy.py +700 -68
  2. package/bundles/antigravity-plugin/hooks.json +2 -1
  3. package/bundles/antigravity-plugin/plugin.json +1 -1
  4. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +63 -12
  5. package/bundles/antigravity-plugin/scripts/caddis_gate.py +21 -0
  6. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +35 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +67 -22
  8. package/bundles/antigravity-plugin/scripts/caddis_review.py +59 -9
  9. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +120 -24
  10. package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +80 -7
  11. package/bundles/antigravity-plugin/scripts/oss_review.py +300 -79
  12. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +4 -0
  13. package/bundles/antigravity-plugin/session_end_agy.py +13 -10
  14. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +53 -8
  15. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +11 -8
  16. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +1 -1
  17. package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +9 -5
  18. package/bundles/antigravity-plugin/skills/implement/SKILL.md +18 -11
  19. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +1 -1
  20. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +44 -4
  21. package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +13 -4
  22. package/bundles/antigravity-plugin/skills/ship/SKILL.md +26 -7
  23. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +31 -0
  24. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +42 -4
  25. package/bundles/antigravity-plugin/warm_start_agy.py +26 -15
  26. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  27. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
  28. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +1 -1
  29. package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +9 -5
  30. package/bundles/manifest.json +3 -3
  31. package/package.json +1 -1
@@ -9,7 +9,8 @@
9
9
  "command": "python guard_agy.py",
10
10
  "windows": "python guard_agy.py",
11
11
  "linux": "python3 guard_agy.py",
12
- "osx": "python3 guard_agy.py"
12
+ "osx": "python3 guard_agy.py",
13
+ "timeout": 30
13
14
  }
14
15
  ]
15
16
  }
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.142",
3
+ "version": "1.3.143",
4
4
  "description": "caddis — agent-agnostic dev harness as an Antigravity (agy) plugin: TDD skills + commands, single-sourced with the Claude Code plugin."
5
5
  }
@@ -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
- def is_shared_checkout(root: Path, own_files: set[str]) -> tuple[bool, str]:
83
- """(True, reason) when handoff must not write tracked files in `root`; else (False, "").
87
+ class CheckoutStatusError(OSError):
88
+ """`git status` could not run in the checkout, so foreign changes cannot be ruled out."""
84
89
 
85
- `Shared checkout: no` in AGENTS.md never hides a change this session did not make. When git status cannot
86
- run, the checkout counts as shared: a wrong "no" could commit another session's files.
87
- """
88
- agents = Path(root) / "AGENTS.md"
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
- text = agents.read_text(encoding="utf-8", errors="replace") if agents.is_file() else ""
106
+ line = gitfile.read_text(encoding="utf-8", errors="replace").strip()
91
107
  except OSError:
92
- text = ""
93
- if SHARED_RE.search(text):
94
- return True, "AGENTS.md says Shared checkout: yes"
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
- return True, "git status failed, so changes by other sessions cannot be ruled out"
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; "no checks reported" waits; a failed check stops;
5
- no merge while a push run is in progress on the default branch; squash merge with --match-head-commit; then the merge
6
- SHA must get a push run, and `caddis_prod_check.py` must pass before the next PR starts. A STOP file is checked before
7
- each update-branch and each merge. --dry-run makes read-only `gh` calls and prints what it would do.
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 _wait_checks(ctx: ChainContext, pr: int) -> str:
94
- """Return 'pass', 'fail' or 'cancel'. Raise Stop when no check reports in time."""
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", "checks", str(pr), "--json", "name,bucket")
98
- if proc.rc != 0 and not proc.out.strip() and "no checks reported" not in proc.err + proc.out:
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 checks failed: {(proc.err or proc.out).strip()[:300]}")
101
- buckets: list[str] = []
133
+ raise Stop(f"#{pr}: gh pr view failed: {(proc.err or proc.out).strip()[:300]}")
102
134
  try:
103
- buckets = [c.get("bucket", "") for c in prod.json.loads(proc.out)] if proc.out.strip() else []
135
+ data = prod.json.loads(proc.out) if proc.out.strip() else {}
104
136
  except ValueError:
105
- buckets = []
106
- if "fail" in buckets:
107
- return "fail"
108
- if "cancel" in buckets:
109
- return "cancel"
110
- if buckets and "pending" not in buckets:
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
- waiting = "no checks reported yet" if not buckets else "checks pending"
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 == "cancel":
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
- res = subprocess.run(cmd, cwd=cwd, stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8",
124
- errors="replace", env=env)
125
- if res.returncode != 0 and res.stderr:
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 res.returncode, (res.stdout + "\n" + res.stderr).strip() + "\n"
128
- return res.returncode, res.stdout
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
- has_usage_limit = bool(USAGE_LIMIT_RE.search(output)) and not (
265
- code == 0 and _verdicts(_extract_review_text(output)))
266
- needs_switch = (code == 3) or (code == 2) or has_usage_limit
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(_extract_review_text(output), masks)
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
- _git(root, "fetch", "--quiet", "origin")
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
- slugs, on_origin = list_roadmaps_checked(root) if args.all else ([args.roadmap], None)
2270
- failed = written = pruned = 0
2271
- for slug in slugs: # --all: one bad roadmap must not stop the others
2272
- try:
2273
- path = caddis_roadmap_page.write_page(root, slug, Path(args.out) if args.out else None,
2274
- sys.modules[__name__])
2275
- written += 1
2276
- print(f"[ROADMAP] page written: {path.resolve()}")
2277
- except Exception as exc: # --all: one roadmap must never stop the others
2278
- if not args.all:
2279
- raise
2280
- failed += 1
2281
- print(f"[ROADMAP] {slug}: page not written: {exc}", file=sys.stderr)
2282
- try: # the hub is a convenience: nothing below ever fails a render or changes its exit code
2283
- if args.all and on_origin is not None: # a roadmap removed from origin leaves the hub too; a listing
2284
- # that failed removes nothing, and a folder still on origin keeps its page even if its render failed
2285
- pruned = caddis_roadmap_page.prune_folder(
2286
- caddis_roadmap_page.default_page_path(root, "_").parent, on_origin | set(slugs))
2287
- if not args.out and (written or pruned):
2288
- print(f"[ROADMAP] hub written: {caddis_roadmap_page.write_hub().resolve()}")
2289
- except Exception as exc:
2290
- print(f"[ROADMAP] hub not written: {exc}", file=sys.stderr)
2291
- return caddis_exit.BLOCKED if failed else caddis_exit.CLEAN
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