@caddis/cli 0.4.12 → 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.
@@ -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())