@caddis/cli 0.4.13 → 0.4.14
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/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +146 -0
- package/bundles/antigravity-plugin/scripts/caddis-run.ps1 +11 -0
- package/bundles/antigravity-plugin/scripts/caddis-run.sh +9 -0
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +24 -3
- package/bundles/antigravity-plugin/scripts/caddis_governor.py +542 -0
- package/bundles/antigravity-plugin/scripts/caddis_host_guard.py +689 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +1632 -0
- package/bundles/antigravity-plugin/scripts/install-host-guard.ps1 +393 -0
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +30 -4
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +48 -1
- package/bundles/antigravity-plugin/warm_start_agy.py +38 -4
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,1632 @@
|
|
|
1
|
+
"""caddis_lanes — run one plan phase on an execution lane, re-check it, and write a result packet.
|
|
2
|
+
|
|
3
|
+
The pure core resolves configured lanes and builds confined child-process invocations.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import importlib.util
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shlex
|
|
13
|
+
import shutil
|
|
14
|
+
import signal
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
import time
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
24
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
25
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
26
|
+
|
|
27
|
+
EXIT_OK, EXIT_FAIL, EXIT_USAGE, EXIT_CONFIG, EXIT_ALL_OUT = 0, 1, 2, 3, 4
|
|
28
|
+
RESULT_OK = "OK"
|
|
29
|
+
RESULT_LANE_FAILED = "LANE-FAILED"
|
|
30
|
+
RESULT_OUT_OF_BUDGET = "OUT-OF-BUDGET"
|
|
31
|
+
RESULT_CONFIG_ERROR = "CONFIG-ERROR"
|
|
32
|
+
RESULT_RESOURCE = "FAILED-RESOURCE"
|
|
33
|
+
MAX_ATTEMPTS = 2
|
|
34
|
+
DEFAULT_TIMEOUT_S = 3600
|
|
35
|
+
LANE_IDS = ("claude", "glm", "codex-sol", "codex-astra", "agy")
|
|
36
|
+
TASK_TYPES = ("backend", "hard", "ui", "tests", "docs")
|
|
37
|
+
LANES_TMP = Path(tempfile.gettempdir()) / "caddis-lanes"
|
|
38
|
+
DEFAULT_MODELS = {
|
|
39
|
+
"claude": "claude-opus-5",
|
|
40
|
+
"glm": "glm-5.3[1m]",
|
|
41
|
+
"codex-sol": "gpt-5.6-sol",
|
|
42
|
+
"codex-astra": "gpt-6-astra",
|
|
43
|
+
"agy": "gemini-3.8-flash",
|
|
44
|
+
}
|
|
45
|
+
DEFAULT_EFFORT = {
|
|
46
|
+
"claude": "high",
|
|
47
|
+
"glm": "medium",
|
|
48
|
+
"codex-sol": "medium",
|
|
49
|
+
"codex-astra": "high",
|
|
50
|
+
"agy": "medium",
|
|
51
|
+
}
|
|
52
|
+
DEFAULT_CHAINS = {
|
|
53
|
+
"backend": ("glm", "codex-sol", "claude"),
|
|
54
|
+
"hard": ("claude", "codex-astra"),
|
|
55
|
+
"ui": ("claude", "codex-astra"),
|
|
56
|
+
"tests": ("codex-sol", "glm", "claude"),
|
|
57
|
+
"docs": ("agy", "glm", "claude"),
|
|
58
|
+
}
|
|
59
|
+
BUDGET_PATTERNS: dict[str, tuple[str, ...]] = {
|
|
60
|
+
"claude": (),
|
|
61
|
+
# https://docs.z.ai/api-reference/api-code
|
|
62
|
+
"glm": (
|
|
63
|
+
r"API Error: 429.*\"type\"\s*:\s*\"(?:1113|1308|1309|1310|1316|1317)\"",
|
|
64
|
+
r"Insufficient balance or no resource package",
|
|
65
|
+
r"Usage limit reached for",
|
|
66
|
+
r"Limit Exhausted",
|
|
67
|
+
),
|
|
68
|
+
# https://raw.githubusercontent.com/openai/codex/main/codex-rs/protocol/src/error.rs
|
|
69
|
+
"codex-sol": (
|
|
70
|
+
r"You've hit your usage limit",
|
|
71
|
+
r"Quota exceeded\. Check your plan",
|
|
72
|
+
r"workspace is out of credits",
|
|
73
|
+
),
|
|
74
|
+
# https://raw.githubusercontent.com/openai/codex/main/codex-rs/protocol/src/error.rs
|
|
75
|
+
"codex-astra": (
|
|
76
|
+
r"You've hit your usage limit",
|
|
77
|
+
r"Quota exceeded\. Check your plan",
|
|
78
|
+
r"workspace is out of credits",
|
|
79
|
+
),
|
|
80
|
+
"agy": (r"RESOURCE_EXHAUSTED.*quota", r"Individual quota reached"),
|
|
81
|
+
}
|
|
82
|
+
CONFIG_PATTERNS: dict[str, tuple[str, ...]] = {
|
|
83
|
+
"claude": (),
|
|
84
|
+
"glm": (
|
|
85
|
+
r"API Error: 400 \[1211\]",
|
|
86
|
+
r"Unknown Model, please check the model code",
|
|
87
|
+
r"no API key for provider",
|
|
88
|
+
),
|
|
89
|
+
"codex-sol": (
|
|
90
|
+
r"model is not supported when using Codex",
|
|
91
|
+
r"Model metadata for .* not found",
|
|
92
|
+
),
|
|
93
|
+
"codex-astra": (
|
|
94
|
+
r"model is not supported when using Codex",
|
|
95
|
+
r"Model metadata for .* not found",
|
|
96
|
+
),
|
|
97
|
+
"agy": (r"invalid model selection", r"is not recognized as a known model"),
|
|
98
|
+
}
|
|
99
|
+
LOAD_WORDING = (
|
|
100
|
+
r"(?i)\bcpu\s+burner",
|
|
101
|
+
r"(?i)\bburner",
|
|
102
|
+
r"(?i)stress[- ]?test",
|
|
103
|
+
r"(?i)load[- ]?test",
|
|
104
|
+
r"(?i)every\s+(logical\s+)?(core|processor)",
|
|
105
|
+
r"(?i)\bsaturat\w*",
|
|
106
|
+
r"(?i)busy[- ]?(loop|spin|wait)",
|
|
107
|
+
r"(?i)do\s+not\s+lower\s+(its|the)\s+priority",
|
|
108
|
+
)
|
|
109
|
+
ORCHESTRATOR_AGENTS_MD = """# .caddis/orchestrator/ — multi-lane orchestration artefacts
|
|
110
|
+
|
|
111
|
+
Created by caddis_lanes.py. Binding for every agent that writes here.
|
|
112
|
+
|
|
113
|
+
- Everything the multi-lane orchestration machinery writes in this repo lives in this folder.
|
|
114
|
+
- `lane-runs/<plan-stem>/` holds run packets (`phase-NN.attempt-K.json`) and phase locks. It is gitignored. Never commit it, and never edit a packet by hand.
|
|
115
|
+
- Evidence notes (recorded lane exit signals, live runs, dry runs, bake-off results) are markdown files directly in this folder, with caddis frontmatter (`type: analysis` or `type: review`). They are tracked.
|
|
116
|
+
- Never write orchestration notes to `.caddis/kb/`, the repo root or `.github/`.
|
|
117
|
+
- Never overwrite this file.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class LaneConfigError(Exception):
|
|
122
|
+
"""A lane, chain, model or flag set that must not run."""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class LaneRun:
|
|
127
|
+
returncode: int | None
|
|
128
|
+
output: str
|
|
129
|
+
timed_out: bool
|
|
130
|
+
duration_s: float
|
|
131
|
+
pid: int
|
|
132
|
+
started: float
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class GateResult:
|
|
137
|
+
cmd: str
|
|
138
|
+
returncode: int
|
|
139
|
+
passed: bool
|
|
140
|
+
tail: str
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class ReviewResult:
|
|
145
|
+
status: str
|
|
146
|
+
returncode: int | None
|
|
147
|
+
tail: str
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class Worktree:
|
|
152
|
+
path: Path
|
|
153
|
+
branch: str
|
|
154
|
+
base_sha: str
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _git_output(repo_root: Path, *args: str) -> str:
|
|
158
|
+
try:
|
|
159
|
+
result = subprocess.run(
|
|
160
|
+
["git", "-C", str(repo_root), *args],
|
|
161
|
+
capture_output=True,
|
|
162
|
+
text=True,
|
|
163
|
+
encoding="utf-8",
|
|
164
|
+
errors="replace",
|
|
165
|
+
)
|
|
166
|
+
except OSError as exc:
|
|
167
|
+
raise LaneConfigError(str(exc)) from exc
|
|
168
|
+
if result.returncode != 0:
|
|
169
|
+
message = result.stderr.strip() or result.stdout.strip()
|
|
170
|
+
raise LaneConfigError(message or f"git {' '.join(args)} failed")
|
|
171
|
+
return result.stdout.strip()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def create_phase_worktree(
|
|
175
|
+
repo_root: Path,
|
|
176
|
+
plan_stem: str,
|
|
177
|
+
phase: int,
|
|
178
|
+
attempt: int,
|
|
179
|
+
) -> Worktree:
|
|
180
|
+
base_sha = _git_output(repo_root, "rev-parse", "HEAD")
|
|
181
|
+
branch = f"lane/{plan_stem}/phase-{phase:02d}-a{attempt}"
|
|
182
|
+
repo_hash = hashlib.sha256(str(repo_root.resolve()).encode("utf-8")).hexdigest()[:8]
|
|
183
|
+
path = LANES_TMP / (
|
|
184
|
+
f"{repo_root.name}-{repo_hash}-{plan_stem}-p{phase:02d}-a{attempt}"
|
|
185
|
+
)
|
|
186
|
+
if path.exists():
|
|
187
|
+
raise LaneConfigError(f"worktree already exists: {path} — run abandon first")
|
|
188
|
+
|
|
189
|
+
LANES_TMP.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
_git_output(
|
|
191
|
+
repo_root,
|
|
192
|
+
"worktree",
|
|
193
|
+
"add",
|
|
194
|
+
"-b",
|
|
195
|
+
branch,
|
|
196
|
+
str(path),
|
|
197
|
+
base_sha,
|
|
198
|
+
)
|
|
199
|
+
print(f"[caddis-lanes] worktree: {path}", file=sys.stderr)
|
|
200
|
+
return Worktree(path=path, branch=branch, base_sha=base_sha)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def remove_phase_worktree(
|
|
204
|
+
repo_root: Path,
|
|
205
|
+
wt: Worktree,
|
|
206
|
+
delete_branch: bool,
|
|
207
|
+
) -> None:
|
|
208
|
+
resolved = wt.path.resolve()
|
|
209
|
+
if LANES_TMP.resolve() not in resolved.parents:
|
|
210
|
+
raise LaneConfigError(f"refusing to remove {resolved}: not under {LANES_TMP}")
|
|
211
|
+
if delete_branch and not wt.branch.startswith("lane/"):
|
|
212
|
+
raise LaneConfigError(f"refusing to delete non-lane branch: {wt.branch}")
|
|
213
|
+
|
|
214
|
+
_git_output(repo_root, "worktree", "remove", "--force", str(resolved))
|
|
215
|
+
_git_output(repo_root, "worktree", "prune")
|
|
216
|
+
if delete_branch:
|
|
217
|
+
_git_output(repo_root, "branch", "-D", "--", wt.branch)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _venv_python(venv_root: Path) -> Path | None:
|
|
221
|
+
for candidate in (
|
|
222
|
+
venv_root / "Scripts" / "python.exe",
|
|
223
|
+
venv_root / "bin" / "python",
|
|
224
|
+
):
|
|
225
|
+
if candidate.exists():
|
|
226
|
+
return candidate
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def repo_python(repo_root: Path) -> str:
|
|
231
|
+
python = _venv_python(repo_root / ".venv")
|
|
232
|
+
return str(python) if python is not None else sys.executable
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def gate_env(repo_root: Path, base: dict) -> dict:
|
|
236
|
+
env = base.copy()
|
|
237
|
+
venv = repo_root / ".venv"
|
|
238
|
+
if not venv.exists():
|
|
239
|
+
return env
|
|
240
|
+
|
|
241
|
+
env["VIRTUAL_ENV"] = str(venv)
|
|
242
|
+
for scripts_dir in (venv / "Scripts", venv / "bin"):
|
|
243
|
+
if scripts_dir.exists():
|
|
244
|
+
env["PATH"] = str(scripts_dir) + os.pathsep + env.get("PATH", "")
|
|
245
|
+
break
|
|
246
|
+
return env
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def freeze_snapshot(python: str) -> str | None:
|
|
250
|
+
try:
|
|
251
|
+
result = subprocess.run(
|
|
252
|
+
[python, "-m", "pip", "freeze", "--all"],
|
|
253
|
+
check=True,
|
|
254
|
+
capture_output=True,
|
|
255
|
+
text=True,
|
|
256
|
+
encoding="utf-8",
|
|
257
|
+
errors="replace",
|
|
258
|
+
)
|
|
259
|
+
except Exception:
|
|
260
|
+
return None
|
|
261
|
+
return result.stdout
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def shared_venv_pythons(repo_root: Path, cfg: dict) -> list[str]:
|
|
265
|
+
pythons = []
|
|
266
|
+
shared_venvs = cfg.get("shared_venvs", [])
|
|
267
|
+
if not isinstance(shared_venvs, list):
|
|
268
|
+
return pythons
|
|
269
|
+
|
|
270
|
+
for configured_path in shared_venvs:
|
|
271
|
+
if not isinstance(configured_path, str):
|
|
272
|
+
continue
|
|
273
|
+
venv = Path(configured_path)
|
|
274
|
+
if not venv.is_absolute():
|
|
275
|
+
venv = repo_root / venv
|
|
276
|
+
python = _venv_python(venv)
|
|
277
|
+
if python is not None:
|
|
278
|
+
pythons.append(str(python))
|
|
279
|
+
return pythons
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def governor_path() -> Path:
|
|
283
|
+
shipped = Path(__file__).with_name("caddis_governor.py")
|
|
284
|
+
if shipped.exists():
|
|
285
|
+
return shipped
|
|
286
|
+
source = (
|
|
287
|
+
Path(__file__).resolve().parents[1]
|
|
288
|
+
/ "claude-harness"
|
|
289
|
+
/ "scripts"
|
|
290
|
+
/ "caddis_governor.py"
|
|
291
|
+
)
|
|
292
|
+
if source.exists():
|
|
293
|
+
return source
|
|
294
|
+
raise LaneConfigError(
|
|
295
|
+
"caddis_governor.py not found; refusing to run a lane without resource limits"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def governed_argv(argv: list[str]) -> list[str]:
|
|
300
|
+
return [
|
|
301
|
+
sys.executable,
|
|
302
|
+
str(governor_path()),
|
|
303
|
+
"exec",
|
|
304
|
+
"--scope",
|
|
305
|
+
"lane",
|
|
306
|
+
"--",
|
|
307
|
+
*argv,
|
|
308
|
+
]
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def kill_governed(proc: subprocess.Popen) -> None:
|
|
312
|
+
if sys.platform == "win32":
|
|
313
|
+
proc.kill()
|
|
314
|
+
return
|
|
315
|
+
try:
|
|
316
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
317
|
+
except ProcessLookupError:
|
|
318
|
+
pass
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def process_alive(pid: int) -> bool:
|
|
322
|
+
if sys.platform == "win32":
|
|
323
|
+
import ctypes
|
|
324
|
+
from ctypes import wintypes
|
|
325
|
+
|
|
326
|
+
kernel32 = ctypes.windll.kernel32
|
|
327
|
+
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
328
|
+
kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
329
|
+
kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
|
|
330
|
+
kernel32.GetExitCodeProcess.restype = wintypes.BOOL
|
|
331
|
+
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
332
|
+
kernel32.CloseHandle.restype = wintypes.BOOL
|
|
333
|
+
|
|
334
|
+
handle = kernel32.OpenProcess(0x1000, False, pid)
|
|
335
|
+
if not handle:
|
|
336
|
+
return False
|
|
337
|
+
try:
|
|
338
|
+
exit_code = wintypes.DWORD()
|
|
339
|
+
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
|
340
|
+
return False
|
|
341
|
+
return exit_code.value == 259
|
|
342
|
+
finally:
|
|
343
|
+
kernel32.CloseHandle(handle)
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
os.kill(pid, 0)
|
|
347
|
+
except ProcessLookupError:
|
|
348
|
+
return False
|
|
349
|
+
return True
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _acquisition_guard(lock: Path) -> int:
|
|
353
|
+
guard = lock.with_name(lock.name + ".guard")
|
|
354
|
+
fd = os.open(guard, os.O_CREAT | os.O_RDWR)
|
|
355
|
+
try:
|
|
356
|
+
if sys.platform == "win32":
|
|
357
|
+
import msvcrt
|
|
358
|
+
|
|
359
|
+
if os.fstat(fd).st_size == 0:
|
|
360
|
+
os.write(fd, b"\0")
|
|
361
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
362
|
+
msvcrt.locking(fd, msvcrt.LK_LOCK, 1)
|
|
363
|
+
else:
|
|
364
|
+
import fcntl
|
|
365
|
+
|
|
366
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
367
|
+
except BaseException:
|
|
368
|
+
os.close(fd)
|
|
369
|
+
raise
|
|
370
|
+
return fd
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _release_acquisition_guard(fd: int) -> None:
|
|
374
|
+
try:
|
|
375
|
+
if sys.platform == "win32":
|
|
376
|
+
import msvcrt
|
|
377
|
+
|
|
378
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
379
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
380
|
+
else:
|
|
381
|
+
import fcntl
|
|
382
|
+
|
|
383
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
384
|
+
finally:
|
|
385
|
+
os.close(fd)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def acquire_lock(lock: Path) -> None:
|
|
389
|
+
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
390
|
+
guard_fd = _acquisition_guard(lock)
|
|
391
|
+
try:
|
|
392
|
+
while True:
|
|
393
|
+
try:
|
|
394
|
+
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
395
|
+
except FileExistsError:
|
|
396
|
+
try:
|
|
397
|
+
content = lock.read_text(encoding="utf-8").strip()
|
|
398
|
+
except FileNotFoundError:
|
|
399
|
+
continue
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
pid = int(content)
|
|
403
|
+
except ValueError:
|
|
404
|
+
pid = 0
|
|
405
|
+
if pid > 0 and process_alive(pid):
|
|
406
|
+
raise LaneConfigError(
|
|
407
|
+
f"phase is locked by {lock} (pid {content}); "
|
|
408
|
+
"another worker holds this phase"
|
|
409
|
+
) from None
|
|
410
|
+
|
|
411
|
+
lock.unlink(missing_ok=True)
|
|
412
|
+
continue
|
|
413
|
+
|
|
414
|
+
try:
|
|
415
|
+
os.write(fd, str(os.getpid()).encode("ascii"))
|
|
416
|
+
os.fsync(fd)
|
|
417
|
+
finally:
|
|
418
|
+
os.close(fd)
|
|
419
|
+
return
|
|
420
|
+
finally:
|
|
421
|
+
_release_acquisition_guard(guard_fd)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def release_lock(lock: Path) -> None:
|
|
425
|
+
if not lock.parent.exists():
|
|
426
|
+
return
|
|
427
|
+
guard_fd = _acquisition_guard(lock)
|
|
428
|
+
try:
|
|
429
|
+
lock.unlink(missing_ok=True)
|
|
430
|
+
finally:
|
|
431
|
+
_release_acquisition_guard(guard_fd)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def run_lane(
|
|
435
|
+
argv: list[str],
|
|
436
|
+
cwd: Path,
|
|
437
|
+
prompt: str,
|
|
438
|
+
stdin_prompt: bool,
|
|
439
|
+
timeout_s: int,
|
|
440
|
+
env: dict,
|
|
441
|
+
) -> LaneRun:
|
|
442
|
+
full = governed_argv(argv)
|
|
443
|
+
if not stdin_prompt:
|
|
444
|
+
full.append(prompt)
|
|
445
|
+
validate_codex_argv(full)
|
|
446
|
+
|
|
447
|
+
started = time.time()
|
|
448
|
+
monotonic_started = time.monotonic()
|
|
449
|
+
popen_options = {}
|
|
450
|
+
if sys.platform != "win32":
|
|
451
|
+
popen_options["start_new_session"] = True
|
|
452
|
+
proc = subprocess.Popen(
|
|
453
|
+
full,
|
|
454
|
+
cwd=cwd,
|
|
455
|
+
stdin=subprocess.PIPE if stdin_prompt else subprocess.DEVNULL,
|
|
456
|
+
stdout=subprocess.PIPE,
|
|
457
|
+
stderr=subprocess.STDOUT,
|
|
458
|
+
text=True,
|
|
459
|
+
encoding="utf-8",
|
|
460
|
+
errors="replace",
|
|
461
|
+
env=env,
|
|
462
|
+
**popen_options,
|
|
463
|
+
)
|
|
464
|
+
timed_out = False
|
|
465
|
+
output = ""
|
|
466
|
+
try:
|
|
467
|
+
try:
|
|
468
|
+
output, _ = proc.communicate(
|
|
469
|
+
input=prompt if stdin_prompt else None,
|
|
470
|
+
timeout=timeout_s,
|
|
471
|
+
)
|
|
472
|
+
except subprocess.TimeoutExpired as first_timeout:
|
|
473
|
+
kill_governed(proc)
|
|
474
|
+
timed_out = True
|
|
475
|
+
try:
|
|
476
|
+
output, _ = proc.communicate(timeout=30)
|
|
477
|
+
except subprocess.TimeoutExpired as second_timeout:
|
|
478
|
+
kill_governed(proc)
|
|
479
|
+
captured = second_timeout.output or first_timeout.output or ""
|
|
480
|
+
output = (
|
|
481
|
+
captured.decode("utf-8", errors="replace")
|
|
482
|
+
if isinstance(captured, bytes)
|
|
483
|
+
else captured
|
|
484
|
+
)
|
|
485
|
+
except BaseException:
|
|
486
|
+
kill_governed(proc)
|
|
487
|
+
raise
|
|
488
|
+
|
|
489
|
+
return LaneRun(
|
|
490
|
+
returncode=proc.returncode,
|
|
491
|
+
output=output,
|
|
492
|
+
timed_out=timed_out,
|
|
493
|
+
duration_s=time.monotonic() - monotonic_started,
|
|
494
|
+
pid=proc.pid,
|
|
495
|
+
started=started,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def gate_commands(repo_root: Path, worktree: Path, cfg: dict) -> list[list[str]]:
|
|
500
|
+
configured = cfg.get("gate_cmds")
|
|
501
|
+
if isinstance(configured, list) and configured:
|
|
502
|
+
return [shlex.split(command) for command in configured]
|
|
503
|
+
|
|
504
|
+
inventory_path = Path(__file__).resolve().parent / "caddis_inventory.py"
|
|
505
|
+
spec = importlib.util.spec_from_file_location(
|
|
506
|
+
"_caddis_inventory_for_lanes", inventory_path
|
|
507
|
+
)
|
|
508
|
+
if spec is None or spec.loader is None:
|
|
509
|
+
raise LaneConfigError(f"could not load {inventory_path}")
|
|
510
|
+
inventory = importlib.util.module_from_spec(spec)
|
|
511
|
+
sys.modules[spec.name] = inventory
|
|
512
|
+
spec.loader.exec_module(inventory)
|
|
513
|
+
command = list(inventory._test_command(worktree))
|
|
514
|
+
if command and command[0] == sys.executable:
|
|
515
|
+
command[0] = repo_python(repo_root)
|
|
516
|
+
return [command]
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _combined_output(result: subprocess.CompletedProcess[str]) -> str:
|
|
520
|
+
return (result.stdout or "") + (result.stderr or "")
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _as_text(output: str | bytes | None) -> str:
|
|
524
|
+
if isinstance(output, bytes):
|
|
525
|
+
return output.decode("utf-8", errors="replace")
|
|
526
|
+
return output or ""
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def run_gates(
|
|
530
|
+
cmds: list[list[str]],
|
|
531
|
+
cwd: Path,
|
|
532
|
+
env: dict,
|
|
533
|
+
timeout_s: int = 1800,
|
|
534
|
+
) -> list[GateResult]:
|
|
535
|
+
results = []
|
|
536
|
+
for command in cmds:
|
|
537
|
+
try:
|
|
538
|
+
completed = subprocess.run(
|
|
539
|
+
governed_argv(command),
|
|
540
|
+
cwd=cwd,
|
|
541
|
+
env=env,
|
|
542
|
+
capture_output=True,
|
|
543
|
+
text=True,
|
|
544
|
+
encoding="utf-8",
|
|
545
|
+
errors="replace",
|
|
546
|
+
timeout=timeout_s,
|
|
547
|
+
)
|
|
548
|
+
except subprocess.TimeoutExpired:
|
|
549
|
+
results.append(
|
|
550
|
+
GateResult(
|
|
551
|
+
cmd=shlex.join(command),
|
|
552
|
+
returncode=-1,
|
|
553
|
+
passed=False,
|
|
554
|
+
tail=f"timed out after {timeout_s}s",
|
|
555
|
+
)
|
|
556
|
+
)
|
|
557
|
+
continue
|
|
558
|
+
|
|
559
|
+
output = _combined_output(completed)
|
|
560
|
+
passed = completed.returncode == 0 or (
|
|
561
|
+
completed.returncode == 5 and "pytest" in " ".join(command)
|
|
562
|
+
)
|
|
563
|
+
results.append(
|
|
564
|
+
GateResult(
|
|
565
|
+
cmd=shlex.join(command),
|
|
566
|
+
returncode=completed.returncode,
|
|
567
|
+
passed=passed,
|
|
568
|
+
tail=output[-2000:],
|
|
569
|
+
)
|
|
570
|
+
)
|
|
571
|
+
return results
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def oss_review_path() -> Path | None:
|
|
575
|
+
sibling = Path(__file__).with_name("oss_review.py")
|
|
576
|
+
if sibling.exists():
|
|
577
|
+
return sibling
|
|
578
|
+
source = Path(__file__).resolve().parents[1] / ".github" / "tools" / "oss_review.py"
|
|
579
|
+
return source if source.exists() else None
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def run_review(worktree: Path, env: dict, timeout_s: int = 900) -> ReviewResult:
|
|
583
|
+
path = oss_review_path()
|
|
584
|
+
if path is None:
|
|
585
|
+
return ReviewResult("MISSING", None, "oss_review.py not found")
|
|
586
|
+
|
|
587
|
+
try:
|
|
588
|
+
completed = subprocess.run(
|
|
589
|
+
[sys.executable, str(path), "--cwd", str(worktree)],
|
|
590
|
+
cwd=worktree,
|
|
591
|
+
env=env,
|
|
592
|
+
capture_output=True,
|
|
593
|
+
text=True,
|
|
594
|
+
encoding="utf-8",
|
|
595
|
+
errors="replace",
|
|
596
|
+
timeout=timeout_s,
|
|
597
|
+
)
|
|
598
|
+
except subprocess.TimeoutExpired as exc:
|
|
599
|
+
captured = _as_text(exc.stdout) + _as_text(exc.stderr)
|
|
600
|
+
tail = (captured + f"\ntimed out after {timeout_s}s").lstrip("\n")[-3000:]
|
|
601
|
+
return ReviewResult("MISSING", None, tail)
|
|
602
|
+
except OSError as exc:
|
|
603
|
+
return ReviewResult("MISSING", None, str(exc)[-3000:])
|
|
604
|
+
|
|
605
|
+
status = {0: "CLEAN", 1: "BLOCKING"}.get(completed.returncode, "MISSING")
|
|
606
|
+
return ReviewResult(status, completed.returncode, _combined_output(completed)[-3000:])
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def changed_files(worktree: Path) -> list[str]:
|
|
610
|
+
result = subprocess.run(
|
|
611
|
+
["git", "-C", str(worktree), "status", "--porcelain=v1", "--untracked-files=all"],
|
|
612
|
+
capture_output=True,
|
|
613
|
+
text=True,
|
|
614
|
+
encoding="utf-8",
|
|
615
|
+
errors="replace",
|
|
616
|
+
)
|
|
617
|
+
if result.returncode != 0:
|
|
618
|
+
message = result.stderr.strip() or result.stdout.strip()
|
|
619
|
+
raise LaneConfigError(message or "git status failed")
|
|
620
|
+
|
|
621
|
+
paths = []
|
|
622
|
+
for line in result.stdout.splitlines():
|
|
623
|
+
path = line[3:]
|
|
624
|
+
if " -> " in path:
|
|
625
|
+
path = path.split(" -> ", 1)[1]
|
|
626
|
+
paths.append(path)
|
|
627
|
+
return sorted(paths)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def worktree_digest(worktree: Path, files: list[str]) -> str:
|
|
631
|
+
"""Hash each changed path's mode and content (or link target, or a marker), in the given order.
|
|
632
|
+
|
|
633
|
+
`files` is the sorted list from changed_files(); accept can then prove nothing changed since check.
|
|
634
|
+
"""
|
|
635
|
+
digest = hashlib.sha256()
|
|
636
|
+
for name in files:
|
|
637
|
+
path = worktree / name
|
|
638
|
+
digest.update(name.encode("utf-8") + b"\0")
|
|
639
|
+
try:
|
|
640
|
+
info = path.lstat()
|
|
641
|
+
digest.update(f"{info.st_mode:o}\0".encode("ascii"))
|
|
642
|
+
if path.is_symlink():
|
|
643
|
+
digest.update(b"link:" + os.fsencode(os.readlink(path)))
|
|
644
|
+
elif path.is_file():
|
|
645
|
+
digest.update(path.read_bytes())
|
|
646
|
+
else:
|
|
647
|
+
digest.update(b"<not-a-file>")
|
|
648
|
+
except FileNotFoundError:
|
|
649
|
+
digest.update(b"<absent>")
|
|
650
|
+
except OSError as exc:
|
|
651
|
+
digest.update(f"<unreadable:{exc.errno}>".encode("ascii"))
|
|
652
|
+
digest.update(b"\0")
|
|
653
|
+
return digest.hexdigest()
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _host_guard_module():
|
|
657
|
+
candidates = (
|
|
658
|
+
Path(__file__).with_name("caddis_host_guard.py"),
|
|
659
|
+
Path(__file__).resolve().parents[1]
|
|
660
|
+
/ "claude-harness"
|
|
661
|
+
/ "scripts"
|
|
662
|
+
/ "caddis_host_guard.py",
|
|
663
|
+
)
|
|
664
|
+
path = next((candidate for candidate in candidates if candidate.exists()), None)
|
|
665
|
+
if path is None:
|
|
666
|
+
return None
|
|
667
|
+
|
|
668
|
+
spec = importlib.util.spec_from_file_location("_caddis_host_guard_for_lanes", path)
|
|
669
|
+
if spec is None or spec.loader is None:
|
|
670
|
+
return None
|
|
671
|
+
module = importlib.util.module_from_spec(spec)
|
|
672
|
+
sys.modules[spec.name] = module
|
|
673
|
+
spec.loader.exec_module(module)
|
|
674
|
+
return module
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def guard_killed_since(
|
|
678
|
+
started: float,
|
|
679
|
+
pids: set[int],
|
|
680
|
+
log: Path | None = None,
|
|
681
|
+
) -> bool:
|
|
682
|
+
host_guard = _host_guard_module()
|
|
683
|
+
if host_guard is None:
|
|
684
|
+
return False
|
|
685
|
+
log_path = log or host_guard.guard_log_path()
|
|
686
|
+
try:
|
|
687
|
+
lines = log_path.read_text(encoding="utf-8").splitlines()
|
|
688
|
+
except OSError:
|
|
689
|
+
return False
|
|
690
|
+
|
|
691
|
+
for line in lines:
|
|
692
|
+
try:
|
|
693
|
+
record = json.loads(line)
|
|
694
|
+
except (json.JSONDecodeError, TypeError):
|
|
695
|
+
continue
|
|
696
|
+
killed = record.get("killed", [])
|
|
697
|
+
record_pids = {record.get("pid")}
|
|
698
|
+
if isinstance(killed, list):
|
|
699
|
+
record_pids.update(killed)
|
|
700
|
+
if (
|
|
701
|
+
# Phase 9 names this event "kill"; the current guard records it as "end".
|
|
702
|
+
record.get("kind") in ("kill", "end")
|
|
703
|
+
and isinstance(record.get("ts"), (int, float))
|
|
704
|
+
and record["ts"] >= started
|
|
705
|
+
and bool(pids.intersection(record_pids))
|
|
706
|
+
):
|
|
707
|
+
return True
|
|
708
|
+
return False
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def packet_created() -> str:
|
|
712
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def orchestrator_dir(repo_root: Path) -> Path:
|
|
716
|
+
directory = repo_root / ".caddis" / "orchestrator"
|
|
717
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
718
|
+
for path, content in (
|
|
719
|
+
(directory / "AGENTS.md", ORCHESTRATOR_AGENTS_MD),
|
|
720
|
+
(directory / ".gitignore", "lane-runs/\n"),
|
|
721
|
+
):
|
|
722
|
+
try:
|
|
723
|
+
with path.open("x", encoding="utf-8", newline="\n") as handle:
|
|
724
|
+
handle.write(content)
|
|
725
|
+
except FileExistsError:
|
|
726
|
+
pass
|
|
727
|
+
return directory
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def run_dir(repo_root: Path, plan_stem: str) -> Path:
|
|
731
|
+
return orchestrator_dir(repo_root) / "lane-runs" / plan_stem
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def write_packet(path: Path, packet: dict) -> Path:
|
|
735
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
736
|
+
temporary = path.with_name(path.name + ".tmp")
|
|
737
|
+
temporary.write_text(
|
|
738
|
+
json.dumps(packet, indent=2, sort_keys=True) + "\n",
|
|
739
|
+
encoding="utf-8",
|
|
740
|
+
)
|
|
741
|
+
os.replace(temporary, path)
|
|
742
|
+
return path
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def load_lanes_config(repo_root: Path) -> dict:
|
|
746
|
+
config_path = repo_root / ".caddis" / "config.toml"
|
|
747
|
+
if not config_path.exists():
|
|
748
|
+
return {}
|
|
749
|
+
try:
|
|
750
|
+
import tomllib
|
|
751
|
+
except ImportError as exc:
|
|
752
|
+
raise LaneConfigError(
|
|
753
|
+
f"cannot parse {config_path}: Python 3.11 or later is required"
|
|
754
|
+
) from exc
|
|
755
|
+
try:
|
|
756
|
+
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
|
757
|
+
except (OSError, ValueError) as exc:
|
|
758
|
+
raise LaneConfigError(f"cannot parse {config_path}: {exc}") from exc
|
|
759
|
+
|
|
760
|
+
lanes = data.get("lanes", {})
|
|
761
|
+
if not isinstance(lanes, dict):
|
|
762
|
+
raise LaneConfigError(f"[lanes] in {config_path} is not a table")
|
|
763
|
+
return lanes
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def resolve_models(cfg: dict) -> dict[str, str]:
|
|
767
|
+
models = DEFAULT_MODELS.copy()
|
|
768
|
+
for key, value in cfg.get("models", {}).items():
|
|
769
|
+
if key not in LANE_IDS:
|
|
770
|
+
raise LaneConfigError(f"unknown lane in [lanes.models]: {key}")
|
|
771
|
+
if not isinstance(value, str) or not value:
|
|
772
|
+
raise LaneConfigError(f"empty model id for lane {key}")
|
|
773
|
+
models[key] = value
|
|
774
|
+
return models
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def resolve_chain(task_type: str, cfg: dict, first_lane: str | None = None) -> tuple[str, ...]:
|
|
778
|
+
if task_type not in TASK_TYPES:
|
|
779
|
+
raise LaneConfigError(f"unknown task type: {task_type}")
|
|
780
|
+
|
|
781
|
+
configured_chain = cfg.get("chains", {}).get(task_type)
|
|
782
|
+
chain = configured_chain if isinstance(configured_chain, list) else DEFAULT_CHAINS[task_type]
|
|
783
|
+
if not chain:
|
|
784
|
+
raise LaneConfigError(f"empty chain for {task_type}")
|
|
785
|
+
for lane in chain:
|
|
786
|
+
if lane not in LANE_IDS:
|
|
787
|
+
raise LaneConfigError(f"unknown lane in chain {task_type}: {lane}")
|
|
788
|
+
|
|
789
|
+
if first_lane is None:
|
|
790
|
+
return tuple(chain)
|
|
791
|
+
if first_lane not in LANE_IDS:
|
|
792
|
+
raise LaneConfigError(f"unknown first lane: {first_lane}")
|
|
793
|
+
return (first_lane, *(lane for lane in chain if lane != first_lane))
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def build_argv(
|
|
797
|
+
lane: str,
|
|
798
|
+
model: str,
|
|
799
|
+
effort: str,
|
|
800
|
+
worktree: Path,
|
|
801
|
+
which=shutil.which,
|
|
802
|
+
) -> list[str]:
|
|
803
|
+
fake = os.environ.get("CADDIS_LANES_FAKE")
|
|
804
|
+
if fake:
|
|
805
|
+
return [sys.executable, fake, lane]
|
|
806
|
+
if lane == "claude":
|
|
807
|
+
raise LaneConfigError(
|
|
808
|
+
"the claude lane is run by the orchestrating session, not by this script"
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
if lane == "glm":
|
|
812
|
+
tool = "claude-glm"
|
|
813
|
+
elif lane in ("codex-sol", "codex-astra"):
|
|
814
|
+
tool = "codex"
|
|
815
|
+
elif lane == "agy":
|
|
816
|
+
tool = "agy"
|
|
817
|
+
else:
|
|
818
|
+
raise LaneConfigError(f"unknown lane: {lane}")
|
|
819
|
+
|
|
820
|
+
binary = which(tool)
|
|
821
|
+
if binary is None:
|
|
822
|
+
raise LaneConfigError(f"{tool} not found on PATH")
|
|
823
|
+
|
|
824
|
+
if lane == "glm":
|
|
825
|
+
return [binary, "-p", "--permission-mode", "acceptEdits"]
|
|
826
|
+
if lane in ("codex-sol", "codex-astra"):
|
|
827
|
+
return [
|
|
828
|
+
binary,
|
|
829
|
+
"exec",
|
|
830
|
+
"-m",
|
|
831
|
+
model,
|
|
832
|
+
"-c",
|
|
833
|
+
f"model_reasoning_effort={effort}",
|
|
834
|
+
"-s",
|
|
835
|
+
"workspace-write",
|
|
836
|
+
"-C",
|
|
837
|
+
str(worktree),
|
|
838
|
+
"--skip-git-repo-check",
|
|
839
|
+
"-",
|
|
840
|
+
]
|
|
841
|
+
return [
|
|
842
|
+
binary,
|
|
843
|
+
"--model",
|
|
844
|
+
f"{model}-{effort}",
|
|
845
|
+
"--mode",
|
|
846
|
+
"accept-edits",
|
|
847
|
+
"--dangerously-skip-permissions",
|
|
848
|
+
"--add-dir",
|
|
849
|
+
str(worktree),
|
|
850
|
+
"--print-timeout",
|
|
851
|
+
"60m",
|
|
852
|
+
"--output-format",
|
|
853
|
+
"json",
|
|
854
|
+
"-p",
|
|
855
|
+
]
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def prompt_via_stdin(lane: str) -> bool:
|
|
859
|
+
if os.environ.get("CADDIS_LANES_FAKE"):
|
|
860
|
+
return True
|
|
861
|
+
return lane in ("glm", "codex-sol", "codex-astra")
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def validate_codex_argv(argv: list[str]) -> None:
|
|
865
|
+
codex_index = next(
|
|
866
|
+
(
|
|
867
|
+
index
|
|
868
|
+
for index, value in enumerate(argv[:-1])
|
|
869
|
+
if Path(value).stem.lower() in ("codex",)
|
|
870
|
+
and argv[index + 1] == "exec"
|
|
871
|
+
),
|
|
872
|
+
None,
|
|
873
|
+
)
|
|
874
|
+
if codex_index is None:
|
|
875
|
+
return
|
|
876
|
+
codex_argv = argv[codex_index:]
|
|
877
|
+
if "danger-full-access" in codex_argv:
|
|
878
|
+
raise LaneConfigError("codex must run with -s workspace-write or -s read-only")
|
|
879
|
+
|
|
880
|
+
try:
|
|
881
|
+
sandbox = codex_argv[codex_argv.index("-s") + 1]
|
|
882
|
+
except (ValueError, IndexError):
|
|
883
|
+
sandbox = None
|
|
884
|
+
if sandbox not in ("workspace-write", "read-only"):
|
|
885
|
+
raise LaneConfigError("codex must run with -s workspace-write or -s read-only")
|
|
886
|
+
|
|
887
|
+
if sandbox == "workspace-write":
|
|
888
|
+
try:
|
|
889
|
+
worktree = codex_argv[codex_argv.index("-C") + 1]
|
|
890
|
+
except (ValueError, IndexError):
|
|
891
|
+
worktree = ""
|
|
892
|
+
if not worktree:
|
|
893
|
+
raise LaneConfigError(
|
|
894
|
+
"codex workspace-write must be confined with -C <worktree>"
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def child_env(base: dict, lane: str, model: str) -> dict:
|
|
899
|
+
env = base.copy()
|
|
900
|
+
env["CADDIS_HEADLESS"] = "1"
|
|
901
|
+
if lane == "glm":
|
|
902
|
+
env["OSS_MODEL"] = model
|
|
903
|
+
return env
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def classify_exit(
|
|
907
|
+
lane: str,
|
|
908
|
+
returncode: int | None,
|
|
909
|
+
output: str,
|
|
910
|
+
timed_out: bool,
|
|
911
|
+
guard_killed: bool = False,
|
|
912
|
+
) -> str:
|
|
913
|
+
if guard_killed:
|
|
914
|
+
return RESULT_RESOURCE
|
|
915
|
+
if timed_out:
|
|
916
|
+
return RESULT_LANE_FAILED
|
|
917
|
+
if any(re.search(pattern, output, re.I) for pattern in CONFIG_PATTERNS[lane]):
|
|
918
|
+
return RESULT_CONFIG_ERROR
|
|
919
|
+
if any(re.search(pattern, output, re.I) for pattern in BUDGET_PATTERNS[lane]):
|
|
920
|
+
return RESULT_OUT_OF_BUDGET
|
|
921
|
+
if returncode == 3 and lane == "glm":
|
|
922
|
+
return RESULT_CONFIG_ERROR
|
|
923
|
+
if returncode == 127:
|
|
924
|
+
return RESULT_CONFIG_ERROR
|
|
925
|
+
if returncode == 0:
|
|
926
|
+
return RESULT_OK
|
|
927
|
+
return RESULT_LANE_FAILED
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def load_wording_findings(text: str) -> list[str]:
|
|
931
|
+
findings = []
|
|
932
|
+
seen = set()
|
|
933
|
+
for pattern in LOAD_WORDING:
|
|
934
|
+
for match in re.finditer(pattern, text):
|
|
935
|
+
value = match.group(0)
|
|
936
|
+
if value not in seen:
|
|
937
|
+
seen.add(value)
|
|
938
|
+
findings.append(value)
|
|
939
|
+
return findings
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def build_prompt(
|
|
943
|
+
plan_rel: str,
|
|
944
|
+
phase: int,
|
|
945
|
+
attempt: int,
|
|
946
|
+
findings: str,
|
|
947
|
+
limits_line: str,
|
|
948
|
+
) -> str:
|
|
949
|
+
prompt = (
|
|
950
|
+
f"You are an implementation lane for caddis. Implement ONLY Phase {phase} "
|
|
951
|
+
f"of the plan at {plan_rel} (read it from this working directory).\n"
|
|
952
|
+
"Rules:\n"
|
|
953
|
+
"- Work only inside this working directory. Do not touch files outside it.\n"
|
|
954
|
+
"- Follow the phase's TDD steps: write the failing test first, run it and "
|
|
955
|
+
"confirm it fails for the right reason, then implement until it passes.\n"
|
|
956
|
+
"- Do NOT commit, push, open a PR, or merge. Leave your changes uncommitted; "
|
|
957
|
+
"the orchestrator reviews and commits them.\n"
|
|
958
|
+
"- Never use --no-verify. Never read or edit .env files or any secret. Never "
|
|
959
|
+
"run anything against production.\n"
|
|
960
|
+
"- Do not implement any other phase.\n"
|
|
961
|
+
"Resource budget (enforced by the machine, not by this text):\n"
|
|
962
|
+
f"- You run inside a Windows Job Object: {limits_line}. Exceeding it slows "
|
|
963
|
+
"you down; it does not stop the limit.\n"
|
|
964
|
+
"- This host is shared with a desktop, CI runners and services. Never start "
|
|
965
|
+
"load generators, CPU burners, stress or load tests, or background processes "
|
|
966
|
+
"that outlive your command.\n"
|
|
967
|
+
"- Prove timing behaviour by design: event-driven fakes, ordering assertions, "
|
|
968
|
+
"and an A/B check that fails against the racy version.\n"
|
|
969
|
+
"- When done, print a short summary: files changed, tests run, and their final "
|
|
970
|
+
"result line.\n"
|
|
971
|
+
)
|
|
972
|
+
if attempt > 1 and findings:
|
|
973
|
+
prompt += (
|
|
974
|
+
f"\nThis is attempt {attempt}. The previous attempt was rejected. "
|
|
975
|
+
f"Fix these findings:\n{findings}\n"
|
|
976
|
+
)
|
|
977
|
+
return prompt
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _load_file_module(name: str, path: Path):
|
|
981
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
982
|
+
if spec is None or spec.loader is None:
|
|
983
|
+
raise LaneConfigError(f"could not load {path}")
|
|
984
|
+
module = importlib.util.module_from_spec(spec)
|
|
985
|
+
sys.modules[spec.name] = module
|
|
986
|
+
try:
|
|
987
|
+
spec.loader.exec_module(module)
|
|
988
|
+
except Exception as exc:
|
|
989
|
+
raise LaneConfigError(f"could not load {path}: {exc}") from exc
|
|
990
|
+
return module
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def phase_block_text(plan: Path, phase: int) -> str:
|
|
994
|
+
gate_path = Path(__file__).with_name("caddis_gate.py")
|
|
995
|
+
gate = _load_file_module("_caddis_gate_for_lanes", gate_path)
|
|
996
|
+
try:
|
|
997
|
+
text = plan.read_text(encoding="utf-8")
|
|
998
|
+
except OSError as exc:
|
|
999
|
+
raise LaneConfigError(f"could not read {plan}: {exc}") from exc
|
|
1000
|
+
block = gate.phase_block(text, phase)
|
|
1001
|
+
if not block:
|
|
1002
|
+
raise LaneConfigError(f"phase {phase} not found in {plan}")
|
|
1003
|
+
return block
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def resource_limits_line() -> str:
|
|
1007
|
+
path = governor_path()
|
|
1008
|
+
governor = _load_file_module("_caddis_governor_limits_for_lanes", path)
|
|
1009
|
+
try:
|
|
1010
|
+
limits = governor.load_limits("lane")
|
|
1011
|
+
except Exception as exc:
|
|
1012
|
+
raise LaneConfigError(f"could not load lane resource limits: {exc}") from exc
|
|
1013
|
+
return (
|
|
1014
|
+
f"cpu {limits.cpu_percent}% of the machine, memory {limits.memory_mb} MB, "
|
|
1015
|
+
f"at most {limits.max_processes} processes, BelowNormal priority"
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _repo_path(value: Path | None) -> Path:
|
|
1020
|
+
start = Path.cwd() if value is None else value.resolve()
|
|
1021
|
+
return Path(_git_output(start, "rev-parse", "--show-toplevel")).resolve()
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def _plan_path(repo: Path, value: Path) -> tuple[Path, str]:
|
|
1025
|
+
plan = value if value.is_absolute() else repo / value
|
|
1026
|
+
plan = plan.resolve()
|
|
1027
|
+
if not plan.is_file():
|
|
1028
|
+
raise LaneConfigError(f"plan does not exist: {plan}")
|
|
1029
|
+
try:
|
|
1030
|
+
plan_rel = plan.relative_to(repo).as_posix()
|
|
1031
|
+
except ValueError as exc:
|
|
1032
|
+
raise LaneConfigError(f"plan must be inside the repository: {plan}") from exc
|
|
1033
|
+
return plan, plan_rel
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _packet_path(repo: Path, plan_stem: str, phase: int, attempt: int) -> Path:
|
|
1037
|
+
return run_dir(repo, plan_stem) / f"phase-{phase:02d}.attempt-{attempt}.json"
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def _freeze_path(packet_path: Path) -> Path:
|
|
1041
|
+
return packet_path.with_suffix(".freeze.json")
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _new_packet(
|
|
1045
|
+
plan_rel: str,
|
|
1046
|
+
phase: int,
|
|
1047
|
+
attempt: int,
|
|
1048
|
+
task_type: str = "",
|
|
1049
|
+
) -> dict:
|
|
1050
|
+
return {
|
|
1051
|
+
"plan": plan_rel,
|
|
1052
|
+
"phase": phase,
|
|
1053
|
+
"attempt": attempt,
|
|
1054
|
+
"task_type": task_type,
|
|
1055
|
+
"lanes_tried": [],
|
|
1056
|
+
"lane": "",
|
|
1057
|
+
"model": "",
|
|
1058
|
+
"result": "",
|
|
1059
|
+
"worktree": "",
|
|
1060
|
+
"branch": "",
|
|
1061
|
+
"base_sha": "",
|
|
1062
|
+
"changed_files": [],
|
|
1063
|
+
"gates": [],
|
|
1064
|
+
"gates_passed": False,
|
|
1065
|
+
"review": {"status": "MISSING", "returncode": None, "tail": ""},
|
|
1066
|
+
"freeze_unchanged": None,
|
|
1067
|
+
"next": "stop",
|
|
1068
|
+
"created": packet_created(),
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def _read_packet(path: Path) -> dict | None:
|
|
1073
|
+
if not path.is_file():
|
|
1074
|
+
return None
|
|
1075
|
+
try:
|
|
1076
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
1077
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
1078
|
+
raise LaneConfigError(f"could not read packet {path}: {exc}") from exc
|
|
1079
|
+
if not isinstance(value, dict):
|
|
1080
|
+
raise LaneConfigError(f"packet is not an object: {path}")
|
|
1081
|
+
return value
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def _worktree_from_packet(packet: dict) -> Worktree:
|
|
1085
|
+
try:
|
|
1086
|
+
path = Path(packet["worktree"])
|
|
1087
|
+
branch = str(packet["branch"])
|
|
1088
|
+
base_sha = str(packet["base_sha"])
|
|
1089
|
+
except (KeyError, TypeError) as exc:
|
|
1090
|
+
raise LaneConfigError("packet does not identify a worktree") from exc
|
|
1091
|
+
if not str(path) or not branch:
|
|
1092
|
+
raise LaneConfigError("packet does not identify a worktree")
|
|
1093
|
+
return Worktree(path, branch, base_sha)
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def _gate_dict(result: GateResult) -> dict:
|
|
1097
|
+
return {
|
|
1098
|
+
"cmd": result.cmd,
|
|
1099
|
+
"returncode": result.returncode,
|
|
1100
|
+
"passed": result.passed,
|
|
1101
|
+
"tail": result.tail,
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def _review_dict(result: ReviewResult) -> dict:
|
|
1106
|
+
return {
|
|
1107
|
+
"status": result.status,
|
|
1108
|
+
"returncode": result.returncode,
|
|
1109
|
+
"tail": result.tail,
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
def _run_post_checks(
|
|
1114
|
+
repo: Path,
|
|
1115
|
+
cfg: dict,
|
|
1116
|
+
packet: dict,
|
|
1117
|
+
before: dict[str, str | None],
|
|
1118
|
+
) -> tuple[str, int]:
|
|
1119
|
+
worktree = Path(packet["worktree"])
|
|
1120
|
+
gates = run_gates(
|
|
1121
|
+
gate_commands(repo, worktree, cfg),
|
|
1122
|
+
worktree,
|
|
1123
|
+
gate_env(repo, dict(os.environ)),
|
|
1124
|
+
)
|
|
1125
|
+
review = run_review(worktree, dict(os.environ))
|
|
1126
|
+
pythons = list(before)
|
|
1127
|
+
if pythons:
|
|
1128
|
+
after = {python: freeze_snapshot(python) for python in pythons}
|
|
1129
|
+
freeze_unchanged = all(
|
|
1130
|
+
before[python] is not None
|
|
1131
|
+
and after[python] is not None
|
|
1132
|
+
and before[python] == after[python]
|
|
1133
|
+
for python in pythons
|
|
1134
|
+
)
|
|
1135
|
+
else:
|
|
1136
|
+
freeze_unchanged = None
|
|
1137
|
+
|
|
1138
|
+
gates_passed = all(gate.passed for gate in gates)
|
|
1139
|
+
if gates_passed and freeze_unchanged is not False:
|
|
1140
|
+
next_step = "review"
|
|
1141
|
+
exit_code = EXIT_OK
|
|
1142
|
+
else:
|
|
1143
|
+
next_step = "takeover" if packet["attempt"] >= MAX_ATTEMPTS else "redo"
|
|
1144
|
+
exit_code = EXIT_FAIL
|
|
1145
|
+
|
|
1146
|
+
changed = changed_files(worktree)
|
|
1147
|
+
packet.update(
|
|
1148
|
+
{
|
|
1149
|
+
"changed_files": changed,
|
|
1150
|
+
"worktree_digest": worktree_digest(worktree, changed),
|
|
1151
|
+
"gates": [_gate_dict(gate) for gate in gates],
|
|
1152
|
+
"gates_passed": gates_passed,
|
|
1153
|
+
"review": _review_dict(review),
|
|
1154
|
+
"freeze_unchanged": freeze_unchanged,
|
|
1155
|
+
"next": next_step,
|
|
1156
|
+
}
|
|
1157
|
+
)
|
|
1158
|
+
packet.pop("_freeze_before", None)
|
|
1159
|
+
return next_step, exit_code
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _run_command(
|
|
1163
|
+
args,
|
|
1164
|
+
repo: Path,
|
|
1165
|
+
plan: Path,
|
|
1166
|
+
plan_rel: str,
|
|
1167
|
+
packet_path: Path,
|
|
1168
|
+
) -> int:
|
|
1169
|
+
cfg = load_lanes_config(repo)
|
|
1170
|
+
block = phase_block_text(plan, args.phase)
|
|
1171
|
+
findings_text = ""
|
|
1172
|
+
if args.findings is not None:
|
|
1173
|
+
findings_path = args.findings
|
|
1174
|
+
if not findings_path.is_absolute():
|
|
1175
|
+
findings_path = repo / findings_path
|
|
1176
|
+
try:
|
|
1177
|
+
findings_text = findings_path.read_text(encoding="utf-8")
|
|
1178
|
+
except OSError as exc:
|
|
1179
|
+
raise LaneConfigError(f"could not read findings {findings_path}: {exc}") from exc
|
|
1180
|
+
|
|
1181
|
+
packet = _new_packet(plan_rel, args.phase, args.attempt, args.type)
|
|
1182
|
+
hits = load_wording_findings(block + findings_text)
|
|
1183
|
+
if hits and not args.allow_load:
|
|
1184
|
+
packet.update({"result": RESULT_CONFIG_ERROR, "next": "stop"})
|
|
1185
|
+
write_packet(packet_path, packet)
|
|
1186
|
+
print(
|
|
1187
|
+
"NEXT: stop (load or stress wording found: "
|
|
1188
|
+
f"{hits}; the owner must pass --allow-load)"
|
|
1189
|
+
)
|
|
1190
|
+
return EXIT_CONFIG
|
|
1191
|
+
if hits:
|
|
1192
|
+
print("[caddis-lanes] --allow-load given; resource limits still apply")
|
|
1193
|
+
|
|
1194
|
+
chain = resolve_chain(args.type, cfg, first_lane=args.lane)
|
|
1195
|
+
models = resolve_models(cfg)
|
|
1196
|
+
prompt = build_prompt(
|
|
1197
|
+
plan_rel,
|
|
1198
|
+
args.phase,
|
|
1199
|
+
args.attempt,
|
|
1200
|
+
findings_text,
|
|
1201
|
+
resource_limits_line(),
|
|
1202
|
+
)
|
|
1203
|
+
wt = create_phase_worktree(repo, plan.stem, args.phase, args.attempt)
|
|
1204
|
+
pythons = shared_venv_pythons(repo, cfg)
|
|
1205
|
+
before = {python: freeze_snapshot(python) for python in pythons}
|
|
1206
|
+
packet.update(
|
|
1207
|
+
{
|
|
1208
|
+
"worktree": str(wt.path),
|
|
1209
|
+
"branch": wt.branch,
|
|
1210
|
+
"base_sha": wt.base_sha,
|
|
1211
|
+
}
|
|
1212
|
+
)
|
|
1213
|
+
|
|
1214
|
+
for lane in chain:
|
|
1215
|
+
model = models[lane]
|
|
1216
|
+
effort = DEFAULT_EFFORT[lane]
|
|
1217
|
+
packet.update({"lane": lane, "model": model})
|
|
1218
|
+
if lane == "claude":
|
|
1219
|
+
packet.update(
|
|
1220
|
+
{
|
|
1221
|
+
"result": "PENDING",
|
|
1222
|
+
"next": "claude-implement",
|
|
1223
|
+
}
|
|
1224
|
+
)
|
|
1225
|
+
write_packet(_freeze_path(packet_path), before)
|
|
1226
|
+
write_packet(packet_path, packet)
|
|
1227
|
+
print(f"NEXT: claude-implement worktree={wt.path}")
|
|
1228
|
+
return EXIT_OK
|
|
1229
|
+
|
|
1230
|
+
lane_argv = build_argv(lane, model, effort, wt.path)
|
|
1231
|
+
validate_codex_argv(lane_argv)
|
|
1232
|
+
lane_run = run_lane(
|
|
1233
|
+
lane_argv,
|
|
1234
|
+
wt.path,
|
|
1235
|
+
prompt,
|
|
1236
|
+
prompt_via_stdin(lane),
|
|
1237
|
+
args.timeout,
|
|
1238
|
+
child_env(dict(os.environ), lane, model),
|
|
1239
|
+
)
|
|
1240
|
+
killed = guard_killed_since(lane_run.started, {lane_run.pid})
|
|
1241
|
+
result = classify_exit(
|
|
1242
|
+
lane,
|
|
1243
|
+
lane_run.returncode,
|
|
1244
|
+
lane_run.output,
|
|
1245
|
+
lane_run.timed_out,
|
|
1246
|
+
killed,
|
|
1247
|
+
)
|
|
1248
|
+
packet["lanes_tried"].append(
|
|
1249
|
+
{
|
|
1250
|
+
"lane": lane,
|
|
1251
|
+
"model": model,
|
|
1252
|
+
"effort": effort,
|
|
1253
|
+
"result": result,
|
|
1254
|
+
"returncode": lane_run.returncode,
|
|
1255
|
+
"duration_s": lane_run.duration_s,
|
|
1256
|
+
"timed_out": lane_run.timed_out,
|
|
1257
|
+
}
|
|
1258
|
+
)
|
|
1259
|
+
packet["result"] = result
|
|
1260
|
+
if result == RESULT_RESOURCE:
|
|
1261
|
+
packet["next"] = "stop"
|
|
1262
|
+
write_packet(packet_path, packet)
|
|
1263
|
+
print("NEXT: stop (host guard killed the lane; see the guard log)")
|
|
1264
|
+
return EXIT_FAIL
|
|
1265
|
+
if result == RESULT_OUT_OF_BUDGET:
|
|
1266
|
+
print(f"[caddis-lanes] {lane} out of budget; trying next lane")
|
|
1267
|
+
continue
|
|
1268
|
+
if result == RESULT_CONFIG_ERROR:
|
|
1269
|
+
packet["next"] = "stop"
|
|
1270
|
+
write_packet(packet_path, packet)
|
|
1271
|
+
print(f"NEXT: stop (config error on {lane})")
|
|
1272
|
+
return EXIT_CONFIG
|
|
1273
|
+
if result == RESULT_LANE_FAILED:
|
|
1274
|
+
packet["next"] = (
|
|
1275
|
+
"takeover" if args.attempt >= MAX_ATTEMPTS else "redo"
|
|
1276
|
+
)
|
|
1277
|
+
write_packet(packet_path, packet)
|
|
1278
|
+
print(f"NEXT: {packet['next']}")
|
|
1279
|
+
return EXIT_FAIL
|
|
1280
|
+
break
|
|
1281
|
+
else:
|
|
1282
|
+
packet.update({"result": RESULT_OUT_OF_BUDGET, "next": "stop"})
|
|
1283
|
+
write_packet(packet_path, packet)
|
|
1284
|
+
print("NEXT: stop (every lane in the chain is out of budget)")
|
|
1285
|
+
return EXIT_ALL_OUT
|
|
1286
|
+
|
|
1287
|
+
next_step, exit_code = _run_post_checks(repo, cfg, packet, before)
|
|
1288
|
+
write_packet(packet_path, packet)
|
|
1289
|
+
print(f"NEXT: {next_step} packet={packet_path}")
|
|
1290
|
+
return exit_code
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
def _check_command(
|
|
1294
|
+
args,
|
|
1295
|
+
repo: Path,
|
|
1296
|
+
plan: Path,
|
|
1297
|
+
_plan_rel: str,
|
|
1298
|
+
packet_path: Path,
|
|
1299
|
+
) -> int:
|
|
1300
|
+
packet = _read_packet(packet_path)
|
|
1301
|
+
if packet is None:
|
|
1302
|
+
print(f"refusing: packet not found: {packet_path}")
|
|
1303
|
+
return EXIT_USAGE
|
|
1304
|
+
if packet.get("result") != "PENDING":
|
|
1305
|
+
print(f"refusing: packet result={packet.get('result')}")
|
|
1306
|
+
return EXIT_USAGE
|
|
1307
|
+
|
|
1308
|
+
wt = _worktree_from_packet(packet)
|
|
1309
|
+
_validate_packet_worktree(repo, plan, args.phase, args.attempt, wt)
|
|
1310
|
+
cfg = load_lanes_config(repo)
|
|
1311
|
+
stored_before = _read_packet(_freeze_path(packet_path))
|
|
1312
|
+
configured_pythons = set(shared_venv_pythons(repo, cfg))
|
|
1313
|
+
if stored_before is None or not configured_pythons <= set(stored_before):
|
|
1314
|
+
print("refusing: freeze snapshot missing")
|
|
1315
|
+
return EXIT_USAGE
|
|
1316
|
+
before = {
|
|
1317
|
+
python: snapshot
|
|
1318
|
+
for python, snapshot in stored_before.items()
|
|
1319
|
+
if python in configured_pythons
|
|
1320
|
+
and (isinstance(snapshot, str) or snapshot is None)
|
|
1321
|
+
}
|
|
1322
|
+
packet["result"] = RESULT_OK
|
|
1323
|
+
next_step, exit_code = _run_post_checks(repo, cfg, packet, before)
|
|
1324
|
+
write_packet(packet_path, packet)
|
|
1325
|
+
_freeze_path(packet_path).unlink(missing_ok=True)
|
|
1326
|
+
print(f"NEXT: {next_step} packet={packet_path}")
|
|
1327
|
+
return exit_code
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def _accept_command(
|
|
1331
|
+
args,
|
|
1332
|
+
repo: Path,
|
|
1333
|
+
plan: Path,
|
|
1334
|
+
_plan_rel: str,
|
|
1335
|
+
packet_path: Path,
|
|
1336
|
+
) -> int:
|
|
1337
|
+
programme = _git_output(repo, "rev-parse", "--abbrev-ref", "HEAD")
|
|
1338
|
+
if programme in ("main", "master", "HEAD"):
|
|
1339
|
+
print("NEXT: stop (accept runs on the programme branch, not main)")
|
|
1340
|
+
return EXIT_USAGE
|
|
1341
|
+
|
|
1342
|
+
packet = _read_packet(packet_path)
|
|
1343
|
+
if packet is None:
|
|
1344
|
+
print(f"refusing: packet not found: {packet_path}")
|
|
1345
|
+
return EXIT_USAGE
|
|
1346
|
+
if packet.get("next") != "review":
|
|
1347
|
+
print(f"refusing: packet next={packet.get('next')}")
|
|
1348
|
+
return EXIT_USAGE
|
|
1349
|
+
review_status = packet.get("review", {}).get("status")
|
|
1350
|
+
if review_status == "BLOCKING" and not getattr(args, "override_review", None):
|
|
1351
|
+
print("refusing: review BLOCKING; pass --override-review with a reason")
|
|
1352
|
+
return EXIT_USAGE
|
|
1353
|
+
if review_status == "MISSING":
|
|
1354
|
+
print("WARNING: review MISSING")
|
|
1355
|
+
if _git_output(repo, "rev-parse", "HEAD") != packet.get("base_sha"):
|
|
1356
|
+
print("refusing: programme branch moved since the attempt started")
|
|
1357
|
+
return EXIT_FAIL
|
|
1358
|
+
|
|
1359
|
+
wt = _worktree_from_packet(packet)
|
|
1360
|
+
_validate_packet_worktree(repo, plan, args.phase, args.attempt, wt)
|
|
1361
|
+
files = packet.get("changed_files")
|
|
1362
|
+
if not isinstance(files, list) or not files:
|
|
1363
|
+
print("refusing: packet has no changed files")
|
|
1364
|
+
return EXIT_USAGE
|
|
1365
|
+
if not packet.get("worktree_digest"):
|
|
1366
|
+
print("refusing: packet has no worktree digest; run check again")
|
|
1367
|
+
return EXIT_USAGE
|
|
1368
|
+
if (changed_files(wt.path) != files
|
|
1369
|
+
or packet["worktree_digest"] != worktree_digest(wt.path, files)):
|
|
1370
|
+
print("refusing: worktree changed since check")
|
|
1371
|
+
return EXIT_FAIL
|
|
1372
|
+
if args.override_review is not None:
|
|
1373
|
+
packet["override_review"] = args.override_review
|
|
1374
|
+
write_packet(packet_path, packet)
|
|
1375
|
+
print(f"OVERRIDE REVIEW: {args.override_review}")
|
|
1376
|
+
_git_output(wt.path, "add", "--", *(str(path) for path in files))
|
|
1377
|
+
_git_output(wt.path, "commit", "-m", args.message)
|
|
1378
|
+
accepted_sha = _git_output(wt.path, "rev-parse", "HEAD")
|
|
1379
|
+
_git_output(repo, "merge", "--ff-only", wt.branch)
|
|
1380
|
+
remove_phase_worktree(repo, wt, delete_branch=True)
|
|
1381
|
+
_freeze_path(packet_path).unlink(missing_ok=True)
|
|
1382
|
+
print(f"ACCEPTED {accepted_sha[:7]}")
|
|
1383
|
+
return EXIT_OK
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def _abandon_command(
|
|
1387
|
+
args,
|
|
1388
|
+
repo: Path,
|
|
1389
|
+
plan: Path,
|
|
1390
|
+
_plan_rel: str,
|
|
1391
|
+
packet_path: Path,
|
|
1392
|
+
) -> int:
|
|
1393
|
+
packet = _read_packet(packet_path)
|
|
1394
|
+
if packet is None:
|
|
1395
|
+
print(f"refusing: packet not found: {packet_path}")
|
|
1396
|
+
return EXIT_USAGE
|
|
1397
|
+
wt = _worktree_from_packet(packet)
|
|
1398
|
+
_validate_packet_worktree(repo, plan, args.phase, args.attempt, wt)
|
|
1399
|
+
remove_phase_worktree(repo, wt, delete_branch=True)
|
|
1400
|
+
_freeze_path(packet_path).unlink(missing_ok=True)
|
|
1401
|
+
print(f"ABANDONED {wt.branch}")
|
|
1402
|
+
return EXIT_OK
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def branch_is_merged(repo: Path, branch: str) -> bool:
|
|
1406
|
+
ancestor = subprocess.run(
|
|
1407
|
+
["git", "-C", str(repo), "merge-base", "--is-ancestor", branch, "HEAD"],
|
|
1408
|
+
capture_output=True,
|
|
1409
|
+
)
|
|
1410
|
+
if ancestor.returncode == 0:
|
|
1411
|
+
return True
|
|
1412
|
+
merge_base = _git_output(repo, "merge-base", branch, "HEAD")
|
|
1413
|
+
files = _git_output(repo, "diff", "--name-only", merge_base, branch).splitlines()
|
|
1414
|
+
if not files:
|
|
1415
|
+
return False
|
|
1416
|
+
same_tree = subprocess.run(
|
|
1417
|
+
["git", "-C", str(repo), "diff", "--quiet", branch, "HEAD", "--", *files],
|
|
1418
|
+
capture_output=True,
|
|
1419
|
+
)
|
|
1420
|
+
return same_tree.returncode == 0
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
def _worktree_entries(repo: Path) -> list[tuple[Path, str]]:
|
|
1424
|
+
entries = []
|
|
1425
|
+
current: dict[str, str] = {}
|
|
1426
|
+
output = _git_output(repo, "worktree", "list", "--porcelain")
|
|
1427
|
+
for line in [*output.splitlines(), ""]:
|
|
1428
|
+
if not line:
|
|
1429
|
+
if "worktree" in current:
|
|
1430
|
+
branch = current.get("branch", "")
|
|
1431
|
+
prefix = "refs/heads/"
|
|
1432
|
+
if branch.startswith(prefix):
|
|
1433
|
+
branch = branch[len(prefix) :]
|
|
1434
|
+
entries.append((Path(current["worktree"]), branch))
|
|
1435
|
+
current = {}
|
|
1436
|
+
continue
|
|
1437
|
+
key, _, value = line.partition(" ")
|
|
1438
|
+
if key in ("worktree", "branch"):
|
|
1439
|
+
current[key] = value
|
|
1440
|
+
return entries
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
def _validate_packet_worktree(
|
|
1444
|
+
repo: Path,
|
|
1445
|
+
plan: Path,
|
|
1446
|
+
phase: int,
|
|
1447
|
+
attempt: int,
|
|
1448
|
+
wt: Worktree,
|
|
1449
|
+
) -> None:
|
|
1450
|
+
resolved = wt.path.resolve()
|
|
1451
|
+
expected_branch = f"lane/{plan.stem}/phase-{phase:02d}-a{attempt}"
|
|
1452
|
+
if LANES_TMP.resolve() not in resolved.parents:
|
|
1453
|
+
raise LaneConfigError(f"refusing packet worktree outside {LANES_TMP}: {resolved}")
|
|
1454
|
+
if wt.branch != expected_branch:
|
|
1455
|
+
raise LaneConfigError(
|
|
1456
|
+
f"refusing packet branch {wt.branch}: expected {expected_branch}"
|
|
1457
|
+
)
|
|
1458
|
+
registered = {(path.resolve(), branch) for path, branch in _worktree_entries(repo)}
|
|
1459
|
+
if (resolved, wt.branch) not in registered:
|
|
1460
|
+
raise LaneConfigError(
|
|
1461
|
+
f"refusing packet worktree not registered on {wt.branch}: {resolved}"
|
|
1462
|
+
)
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
def _finish_command(args) -> int:
|
|
1466
|
+
repo = _repo_path(args.repo)
|
|
1467
|
+
plan, _ = _plan_path(repo, args.plan)
|
|
1468
|
+
programme = _git_output(repo, "rev-parse", "--abbrev-ref", "HEAD")
|
|
1469
|
+
if programme in ("main", "master", "HEAD"):
|
|
1470
|
+
print("NEXT: stop (finish runs on the programme branch, not main)")
|
|
1471
|
+
return EXIT_USAGE
|
|
1472
|
+
|
|
1473
|
+
prefix = f"lane/{plan.stem}/"
|
|
1474
|
+
leftovers: list[tuple[str, str]] = []
|
|
1475
|
+
locked_phases = set()
|
|
1476
|
+
directory = run_dir(repo, plan.stem)
|
|
1477
|
+
for lock in sorted(directory.glob("*.lock")):
|
|
1478
|
+
try:
|
|
1479
|
+
pid = int(lock.read_text(encoding="utf-8").strip())
|
|
1480
|
+
except (OSError, ValueError):
|
|
1481
|
+
pid = 0
|
|
1482
|
+
if pid > 0 and process_alive(pid):
|
|
1483
|
+
phase_match = re.fullmatch(r"phase-(\d+)\.lock", lock.name)
|
|
1484
|
+
if phase_match:
|
|
1485
|
+
locked_phases.add(int(phase_match.group(1)))
|
|
1486
|
+
leftovers.append(("LOCKED", str(lock)))
|
|
1487
|
+
else:
|
|
1488
|
+
lock.unlink(missing_ok=True)
|
|
1489
|
+
|
|
1490
|
+
def branch_is_locked(branch: str) -> bool:
|
|
1491
|
+
match = re.fullmatch(
|
|
1492
|
+
rf"{re.escape(prefix)}phase-(\d+)-a\d+",
|
|
1493
|
+
branch,
|
|
1494
|
+
)
|
|
1495
|
+
return bool(match and int(match.group(1)) in locked_phases)
|
|
1496
|
+
|
|
1497
|
+
main_path = repo.resolve()
|
|
1498
|
+
for path, branch in _worktree_entries(repo):
|
|
1499
|
+
resolved = path.resolve()
|
|
1500
|
+
if resolved == main_path:
|
|
1501
|
+
continue
|
|
1502
|
+
in_lanes_tmp = LANES_TMP.resolve() in resolved.parents
|
|
1503
|
+
if not branch.startswith(prefix) or not in_lanes_tmp:
|
|
1504
|
+
print(f"OTHER {path} {branch or 'HEAD'}")
|
|
1505
|
+
continue
|
|
1506
|
+
if branch_is_locked(branch):
|
|
1507
|
+
continue
|
|
1508
|
+
if _git_output(path, "status", "--porcelain"):
|
|
1509
|
+
leftovers.append(("DIRTY", str(path)))
|
|
1510
|
+
elif branch_is_merged(repo, branch):
|
|
1511
|
+
remove_phase_worktree(repo, Worktree(path, branch, ""), delete_branch=True)
|
|
1512
|
+
print(f"REMOVED {path} {branch}")
|
|
1513
|
+
else:
|
|
1514
|
+
leftovers.append(("UNMERGED", branch))
|
|
1515
|
+
|
|
1516
|
+
held_branches = {branch for _, branch in _worktree_entries(repo) if branch}
|
|
1517
|
+
branches = _git_output(
|
|
1518
|
+
repo,
|
|
1519
|
+
"for-each-ref",
|
|
1520
|
+
"--format=%(refname:short)",
|
|
1521
|
+
f"refs/heads/{prefix}",
|
|
1522
|
+
).splitlines()
|
|
1523
|
+
for branch in branches:
|
|
1524
|
+
if branch in held_branches or branch_is_locked(branch):
|
|
1525
|
+
continue
|
|
1526
|
+
if branch_is_merged(repo, branch):
|
|
1527
|
+
_git_output(repo, "branch", "-D", "--", branch)
|
|
1528
|
+
print(f"REMOVED {branch}")
|
|
1529
|
+
else:
|
|
1530
|
+
leftovers.append(("UNMERGED", branch))
|
|
1531
|
+
|
|
1532
|
+
if not leftovers and not args.no_push:
|
|
1533
|
+
remotes = _git_output(repo, "remote").splitlines()
|
|
1534
|
+
if "origin" in remotes:
|
|
1535
|
+
pushed = subprocess.run(
|
|
1536
|
+
["git", "-C", str(repo), "push", "-u", "origin", programme],
|
|
1537
|
+
capture_output=True,
|
|
1538
|
+
text=True,
|
|
1539
|
+
encoding="utf-8",
|
|
1540
|
+
errors="replace",
|
|
1541
|
+
)
|
|
1542
|
+
if pushed.returncode == 0:
|
|
1543
|
+
short_sha = _git_output(repo, "rev-parse", "--short", "HEAD")
|
|
1544
|
+
print(f"PUSHED {programme} {short_sha}")
|
|
1545
|
+
else:
|
|
1546
|
+
leftovers.append(("PUSH-FAILED", programme))
|
|
1547
|
+
|
|
1548
|
+
for kind, target in leftovers:
|
|
1549
|
+
print(f"LEFTOVER {kind} {target}")
|
|
1550
|
+
print(f"LEFTOVERS: {len(leftovers)}")
|
|
1551
|
+
if leftovers:
|
|
1552
|
+
print("NEXT: resolve leftovers")
|
|
1553
|
+
return EXIT_FAIL
|
|
1554
|
+
print(
|
|
1555
|
+
"NEXT: ship-pr (open the PR with /caddis:ship-pr; merging to main and "
|
|
1556
|
+
"post-merge cleanup stay with /caddis:ship-merge after the owner's go)"
|
|
1557
|
+
)
|
|
1558
|
+
return EXIT_OK
|
|
1559
|
+
|
|
1560
|
+
|
|
1561
|
+
def _add_phase_arguments(parser) -> None:
|
|
1562
|
+
parser.add_argument("--plan", type=Path, required=True)
|
|
1563
|
+
parser.add_argument("--phase", type=int, required=True)
|
|
1564
|
+
parser.add_argument("--attempt", type=int, default=1)
|
|
1565
|
+
parser.add_argument("--repo", type=Path)
|
|
1566
|
+
|
|
1567
|
+
|
|
1568
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1569
|
+
import argparse
|
|
1570
|
+
|
|
1571
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
1572
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
1573
|
+
show_chain = subparsers.add_parser("show-chain")
|
|
1574
|
+
show_chain.add_argument("--type", required=True)
|
|
1575
|
+
show_chain.add_argument("--lane")
|
|
1576
|
+
show_chain.add_argument("--repo", type=Path)
|
|
1577
|
+
|
|
1578
|
+
run = subparsers.add_parser("run")
|
|
1579
|
+
_add_phase_arguments(run)
|
|
1580
|
+
run.add_argument("--type", required=True)
|
|
1581
|
+
run.add_argument("--lane")
|
|
1582
|
+
run.add_argument("--findings", type=Path)
|
|
1583
|
+
run.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_S)
|
|
1584
|
+
run.add_argument("--allow-load", action="store_true")
|
|
1585
|
+
|
|
1586
|
+
check = subparsers.add_parser("check")
|
|
1587
|
+
_add_phase_arguments(check)
|
|
1588
|
+
accept = subparsers.add_parser("accept")
|
|
1589
|
+
_add_phase_arguments(accept)
|
|
1590
|
+
accept.add_argument("--message", required=True)
|
|
1591
|
+
accept.add_argument("--override-review")
|
|
1592
|
+
abandon = subparsers.add_parser("abandon")
|
|
1593
|
+
_add_phase_arguments(abandon)
|
|
1594
|
+
finish = subparsers.add_parser("finish")
|
|
1595
|
+
finish.add_argument("--plan", type=Path, required=True)
|
|
1596
|
+
finish.add_argument("--repo", type=Path)
|
|
1597
|
+
finish.add_argument("--no-push", action="store_true")
|
|
1598
|
+
args = parser.parse_args(argv)
|
|
1599
|
+
|
|
1600
|
+
try:
|
|
1601
|
+
if args.command == "show-chain":
|
|
1602
|
+
cfg = load_lanes_config(args.repo if args.repo is not None else Path.cwd())
|
|
1603
|
+
chain = resolve_chain(args.type, cfg, first_lane=args.lane)
|
|
1604
|
+
print(" -> ".join(chain))
|
|
1605
|
+
return EXIT_OK
|
|
1606
|
+
if args.command == "finish":
|
|
1607
|
+
return _finish_command(args)
|
|
1608
|
+
|
|
1609
|
+
repo = _repo_path(args.repo)
|
|
1610
|
+
plan, plan_rel = _plan_path(repo, args.plan)
|
|
1611
|
+
packet_path = _packet_path(repo, plan.stem, args.phase, args.attempt)
|
|
1612
|
+
lock = run_dir(repo, plan.stem) / f"phase-{args.phase:02d}.lock"
|
|
1613
|
+
acquire_lock(lock)
|
|
1614
|
+
try:
|
|
1615
|
+
handlers = {
|
|
1616
|
+
"run": _run_command,
|
|
1617
|
+
"check": _check_command,
|
|
1618
|
+
"accept": _accept_command,
|
|
1619
|
+
"abandon": _abandon_command,
|
|
1620
|
+
}
|
|
1621
|
+
return handlers[args.command](args, repo, plan, plan_rel, packet_path)
|
|
1622
|
+
finally:
|
|
1623
|
+
release_lock(lock)
|
|
1624
|
+
except LaneConfigError as exc:
|
|
1625
|
+
print(f"[caddis-lanes] {exc}", file=sys.stderr)
|
|
1626
|
+
return EXIT_CONFIG
|
|
1627
|
+
|
|
1628
|
+
return EXIT_USAGE
|
|
1629
|
+
|
|
1630
|
+
|
|
1631
|
+
if __name__ == "__main__":
|
|
1632
|
+
sys.exit(main())
|