@caddis/cli 0.4.71 → 0.4.73

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.
@@ -15,18 +15,20 @@ import hashlib
15
15
  import json
16
16
  import os
17
17
  import re
18
+ import shutil
18
19
  import subprocess
19
20
  import sys
21
+ import time
20
22
  from collections.abc import Callable
21
23
  from pathlib import Path
22
24
 
23
25
  try:
24
- from caddis_file_lock import atomic_write, locked_path, retry_denied
26
+ from caddis_file_lock import _try_lock, _unlock, atomic_write, locked_path, retry_denied
25
27
  except ModuleNotFoundError as exc:
26
28
  if exc.name != "caddis_file_lock":
27
29
  raise
28
30
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
29
- from caddis_file_lock import atomic_write, locked_path, retry_denied
31
+ from caddis_file_lock import _try_lock, _unlock, atomic_write, locked_path, retry_denied
30
32
 
31
33
 
32
34
  _TIMEOUT = 10
@@ -43,10 +45,10 @@ _URL = re.compile(
43
45
  )
44
46
 
45
47
 
46
- def _run(args: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
48
+ def _run(args: list[str], cwd: Path | None = None, timeout: float = _TIMEOUT) -> subprocess.CompletedProcess[str]:
47
49
  # utf-8 explicitly: the Windows default code page garbles non-ASCII paths and titles.
48
50
  return subprocess.run(args, cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace",
49
- timeout=_TIMEOUT, creationflags=_NO_WINDOW)
51
+ timeout=timeout, creationflags=_NO_WINDOW)
50
52
 
51
53
 
52
54
  def repo_id(remote_name_with_owner: str | None, is_fork_of: str | None, git_root: Path) -> str:
@@ -130,6 +132,14 @@ def _lock_path(rid: str) -> Path:
130
132
  return _repo_dir(rid) / "coordinator.lock.json"
131
133
 
132
134
 
135
+ def _watcher_lock_path(rid: str) -> Path:
136
+ return _repo_dir(rid) / "watcher.lock"
137
+
138
+
139
+ def _watcher_stop_path(rid: str) -> Path:
140
+ return _repo_dir(rid) / "watcher.stop"
141
+
142
+
133
143
  def read_state(rid: str) -> dict | None:
