@caddis/cli 0.4.56 → 0.4.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundles/antigravity-plugin/caddis_statusline.py +45 -11
- 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_adhoc.py +69 -23
- package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +63 -12
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +9 -8
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +21 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +39 -2
- package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +67 -22
- package/bundles/antigravity-plugin/scripts/caddis_review.py +81 -11
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +124 -29
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +80 -7
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +26 -9
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +24 -4
- package/bundles/antigravity-plugin/scripts/caddis_spend.py +120 -10
- package/bundles/antigravity-plugin/scripts/caddis_statusline.py +45 -11
- package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +14 -9
- package/bundles/antigravity-plugin/scripts/caddis_worktree_safety.py +12 -0
- package/bundles/antigravity-plugin/scripts/hook_log.py +13 -2
- package/bundles/antigravity-plugin/scripts/oss_review.py +300 -79
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +20 -6
- 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 +4 -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/skills/spawn-session/SKILL.md +3 -2
- package/bundles/antigravity-plugin/skills/using-git-worktrees/SKILL.md +52 -175
- 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/codex/.codex/skills/devops/using-git-worktrees/SKILL.md +52 -175
- package/bundles/manifest.json +3 -3
- package/dist/cli.js +310 -46
- package/package.json +1 -1
|
@@ -12,6 +12,7 @@ import ast
|
|
|
12
12
|
import json
|
|
13
13
|
import os
|
|
14
14
|
import re
|
|
15
|
+
import secrets
|
|
15
16
|
import shutil
|
|
16
17
|
import subprocess
|
|
17
18
|
import sys
|
|
@@ -131,7 +132,8 @@ def slugify_prompt(prompt: str) -> str:
|
|
|
131
132
|
if not slug:
|
|
132
133
|
slug = "task"
|
|
133
134
|
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
134
|
-
|
|
135
|
+
# Random suffix: two runs on the same day and topic must never share a branch, plan or run dir.
|
|
136
|
+
return f"{date_str}-{slug}-{secrets.token_hex(3)}"
|
|
135
137
|
|
|
136
138
|
|
|
137
139
|
def synthesize_plan(
|
|
@@ -168,18 +170,73 @@ Task requirements are met, existing tests pass, and new test assertions verify t
|
|
|
168
170
|
return plan_path, slug, title
|
|
169
171
|
|
|
170
172
|
|
|
173
|
+
def session_key() -> str:
|
|
174
|
+
"""Id of this agent session, safe in a file name. One checkout can host several sessions."""
|
|
175
|
+
for var in ("CADDIS_SESSION_ID", "CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID"):
|
|
176
|
+
raw = os.environ.get(var, "").strip()
|
|
177
|
+
key = re.sub(r"[^A-Za-z0-9_-]", "-", raw)[:64].strip("-")
|
|
178
|
+
if key:
|
|
179
|
+
return key
|
|
180
|
+
return "nosession"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _adhoc_dir(repo: Path) -> Path:
|
|
184
|
+
return repo / ".caddis" / "orchestrator" / "adhoc"
|
|
185
|
+
|
|
186
|
+
|
|
171
187
|
def adhoc_latest_path(repo: Path) -> Path:
|
|
172
|
-
return repo / "
|
|
188
|
+
return _adhoc_dir(repo) / f"latest-{session_key()}.json"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def write_latest(repo: Path, state: dict) -> None:
|
|
192
|
+
path = adhoc_latest_path(repo)
|
|
193
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
path.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
|
|
173
195
|
|
|
174
196
|
|
|
175
197
|
def read_latest(repo: Path) -> dict | None:
|
|
176
198
|
path = adhoc_latest_path(repo)
|
|
177
199
|
if not path.is_file():
|
|
178
|
-
|
|
200
|
+
# Migration: the old single latest.json belongs to whoever reads it first. The rename is
|
|
201
|
+
# atomic, so exactly one session adopts it and no other session ever sees it.
|
|
202
|
+
legacy = _adhoc_dir(repo) / "latest.json"
|
|
203
|
+
if not legacy.is_file():
|
|
204
|
+
return None
|
|
205
|
+
try:
|
|
206
|
+
os.replace(legacy, path)
|
|
207
|
+
except OSError:
|
|
208
|
+
return None
|
|
179
209
|
try:
|
|
180
|
-
|
|
210
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
181
211
|
except (OSError, json.JSONDecodeError):
|
|
182
212
|
return None
|
|
213
|
+
return data if isinstance(data, dict) else None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def move_branch(repo: Path, branch: str, commit_sha: str, base_sha: str) -> str:
|
|
217
|
+
"""Point `branch` at `commit_sha`. Return "" on success, else why it was refused.
|
|
218
|
+
|
|
219
|
+
An existing branch is moved only when its tip is merged into the base, equals the new commit, or
|
|
220
|
+
is the tip this session recorded for it. Otherwise it holds work that moving would orphan.
|
|
221
|
+
"""
|
|
222
|
+
tip = git_output(repo, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}")
|
|
223
|
+
if tip and tip != commit_sha:
|
|
224
|
+
merged = subprocess.run(
|
|
225
|
+
["git", "-C", str(repo), "merge-base", "--is-ancestor", tip, base_sha],
|
|
226
|
+
capture_output=True,
|
|
227
|
+
).returncode == 0
|
|
228
|
+
state = read_latest(repo) or {}
|
|
229
|
+
own = state.get("branch") == branch and state.get("commit_sha") == tip
|
|
230
|
+
if not merged and not own:
|
|
231
|
+
return (f"branch '{branch}' has commits not merged into {base_sha[:7]} "
|
|
232
|
+
f"(tip {tip[:7]}); refusing to move it. Delete or merge it first.")
|
|
233
|
+
res = subprocess.run(
|
|
234
|
+
["git", "-C", str(repo), "branch", "-f", branch, commit_sha],
|
|
235
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
236
|
+
)
|
|
237
|
+
if res.returncode != 0:
|
|
238
|
+
return f"branch update failed: {res.stderr.strip()}"
|
|
239
|
+
return ""
|
|
183
240
|
|
|
184
241
|
|
|
185
242
|
# Tool caches every gate run regenerates. They are the only ignored files a worktree may be
|
|
@@ -355,7 +412,7 @@ def run_adhoc(
|
|
|
355
412
|
}
|
|
356
413
|
if len(kept_paths) > 1:
|
|
357
414
|
state["kept_worktrees"] = [str(path) for path in kept_paths]
|
|
358
|
-
|
|
415
|
+
write_latest(repo, state)
|
|
359
416
|
return EXIT_FAIL
|
|
360
417
|
|
|
361
418
|
worktree_str = packet.get("worktree")
|
|
@@ -468,20 +525,14 @@ def run_adhoc(
|
|
|
468
525
|
|
|
469
526
|
if not reason:
|
|
470
527
|
try:
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
474
|
-
)
|
|
475
|
-
if branch_res.returncode != 0:
|
|
476
|
-
reason = f"branch update failed: {branch_res.stderr.strip()}"
|
|
477
|
-
else:
|
|
528
|
+
reason = move_branch(repo, adhoc_branch, commit_sha, base_sha)
|
|
529
|
+
if not reason:
|
|
478
530
|
# An OSError from the helper (git not runnable) lands in the `except OSError` below.
|
|
479
531
|
problem = caddis_worktree_safety.safe_remove_worktree(repo, wt_path)
|
|
480
532
|
if problem or wt_path.exists():
|
|
481
533
|
reason = problem or f"worktree remove failed: {wt_path}"
|
|
482
534
|
else:
|
|
483
|
-
kept_worktree = None
|
|
484
|
-
subprocess.run(["git", "-C", str(repo), "worktree", "prune"], capture_output=True)
|
|
535
|
+
kept_worktree = None # `git worktree remove` dropped its registration; no prune (robustness #19)
|
|
485
536
|
attempt_branch = packet.get("branch")
|
|
486
537
|
if attempt_branch and attempt_branch != adhoc_branch:
|
|
487
538
|
subprocess.run(
|
|
@@ -503,7 +554,7 @@ def run_adhoc(
|
|
|
503
554
|
diff_stat = git_output(repo, "diff", "--shortstat", base_sha, diff_ref)
|
|
504
555
|
diff_names = verified_names or changed_files
|
|
505
556
|
|
|
506
|
-
# Save state
|
|
557
|
+
# Save this session's state (latest-<session>.json)
|
|
507
558
|
state = {
|
|
508
559
|
"slug": slug,
|
|
509
560
|
"title": title,
|
|
@@ -525,7 +576,7 @@ def run_adhoc(
|
|
|
525
576
|
"plan_path": plan_rel,
|
|
526
577
|
"created": datetime.now(timezone.utc).isoformat(),
|
|
527
578
|
}
|
|
528
|
-
|
|
579
|
+
write_latest(repo, state)
|
|
529
580
|
|
|
530
581
|
# Render Verdict Card
|
|
531
582
|
is_success = not reason and kept_worktree is None
|
|
@@ -618,13 +669,8 @@ def drop_adhoc(repo: Path) -> int:
|
|
|
618
669
|
if problem:
|
|
619
670
|
print(f"[mmr] Could not remove kept worktree: {problem}")
|
|
620
671
|
return EXIT_FAIL
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
624
|
-
)
|
|
625
|
-
if pruned.returncode != 0:
|
|
626
|
-
print(f"[mmr] Could not prune worktrees: {pruned.stderr.strip()}")
|
|
627
|
-
return EXIT_FAIL
|
|
672
|
+
# No `git worktree prune` here: it drops the registrations of every other worktree whose folder is missing
|
|
673
|
+
# for the moment, and `git worktree remove` has already dropped this one (robustness #19).
|
|
628
674
|
|
|
629
675
|
if branch:
|
|
630
676
|
subprocess.run(["git", "-C", str(repo), "branch", "-D", branch], capture_output=True)
|
|
@@ -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, ""
|
|
@@ -209,11 +209,10 @@ def _remove(main: Path, worktree: Path, branch: str) -> None:
|
|
|
209
209
|
caddis_worktree_safety.safe_remove_worktree(main, worktree, force_locked=True, env=roadmap._quiet_env())
|
|
210
210
|
except OSError:
|
|
211
211
|
pass
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
pass
|
|
212
|
+
try: # no `git worktree prune`: it would drop other tools' registrations too (robustness #19)
|
|
213
|
+
_git(main, "branch", "-D", branch, check=False)
|
|
214
|
+
except (roadmap.RoadmapError, OSError):
|
|
215
|
+
pass
|
|
217
216
|
if worktree.exists():
|
|
218
217
|
caddis_worktree_safety.remove_tree(worktree)
|
|
219
218
|
|
|
@@ -234,6 +233,7 @@ def finish(worktree: Path, title: str, body: str, out=print) -> int:
|
|
|
234
233
|
out("PR: none")
|
|
235
234
|
return caddis_exit.CLEAN
|
|
236
235
|
pushed = False
|
|
236
|
+
pushed_tip = ""
|
|
237
237
|
try:
|
|
238
238
|
gh_repo = roadmap.gh_repo_of(main)
|
|
239
239
|
if gh_repo is None:
|
|
@@ -242,14 +242,15 @@ def finish(worktree: Path, title: str, body: str, out=print) -> int:
|
|
|
242
242
|
_git(worktree, "commit", "--quiet", "-m", title)
|
|
243
243
|
_git(worktree, "push", "--quiet", "-u", "origin", branch)
|
|
244
244
|
pushed = True
|
|
245
|
+
pushed_tip = _git(worktree, "rev-parse", "HEAD").stdout.strip() # the only tip we may delete
|
|
245
246
|
url = open_pr(gh_repo, branch, base, title, body)
|
|
246
247
|
except (DocsPrError, roadmap.RoadmapError, OSError) as exc:
|
|
247
248
|
out(f"docs PR failed: {exc}")
|
|
248
249
|
out(f"PATCH: {patch}")
|
|
249
250
|
if pushed:
|
|
250
|
-
try:
|
|
251
|
-
_git(main,
|
|
252
|
-
except (roadmap.RoadmapError, OSError):
|
|
251
|
+
try: # only while the remote tip is still the commit we pushed
|
|
252
|
+
_git(main, *caddis_worktree_safety.remote_delete_args(branch, pushed_tip), check=False)
|
|
253
|
+
except (roadmap.RoadmapError, OSError, ValueError):
|
|
253
254
|
pass
|
|
254
255
|
_remove(main, worktree, branch)
|
|
255
256
|
return getattr(exc, "code", caddis_exit.NOT_RUN)
|
|
@@ -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
|
|
@@ -274,7 +275,6 @@ def remove_phase_worktree(
|
|
|
274
275
|
problem = f"worktree remove left {resolved} on disk"
|
|
275
276
|
if problem:
|
|
276
277
|
raise LaneConfigError(problem)
|
|
277
|
-
_git_output(repo_root, "worktree", "prune")
|
|
278
278
|
if delete_branch:
|
|
279
279
|
_git_output(repo_root, "branch", "-D", "--", wt.branch)
|
|
280
280
|
|
|
@@ -1265,7 +1265,10 @@ def child_env(base: dict, lane: str, model: str) -> dict:
|
|
|
1265
1265
|
for name in LANE_SECRET_VARS:
|
|
1266
1266
|
env.pop(name, None)
|
|
1267
1267
|
env.update(LANE_NO_PROMPT_ENV)
|
|
1268
|
-
|
|
1268
|
+
try:
|
|
1269
|
+
env = caddis_spend.bump_headless_depth(env) # sets CADDIS_HEADLESS=1 and the depth (robustness #19)
|
|
1270
|
+
except caddis_spend.HeadlessDepthExceeded as exc:
|
|
1271
|
+
raise LaneConfigError(str(exc)) from exc
|
|
1269
1272
|
if lane == "glm":
|
|
1270
1273
|
env["OSS_MODEL"] = model
|
|
1271
1274
|
return env
|
|
@@ -2165,6 +2168,28 @@ def _printable(value: object) -> str:
|
|
|
2165
2168
|
return _CONTROL_CHARS.sub("", text)
|
|
2166
2169
|
|
|
2167
2170
|
|
|
2171
|
+
# Accept fast-forwards the SESSION checkout. In a checkout a dev service serves from, or one that holds a peer's
|
|
2172
|
+
# uncommitted edits, that moves files under them (robustness #11). The lane's own scaffolding is not a peer's change.
|
|
2173
|
+
_LANE_OWN_PREFIX = ".caddis/orchestrator/"
|
|
2174
|
+
|
|
2175
|
+
|
|
2176
|
+
def shared_checkout_reason(repo: Path) -> str:
|
|
2177
|
+
"""Why accept must not fast-forward `repo`, or ''. Shared (AGENTS.md) and/or foreign changes (git status):
|
|
2178
|
+
every reason that holds, joined with "; ", so an --allow-shared audit records all of them."""
|
|
2179
|
+
reasons = []
|
|
2180
|
+
if caddis_checkout_guard.checkout_is_shared(repo):
|
|
2181
|
+
reasons.append("AGENTS.md says Shared checkout: yes")
|
|
2182
|
+
try:
|
|
2183
|
+
dirty = caddis_checkout_guard.foreign_changes(repo)
|
|
2184
|
+
except caddis_checkout_guard.CheckoutStatusError:
|
|
2185
|
+
reasons.append("git status failed, so changes by other sessions cannot be ruled out")
|
|
2186
|
+
dirty = []
|
|
2187
|
+
foreign = [path for path in dirty if not path.replace("\\", "/").startswith(_LANE_OWN_PREFIX)]
|
|
2188
|
+
if foreign:
|
|
2189
|
+
reasons.append("changes not made by this lane: " + ", ".join(foreign[:3]))
|
|
2190
|
+
return "; ".join(reasons)
|
|
2191
|
+
|
|
2192
|
+
|
|
2168
2193
|
def _accept_command(
|
|
2169
2194
|
args,
|
|
2170
2195
|
repo: Path,
|
|
@@ -2221,6 +2246,16 @@ def _accept_command(
|
|
|
2221
2246
|
or packet["worktree_digest"] != worktree_digest(wt.path, files)):
|
|
2222
2247
|
print("refusing: worktree changed since check")
|
|
2223
2248
|
return EXIT_FAIL
|
|
2249
|
+
unsafe = shared_checkout_reason(repo)
|
|
2250
|
+
if unsafe and not getattr(args, "allow_shared", False):
|
|
2251
|
+
print(f"refusing: the session checkout is not safe to fast-forward ({_printable(unsafe)}). "
|
|
2252
|
+
"Run the programme from a worktree of origin/<default> under %TEMP% (git worktree add), "
|
|
2253
|
+
"or pass --allow-shared to fast-forward here anyway")
|
|
2254
|
+
return EXIT_USAGE
|
|
2255
|
+
if unsafe:
|
|
2256
|
+
packet["allow_shared"] = unsafe
|
|
2257
|
+
write_packet(packet_path, packet)
|
|
2258
|
+
print(f"ALLOW SHARED: {_printable(unsafe)}")
|
|
2224
2259
|
if args.override_review is not None:
|
|
2225
2260
|
packet["override_review"] = args.override_review
|
|
2226
2261
|
write_packet(packet_path, packet)
|
|
@@ -2458,6 +2493,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
2458
2493
|
_add_phase_arguments(accept)
|
|
2459
2494
|
accept.add_argument("--message", required=True)
|
|
2460
2495
|
accept.add_argument("--override-review")
|
|
2496
|
+
accept.add_argument("--allow-shared", action="store_true",
|
|
2497
|
+
help="fast-forward a shared checkout or one with foreign changes anyway (recorded)")
|
|
2461
2498
|
abandon = subparsers.add_parser("abandon")
|
|
2462
2499
|
_add_phase_arguments(abandon)
|
|
2463
2500
|
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
|
|