134
144
  """Return the last good parse of `coordinator.json`; None when it is missing or was never good.
135
145
 
@@ -365,6 +375,17 @@ def acquire_lock(rid: str, pid: int, session_id: str, name: str) -> tuple[bool,
365
375
  return True, None
366
376
 
367
377
 
378
+ def lock_holder(rid: str) -> dict | None:
379
+ """Return the lock holder with its `alive` flag, or None when no holder. Never takes or changes the lock.
380
+
381
+ Raise OSError when the lock file cannot be read (the caller fails closed)."""
382
+ holder = _read_holder(_lock_path(rid))
383
+ if holder is None:
384
+ return None
385
+ return {"pid": holder["pid"], "sessionId": holder.get("sessionId"), "name": holder.get("name"),
386
+ "started_at": holder.get("started_at"), "alive": _holder_alive(holder)}
387
+
388
+
368
389
  def release_lock(rid: str, pid: int) -> None:
369
390
  """Remove the lock if `pid` holds it; do nothing otherwise."""
370
391
  with locked_path(_lock_path(rid)) as path:
@@ -815,8 +836,8 @@ def queue_merged(rid: str, pr: int, merge_sha: str) -> bool:
815
836
 
816
837
 
817
838
  def deploy_check_set(rid: str, pr: int, *, deploy_run: int | None = None, health: str | None = None) -> None:
818
- """Set the deploy run and/or health of the newest deploy check for a PR. Raises ValueError for a health not in
819
- `DEPLOY_HEALTH`, a run that is not an integer, or a PR with no deploy check."""
839
+ """Set the deploy run and/or health of the newest deploy check for a PR; a health also sets `at` to now.
840
+ Raises ValueError for a health not in `DEPLOY_HEALTH`, a run that is not an integer, or a PR with no deploy check."""
820
841
  if health is not None and health not in DEPLOY_HEALTH:
821
842
  raise ValueError(f"unknown deploy health: {health!r}")
822
843
  if deploy_run is not None and (isinstance(deploy_run, bool) or not isinstance(deploy_run, int)):
@@ -829,8 +850,9 @@ def deploy_check_set(rid: str, pr: int, *, deploy_run: int | None = None, health
829
850
  raise ValueError(f"no deploy check for PR {pr}")
830
851
  if deploy_run is not None:
831
852
  check["deploy_run"] = deploy_run
832
- if health is not None:
853
+ if health is not None: # stamp `at`, so the newest entry by `at` is the newest health (freeze rule)
833
854
  check["health"] = health
855
+ check["at"] = _now()
834
856
  _mutate_state(rid, change)
835
857
 
836
858
 
@@ -886,6 +908,20 @@ def _gh_head_of(rid: str) -> Callable[[int], str | None]:
886
908
  return head_of
887
909
 
888
910
 
911
+ def _gh_title(rid: str, pr: int) -> str:
912
+ """The PR title from gh, so the model never puts free text on a command line; `PR #<n>` on any failure."""
913
+ fallback = f"PR #{pr}"
914
+ if rid.startswith("local__") or "__" not in rid:
915
+ return fallback
916
+ owner, name = rid.split("__", 1)
917
+ try:
918
+ result = _run(["gh", "pr", "view", str(pr), "-R", f"{owner}/{name}", "--json", "title", "-q", ".title"])
919
+ except (OSError, subprocess.SubprocessError):
920
+ return fallback
921
+ title = result.stdout.strip() if result.returncode == 0 else ""
922
+ return title or fallback
923
+
924
+
889
925
  def _claim_state(rid: str, session: dict) -> None:
890
926
  """After the lock is taken: create `coordinator.json`, or record the new coordinator session in it."""
891
927
  with locked_path(state_path(rid)) as path:
@@ -899,6 +935,534 @@ def _claim_state(rid: str, session: dict) -> None:
899
935
  _write_state_unlocked(path, state)
900
936
 
901
937
 
938
+ def session_lookup(sessions: list[dict], *, session_id: str | None = None, name: str | None = None) -> dict:
939
+ """Return `{"pid", "sessionId", "name"}` of the ONE interactive session with this sessionId or name.
940
+
941
+ Background entries are ignored: their pid is not a long-lived Claude Code session (ruling R12)."""
942
+ if (session_id is None) == (name is None):
943
+ raise ValueError("session_lookup takes exactly one of session_id or name")
944
+ key, wanted = ("sessionId", session_id) if session_id is not None else ("name", name)
945
+ hits = [s for s in sessions if isinstance(s, dict) and s.get("kind") == "interactive" and s.get(key) == wanted]
946
+ if not hits:
947
+ raise ValueError(f"no such session: {key} {wanted!r}")
948
+ if len(hits) > 1:
949
+ pids = ", ".join(str(s.get("pid")) for s in hits)
950
+ raise ValueError(f"two interactive sessions share {key} {wanted!r}: pids {pids}")
951
+ hit = hits[0]
952
+ pid = hit.get("pid")
953
+ if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
954
+ raise ValueError(f"session {key} {wanted!r} has no valid pid: {pid!r}")
955
+ for field in ("sessionId", "name"):
956
+ if not isinstance(hit.get(field), str) or not hit[field]:
957
+ raise ValueError(f"session {key} {wanted!r} has no {field}")
958
+ return {"pid": pid, "sessionId": hit["sessionId"], "name": hit["name"]}
959
+
960
+
961
+ # --- the per-repo config `.caddis/coordinator.md` (C2.2, ruling R10: one fenced json block) --------------------
962
+
963
+ CONFIG_KEYS = ("schema", "default_branch", "merge_method", "required_checks", "not_required", "docs_only",
964
+ "main_workflow", "reviews", "peer_ack", "deploy", "owner_reserved", "stall", "quiet")
965
+ OPTIONAL_CONFIG_KEYS = ("relay",) # the coordinator's own relay: `.caddis/relay/<relay>.md`
966
+ CONFIG_SCHEMA = 1
967
+ _PLACEHOLDER = "<fill:"
968
+ _FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})[ \t]*([^\s`]*)") # a Markdown code fence and its info string
969
+ _TEMPLATE_PATH = Path(__file__).resolve().parent / "templates" / "coordinator.md"
970
+
971
+
972
+ def config_path(repo_root: Path) -> Path:
973
+ return Path(repo_root) / ".caddis" / "coordinator.md"
974
+
975
+
976
+ def _closes(line: str, fence: str) -> bool:
977
+ """True when `line` closes a block opened by `fence`: same character, at least as long, nothing after it."""
978
+ closed = re.match(r"^ {0,3}(`{3,}|~{3,})[ \t]*$", line)
979
+ return bool(closed) and closed.group(1)[0] == fence[0] and len(closed.group(1)) >= len(fence)
980
+
981
+
982
+ def _config_block(text: str) -> object:
983
+ """Parse the FIRST fenced block whose info string is `json`. Raise ValueError when there is none."""
984
+ lines = text.splitlines()
985
+ i = 0
986
+ while i < len(lines):
987
+ opened = _FENCE.match(lines[i])
988
+ if not opened:
989
+ i += 1
990
+ continue
991
+ fence, info = opened.group(1), opened.group(2).lower()
992
+ body: list[str] = []
993
+ i += 1
994
+ while i < len(lines) and not _closes(lines[i], fence):
995
+ body.append(lines[i])
996
+ i += 1
997
+ i += 1
998
+ if info == "json":
999
+ try:
1000
+ return json.loads("\n".join(body))
1001
+ except ValueError as exc:
1002
+ raise ValueError(f"the json block in the config does not parse: {exc}") from exc
1003
+ raise ValueError("the config has no fenced json block")
1004
+
1005
+
1006
+ def _placeholders(value: object, where: str = "") -> list[str]:
1007
+ """Every key path (at any depth) whose value is a string that starts with `<fill:`."""
1008
+ if isinstance(value, str):
1009
+ return [where] if value.startswith(_PLACEHOLDER) else []
1010
+ if isinstance(value, dict):
1011
+ return [hit for k, v in value.items() for hit in _placeholders(v, f"{where}.{k}" if where else str(k))]
1012
+ if isinstance(value, list):
1013
+ return [hit for n, v in enumerate(value) for hit in _placeholders(v, f"{where}[{n}]")]
1014
+ return []
1015
+
1016
+
1017
+ def load_config(repo_root: Path) -> dict:
1018
+ """Read `<repo_root>/.caddis/coordinator.md` and return its json block.
1019
+
1020
+ Raise FileNotFoundError when the file is missing, ValueError when there is no json block, a key is missing,
1021
+ `schema` is not 1, a value is still a `<fill: ...>` placeholder, or the optional `relay` is not a plain
1022
+ workstream name (`caddis_workstreams.is_plain_name`: lower case `a-z0-9_-`, no Windows device name)."""
1023
+ path = config_path(repo_root)
1024
+ try:
1025
+ text = path.read_text(encoding="utf-8")
1026
+ except FileNotFoundError as exc:
1027
+ raise FileNotFoundError(f"no coordinator config at {path} (run: caddis_coordinate.py config init)") from exc
1028
+ config = _config_block(text)
1029
+ if not isinstance(config, dict):
1030
+ raise ValueError("the config json block must be one JSON object")
1031
+ missing = [key for key in CONFIG_KEYS if key not in config]
1032
+ if missing:
1033
+ raise ValueError(f"the config is missing keys: {', '.join(missing)}")
1034
+ schema = config["schema"]
1035
+ if not isinstance(schema, int) or isinstance(schema, bool) or schema != CONFIG_SCHEMA:
1036
+ raise ValueError(f"the config schema must be {CONFIG_SCHEMA}, not {schema!r}")
1037
+ unfilled = _placeholders(config)
1038
+ if unfilled:
1039
+ raise ValueError(f"the config still has {_PLACEHOLDER} placeholders at: {', '.join(unfilled)}")
1040
+ if "relay" in config and not _plain_workstream_name(config["relay"]):
1041
+ raise ValueError(f"the config relay must be a plain workstream name such as coordinator, "
1042
+ f"not {config['relay']!r}")
1043
+ return config
1044
+
1045
+
1046
+ def _plain_workstream_name(value: object) -> bool:
1047
+ """The relay name rule of `caddis_workstreams` (one rule, so a relay that loads here can also be read there)."""
1048
+ try:
1049
+ import caddis_workstreams
1050
+ except ModuleNotFoundError as exc:
1051
+ if exc.name != "caddis_workstreams":
1052
+ raise
1053
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
1054
+ import caddis_workstreams
1055
+ return caddis_workstreams.is_plain_name(value)
1056
+
1057
+
1058
+ def init_config(repo_root: Path) -> Path:
1059
+ """Copy the template to `<repo_root>/.caddis/coordinator.md` when it is absent. Never overwrite."""
1060
+ path = config_path(repo_root)
1061
+ template = _TEMPLATE_PATH.read_text(encoding="utf-8")
1062
+ path.parent.mkdir(parents=True, exist_ok=True)
1063
+ try:
1064
+ with open(path, "x", encoding="utf-8", newline="") as handle: # "x": fails if the file exists, no race
1065
+ handle.write(template)
1066
+ except FileExistsError:
1067
+ pass
1068
+ return path
1069
+
1070
+
1071
+ # --- the watcher loop (C2.3) --------------------------------------------------------------------------------------
1072
+
1073
+
1074
+ def _say(exc: BaseException) -> str:
1075
+ return " ".join(str(exc).split()) or type(exc).__name__
1076
+
1077
+
1078
+ def watch_cycle(rid: str, pid: int, now: datetime.datetime, head_of: Callable[[int], str | None]) -> dict:
1079
+ """One watcher cycle: check the lock still names `pid` and that session is alive, then heartbeat and intake.
1080
+
1081
+ Returns `{"stop": "lock lost" | "session gone" | "state gone"}` when the watcher must end, else
1082
+ `{"stop": None, "results": [...], "error": str | None}`. A transient failure (a lock file or state file that
1083
+ cannot be read now, a lock timeout) goes into `error` and is never raised: the next cycle retries. The watcher
1084
+ never takes the lock and changes the queue only through `intake`. The pid check is local (no `claude agents`).
1085
+ """
1086
+ def failed(message: str) -> dict:
1087
+ return {"stop": None, "results": [], "error": message}
1088
+
1089
+ try:
1090
+ holder = _read_holder(_lock_path(rid))
1091
+ except OSError as exc: # fail closed: it may still name us, so retry next tick rather than stop
1092
+ if not _pid_alive(pid): # ... unless our own session is dead: then nothing is left to watch for
1093
+ return {"stop": "session gone"}
1094
+ return failed(f"lock file unreadable: {_say(exc)}")
1095
+ if holder is None or holder["pid"] != pid:
1096
+ return {"stop": "lock lost"}
1097
+ if not _holder_alive(holder): # the pid is dead, or now belongs to a newer process (pid reuse)
1098
+ return {"stop": "session gone"}
1099
+ if not state_path(rid).exists(): # checked first: heartbeat must never recreate the repo folder
1100
+ return {"stop": "state gone"}
1101
+ try:
1102
+ heartbeat(rid)
1103
+ results = intake(rid, now, head_of)
1104
+ except FileNotFoundError:
1105
+ return {"stop": "state gone"}
1106
+ except ValueError as exc:
1107
+ if not state_path(rid).exists():
1108
+ return {"stop": "state gone"}
1109
+ return failed(_say(exc))
1110
+ except OSError as exc: # sharing error, lock timeout (TimeoutError): retry next tick
1111
+ return failed(_say(exc))
1112
+ except Exception as exc: # e.g. a hand-edited state file: report it, keep watching
1113
+ return failed(f"unexpected {type(exc).__name__}: {_say(exc)}")
1114
+ return {"stop": None, "results": results, "error": None}
1115
+
1116
+
1117
+ _TAKEOVER_MAX_S = 150.0 # the longest a `--takeover` waits for the old watcher
1118
+ _TAKEOVER_POLL_S = 0.2
1119
+
1120
+
1121
+ def _print_line(value: dict) -> None:
1122
+ print(json.dumps(value, separators=(",", ":")), flush=True)
1123
+
1124
+
1125
+ def _takeover_wait(interval: float) -> float:
1126
+ """The old watcher checks the stop file right after each sleep, and one cycle can run long (gh calls): wait one
1127
+ interval plus 30 s, at most `_TAKEOVER_MAX_S`."""
1128
+ return min(interval + 30, _TAKEOVER_MAX_S)
1129
+
1130
+
1131
+ def _remove_stop_file(rid: str) -> None:
1132
+ retry_denied(lambda: _watcher_stop_path(rid).unlink(missing_ok=True))
1133
+
1134
+
1135
+ def _remove_own_stop_file(rid: str) -> None:
1136
+ """Remove the stop file only when this process wrote it: a newer takeover's request stays."""
1137
+ try:
1138
+ owner = json.loads(_watcher_stop_path(rid).read_text(encoding="utf-8")).get("pid")
1139
+ except (OSError, ValueError, AttributeError):
1140
+ return
1141
+ if owner == os.getpid():
1142
+ _remove_stop_file(rid)
1143
+
1144
+
1145
+ def watch(rid: str, pid: int, interval: float, max_cycles: int | None = None, takeover: bool = False) -> int:
1146
+ """Run `watch_cycle` every `interval` seconds. Print one JSON line (flushed) for each cycle with results or a
1147
+ stop, and for an error that differs from the previous cycle's error; print nothing for a quiet cycle or a
1148
+ repeated error. Return 0 on a stop or after `max_cycles` cycles.
1149
+
1150
+ One watcher per repo: the loop holds the OS lock on `<hub>/repos/<rid>/watcher.lock` for its whole life (the OS
1151
+ frees it when the process dies). When another watcher holds it:
1152
+ - without `takeover`, print `{"stop": "another watcher is running"}` and return 0 at once;
1153
+ - with `takeover`, write `<hub>/repos/<rid>/watcher.stop` and wait (`_takeover_wait`) for the lock. The old
1154
+ watcher sees the stop file right after its next sleep, prints `{"stop": "takeover requested"}` and ends.
1155
+ A `finally` removes this process's own stop file (on success, timeout or an exception; a newer takeover's
1156
+ file stays). When the lock does not come in time, print `{"stop": "old watcher did not stop"}` and
1157
+ return 1. After a `/clear` the old watcher runs on in the same process, but its output belongs to the old
1158
+ conversation: takeover gives the new conversation a watcher whose output it can read.
1159
+ The watcher that gets the lock removes any stop file before its first cycle. When the repo folder does not
1160
+ exist, no lock or stop file is made: the first cycle then stops (it never recreates the folder)."""
1161
+ path = _watcher_lock_path(rid)
1162
+ if not path.parent.is_dir():
1163
+ return _watch_loop(rid, pid, interval, max_cycles)
1164
+ flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0)
1165
+ with os.fdopen(os.open(path, flags, 0o666), "r+b") as handle:
1166
+ held = _try_lock(handle)
1167
+ if not held and not takeover:
1168
+ _print_line({"stop": "another watcher is running"})
1169
+ return EXIT_OK
1170
+ if not held:
1171
+ try:
1172
+ atomic_write(_watcher_stop_path(rid), json.dumps({"pid": os.getpid(), "at": _now()}) + "\n")
1173
+ deadline = time.monotonic() + _takeover_wait(interval)
1174
+ while not held and time.monotonic() < deadline:
1175
+ time.sleep(_TAKEOVER_POLL_S)
1176
+ held = _try_lock(handle)
1177
+ finally: # on success, on timeout and on an exception: never leave this process's request behind
1178
+ _remove_own_stop_file(rid)
1179
+ if not held:
1180
+ _print_line({"stop": "old watcher did not stop"})
1181
+ return EXIT_ERROR
1182
+ try:
1183
+ _remove_stop_file(rid)
1184
+ return _watch_loop(rid, pid, interval, max_cycles)
1185
+ finally:
1186
+ _unlock(handle)
1187
+
1188
+
1189
+ def _watch_loop(rid: str, pid: int, interval: float, max_cycles: int | None) -> int:
1190
+ head_of = _gh_head_of(rid)
1191
+ cycles = 0
1192
+ last_error: str | None = None
1193
+ try:
1194
+ while True:
1195
+ out = watch_cycle(rid, pid, datetime.datetime.now(datetime.timezone.utc), head_of)
1196
+ error = out.get("error")
1197
+ if out.get("stop") or out.get("results") or (error and error != last_error):
1198
+ print(json.dumps(out, separators=(",", ":")), flush=True)
1199
+ last_error = error
1200
+ if out.get("stop"):
1201
+ return EXIT_OK
1202
+ cycles += 1
1203
+ if max_cycles is not None and cycles >= max_cycles:
1204
+ return EXIT_OK
1205
+ time.sleep(interval)
1206
+ if _watcher_stop_path(rid).exists(): # right after the sleep: a newer watcher asked for the lock
1207
+ _print_line({"stop": "takeover requested"})
1208
+ return EXIT_OK
1209
+ except KeyboardInterrupt:
1210
+ return EXIT_OK
1211
+
1212
+
1213
+ # --- deploy check D1-D5 by script (C2.4, ruling R11) -------------------------------------------------------------
1214
+
1215
+ DEPLOY_CHECKS = ("D1", "D2", "D3", "D4", "D5")
1216
+ _DEPLOY_TIMEOUT = 60 # one subprocess: `git fetch --tags`, or curl with its own --max-time 20
1217
+ _CURL_MAX_TIME = "20"
1218
+
1219
+
1220
+ def _deploy_run(args: list[str]) -> subprocess.CompletedProcess[str]:
1221
+ """The default runner of `deploy_verify`: `_run` with a timeout longer than curl's own `--max-time`."""
1222
+ return _run(args, timeout=_DEPLOY_TIMEOUT)
1223
+
1224
+
1225
+ def _curl() -> str:
1226
+ """Windows: System32 curl.exe under %SystemRoot%, by full path, when it exists (a bare name lets CreateProcess
1227
+ pick a curl.exe from the current folder first), else `curl.exe` on PATH. Elsewhere: `curl` on PATH."""
1228
+ if os.name != "nt":
1229
+ return "curl"
1230
+ system = Path(os.environ.get("SystemRoot") or "") / "System32" / "curl.exe"
1231
+ return str(system) if os.environ.get("SystemRoot") and system.is_file() else "curl.exe"
1232
+
1233
+
1234
+ def _plain_path(path: object) -> str:
1235
+ """A URL path compared for `never_health_path`: lowercased, no query string or fragment, no trailing slash."""
1236
+ return re.split(r"[?#]", str(path), maxsplit=1)[0].rstrip("/").lower()
1237
+
1238
+
1239
+ def _unset(value: object) -> bool:
1240
+ return value is None or (isinstance(value, str) and value.strip().lower() in ("", "none", "null"))
1241
+
1242
+
1243
+ def _checks_summary(checks: dict, docs_only: bool) -> str:
1244
+ """`passed: D1, D2, D5; skipped: D3, D4`, from the checks dict, with a `docs-only: ` prefix."""
1245
+ parts = []
1246
+ for label, mark in (("passed", "pass"), ("skipped", "skip")):
1247
+ names = [name for name, value in checks.items() if value == mark]
1248
+ if names:
1249
+ parts.append(f"{label}: {', '.join(names)}")
1250
+ return ("docs-only: " if docs_only else "") + "; ".join(parts)
1251
+
1252
+
1253
+ class _CheckFailed(Exception):
1254
+ def __init__(self, reason: str, health: str = "failed") -> None:
1255
+ super().__init__(reason)
1256
+ self.health = health
1257
+
1258
+
1259
+ def _call(runner: Callable[[list[str]], subprocess.CompletedProcess[str]], argv: list[str],
1260
+ what: str) -> subprocess.CompletedProcess[str]:
1261
+ """Run one command; a missing tool, a timeout or a non-zero exit is a check failure, never a crash."""
1262
+ try:
1263
+ result = runner(argv)
1264
+ except subprocess.TimeoutExpired:
1265
+ raise _CheckFailed(f"{what} timed out") from None
1266
+ except (OSError, subprocess.SubprocessError) as exc:
1267
+ raise _CheckFailed(f"{what} could not run ({Path(argv[0]).name}): {_say(exc)}") from None
1268
+ if result.returncode != 0:
1269
+ detail = " ".join((result.stderr or result.stdout or "").split()) or f"exit {result.returncode}"
1270
+ raise _CheckFailed(f"{what} failed: {detail}")
1271
+ return result
1272
+
1273
+
1274
+ def deploy_verify(rid: str, cfg: dict, pr: int, merge_sha: str, *, docs_only: bool,
1275
+ runner: Callable[[list[str]], subprocess.CompletedProcess[str]] | None = None,
1276
+ env=os.environ, repo_root: Path | None = None) -> dict:
1277
+ """Run deploy checks D1-D5 for a merged PR, record the result with `deploy_check_set`, and return
1278
+ `{"checks": {"D1".."D5": "pass|fail|skip"}, "run": int | None, "health": "ok|failed|pending", "reason": str}`.
1279
+
1280
+ Checks run in order and stop at the first failure (the rest are `skip`). D2 does not wait: the model waits
1281
+ with `caddis_lanes.py ci-watch --run` first. Docs-only runs D1, D2 and D5 only, and D2 fails when the run
1282
+ contradicts the flag (a job named `deploy...` succeeded, or `deploy.jobs` jobs succeeded). A non-docs run needs
1283
+ `deploy.jobs` successful jobs, and every job when it has more. `deploy.never_health_path` is never requested.
1284
+ `GH_TOKEN` comes from the inherited environment only. `repo_root` is where the git commands of D3 run.
1285
+ A run still in progress at D2 records health `pending`, not `failed`.
1286
+ Raises ValueError, before any network call and with nothing recorded, for a merge SHA that is not a full SHA,
1287
+ a PR with no deploy check, or a merge SHA that differs from the one in the PR's newest deploy check."""
1288
+ if not isinstance(merge_sha, str) or not _SHA.fullmatch(merge_sha):
1289
+ raise ValueError(f"the merge SHA must be a full 40-character SHA: {merge_sha!r}")
1290
+ state = read_state(rid)
1291
+ check = next((c for c in reversed((state or {}).get("deploy_checks") or [])
1292
+ if isinstance(c, dict) and c.get("pr") == pr), None)
1293
+ if check is None:
1294
+ raise ValueError(f"no deploy check for PR {pr} (run queue merged first)")
1295
+ if str(check.get("merge_sha", "")).lower() != merge_sha.lower():
1296
+ raise ValueError(f"merge SHA {merge_sha[:12]} is not the recorded merge SHA "
1297
+ f"{str(check.get('merge_sha'))[:12]} of PR {pr}")
1298
+ runner = runner or _deploy_run
1299
+ deploy = cfg.get("deploy") if isinstance(cfg.get("deploy"), dict) else {}
1300
+ sha = merge_sha.lower()
1301
+ checks = {name: "skip" for name in DEPLOY_CHECKS}
1302
+ found: dict = {"run": None}
1303
+
1304
+ def curl(path: str, *extra: str) -> list[str]:
1305
+ auth = deploy.get("auth")
1306
+ if _unset(auth):
1307
+ auth_args: list[str] = []
1308
+ elif auth == "ntlm":
1309
+ auth_args = ["--ntlm", "-u", ":"]
1310
+ else:
1311
+ raise _CheckFailed(f"unknown deploy.auth {auth!r} (known: ntlm, none)")
1312
+ name = deploy.get("base_url_env")
1313
+ if _unset(name):
1314
+ raise _CheckFailed("deploy.base_url_env is not set in the config")
1315
+ base = env.get(name)
1316
+ if not base:
1317
+ raise _CheckFailed(f"base URL env {name} not set")
1318
+ if not isinstance(path, str) or not path.startswith("/"):
1319
+ raise _CheckFailed(f"a deploy path must start with '/': {path!r}")
1320
+ never = deploy.get("never_health_path")
1321
+ if not _unset(never) and _plain_path(path) == _plain_path(never):
1322
+ raise _CheckFailed(f"refused: {path} is deploy.never_health_path")
1323
+ return [_curl(), "-s", "--max-time", _CURL_MAX_TIME, *auth_args, *extra, base.rstrip("/") + path]
1324
+
1325
+ def d1() -> None:
1326
+ if rid.startswith("local__") or "__" not in rid:
1327
+ raise _CheckFailed(f"repo {rid} has no GitHub repository")
1328
+ owner, name = rid.split("__", 1)
1329
+ branch, workflow = cfg.get("default_branch"), cfg.get("main_workflow")
1330
+ event = deploy.get("event") or "push"
1331
+ if _unset(workflow) or not isinstance(workflow, str):
1332
+ raise _CheckFailed("main_workflow is not set in the config")
1333
+ out = _call(runner, ["gh", "run", "list", "-R", f"{owner}/{name}", "--branch", str(branch),
1334
+ "--event", str(event), "--workflow", workflow, "--limit", "50",
1335
+ "--json", "databaseId,headSha,status,conclusion"], "gh run list")
1336
+ try:
1337
+ runs = json.loads(out.stdout)
1338
+ except ValueError:
1339
+ raise _CheckFailed("gh run list did not print JSON") from None
1340
+ hits = [r for r in runs if isinstance(r, dict) and str(r.get("headSha", "")).lower() == sha] \
1341
+ if isinstance(runs, list) else []
1342
+ if not hits:
1343
+ raise _CheckFailed(f"no {event} run of {workflow} on {branch} for {merge_sha[:12]}")
1344
+ found["run"] = hits[0] # gh lists newest first
1345
+ run_id = hits[0].get("databaseId")
1346
+ found["id"] = run_id if isinstance(run_id, int) and not isinstance(run_id, bool) else None
1347
+
1348
+ def d2() -> None:
1349
+ run = found["run"]
1350
+ status, conclusion = run.get("status"), run.get("conclusion")
1351
+ if status != "completed":
1352
+ raise _CheckFailed(f"run {found['id']} still running: wait for it with ci-watch --run first", "pending")
1353
+ if conclusion != "success":
1354
+ raise _CheckFailed(f"run {found['id']} concluded {conclusion}")
1355
+ want = deploy.get("jobs")
1356
+ want_ok = isinstance(want, int) and not isinstance(want, bool) and want >= 1
1357
+ if not docs_only and not want_ok:
1358
+ raise _CheckFailed(f"deploy.jobs must be a job count of 1 or more, not {want!r}")
1359
+ if found["id"] is None:
1360
+ raise _CheckFailed("the push run has no databaseId")
1361
+ owner, name = rid.split("__", 1)
1362
+ out = _call(runner, ["gh", "run", "view", str(found["id"]), "-R", f"{owner}/{name}", "--json", "jobs"],
1363
+ "gh run view")
1364
+ try:
1365
+ jobs = json.loads(out.stdout).get("jobs")
1366
+ except (ValueError, AttributeError):
1367
+ jobs = None
1368
+ if not isinstance(jobs, list):
1369
+ raise _CheckFailed("gh run view did not print a jobs list")
1370
+ succeeded = [j for j in jobs if isinstance(j, dict) and j.get("conclusion") == "success"]
1371
+ done = len(succeeded)
1372
+ if docs_only: # a docs-only push deploys nothing: a run that deployed contradicts the flag
1373
+ deployed = any("deploy" in str(j.get("name", "")).lower() for j in succeeded)
1374
+ if deployed or (want_ok and done >= want):
1375
+ raise _CheckFailed(f"docs-only flag contradicts the run: {done} jobs succeeded")
1376
+ return
1377
+ others = ", ".join(f"{j.get('name')}={j.get('conclusion')}" for j in jobs
1378
+ if isinstance(j, dict) and j.get("conclusion") != "success")
1379
+ detail = f" ({others})" if others else ""
1380
+ if done < want:
1381
+ raise _CheckFailed(f"run {found['id']}: {done} of {want} jobs succeeded{detail}")
1382
+ if len(jobs) != want and done != len(jobs): # extra jobs: every one must succeed
1383
+ raise _CheckFailed(f"run {found['id']}: {len(jobs)} jobs, not {want}, and not all succeeded{detail}")
1384
+
1385
+ def d3() -> None:
1386
+ git = ["git", "-C", str(repo_root)] if repo_root is not None else ["git"]
1387
+ _call(runner, [*git, "fetch", "--tags"], "git fetch --tags")
1388
+ out = _call(runner, [*git, "tag", "--points-at", merge_sha], "git tag --points-at")
1389
+ if not out.stdout.strip():
1390
+ raise _CheckFailed(f"no release tag points at {merge_sha[:12]}")
1391
+
1392
+ def d4() -> None:
1393
+ path = deploy.get("version_path")
1394
+ out = _call(runner, curl(path), f"curl {path}")
1395
+ try:
1396
+ body = json.loads(out.stdout)
1397
+ except ValueError:
1398
+ raise _CheckFailed(f"{path} did not return JSON") from None
1399
+ reported = body.get("sha") if isinstance(body, dict) else None
1400
+ if not isinstance(reported, str) or not reported.lower().startswith(sha):
1401
+ raise _CheckFailed(f"{path} reports sha {reported!r}, not {merge_sha[:12]}")
1402
+
1403
+ def d5() -> None:
1404
+ path = deploy.get("health_path")
1405
+ if _unset(path):
1406
+ raise _CheckFailed("deploy.health_path is not set in the config")
1407
+ null = "NUL" if os.name == "nt" else "/dev/null"
1408
+ out = _call(runner, curl(path, "-o", null, "-w", "%{http_code}"), f"curl {path}")
1409
+ code = out.stdout.strip()
1410
+ if code != "200":
1411
+ raise _CheckFailed(f"{path} returned HTTP {code or 'nothing'}, not 200")
1412
+
1413
+ steps = [("D1", d1, True), ("D2", d2, True),
1414
+ ("D3", d3, not docs_only and not _unset(deploy.get("tag"))),
1415
+ ("D4", d4, not docs_only and not _unset(deploy.get("version_path"))),
1416
+ ("D5", d5, True)]
1417
+ health, reason = "ok", ""
1418
+ for name, step, applies in steps:
1419
+ if not applies:
1420
+ continue
1421
+ try:
1422
+ step()
1423
+ except _CheckFailed as exc:
1424
+ checks[name], health, reason = "fail", exc.health, str(exc)
1425
+ break
1426
+ checks[name] = "pass"
1427
+ if health == "ok":
1428
+ reason = _checks_summary(checks, docs_only)
1429
+ run_id = found.get("id")
1430
+ deploy_check_set(rid, pr, deploy_run=run_id, health=health)
1431
+ return {"checks": checks, "run": run_id, "health": health, "reason": reason}
1432
+
1433
+
1434
+ def _deploy_root(config_path_arg: str) -> Path:
1435
+ """`--config-path` names the config file or the repo root; return the repo root."""
1436
+ given = Path(config_path_arg)
1437
+ if given.is_dir():
1438
+ return given
1439
+ root = given.resolve().parent.parent
1440
+ if config_path(root).resolve() != given.resolve():
1441
+ raise ValueError(f"--config-path must be a repo root or its .caddis/coordinator.md: {given}")
1442
+ return root
1443
+
1444
+
1445
+ def _claude_agents() -> list[dict]:
1446
+ """Run `claude agents --json` and return its list. Raise OSError or ValueError on failure."""
1447
+ claude = shutil.which("claude") # on Windows this finds claude.CMD
1448
+ if not claude:
1449
+ raise OSError("claude not found on PATH")
1450
+ try:
1451
+ result = _run([claude, "agents", "--json"])
1452
+ except subprocess.TimeoutExpired as exc:
1453
+ raise OSError(f"claude agents --json timed out after {_TIMEOUT} s") from exc
1454
+ if result.returncode != 0:
1455
+ detail = " ".join((result.stderr or result.stdout or "").split()) or f"exit {result.returncode}"
1456
+ raise OSError(f"claude agents --json failed: {detail}")
1457
+ try:
1458
+ value = json.loads(result.stdout)
1459
+ except ValueError as exc:
1460
+ raise ValueError("claude agents --json did not print JSON") from exc
1461
+ if not isinstance(value, list):
1462
+ raise ValueError("claude agents --json did not print a JSON list")
1463
+ return value
1464
+
1465
+
902
1466
  def _waits_on(text: str) -> list[dict]:
903
1467
  try:
904
1468
  value = json.loads(text)
@@ -932,6 +1496,16 @@ def _parser() -> argparse.ArgumentParser:
932
1496
  p = command("repo-id", repo=False)
933
1497
  p.add_argument("--path", default=".", help="a folder inside the checkout (default: current folder)")
934
1498
 
1499
+ config = sub.add_parser("config").add_subparsers(dest="config_command", required=True, parser_class=_Parser)
1500
+ for name in ("show", "init"):
1501
+ p = command(name, config, repo=False)
1502
+ p.add_argument("--path", default=".", help="the repo root (default: current folder)")
1503
+
1504
+ p = command("session", repo=False)
1505
+ which = p.add_mutually_exclusive_group(required=True)
1506
+ which.add_argument("--session-id", help="the Claude Code sessionId to find")
1507
+ which.add_argument("--name", help="the Claude Code session name to find")
1508
+
935
1509
  lock = sub.add_parser("lock").add_subparsers(dest="lock_command", required=True, parser_class=_Parser)
936
1510
  p = command("acquire", lock)
937
1511
  p.add_argument("--pid", type=int, required=True)
@@ -939,16 +1513,23 @@ def _parser() -> argparse.ArgumentParser:
939
1513
  p.add_argument("--name", required=True)
940
1514
  p = command("release", lock)
941
1515
  p.add_argument("--pid", type=int, required=True)
1516
+ command("holder", lock)
942
1517
 
943
1518
  command("heartbeat")
944
1519
  command("intake")
1520
+ p = command("watch")
1521
+ p.add_argument("--pid", type=int, required=True, help="the coordinator session pid that holds the lock")
1522
+ p.add_argument("--interval", type=float, default=30.0, help="seconds between cycles (default 30)")
1523
+ p.add_argument("--max-cycles", type=int, help="stop after this many cycles (for tests)")
1524
+ p.add_argument("--takeover", action="store_true",
1525
+ help="ask a running watcher of this repo to stop, then take its place (after a /clear)")
945
1526
  command("state")
946
1527
 
947
1528
  queue = sub.add_parser("queue").add_subparsers(dest="queue_command", required=True, parser_class=_Parser)
948
1529
  p = command("add", queue)
949
1530
  p.add_argument("--pr", type=int, required=True)
950
1531
  p.add_argument("--head-sha", required=True)
951
- p.add_argument("--title", required=True)
1532
+ p.add_argument("--title", help="the PR title (default: read from gh; `PR #<n>` when gh fails)")
952
1533
  p = command("set", queue)
953
1534
  p.add_argument("--pr", type=int, required=True)
954
1535
  p.add_argument("--state", choices=QUEUE_STATES)
@@ -970,6 +1551,12 @@ def _parser() -> argparse.ArgumentParser:
970
1551
  p.add_argument("--run", type=int, help="the deploy run id")
971
1552
  p.add_argument("--health", choices=DEPLOY_HEALTH)
972
1553
 
1554
+ p = command("deploy-verify")
1555
+ p.add_argument("--pr", type=int, required=True)
1556
+ p.add_argument("--merge-sha", required=True, help="the full merge commit SHA")
1557
+ p.add_argument("--config-path", required=True, help="the repo root, or its .caddis/coordinator.md")
1558
+ p.add_argument("--docs-only", action="store_true", help="a docs-only merge: D1, D2 and D5 only")
1559
+
973
1560
  ledger = sub.add_parser("ledger").add_subparsers(dest="ledger_command", required=True, parser_class=_Parser)
974
1561
  p = command("record", ledger)
975
1562
  p.add_argument("--what", required=True)
@@ -990,6 +1577,18 @@ def _dispatch(args: argparse.Namespace) -> int:
990
1577
  rid = repo_id_for(Path(args.path))
991
1578
  _emit(args, {"repo": rid}, rid)
992
1579
  return EXIT_OK
1580
+ if args.command == "session":
1581
+ found = session_lookup(_claude_agents(), session_id=args.session_id, name=args.name)
1582
+ _emit(args, found, f"pid {found['pid']} ({found['name']})")
1583
+ return EXIT_OK
1584
+ if args.command == "config":
1585
+ if args.config_command == "init":
1586
+ path = init_config(Path(args.path))
1587
+ _emit(args, {"path": str(path)}, str(path))
1588
+ else:
1589
+ loaded = load_config(Path(args.path))
1590
+ _emit(args, loaded, json.dumps(loaded, indent=2))
1591
+ return EXIT_OK
993
1592
  rid = args.repo or repo_id_for(Path.cwd())
994
1593
  _repo_dir(rid) # refuse a bad id before anything is written
995
1594
 
@@ -1003,6 +1602,12 @@ def _dispatch(args: argparse.Namespace) -> int:
1003
1602
  _claim_state(rid, {"name": args.name, "sessionId": args.session_id, "pid": args.pid})
1004
1603
  _emit(args, {"acquired": True, "holder": None}, "acquired")
1005
1604
  return EXIT_OK
1605
+ if args.lock_command == "holder":
1606
+ holder = lock_holder(rid)
1607
+ _emit(args, holder or {"holder": None}, "no holder" if holder is None else
1608
+ f"pid {holder['pid']} ({holder['name']}, session {holder['sessionId']}, "
1609
+ f"{'alive' if holder['alive'] else 'not alive'})")
1610
+ return EXIT_OK
1006
1611
  release_lock(rid, args.pid)
1007
1612
  _emit(args, {"released": True}, "released")
1008
1613
  return EXIT_OK
@@ -1012,6 +1617,8 @@ def _dispatch(args: argparse.Namespace) -> int:
1012
1617
  heartbeat(rid)
1013
1618
  _emit(args, {"ok": True}, "ok")
1014
1619
  return EXIT_OK
1620
+ if args.command == "watch":
1621
+ return watch(rid, args.pid, args.interval, args.max_cycles, takeover=args.takeover)
1015
1622
  if args.command == "intake":
1016
1623
  results = intake(rid, datetime.datetime.now(datetime.timezone.utc), _gh_head_of(rid))
1017
1624
  lines = [f"{r['id']}: {r['result']} {r['reason']}".rstrip() for r in results]
@@ -1030,13 +1637,20 @@ def _dispatch(args: argparse.Namespace) -> int:
1030
1637
  item = queue_next(state)
1031
1638
  _emit(args, {"next": item}, f"PR #{item['pr']}" if item else "none")
1032
1639
  return EXIT_OK
1640
+ if args.command == "deploy-verify":
1641
+ root = _deploy_root(args.config_path)
1642
+ out = deploy_verify(rid, load_config(root), args.pr, args.merge_sha, docs_only=args.docs_only,
1643
+ runner=_deploy_run, repo_root=root)
1644
+ marks = " ".join(f"{name}={mark}" for name, mark in out["checks"].items())
1645
+ _emit(args, out, f"{out['health']}: {marks} ({out['reason']})")
1646
+ return EXIT_OK if out["health"] == "ok" else EXIT_ERROR
1033
1647
  if args.command == "deploy-check":
1034
1648
  deploy_check_set(rid, args.pr, deploy_run=args.run, health=args.health)
1035
1649
  elif args.command == "ledger":
1036
1650
  ledger_record(rid, what=args.what, decision=args.decision, by=args.by, reason=args.reason,
1037
1651
  window=args.window, request_id=args.request_id)
1038
1652
  elif args.queue_command == "add":
1039
- queue_add(rid, args.pr, args.head_sha, args.title)
1653
+ queue_add(rid, args.pr, args.head_sha, args.title if args.title is not None else _gh_title(rid, args.pr))
1040
1654
  elif args.queue_command == "set":
1041
1655
  queue_set(rid, args.pr, state=args.state, head_sha=args.head_sha, why_waiting=args.why_waiting,
1042
1656
  waits_on=args.waits_on)
@@ -1060,8 +1674,17 @@ def main(argv: list[str] | None = None) -> int:
1060
1674
  args = parser.parse_args(argv)
1061
1675
  if args.command == "deploy-check" and args.run is None and args.health is None:
1062
1676
  parser.error("deploy-check set needs --run or --health")
1677
+ if args.command == "deploy-verify" and not _SHA.fullmatch(args.merge_sha):
1678
+ parser.error("deploy-verify --merge-sha must be a full 40-character SHA")
1063
1679
  if args.command == "lock" and args.lock_command == "acquire" and args.pid <= 0:
1064
1680
  parser.error("lock acquire --pid must be a positive process id")
1681
+ if args.command == "watch":
1682
+ if args.pid <= 0:
1683
+ parser.error("watch --pid must be a positive process id")
1684
+ if not 1 <= args.interval <= 86400: # also refuses NaN and inf (time.sleep(inf) raises)
1685
+ parser.error("watch --interval must be 1 to 86400 seconds")
1686
+ if args.max_cycles is not None and args.max_cycles <= 0:
1687
+ parser.error("watch --max-cycles must be 1 or more")
1065
1688
  except _UsageError as exc:
1066
1689
  print(f"usage error: {exc}", file=sys.stderr)
1067
1690
  return EXIT_USAGE