@caddis/cli 0.4.70 → 0.4.72

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,1589 @@
1
+ """Deterministic coordinator core for the caddis hub (spec sections 7, 7a, 8).
2
+
3
+ Why this exists: one coordinator per repository applies owner requests (approve, hold, merge) to a
4
+ queue. A model must never write the queue files by hand, so every read and write goes through
5
+ this script. It needs one stable key per repository, so that a clone, a fork and a worktree of
6
+ the same project share one hub folder: that key is the repo id.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import copy
13
+ import datetime
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import re
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ from collections.abc import Callable
23
+ from pathlib import Path
24
+
25
+ try:
26
+ from caddis_file_lock import atomic_write, locked_path, retry_denied
27
+ except ModuleNotFoundError as exc:
28
+ if exc.name != "caddis_file_lock":
29
+ raise
30
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
31
+ from caddis_file_lock import atomic_write, locked_path, retry_denied
32
+
33
+
34
+ _TIMEOUT = 10
35
+ _SHA = re.compile(r"[0-9a-fA-F]{40}") # a full git commit SHA
36
+ SCHEMA = 1
37
+ _READ_SCHEMAS = (SCHEMA - 1, SCHEMA) # readers accept N and N-1 (spec section 9)
38
+ _STATE_CACHE: dict[Path, tuple[tuple[int, int], dict]] = {} # path -> ((mtime_ns, size), last good parse)
39
+ _NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
40
+ # https://host/owner/name(.git), ssh://user@host(:port)/owner/name(.git), user@host:owner/name(.git)
41
+ _URL = re.compile(
42
+ r"^(?:[a-z][a-z0-9+.-]*://(?:[^@/]+@)?[^/]+/|[^@/:]+@[^:/]+:)"
43
+ r"(?P<owner>[^/]+)/(?P<name>[^/]+?)(?:\.git)?/?$",
44
+ re.IGNORECASE,
45
+ )
46
+
47
+
48
+ def _run(args: list[str], cwd: Path | None = None, timeout: float = _TIMEOUT) -> subprocess.CompletedProcess[str]:
49
+ # utf-8 explicitly: the Windows default code page garbles non-ASCII paths and titles.
50
+ return subprocess.run(args, cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace",
51
+ timeout=timeout, creationflags=_NO_WINDOW)
52
+
53
+
54
+ def repo_id(remote_name_with_owner: str | None, is_fork_of: str | None, git_root: Path) -> str:
55
+ """Return the hub key: `owner__name` (upstream for a fork), else `local__<folder>__<hash6>`."""
56
+ upstream = is_fork_of or remote_name_with_owner
57
+ if upstream:
58
+ return upstream.strip().lower().replace("/", "__")
59
+ digest = hashlib.sha256(os.path.normcase(str(git_root)).encode("utf-8")).hexdigest()[:6]
60
+ return f"local__{git_root.name}__{digest}"
61
+
62
+
63
+ def _owner_name_from_url(url: str) -> str | None:
64
+ """Return `owner/name` from an https, ssh or scp-style git remote URL, else None."""
65
+ match = _URL.match(url.strip())
66
+ if not match:
67
+ return None
68
+ return f"{match['owner']}/{match['name']}"
69
+
70
+
71
+ def _gh_name_with_owner(path: Path) -> tuple[str | None, str | None]:
72
+ """Return (nameWithOwner, parent nameWithOwner or None) from gh; (None, None) on any failure."""
73
+ try:
74
+ result = _run(["gh", "repo", "view", "--json", "nameWithOwner,parent"], cwd=path)
75
+ if result.returncode != 0:
76
+ return None, None
77
+ data = json.loads(result.stdout)
78
+ name = data.get("nameWithOwner") or None
79
+ if name is None:
80
+ return None, None
81
+ parent = data.get("parent") or {}
82
+ return name, parent.get("nameWithOwner") or None
83
+ except (OSError, subprocess.SubprocessError, ValueError, AttributeError):
84
+ return None, None
85
+
86
+
87
+ def repo_id_for(path: Path) -> str:
88
+ """Return the repo id for a checkout or any of its worktrees."""
89
+ common = _run(["git", "-C", str(path), "rev-parse", "--path-format=absolute", "--git-common-dir"])
90
+ if common.returncode != 0:
91
+ raise ValueError(f"not a git repository: {path}")
92
+ git_root = Path(common.stdout.strip()).parent
93
+ name, parent = _gh_name_with_owner(git_root)
94
+ if name is None:
95
+ origin = _run(["git", "-C", str(git_root), "remote", "get-url", "origin"])
96
+ if origin.returncode == 0:
97
+ name = _owner_name_from_url(origin.stdout)
98
+ return repo_id(name, parent, git_root)
99
+
100
+
101
+ # --- state file and single-coordinator lock (C1.2) ---------------------------------------------------------------
102
+
103
+
104
+ def _now() -> str:
105
+ return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
106
+
107
+
108
+ def hub_home() -> Path:
109
+ """Return `$CADDIS_HUB_HOME`, else `~/.caddis/hub`."""
110
+ override = os.environ.get("CADDIS_HUB_HOME")
111
+ return Path(override) if override else Path.home() / ".caddis" / "hub"
112
+
113
+
114
+ _RESERVED = frozenset(["con", "prn", "aux", "nul", *(f"com{i}" for i in range(1, 10)),
115
+ *(f"lpt{i}" for i in range(1, 10))]) # Windows device names, also with any extension
116
+
117
+
118
+ def _repo_dir(rid: str) -> Path:
119
+ # The repo id names a folder: refuse anything that could leave `<hub>/repos`, or that Windows would not
120
+ # store as given (a trailing dot or space is dropped; a device name opens the device, not a folder).
121
+ if (not rid or rid == "." or ".." in rid or any(c in rid for c in "/\\:") or rid[-1] in ". "
122
+ or rid.split(".", 1)[0].lower() in _RESERVED):
123
+ raise ValueError(f"bad repo id: {rid!r}")
124
+ return hub_home() / "repos" / rid
125
+
126
+
127
+ def state_path(rid: str) -> Path:
128
+ return _repo_dir(rid) / "coordinator.json"
129
+
130
+
131
+ def _lock_path(rid: str) -> Path:
132
+ return _repo_dir(rid) / "coordinator.lock.json"
133
+
134
+
135
+ def read_state(rid: str) -> dict | None:
136
+ """Return the last good parse of `coordinator.json`; None when it is missing or was never good.
137
+
138
+ A transient Windows sharing error is retried first (`retry_denied`). A partial file (a writer died mid-write)
139
+ or a sharing error that remains returns the cached last good parse, never an exception. The cache is keyed by
140
+ path and checked against the file's mtime and size, and lives in this process only: a one-shot process (each
141
+ CLI call) has no last good parse and gets None, so the hub must keep its own last good state.
142
+ """
143
+ path = state_path(rid)
144
+ cached = _STATE_CACHE.get(path)
145
+ try:
146
+ info = retry_denied(path.stat)
147
+ except FileNotFoundError:
148
+ return None
149
+ except OSError:
150
+ return copy.deepcopy(cached[1]) if cached else None
151
+ key = (info.st_mtime_ns, info.st_size)
152
+ if cached and cached[0] == key:
153
+ return copy.deepcopy(cached[1])
154
+ try:
155
+ data = json.loads(retry_denied(path.read_bytes).decode("utf-8"))
156
+ if not isinstance(data, dict) or data.get("schema") not in _READ_SCHEMAS:
157
+ raise ValueError("not a coordinator state")
158
+ except FileNotFoundError:
159
+ return None
160
+ except (OSError, ValueError): # sharing error, partial file, bad UTF-8 (UnicodeDecodeError is a ValueError)
161
+ return copy.deepcopy(cached[1]) if cached else None
162
+ _STATE_CACHE[path] = (key, data)
163
+ return copy.deepcopy(data)
164
+
165
+
166
+ def _read_state_strict(path: Path) -> dict:
167
+ """Read the state file now, for read/modify/write under its lock: no cache, raises on any failure.
168
+
169
+ `read_state` falls back to the last good parse, which is right for display but would write an old snapshot
170
+ over newer state here (a lost update).
171
+ """
172
+ data = json.loads(retry_denied(path.read_bytes).decode("utf-8"))
173
+ if not isinstance(data, dict) or data.get("schema") not in _READ_SCHEMAS:
174
+ raise ValueError(f"not a coordinator state: {path}")
175
+ return data
176
+
177
+
178
+ def _write_state_unlocked(path: Path, state: dict) -> None:
179
+ atomic_write(path, json.dumps(state, indent=2) + "\n")
180
+
181
+
182
+ def write_state(rid: str, state: dict) -> None:
183
+ """Validate `schema == 1`, stamp `heartbeat_at`, and replace `coordinator.json` atomically under its lock."""
184
+ if not isinstance(state, dict) or state.get("schema") != SCHEMA:
185
+ raise ValueError(f"coordinator state must have schema {SCHEMA}")
186
+ state = {**state, "heartbeat_at": _now()}
187
+ with locked_path(state_path(rid)) as path:
188
+ _write_state_unlocked(path, state)
189
+
190
+
191
+ def heartbeat(rid: str) -> None:
192
+ """Rewrite `heartbeat_at` only. Raises (OSError, ValueError) when the file cannot be read and parsed now."""
193
+ with locked_path(state_path(rid)) as path:
194
+ state = _read_state_strict(path)
195
+ state["heartbeat_at"] = _now()
196
+ _write_state_unlocked(path, state)
197
+
198
+
199
+ def new_state(rid: str, session: dict) -> dict:
200
+ """Return an empty `coordinator.json` document for this repo and coordinator session (spec section 8)."""
201
+ return {
202
+ "schema": SCHEMA,
203
+ "repo": rid,
204
+ "coordinator_session": {"name": session.get("name"), "sessionId": session.get("sessionId"),
205
+ "pid": session.get("pid")},
206
+ "heartbeat_at": _now(),
207
+ "queue": [],
208
+ "merging_now": None,
209
+ "deploy_checks": [],
210
+ "ledger": [],
211
+ "processed_requests": [],
212
+ }
213
+
214
+
215
+ # Copied from scripts/caddis_roadmap.py (_win_open_process, _pid_alive). Not imported: importing that module
216
+ # loads four more modules and changes sys.path (controller ruling R4).
217
+ def _win_open_process(pid: int) -> tuple[bool | None, int]:
218
+ """Windows: (still running, 0) when OpenProcess works; (None, GetLastError()) when it does not."""
219
+ import ctypes
220
+ from ctypes import wintypes
221
+
222
+ k = ctypes.WinDLL("kernel32", use_last_error=True)
223
+ k.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
224
+ k.OpenProcess.restype = wintypes.HANDLE
225
+ k.GetExitCodeProcess.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
226
+ k.GetExitCodeProcess.restype = wintypes.BOOL
227
+ k.CloseHandle.argtypes = (wintypes.HANDLE,)
228
+ handle = k.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
229
+ if not handle:
230
+ return None, ctypes.get_last_error()
231
+ code = wintypes.DWORD()
232
+ ok = k.GetExitCodeProcess(handle, ctypes.byref(code))
233
+ k.CloseHandle(handle)
234
+ return bool(ok) and code.value == 259, 0 # STILL_ACTIVE
235
+
236
+
237
+ def _pid_alive(pid: int) -> bool:
238
+ if pid <= 0:
239
+ return False
240
+ if os.name == "nt":
241
+ running, error = _win_open_process(pid)
242
+ if running is None:
243
+ return error == 5 # ERROR_ACCESS_DENIED: the process exists, this user may not open it
244
+ return running
245
+ try:
246
+ os.kill(pid, 0)
247
+ except ProcessLookupError:
248
+ return False
249
+ except PermissionError:
250
+ return True
251
+ return True
252
+
253
+
254
+ # Copied from scripts/caddis_roadmap.py (PID_START_TOLERANCE_S, _filetime_utc, _win_process_start,
255
+ # _proc_process_start, process_start_time), for the same reason as _pid_alive above.
256
+ PID_START_TOLERANCE_S = 5.0 # `started_at` has whole seconds; the holder's own process started before it
257
+
258
+
259
+ def _filetime_utc(value: int) -> datetime.datetime:
260
+ return datetime.datetime(1601, 1, 1, tzinfo=datetime.timezone.utc) + datetime.timedelta(microseconds=value // 10)
261
+
262
+
263
+ def _win_process_start(pid: int) -> datetime.datetime | None:
264
+ import ctypes
265
+ from ctypes import wintypes
266
+
267
+ k = ctypes.WinDLL("kernel32", use_last_error=True)
268
+ k.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
269
+ k.OpenProcess.restype = wintypes.HANDLE
270
+ k.GetProcessTimes.argtypes = (wintypes.HANDLE,) + (ctypes.POINTER(wintypes.FILETIME),) * 4
271
+ k.GetProcessTimes.restype = wintypes.BOOL
272
+ k.CloseHandle.argtypes = (wintypes.HANDLE,)
273
+ handle = k.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
274
+ if not handle:
275
+ return None
276
+ try:
277
+ times = [wintypes.FILETIME() for _ in range(4)]
278
+ if not k.GetProcessTimes(handle, *(ctypes.byref(x) for x in times)):
279
+ return None
280
+ created = times[0]
281
+ return _filetime_utc((created.dwHighDateTime << 32) | created.dwLowDateTime)
282
+ finally:
283
+ k.CloseHandle(handle)
284
+
285
+
286
+ def _proc_process_start(pid: int) -> datetime.datetime | None:
287
+ """POSIX with /proc: field 22 of /proc/<pid>/stat (clock ticks after boot) plus `btime` of /proc/stat."""
288
+ try:
289
+ stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
290
+ ticks = int(stat.rsplit(")", 1)[1].split()[19])
291
+ btime = next(int(line.split()[1]) for line in Path("/proc/stat").read_text(encoding="utf-8").splitlines()
292
+ if line.startswith("btime "))
293
+ hz = os.sysconf("SC_CLK_TCK")
294
+ except (OSError, ValueError, IndexError, StopIteration, AttributeError):
295
+ return None
296
+ return datetime.datetime.fromtimestamp(btime + ticks / hz, datetime.timezone.utc)
297
+
298
+
299
+ def process_start_time(pid: int) -> datetime.datetime | None:
300
+ """When the process now holding `pid` started, or None when it cannot be read (psutil when installed, else
301
+ GetProcessTimes on Windows, else /proc)."""
302
+ try:
303
+ import psutil # optional
304
+ return datetime.datetime.fromtimestamp(psutil.Process(pid).create_time(), datetime.timezone.utc)
305
+ except ImportError:
306
+ pass
307
+ except Exception: # no such process, access denied: fall through to the platform reader
308
+ pass
309
+ try:
310
+ return _win_process_start(pid) if os.name == "nt" else _proc_process_start(pid)
311
+ except Exception:
312
+ return None
313
+
314
+
315
+ def _parse_utc(text: object) -> datetime.datetime | None:
316
+ if not isinstance(text, str):
317
+ return None
318
+ try:
319
+ return datetime.datetime.strptime(text, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc)
320
+ except ValueError:
321
+ return None
322
+
323
+
324
+ def _holder_alive(holder: dict) -> bool:
325
+ """Alive: the pid runs, and that process started at or before `started_at` + tolerance.
326
+
327
+ When the start time or `started_at` cannot be read, a running pid counts as alive (refuse, fail closed).
328
+ """
329
+ if not _pid_alive(holder["pid"]):
330
+ return False
331
+ started = _parse_utc(holder.get("started_at"))
332
+ born = process_start_time(holder["pid"]) if started is not None else None
333
+ return born is None or born <= started + datetime.timedelta(seconds=PID_START_TOLERANCE_S)
334
+
335
+
336
+ def _read_holder(path: Path) -> dict | None:
337
+ """Return the lock holder, or None when there is no lock or its content is not a holder (treated as free).
338
+
339
+ A transient sharing error is retried. Any other OSError is raised: a file that cannot be read may name a live
340
+ holder, so the caller must refuse (fail closed), never treat it as free."""
341
+ try:
342
+ data = json.loads(retry_denied(path.read_bytes).decode("utf-8"))
343
+ except FileNotFoundError:
344
+ return None
345
+ except ValueError: # bad JSON or UTF-8: atomic_write never leaves this, so no live holder wrote it
346
+ return None
347
+ return data if isinstance(data, dict) and isinstance(data.get("pid"), int) else None
348
+
349
+
350
+ def acquire_lock(rid: str, pid: int, session_id: str, name: str) -> tuple[bool, dict | None]:
351
+ """Take the single-coordinator lock: (True, None), or (False, holder) while another live pid holds it.
352
+
353
+ A holder whose pid is dead, or whose pid now belongs to a process that started after the lock was taken
354
+ (pid reuse, often after a reboot), is replaced. The same pid taking it again refreshes it. A lock file that
355
+ cannot be read is refused, with a holder whose pid is None (fail closed).
356
+ """
357
+ with locked_path(_lock_path(rid)) as path:
358
+ try:
359
+ holder = _read_holder(path)
360
+ except OSError as exc:
361
+ return False, {"pid": None, "sessionId": None, "name": None,
362
+ "error": f"lock file unreadable: {' '.join(str(exc).split()) or type(exc).__name__}"}
363
+ if holder is not None and holder["pid"] != pid and _holder_alive(holder):
364
+ return False, holder
365
+ atomic_write(path, json.dumps({"pid": pid, "sessionId": session_id, "name": name,
366
+ "started_at": _now()}) + "\n")
367
+ return True, None
368
+
369
+
370
+ def release_lock(rid: str, pid: int) -> None:
371
+ """Remove the lock if `pid` holds it; do nothing otherwise."""
372
+ with locked_path(_lock_path(rid)) as path:
373
+ holder = _read_holder(path)
374
+ if holder is not None and holder["pid"] == pid:
375
+ retry_denied(lambda: path.unlink(missing_ok=True))
376
+
377
+
378
+ # --- queue operations (C1.3) --------------------------------------------------------------------------------------
379
+
380
+ QUEUE_STATES = ("waiting", "ci", "ready", "merging", "blocked", "held") # spec section 8
381
+
382
+
383
+ def _mutate_state(rid: str, change: Callable[[dict], bool | None]) -> None:
384
+ """Read `coordinator.json` strictly under its lock, apply `change(state)`, write it back.
385
+
386
+ Raises ValueError when the repo has no state file: these operations need a coordinator that has started.
387
+ When `change` raises, or returns False (no change), nothing is written.
388
+ """
389
+ if not state_path(rid).exists(): # checked first so an unknown repo id leaves no folder or lock file behind
390
+ raise ValueError(f"no coordinator state for repo {rid!r}")
391
+ with locked_path(state_path(rid)) as path:
392
+ try:
393
+ state = _read_state_strict(path)
394
+ except FileNotFoundError:
395
+ raise ValueError(f"no coordinator state for repo {rid!r}") from None
396
+ if change(state) is False:
397
+ return
398
+ _write_state_unlocked(path, state)
399
+
400
+
401
+ def _find(queue: list[dict], pr: int) -> dict | None:
402
+ return next((item for item in queue if item.get("pr") == pr), None)
403
+
404
+
405
+ def _names_pr(merging_now: object, pr: int) -> bool:
406
+ """`merging_now` is a PR number or an object with `pr` (schema); True when it names `pr`."""
407
+ if isinstance(merging_now, dict):
408
+ merging_now = merging_now.get("pr")
409
+ return not isinstance(merging_now, bool) and merging_now == pr
410
+
411
+
412
+ def _mutate_item(rid: str, pr: int, change: Callable[[dict], None]) -> None:
413
+ """`_mutate_state` for a change to one queue item (`change(state)`), keeping `merging_now` in step with it.
414
+
415
+ The item entering `merging` sets `merging_now` to its PR. The item leaving `merging` (another state, a new
416
+ head, or removal) clears `merging_now` when it names this PR."""
417
+ def wrapped(state: dict) -> None:
418
+ before = _find(state["queue"], pr)
419
+ was = before is not None and before.get("state") == "merging"
420
+ change(state)
421
+ after = _find(state["queue"], pr)
422
+ if after is not None and after.get("state") == "merging":
423
+ state["merging_now"] = pr
424
+ elif was and _names_pr(state.get("merging_now"), pr):
425
+ state["merging_now"] = None
426
+ _mutate_state(rid, wrapped)
427
+
428
+
429
+ def _set_head(item: dict, head_sha: str) -> None:
430
+ """Record a new head. It has not been ready yet and is untested: clear the ready time and demote `ready` or
431
+ `merging` to `ci` (ruling R5). The same head changes nothing."""
432
+ if item.get("head_sha") == head_sha:
433
+ return
434
+ item["head_sha"] = head_sha
435
+ item["entered_ready_at"] = None
436
+ if item.get("state") in ("ready", "merging"):
437
+ item["state"] = "ci"
438
+
439
+
440
+ def queue_add(rid: str, pr: int, head_sha: str, title: str) -> None:
441
+ """Add a PR as `waiting`. Adding a PR already in the queue updates its title and head (a new head demotes a
442
+ `ready` or `merging` item to `ci`), nothing else."""
443
+ def change(state: dict) -> None:
444
+ queue = state["queue"]
445
+ item = _find(queue, pr)
446
+ if item is None:
447
+ queue.append({"pr": pr, "head_sha": head_sha, "title": title, "state": "waiting", "why_waiting": "",
448
+ "waits_on": [], "hotfix": False, "held": False, "entered_ready_at": None})
449
+ return
450
+ item["title"] = title
451
+ _set_head(item, head_sha)
452
+ _mutate_item(rid, pr, change)
453
+
454
+
455
+ def queue_set(rid: str, pr: int, *, state: str | None = None, head_sha: str | None = None,
456
+ why_waiting: str | None = None, waits_on: list[dict] | None = None) -> None:
457
+ """Change the given fields of one queue item. Raises ValueError for an unknown state or a PR not in the queue.
458
+
459
+ `entered_ready_at` is stamped the first time the item is set `ready`, and kept when it leaves and re-enters
460
+ `ready` with the same head. A new head clears it and demotes `ready`/`merging` to `ci` (ruling R5); a `state`
461
+ given in the same call is applied after that. `held` is not touched here (ruling R1).
462
+
463
+ Entering `merging` sets `merging_now` to this PR, and leaving it clears `merging_now`. Raises ValueError when
464
+ another queued PR is merging now (one merge at a time); nothing is written then.
465
+ """
466
+ if state is not None and state not in QUEUE_STATES:
467
+ raise ValueError(f"unknown queue state: {state!r}")
468
+
469
+ def change(doc: dict) -> None:
470
+ queue = doc["queue"]
471
+ item = _find(queue, pr)
472
+ if item is None:
473
+ raise ValueError(f"PR {pr} is not in the queue")
474
+ if state == "merging":
475
+ other = next((i.get("pr") for i in queue if i.get("pr") != pr and (
476
+ i.get("state") == "merging" or _names_pr(doc.get("merging_now"), i.get("pr")))), None)
477
+ if other is not None:
478
+ raise ValueError(f"PR {other} is merging now: one merge at a time")
479
+ if head_sha is not None:
480
+ _set_head(item, head_sha)
481
+ if why_waiting is not None:
482
+ item["why_waiting"] = why_waiting
483
+ if waits_on is not None:
484
+ item["waits_on"] = [dict(w) for w in waits_on]
485
+ if state is not None:
486
+ item["state"] = state
487
+ if state == "ready" and not item.get("entered_ready_at"):
488
+ item["entered_ready_at"] = _now()
489
+ _mutate_item(rid, pr, change)
490
+
491
+
492
+ def queue_remove(rid: str, pr: int) -> None:
493
+ """Remove a PR from the queue; do nothing when it is not there."""
494
+ def change(state: dict) -> None:
495
+ state["queue"] = [item for item in state["queue"] if item.get("pr") != pr]
496
+ _mutate_item(rid, pr, change)
497
+
498
+
499
+ def queue_order(state: dict) -> list[dict]:
500
+ """Return the queue in merge order (spec D5). Pure: the state is not changed.
501
+
502
+ The `merging` item comes first. Then hotfix items, then the rest. Inside each group, ready items that are not
503
+ held go first by `entered_ready_at` ascending; held and non-ready items follow in add order.
504
+ """
505
+ queue = list(state.get("queue") or [])
506
+
507
+ def ready(item: dict) -> bool:
508
+ return item.get("state") == "ready" and not item.get("held")
509
+
510
+ def rank(index_item: tuple[int, dict]) -> tuple:
511
+ index, item = index_item
512
+ if item.get("state") == "merging":
513
+ return (0, 0, 0, "", index)
514
+ group = 1 if item.get("hotfix") else 2
515
+ if ready(item):
516
+ at = item.get("entered_ready_at")
517
+ return (group, 0, 0 if at else 1, at or "", index) # a ready item without a time sorts last of the ready
518
+ return (group, 1, 0, "", index)
519
+
520
+ return [item for _, item in sorted(enumerate(queue), key=rank)]
521
+
522
+
523
+ def queue_next(state: dict) -> dict | None:
524
+ """Return the item to merge next (pure): the `merging` item; else the first `ready`, not held item in
525
+ `queue_order`; else None."""
526
+ for item in queue_order(state):
527
+ if item.get("state") == "merging" or (item.get("state") == "ready" and not item.get("held")):
528
+ return item
529
+ return None
530
+
531
+
532
+ # --- owner requests and intake (C1.4) -----------------------------------------------------------------------------
533
+
534
+ REQUEST_V = 1
535
+ _REQUEST_VS = (REQUEST_V - 1, REQUEST_V) # readers accept N and N-1
536
+ _REQUEST_KEYS = frozenset(("v", "id", "created_at", "expires_at", "repo", "pr", "head_sha", "action", "args"))
537
+ # Inside one batch only the last request per (pr, family) is applied.
538
+ _FAMILY = {"hotfix": "hotfix", "unhotfix": "hotfix", "hold": "hold", "release": "hold",
539
+ "approve": "approve", "decline": "approve"}
540
+ PROCESSED_KEEP = datetime.timedelta(hours=24)
541
+ MAX_REQUEST_LIFE = datetime.timedelta(hours=1)
542
+ MAX_CLOCK_SKEW = datetime.timedelta(minutes=5) # a request created later than now + this is refused
543
+
544
+
545
+ def requests_path(rid: str) -> Path:
546
+ return _repo_dir(rid) / "requests.jsonl"
547
+
548
+
549
+ def _cursor_path(rid: str) -> Path:
550
+ return _repo_dir(rid) / "intake.cursor"
551
+
552
+
553
+ def _fmt(when: datetime.datetime) -> str:
554
+ return when.astimezone(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
555
+
556
+
557
+ def append_request(rid: str, req: dict) -> None:
558
+ """Append one request as one JSON line under the requests file's lock.
559
+
560
+ Under the lock, a tail with no newline can only come from a writer that died. It is closed with a newline
561
+ first, so it becomes one bad line (rejected by intake) and never joins this request.
562
+ """
563
+ if not isinstance(req, dict):
564
+ raise ValueError("a request is a JSON object")
565
+ line = (json.dumps(req, separators=(",", ":"), ensure_ascii=True) + "\n").encode("utf-8")
566
+ with locked_path(requests_path(rid)) as path:
567
+ def append() -> None:
568
+ with open(path, "a+b") as fh:
569
+ fh.seek(0, os.SEEK_END)
570
+ prefix = b""
571
+ if fh.tell() > 0:
572
+ fh.seek(-1, os.SEEK_END)
573
+ if fh.read(1) != b"\n":
574
+ prefix = b"\n"
575
+ fh.write(prefix + line) # append mode: always at the end
576
+ fh.flush()
577
+ os.fsync(fh.fileno())
578
+ retry_denied(append)
579
+
580
+
581
+ def _read_cursor(path: Path) -> int:
582
+ try:
583
+ value = int(retry_denied(lambda: path.read_text(encoding="utf-8")).strip())
584
+ except (OSError, ValueError): # missing or unreadable: start from 0; processed ids prevent replay
585
+ return 0
586
+ return max(value, 0)
587
+
588
+
589
+ def _check_request(req: object, rid: str) -> str | None:
590
+ """Return the rejection reason for a malformed request, or None when it is well formed."""
591
+ if not isinstance(req, dict) or set(req) != _REQUEST_KEYS:
592
+ return "bad request"
593
+ if not isinstance(req["action"], str) or req["action"] not in _FAMILY:
594
+ return "unknown action"
595
+ if (isinstance(req["v"], bool) or req["v"] not in _REQUEST_VS or req["repo"] != rid
596
+ or not isinstance(req["id"], str) or not req["id"]
597
+ or not isinstance(req["args"], dict)):
598
+ return "bad request"
599
+ if _parse_utc(req["created_at"]) is None or _parse_utc(req["expires_at"]) is None:
600
+ return "bad request"
601
+ pr = req["pr"]
602
+ if pr is None: # ruling R2: only approve/decline may name no PR, and then `args.what` says what is decided
603
+ what = req["args"].get("what")
604
+ if _FAMILY[req["action"]] != "approve" or not isinstance(what, str) or not what.strip():
605
+ return "bad request"
606
+ return None
607
+ if isinstance(pr, bool) or not isinstance(pr, int) or pr <= 0:
608
+ return "bad request"
609
+ if not isinstance(req["head_sha"], str) or not _SHA.fullmatch(req["head_sha"]):
610
+ return "bad request"
611
+ return None
612
+
613
+
614
+ def _window_ok(req: dict, now: datetime.datetime) -> bool:
615
+ """Spec rule 4 gives expires_at = created_at + 30 min. A longer window could outlive the 24 h dedup of
616
+ processed ids and replay after a lost cursor, so a window over 1 hour, one that ends before it starts, or one
617
+ created more than `MAX_CLOCK_SKEW` in the future is refused. Checked after expiry: an expired request has no
618
+ effect, so it cannot replay."""
619
+ created, expires = _parse_utc(req["created_at"]), _parse_utc(req["expires_at"])
620
+ return created <= expires <= created + MAX_REQUEST_LIFE and created <= now + MAX_CLOCK_SKEW
621
+
622
+
623
+ def _new_lines(rid: str, offset: int, stop: int | None = None) -> tuple[list[tuple[int, bytes]], int]:
624
+ """Return the complete lines after `offset` as (byte offset, line), and the new cursor (just after the last
625
+ newline). A tail with no newline is left for the next cycle. Bytes at or after `stop` are not read."""
626
+ try:
627
+ data = retry_denied(requests_path(rid).read_bytes)
628
+ except FileNotFoundError:
629
+ return [], offset
630
+ if offset > len(data): # the file was replaced by a shorter one: read it again from the start
631
+ offset = 0
632
+ if stop is not None:
633
+ data = data[:stop]
634
+ end = data.rfind(b"\n", offset)
635
+ if end < 0:
636
+ return [], offset
637
+ lines, start = [], offset
638
+ for raw in data[offset:end].split(b"\n"):
639
+ lines.append((start, raw))
640
+ start += len(raw) + 1
641
+ return lines, end + 1
642
+
643
+
644
+ def _parse_line(start: int, raw: bytes) -> tuple[str, object]:
645
+ """Return (key, parsed request or None). A line with no usable id is named by its byte offset, which stays
646
+ the same when the file is read again."""
647
+ try:
648
+ req = json.loads(raw.decode("utf-8"))
649
+ except (ValueError, RecursionError): # bad JSON, bad UTF-8, or nesting too deep
650
+ req = None
651
+ given = req.get("id") if isinstance(req, dict) else None
652
+ return (given if isinstance(given, str) and given else f"line@{start}"), req
653
+
654
+
655
+ def _live_heads(rid: str, lines: list[tuple[int, bytes]], head_of: Callable[[int], str | None],
656
+ now: datetime.datetime) -> dict:
657
+ """Look up the live head once per PR named by a well-formed, unexpired, not yet processed line. Called with
658
+ no lock held, so the processed ids come from a plain read; a stale read only costs an extra lookup.
659
+
660
+ Any lookup failure gives None: an unknown head (ruling R3)."""
661
+ done = {p.get("id") for p in (read_state(rid) or {}).get("processed_requests") or [] if isinstance(p, dict)}
662
+ prs: list[int] = []
663
+ for start, raw in lines:
664
+ if not raw.strip():
665
+ continue
666
+ key, req = _parse_line(start, raw)
667
+ if (key not in done and _check_request(req, rid) is None and now <= _parse_utc(req["expires_at"])
668
+ and _window_ok(req, now) and req["pr"] is not None and req["pr"] not in prs):
669
+ prs.append(req["pr"])
670
+ heads: dict[int, str | None] = {}
671
+ for pr in prs:
672
+ try:
673
+ heads[pr] = head_of(pr)
674
+ except Exception:
675
+ heads[pr] = None
676
+ return heads
677
+
678
+
679
+ def intake(rid: str, now: datetime.datetime, head_of: Callable[[int], str | None]) -> list[dict]:
680
+ """Apply new owner requests to the queue and ledger; return this cycle's results (spec section 7 rule 5).
681
+
682
+ Reads the complete lines after `intake.cursor`. A request id already in `processed_requests` is skipped with
683
+ no result, so a lost cursor never replays a request. State and cursor are written in one locked section,
684
+ state first: a crash between the two re-reads lines whose ids are already processed. `head_of(pr)` gives the
685
+ live head SHA, or None when it is unknown; then the request is rejected (fail closed, ruling R3).
686
+ """
687
+ if not state_path(rid).exists(): # checked first so an unknown repo id leaves no folder or lock file behind
688
+ raise ValueError(f"no coordinator state for repo {rid!r}")
689
+ at = _fmt(now)
690
+ cursor_path = _cursor_path(rid)
691
+ # head_of may call gh and take seconds. Other writers wait at most 5 s for the state lock, so the live heads
692
+ # are looked up first, with no lock held. A head that moves in the gap is the same race as one that moves
693
+ # just after the check. Lines that arrive in the gap are left for the next cycle (`stop`).
694
+ seen_lines, stop = _new_lines(rid, _read_cursor(cursor_path))
695
+ heads = _live_heads(rid, seen_lines, head_of, now)
696
+ with locked_path(state_path(rid)) as path:
697
+ try:
698
+ state = _read_state_strict(path)
699
+ except FileNotFoundError:
700
+ raise ValueError(f"no coordinator state for repo {rid!r}") from None
701
+ offset = _read_cursor(cursor_path)
702
+ lines, new_offset = _new_lines(rid, offset, stop)
703
+
704
+ processed = [p for p in state.get("processed_requests") or [] if isinstance(p, dict)]
705
+ seen = {p.get("id") for p in processed}
706
+ results: dict[str, dict | None] = {} # id -> result, in line order; None until the winner is applied
707
+ winners: dict[tuple, dict] = {}
708
+ for start, raw in lines:
709
+ if not raw.strip():
710
+ continue
711
+ key, req = _parse_line(start, raw)
712
+ if key in seen or key in results:
713
+ continue
714
+ reason = _check_request(req, rid)
715
+ if reason is not None:
716
+ results[key] = {"id": key, "result": "rejected", "reason": reason, "at": at}
717
+ elif now > _parse_utc(req["expires_at"]):
718
+ results[key] = {"id": key, "result": "expired", "reason": "expired", "at": at}
719
+ elif not _window_ok(req, now):
720
+ results[key] = {"id": key, "result": "rejected", "reason": "bad request", "at": at}
721
+ else:
722
+ family = _FAMILY[req["action"]]
723
+ # Two non-PR decisions supersede each other only when they decide the same thing.
724
+ group = (req["pr"], family) if req["pr"] is not None else (None, family, req["args"]["what"].strip())
725
+ earlier = winners.get(group)
726
+ if earlier is not None:
727
+ results[earlier["id"]] = {"id": earlier["id"], "result": "rejected", "reason": "superseded",
728
+ "at": at}
729
+ winners[group] = req
730
+ results[key] = None
731
+
732
+ for req in winners.values():
733
+ results[req["id"]] = _apply(state, req, heads, at)
734
+ out = list(results.values())
735
+
736
+ cutoff = now - PROCESSED_KEEP
737
+ kept = [p for p in processed if (_parse_utc(p.get("at")) or cutoff) > cutoff]
738
+ if out or len(kept) != len(state.get("processed_requests") or []):
739
+ state["processed_requests"] = kept + out
740
+ _write_state_unlocked(path, state)
741
+ if new_offset != offset:
742
+ atomic_write(cursor_path, f"{new_offset}\n")
743
+ return out
744
+
745
+
746
+ def _apply(state: dict, req: dict, heads: dict[int, str | None], at: str) -> dict:
747
+ """Check one winning request against the queue and the live head, then apply it. Returns its result.
748
+
749
+ `heads` comes from `_live_heads`; a PR missing from it has an unknown head."""
750
+ def result(kind: str, reason: str) -> dict:
751
+ return {"id": req["id"], "result": kind, "reason": reason, "at": at}
752
+
753
+ action, pr = req["action"], req["pr"]
754
+ item = None
755
+ if pr is not None:
756
+ item = _find(state["queue"], pr)
757
+ if item is None:
758
+ return result("rejected", "not in queue")
759
+ live = heads.get(pr)
760
+ if not isinstance(live, str) or not live:
761
+ return result("rejected", "head unknown")
762
+ if live.lower() != req["head_sha"].lower():
763
+ return result("rejected", "head moved")
764
+ if action in ("hotfix", "unhotfix"):
765
+ item["hotfix"] = action == "hotfix"
766
+ elif action in ("hold", "release"):
767
+ item["held"] = action == "hold"
768
+ else:
769
+ what = req["args"].get("what")
770
+ what = what.strip() if isinstance(what, str) and what.strip() else f"PR #{pr}"
771
+ # The owner's click in the hub is the approval (spec 7a): that is its reason and its window.
772
+ state.setdefault("ledger", []).append(_ledger_entry(what=what, decision=action, by="owner",
773
+ reason="owner click", window="hub",
774
+ request_id=req["id"], at=at))
775
+ return result("done", "")
776
+
777
+
778
+ # --- merge record, deploy checks, ledger (C1.6) -------------------------------------------------------------------
779
+
780
+ DEPLOY_HEALTH = ("pending", "ok", "failed")
781
+ LEDGER_DECISIONS = ("approve", "decline")
782
+ LEDGER_BY = ("owner", "coordinator") # spec D15, D16: routine gates by the coordinator, the owner-only list by the owner
783
+
784
+
785
+ def queue_merged(rid: str, pr: int, merge_sha: str) -> bool:
786
+ """Record a merge: remove the PR from the queue, clear `merging_now` when it names this PR, and append a
787
+ `pending` deploy check. Returns True when recorded now.
788
+
789
+ Idempotent: when the PR is not in the queue but a deploy check with this `pr` and `merge_sha` exists, the
790
+ merge was already recorded (a retry after a successful write): returns False and writes nothing. Raises
791
+ ValueError for a merge SHA that is not 40 hex characters, or a PR neither queued nor recorded with this SHA
792
+ (nothing written). The SHA is stored lower-cased."""
793
+ if not isinstance(merge_sha, str) or not _SHA.fullmatch(merge_sha):
794
+ raise ValueError(f"a merge SHA is 40 hex characters: {merge_sha!r}")
795
+ merge_sha = merge_sha.lower()
796
+
797
+ def change(state: dict) -> bool:
798
+ if _find(state["queue"], pr) is None:
799
+ if any(isinstance(c, dict) and c.get("pr") == pr and c.get("merge_sha") == merge_sha
800
+ for c in state.get("deploy_checks") or []):
801
+ return False
802
+ raise ValueError(f"PR {pr} is not in the queue")
803
+ state["queue"] = [item for item in state["queue"] if item.get("pr") != pr]
804
+ if _names_pr(state.get("merging_now"), pr):
805
+ state["merging_now"] = None
806
+ state.setdefault("deploy_checks", []).append({"pr": pr, "merge_sha": merge_sha, "deploy_run": None,
807
+ "health": "pending", "at": _now()})
808
+ return True
809
+
810
+ outcome: list[bool] = []
811
+
812
+ def change_and_keep(state: dict) -> bool:
813
+ outcome.append(change(state))
814
+ return outcome[-1]
815
+ _mutate_state(rid, change_and_keep)
816
+ return outcome[-1]
817
+
818
+
819
+ def deploy_check_set(rid: str, pr: int, *, deploy_run: int | None = None, health: str | None = None) -> None:
820
+ """Set the deploy run and/or health of the newest deploy check for a PR; a health also sets `at` to now.
821
+ Raises ValueError for a health not in `DEPLOY_HEALTH`, a run that is not an integer, or a PR with no deploy check."""
822
+ if health is not None and health not in DEPLOY_HEALTH:
823
+ raise ValueError(f"unknown deploy health: {health!r}")
824
+ if deploy_run is not None and (isinstance(deploy_run, bool) or not isinstance(deploy_run, int)):
825
+ raise ValueError(f"a deploy run is an integer: {deploy_run!r}")
826
+
827
+ def change(state: dict) -> None:
828
+ check = next((c for c in reversed(state.get("deploy_checks") or [])
829
+ if isinstance(c, dict) and c.get("pr") == pr), None)
830
+ if check is None:
831
+ raise ValueError(f"no deploy check for PR {pr}")
832
+ if deploy_run is not None:
833
+ check["deploy_run"] = deploy_run
834
+ if health is not None: # stamp `at`, so the newest entry by `at` is the newest health (freeze rule)
835
+ check["health"] = health
836
+ check["at"] = _now()
837
+ _mutate_state(rid, change)
838
+
839
+
840
+ def _ledger_entry(*, what: str, decision: str, by: str, reason: str, window: str | None, request_id: str | None,
841
+ at: str) -> dict:
842
+ """Validate and build one ledger entry (keys in schema order). Raises ValueError."""
843
+ def text(name: str, value: object, optional: bool) -> str | None:
844
+ if value is None and optional:
845
+ return None
846
+ if not isinstance(value, str) or not value.strip():
847
+ raise ValueError(f"ledger {name} must be a non-empty string")
848
+ return value.strip()
849
+
850
+ if decision not in LEDGER_DECISIONS:
851
+ raise ValueError(f"unknown ledger decision: {decision!r}")
852
+ if by not in LEDGER_BY:
853
+ raise ValueError(f"unknown approver: {by!r}")
854
+ return {"what": text("what", what, False), "decision": decision, "approved_by": by,
855
+ "reason": text("reason", reason, False), "window": text("window", window, True),
856
+ "request_id": text("request_id", request_id, True), "at": at}
857
+
858
+
859
+ def ledger_record(rid: str, *, what: str, decision: str, by: str, reason: str, window: str | None = None,
860
+ request_id: str | None = None) -> None:
861
+ """Append a decision to the ledger: a routine gate by the coordinator, or an owner decision given in a session
862
+ window (D15, D16). `window` names the session window it was decided in. Raises ValueError (nothing written)."""
863
+ entry = _ledger_entry(what=what, decision=decision, by=by, reason=reason, window=window, request_id=request_id,
864
+ at=_now())
865
+ _mutate_state(rid, lambda state: state.setdefault("ledger", []).append(entry))
866
+
867
+
868
+ # --- command line (C1.5) ------------------------------------------------------------------------------------------
869
+
870
+ EXIT_OK, EXIT_ERROR, EXIT_USAGE, EXIT_LOCK_REFUSED = 0, 1, 2, 3
871
+
872
+
873
+ def _gh_head_of(rid: str) -> Callable[[int], str | None]:
874
+ """Return `head_of(pr)` for intake: the live head SHA from gh, or None (ruling R3).
875
+
876
+ None for a `local__` id (no GitHub repository) and on any gh failure. gh reads `GH_TOKEN` from the inherited
877
+ environment when it is set; this script never chooses an account."""
878
+ def head_of(pr: int) -> str | None:
879
+ if rid.startswith("local__") or "__" not in rid:
880
+ return None
881
+ owner, name = rid.split("__", 1) # GitHub owners have no underscore, so the first `__` splits them
882
+ try:
883
+ result = _run(["gh", "pr", "view", str(pr), "-R", f"{owner}/{name}", "--json", "headRefOid",
884
+ "-q", ".headRefOid"])
885
+ except (OSError, subprocess.SubprocessError):
886
+ return None
887
+ sha = result.stdout.strip() if result.returncode == 0 else ""
888
+ return sha or None
889
+ return head_of
890
+
891
+
892
+ def _gh_title(rid: str, pr: int) -> str:
893
+ """The PR title from gh, so the model never puts free text on a command line; `PR #<n>` on any failure."""
894
+ fallback = f"PR #{pr}"
895
+ if rid.startswith("local__") or "__" not in rid:
896
+ return fallback
897
+ owner, name = rid.split("__", 1)
898
+ try:
899
+ result = _run(["gh", "pr", "view", str(pr), "-R", f"{owner}/{name}", "--json", "title", "-q", ".title"])
900
+ except (OSError, subprocess.SubprocessError):
901
+ return fallback
902
+ title = result.stdout.strip() if result.returncode == 0 else ""
903
+ return title or fallback
904
+
905
+
906
+ def _claim_state(rid: str, session: dict) -> None:
907
+ """After the lock is taken: create `coordinator.json`, or record the new coordinator session in it."""
908
+ with locked_path(state_path(rid)) as path:
909
+ try:
910
+ state = _read_state_strict(path)
911
+ except FileNotFoundError:
912
+ state = new_state(rid, session)
913
+ state["coordinator_session"] = {"name": session["name"], "sessionId": session["sessionId"],
914
+ "pid": session["pid"]}
915
+ state["heartbeat_at"] = _now()
916
+ _write_state_unlocked(path, state)
917
+
918
+
919
+ def session_lookup(sessions: list[dict], *, session_id: str | None = None, name: str | None = None) -> dict:
920
+ """Return `{"pid", "sessionId", "name"}` of the ONE interactive session with this sessionId or name.
921
+
922
+ Background entries are ignored: their pid is not a long-lived Claude Code session (ruling R12)."""
923
+ if (session_id is None) == (name is None):
924
+ raise ValueError("session_lookup takes exactly one of session_id or name")
925
+ key, wanted = ("sessionId", session_id) if session_id is not None else ("name", name)
926
+ hits = [s for s in sessions if isinstance(s, dict) and s.get("kind") == "interactive" and s.get(key) == wanted]
927
+ if not hits:
928
+ raise ValueError(f"no such session: {key} {wanted!r}")
929
+ if len(hits) > 1:
930
+ pids = ", ".join(str(s.get("pid")) for s in hits)
931
+ raise ValueError(f"two interactive sessions share {key} {wanted!r}: pids {pids}")
932
+ hit = hits[0]
933
+ pid = hit.get("pid")
934
+ if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
935
+ raise ValueError(f"session {key} {wanted!r} has no valid pid: {pid!r}")
936
+ for field in ("sessionId", "name"):
937
+ if not isinstance(hit.get(field), str) or not hit[field]:
938
+ raise ValueError(f"session {key} {wanted!r} has no {field}")
939
+ return {"pid": pid, "sessionId": hit["sessionId"], "name": hit["name"]}
940
+
941
+
942
+ # --- the per-repo config `.caddis/coordinator.md` (C2.2, ruling R10: one fenced json block) --------------------
943
+
944
+ CONFIG_KEYS = ("schema", "default_branch", "merge_method", "required_checks", "not_required", "docs_only",
945
+ "main_workflow", "reviews", "peer_ack", "deploy", "owner_reserved", "stall", "quiet")
946
+ CONFIG_SCHEMA = 1
947
+ _PLACEHOLDER = "<fill:"
948
+ _FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})[ \t]*([^\s`]*)") # a Markdown code fence and its info string
949
+ _TEMPLATE_PATH = Path(__file__).resolve().parent / "templates" / "coordinator.md"
950
+
951
+
952
+ def config_path(repo_root: Path) -> Path:
953
+ return Path(repo_root) / ".caddis" / "coordinator.md"
954
+
955
+
956
+ def _closes(line: str, fence: str) -> bool:
957
+ """True when `line` closes a block opened by `fence`: same character, at least as long, nothing after it."""
958
+ closed = re.match(r"^ {0,3}(`{3,}|~{3,})[ \t]*$", line)
959
+ return bool(closed) and closed.group(1)[0] == fence[0] and len(closed.group(1)) >= len(fence)
960
+
961
+
962
+ def _config_block(text: str) -> object:
963
+ """Parse the FIRST fenced block whose info string is `json`. Raise ValueError when there is none."""
964
+ lines = text.splitlines()
965
+ i = 0
966
+ while i < len(lines):
967
+ opened = _FENCE.match(lines[i])
968
+ if not opened:
969
+ i += 1
970
+ continue
971
+ fence, info = opened.group(1), opened.group(2).lower()
972
+ body: list[str] = []
973
+ i += 1
974
+ while i < len(lines) and not _closes(lines[i], fence):
975
+ body.append(lines[i])
976
+ i += 1
977
+ i += 1
978
+ if info == "json":
979
+ try:
980
+ return json.loads("\n".join(body))
981
+ except ValueError as exc:
982
+ raise ValueError(f"the json block in the config does not parse: {exc}") from exc
983
+ raise ValueError("the config has no fenced json block")
984
+
985
+
986
+ def _placeholders(value: object, where: str = "") -> list[str]:
987
+ """Every key path (at any depth) whose value is a string that starts with `<fill:`."""
988
+ if isinstance(value, str):
989
+ return [where] if value.startswith(_PLACEHOLDER) else []
990
+ if isinstance(value, dict):
991
+ return [hit for k, v in value.items() for hit in _placeholders(v, f"{where}.{k}" if where else str(k))]
992
+ if isinstance(value, list):
993
+ return [hit for n, v in enumerate(value) for hit in _placeholders(v, f"{where}[{n}]")]
994
+ return []
995
+
996
+
997
+ def load_config(repo_root: Path) -> dict:
998
+ """Read `<repo_root>/.caddis/coordinator.md` and return its json block.
999
+
1000
+ Raise FileNotFoundError when the file is missing, ValueError when there is no json block, a key is missing,
1001
+ `schema` is not 1, or a value is still a `<fill: ...>` placeholder."""
1002
+ path = config_path(repo_root)
1003
+ try:
1004
+ text = path.read_text(encoding="utf-8")
1005
+ except FileNotFoundError as exc:
1006
+ raise FileNotFoundError(f"no coordinator config at {path} (run: caddis_coordinate.py config init)") from exc
1007
+ config = _config_block(text)
1008
+ if not isinstance(config, dict):
1009
+ raise ValueError("the config json block must be one JSON object")
1010
+ missing = [key for key in CONFIG_KEYS if key not in config]
1011
+ if missing:
1012
+ raise ValueError(f"the config is missing keys: {', '.join(missing)}")
1013
+ schema = config["schema"]
1014
+ if not isinstance(schema, int) or isinstance(schema, bool) or schema != CONFIG_SCHEMA:
1015
+ raise ValueError(f"the config schema must be {CONFIG_SCHEMA}, not {schema!r}")
1016
+ unfilled = _placeholders(config)
1017
+ if unfilled:
1018
+ raise ValueError(f"the config still has {_PLACEHOLDER} placeholders at: {', '.join(unfilled)}")
1019
+ return config
1020
+
1021
+
1022
+ def init_config(repo_root: Path) -> Path:
1023
+ """Copy the template to `<repo_root>/.caddis/coordinator.md` when it is absent. Never overwrite."""
1024
+ path = config_path(repo_root)
1025
+ template = _TEMPLATE_PATH.read_text(encoding="utf-8")
1026
+ path.parent.mkdir(parents=True, exist_ok=True)
1027
+ try:
1028
+ with open(path, "x", encoding="utf-8", newline="") as handle: # "x": fails if the file exists, no race
1029
+ handle.write(template)
1030
+ except FileExistsError:
1031
+ pass
1032
+ return path
1033
+
1034
+
1035
+ # --- the watcher loop (C2.3) --------------------------------------------------------------------------------------
1036
+
1037
+
1038
+ def _say(exc: BaseException) -> str:
1039
+ return " ".join(str(exc).split()) or type(exc).__name__
1040
+
1041
+
1042
+ def watch_cycle(rid: str, pid: int, now: datetime.datetime, head_of: Callable[[int], str | None]) -> dict:
1043
+ """One watcher cycle: check the lock still names `pid` and that session is alive, then heartbeat and intake.
1044
+
1045
+ Returns `{"stop": "lock lost" | "session gone" | "state gone"}` when the watcher must end, else
1046
+ `{"stop": None, "results": [...], "error": str | None}`. A transient failure (a lock file or state file that
1047
+ cannot be read now, a lock timeout) goes into `error` and is never raised: the next cycle retries. The watcher
1048
+ never takes the lock and changes the queue only through `intake`. The pid check is local (no `claude agents`).
1049
+ """
1050
+ def failed(message: str) -> dict:
1051
+ return {"stop": None, "results": [], "error": message}
1052
+
1053
+ try:
1054
+ holder = _read_holder(_lock_path(rid))
1055
+ except OSError as exc: # fail closed: it may still name us, so retry next tick rather than stop
1056
+ if not _pid_alive(pid): # ... unless our own session is dead: then nothing is left to watch for
1057
+ return {"stop": "session gone"}
1058
+ return failed(f"lock file unreadable: {_say(exc)}")
1059
+ if holder is None or holder["pid"] != pid:
1060
+ return {"stop": "lock lost"}
1061
+ if not _holder_alive(holder): # the pid is dead, or now belongs to a newer process (pid reuse)
1062
+ return {"stop": "session gone"}
1063
+ if not state_path(rid).exists(): # checked first: heartbeat must never recreate the repo folder
1064
+ return {"stop": "state gone"}
1065
+ try:
1066
+ heartbeat(rid)
1067
+ results = intake(rid, now, head_of)
1068
+ except FileNotFoundError:
1069
+ return {"stop": "state gone"}
1070
+ except ValueError as exc:
1071
+ if not state_path(rid).exists():
1072
+ return {"stop": "state gone"}
1073
+ return failed(_say(exc))
1074
+ except OSError as exc: # sharing error, lock timeout (TimeoutError): retry next tick
1075
+ return failed(_say(exc))
1076
+ except Exception as exc: # e.g. a hand-edited state file: report it, keep watching
1077
+ return failed(f"unexpected {type(exc).__name__}: {_say(exc)}")
1078
+ return {"stop": None, "results": results, "error": None}
1079
+
1080
+
1081
+ def watch(rid: str, pid: int, interval: float, max_cycles: int | None = None) -> int:
1082
+ """Run `watch_cycle` every `interval` seconds. Print one JSON line (flushed) for each cycle with results or a
1083
+ stop, and for an error that differs from the previous cycle's error; print nothing for a quiet cycle or a
1084
+ repeated error. Return 0 on a stop or after `max_cycles` cycles."""
1085
+ head_of = _gh_head_of(rid)
1086
+ cycles = 0
1087
+ last_error: str | None = None
1088
+ try:
1089
+ while True:
1090
+ out = watch_cycle(rid, pid, datetime.datetime.now(datetime.timezone.utc), head_of)
1091
+ error = out.get("error")
1092
+ if out.get("stop") or out.get("results") or (error and error != last_error):
1093
+ print(json.dumps(out, separators=(",", ":")), flush=True)
1094
+ last_error = error
1095
+ if out.get("stop"):
1096
+ return EXIT_OK
1097
+ cycles += 1
1098
+ if max_cycles is not None and cycles >= max_cycles:
1099
+ return EXIT_OK
1100
+ time.sleep(interval)
1101
+ except KeyboardInterrupt:
1102
+ return EXIT_OK
1103
+
1104
+
1105
+ # --- deploy check D1-D5 by script (C2.4, ruling R11) -------------------------------------------------------------
1106
+
1107
+ DEPLOY_CHECKS = ("D1", "D2", "D3", "D4", "D5")
1108
+ _DEPLOY_TIMEOUT = 60 # one subprocess: `git fetch --tags`, or curl with its own --max-time 20
1109
+ _CURL_MAX_TIME = "20"
1110
+
1111
+
1112
+ def _deploy_run(args: list[str]) -> subprocess.CompletedProcess[str]:
1113
+ """The default runner of `deploy_verify`: `_run` with a timeout longer than curl's own `--max-time`."""
1114
+ return _run(args, timeout=_DEPLOY_TIMEOUT)
1115
+
1116
+
1117
+ def _curl() -> str:
1118
+ """Windows: System32 curl.exe under %SystemRoot%, by full path, when it exists (a bare name lets CreateProcess
1119
+ pick a curl.exe from the current folder first), else `curl.exe` on PATH. Elsewhere: `curl` on PATH."""
1120
+ if os.name != "nt":
1121
+ return "curl"
1122
+ system = Path(os.environ.get("SystemRoot") or "") / "System32" / "curl.exe"
1123
+ return str(system) if os.environ.get("SystemRoot") and system.is_file() else "curl.exe"
1124
+
1125
+
1126
+ def _plain_path(path: object) -> str:
1127
+ """A URL path compared for `never_health_path`: lowercased, no query string or fragment, no trailing slash."""
1128
+ return re.split(r"[?#]", str(path), maxsplit=1)[0].rstrip("/").lower()
1129
+
1130
+
1131
+ def _unset(value: object) -> bool:
1132
+ return value is None or (isinstance(value, str) and value.strip().lower() in ("", "none", "null"))
1133
+
1134
+
1135
+ def _checks_summary(checks: dict, docs_only: bool) -> str:
1136
+ """`passed: D1, D2, D5; skipped: D3, D4`, from the checks dict, with a `docs-only: ` prefix."""
1137
+ parts = []
1138
+ for label, mark in (("passed", "pass"), ("skipped", "skip")):
1139
+ names = [name for name, value in checks.items() if value == mark]
1140
+ if names:
1141
+ parts.append(f"{label}: {', '.join(names)}")
1142
+ return ("docs-only: " if docs_only else "") + "; ".join(parts)
1143
+
1144
+
1145
+ class _CheckFailed(Exception):
1146
+ def __init__(self, reason: str, health: str = "failed") -> None:
1147
+ super().__init__(reason)
1148
+ self.health = health
1149
+
1150
+
1151
+ def _call(runner: Callable[[list[str]], subprocess.CompletedProcess[str]], argv: list[str],
1152
+ what: str) -> subprocess.CompletedProcess[str]:
1153
+ """Run one command; a missing tool, a timeout or a non-zero exit is a check failure, never a crash."""
1154
+ try:
1155
+ result = runner(argv)
1156
+ except subprocess.TimeoutExpired:
1157
+ raise _CheckFailed(f"{what} timed out") from None
1158
+ except (OSError, subprocess.SubprocessError) as exc:
1159
+ raise _CheckFailed(f"{what} could not run ({Path(argv[0]).name}): {_say(exc)}") from None
1160
+ if result.returncode != 0:
1161
+ detail = " ".join((result.stderr or result.stdout or "").split()) or f"exit {result.returncode}"
1162
+ raise _CheckFailed(f"{what} failed: {detail}")
1163
+ return result
1164
+
1165
+
1166
+ def deploy_verify(rid: str, cfg: dict, pr: int, merge_sha: str, *, docs_only: bool,
1167
+ runner: Callable[[list[str]], subprocess.CompletedProcess[str]] | None = None,
1168
+ env=os.environ, repo_root: Path | None = None) -> dict:
1169
+ """Run deploy checks D1-D5 for a merged PR, record the result with `deploy_check_set`, and return
1170
+ `{"checks": {"D1".."D5": "pass|fail|skip"}, "run": int | None, "health": "ok|failed|pending", "reason": str}`.
1171
+
1172
+ Checks run in order and stop at the first failure (the rest are `skip`). D2 does not wait: the model waits
1173
+ with `caddis_lanes.py ci-watch --run` first. Docs-only runs D1, D2 and D5 only, and D2 fails when the run
1174
+ contradicts the flag (a job named `deploy...` succeeded, or `deploy.jobs` jobs succeeded). A non-docs run needs
1175
+ `deploy.jobs` successful jobs, and every job when it has more. `deploy.never_health_path` is never requested.
1176
+ `GH_TOKEN` comes from the inherited environment only. `repo_root` is where the git commands of D3 run.
1177
+ A run still in progress at D2 records health `pending`, not `failed`.
1178
+ Raises ValueError, before any network call and with nothing recorded, for a merge SHA that is not a full SHA,
1179
+ a PR with no deploy check, or a merge SHA that differs from the one in the PR's newest deploy check."""
1180
+ if not isinstance(merge_sha, str) or not _SHA.fullmatch(merge_sha):
1181
+ raise ValueError(f"the merge SHA must be a full 40-character SHA: {merge_sha!r}")
1182
+ state = read_state(rid)
1183
+ check = next((c for c in reversed((state or {}).get("deploy_checks") or [])
1184
+ if isinstance(c, dict) and c.get("pr") == pr), None)
1185
+ if check is None:
1186
+ raise ValueError(f"no deploy check for PR {pr} (run queue merged first)")
1187
+ if str(check.get("merge_sha", "")).lower() != merge_sha.lower():
1188
+ raise ValueError(f"merge SHA {merge_sha[:12]} is not the recorded merge SHA "
1189
+ f"{str(check.get('merge_sha'))[:12]} of PR {pr}")
1190
+ runner = runner or _deploy_run
1191
+ deploy = cfg.get("deploy") if isinstance(cfg.get("deploy"), dict) else {}
1192
+ sha = merge_sha.lower()
1193
+ checks = {name: "skip" for name in DEPLOY_CHECKS}
1194
+ found: dict = {"run": None}
1195
+
1196
+ def curl(path: str, *extra: str) -> list[str]:
1197
+ auth = deploy.get("auth")
1198
+ if _unset(auth):
1199
+ auth_args: list[str] = []
1200
+ elif auth == "ntlm":
1201
+ auth_args = ["--ntlm", "-u", ":"]
1202
+ else:
1203
+ raise _CheckFailed(f"unknown deploy.auth {auth!r} (known: ntlm, none)")
1204
+ name = deploy.get("base_url_env")
1205
+ if _unset(name):
1206
+ raise _CheckFailed("deploy.base_url_env is not set in the config")
1207
+ base = env.get(name)
1208
+ if not base:
1209
+ raise _CheckFailed(f"base URL env {name} not set")
1210
+ if not isinstance(path, str) or not path.startswith("/"):
1211
+ raise _CheckFailed(f"a deploy path must start with '/': {path!r}")
1212
+ never = deploy.get("never_health_path")
1213
+ if not _unset(never) and _plain_path(path) == _plain_path(never):
1214
+ raise _CheckFailed(f"refused: {path} is deploy.never_health_path")
1215
+ return [_curl(), "-s", "--max-time", _CURL_MAX_TIME, *auth_args, *extra, base.rstrip("/") + path]
1216
+
1217
+ def d1() -> None:
1218
+ if rid.startswith("local__") or "__" not in rid:
1219
+ raise _CheckFailed(f"repo {rid} has no GitHub repository")
1220
+ owner, name = rid.split("__", 1)
1221
+ branch, workflow = cfg.get("default_branch"), cfg.get("main_workflow")
1222
+ event = deploy.get("event") or "push"
1223
+ if _unset(workflow) or not isinstance(workflow, str):
1224
+ raise _CheckFailed("main_workflow is not set in the config")
1225
+ out = _call(runner, ["gh", "run", "list", "-R", f"{owner}/{name}", "--branch", str(branch),
1226
+ "--event", str(event), "--workflow", workflow, "--limit", "50",
1227
+ "--json", "databaseId,headSha,status,conclusion"], "gh run list")
1228
+ try:
1229
+ runs = json.loads(out.stdout)
1230
+ except ValueError:
1231
+ raise _CheckFailed("gh run list did not print JSON") from None
1232
+ hits = [r for r in runs if isinstance(r, dict) and str(r.get("headSha", "")).lower() == sha] \
1233
+ if isinstance(runs, list) else []
1234
+ if not hits:
1235
+ raise _CheckFailed(f"no {event} run of {workflow} on {branch} for {merge_sha[:12]}")
1236
+ found["run"] = hits[0] # gh lists newest first
1237
+ run_id = hits[0].get("databaseId")
1238
+ found["id"] = run_id if isinstance(run_id, int) and not isinstance(run_id, bool) else None
1239
+
1240
+ def d2() -> None:
1241
+ run = found["run"]
1242
+ status, conclusion = run.get("status"), run.get("conclusion")
1243
+ if status != "completed":
1244
+ raise _CheckFailed(f"run {found['id']} still running: wait for it with ci-watch --run first", "pending")
1245
+ if conclusion != "success":
1246
+ raise _CheckFailed(f"run {found['id']} concluded {conclusion}")
1247
+ want = deploy.get("jobs")
1248
+ want_ok = isinstance(want, int) and not isinstance(want, bool) and want >= 1
1249
+ if not docs_only and not want_ok:
1250
+ raise _CheckFailed(f"deploy.jobs must be a job count of 1 or more, not {want!r}")
1251
+ if found["id"] is None:
1252
+ raise _CheckFailed("the push run has no databaseId")
1253
+ owner, name = rid.split("__", 1)
1254
+ out = _call(runner, ["gh", "run", "view", str(found["id"]), "-R", f"{owner}/{name}", "--json", "jobs"],
1255
+ "gh run view")
1256
+ try:
1257
+ jobs = json.loads(out.stdout).get("jobs")
1258
+ except (ValueError, AttributeError):
1259
+ jobs = None
1260
+ if not isinstance(jobs, list):
1261
+ raise _CheckFailed("gh run view did not print a jobs list")
1262
+ succeeded = [j for j in jobs if isinstance(j, dict) and j.get("conclusion") == "success"]
1263
+ done = len(succeeded)
1264
+ if docs_only: # a docs-only push deploys nothing: a run that deployed contradicts the flag
1265
+ deployed = any("deploy" in str(j.get("name", "")).lower() for j in succeeded)
1266
+ if deployed or (want_ok and done >= want):
1267
+ raise _CheckFailed(f"docs-only flag contradicts the run: {done} jobs succeeded")
1268
+ return
1269
+ others = ", ".join(f"{j.get('name')}={j.get('conclusion')}" for j in jobs
1270
+ if isinstance(j, dict) and j.get("conclusion") != "success")
1271
+ detail = f" ({others})" if others else ""
1272
+ if done < want:
1273
+ raise _CheckFailed(f"run {found['id']}: {done} of {want} jobs succeeded{detail}")
1274
+ if len(jobs) != want and done != len(jobs): # extra jobs: every one must succeed
1275
+ raise _CheckFailed(f"run {found['id']}: {len(jobs)} jobs, not {want}, and not all succeeded{detail}")
1276
+
1277
+ def d3() -> None:
1278
+ git = ["git", "-C", str(repo_root)] if repo_root is not None else ["git"]
1279
+ _call(runner, [*git, "fetch", "--tags"], "git fetch --tags")
1280
+ out = _call(runner, [*git, "tag", "--points-at", merge_sha], "git tag --points-at")
1281
+ if not out.stdout.strip():
1282
+ raise _CheckFailed(f"no release tag points at {merge_sha[:12]}")
1283
+
1284
+ def d4() -> None:
1285
+ path = deploy.get("version_path")
1286
+ out = _call(runner, curl(path), f"curl {path}")
1287
+ try:
1288
+ body = json.loads(out.stdout)
1289
+ except ValueError:
1290
+ raise _CheckFailed(f"{path} did not return JSON") from None
1291
+ reported = body.get("sha") if isinstance(body, dict) else None
1292
+ if not isinstance(reported, str) or not reported.lower().startswith(sha):
1293
+ raise _CheckFailed(f"{path} reports sha {reported!r}, not {merge_sha[:12]}")
1294
+
1295
+ def d5() -> None:
1296
+ path = deploy.get("health_path")
1297
+ if _unset(path):
1298
+ raise _CheckFailed("deploy.health_path is not set in the config")
1299
+ null = "NUL" if os.name == "nt" else "/dev/null"
1300
+ out = _call(runner, curl(path, "-o", null, "-w", "%{http_code}"), f"curl {path}")
1301
+ code = out.stdout.strip()
1302
+ if code != "200":
1303
+ raise _CheckFailed(f"{path} returned HTTP {code or 'nothing'}, not 200")
1304
+
1305
+ steps = [("D1", d1, True), ("D2", d2, True),
1306
+ ("D3", d3, not docs_only and not _unset(deploy.get("tag"))),
1307
+ ("D4", d4, not docs_only and not _unset(deploy.get("version_path"))),
1308
+ ("D5", d5, True)]
1309
+ health, reason = "ok", ""
1310
+ for name, step, applies in steps:
1311
+ if not applies:
1312
+ continue
1313
+ try:
1314
+ step()
1315
+ except _CheckFailed as exc:
1316
+ checks[name], health, reason = "fail", exc.health, str(exc)
1317
+ break
1318
+ checks[name] = "pass"
1319
+ if health == "ok":
1320
+ reason = _checks_summary(checks, docs_only)
1321
+ run_id = found.get("id")
1322
+ deploy_check_set(rid, pr, deploy_run=run_id, health=health)
1323
+ return {"checks": checks, "run": run_id, "health": health, "reason": reason}
1324
+
1325
+
1326
+ def _deploy_root(config_path_arg: str) -> Path:
1327
+ """`--config-path` names the config file or the repo root; return the repo root."""
1328
+ given = Path(config_path_arg)
1329
+ if given.is_dir():
1330
+ return given
1331
+ root = given.resolve().parent.parent
1332
+ if config_path(root).resolve() != given.resolve():
1333
+ raise ValueError(f"--config-path must be a repo root or its .caddis/coordinator.md: {given}")
1334
+ return root
1335
+
1336
+
1337
+ def _claude_agents() -> list[dict]:
1338
+ """Run `claude agents --json` and return its list. Raise OSError or ValueError on failure."""
1339
+ claude = shutil.which("claude") # on Windows this finds claude.CMD
1340
+ if not claude:
1341
+ raise OSError("claude not found on PATH")
1342
+ try:
1343
+ result = _run([claude, "agents", "--json"])
1344
+ except subprocess.TimeoutExpired as exc:
1345
+ raise OSError(f"claude agents --json timed out after {_TIMEOUT} s") from exc
1346
+ if result.returncode != 0:
1347
+ detail = " ".join((result.stderr or result.stdout or "").split()) or f"exit {result.returncode}"
1348
+ raise OSError(f"claude agents --json failed: {detail}")
1349
+ try:
1350
+ value = json.loads(result.stdout)
1351
+ except ValueError as exc:
1352
+ raise ValueError("claude agents --json did not print JSON") from exc
1353
+ if not isinstance(value, list):
1354
+ raise ValueError("claude agents --json did not print a JSON list")
1355
+ return value
1356
+
1357
+
1358
+ def _waits_on(text: str) -> list[dict]:
1359
+ try:
1360
+ value = json.loads(text)
1361
+ except ValueError:
1362
+ value = None
1363
+ if not isinstance(value, list) or not all(isinstance(w, dict) for w in value):
1364
+ raise argparse.ArgumentTypeError('takes a JSON list of objects, for example [{"repo": "acme__lib", "pr": 3}]')
1365
+ return value
1366
+
1367
+
1368
+ class _UsageError(Exception):
1369
+ pass
1370
+
1371
+
1372
+ class _Parser(argparse.ArgumentParser):
1373
+ def error(self, message: str) -> None: # raise instead of sys.exit, so main() returns 2
1374
+ raise _UsageError(f"{self.prog}: {message}")
1375
+
1376
+
1377
+ def _parser() -> argparse.ArgumentParser:
1378
+ top = _Parser(prog="caddis_coordinate.py", description="Deterministic coordinator core for the caddis hub.")
1379
+ sub = top.add_subparsers(dest="command", required=True, parser_class=_Parser)
1380
+
1381
+ def command(name: str, parent=sub, repo: bool = True) -> argparse.ArgumentParser:
1382
+ p = parent.add_parser(name)
1383
+ p.add_argument("--json", action="store_true", help="print JSON on stdout")
1384
+ if repo:
1385
+ p.add_argument("--repo", help="repo id (default: the repo id of the current folder)")
1386
+ return p
1387
+
1388
+ p = command("repo-id", repo=False)
1389
+ p.add_argument("--path", default=".", help="a folder inside the checkout (default: current folder)")
1390
+
1391
+ config = sub.add_parser("config").add_subparsers(dest="config_command", required=True, parser_class=_Parser)
1392
+ for name in ("show", "init"):
1393
+ p = command(name, config, repo=False)
1394
+ p.add_argument("--path", default=".", help="the repo root (default: current folder)")
1395
+
1396
+ p = command("session", repo=False)
1397
+ which = p.add_mutually_exclusive_group(required=True)
1398
+ which.add_argument("--session-id", help="the Claude Code sessionId to find")
1399
+ which.add_argument("--name", help="the Claude Code session name to find")
1400
+
1401
+ lock = sub.add_parser("lock").add_subparsers(dest="lock_command", required=True, parser_class=_Parser)
1402
+ p = command("acquire", lock)
1403
+ p.add_argument("--pid", type=int, required=True)
1404
+ p.add_argument("--session-id", required=True)
1405
+ p.add_argument("--name", required=True)
1406
+ p = command("release", lock)
1407
+ p.add_argument("--pid", type=int, required=True)
1408
+
1409
+ command("heartbeat")
1410
+ command("intake")
1411
+ p = command("watch")
1412
+ p.add_argument("--pid", type=int, required=True, help="the coordinator session pid that holds the lock")
1413
+ p.add_argument("--interval", type=float, default=30.0, help="seconds between cycles (default 30)")
1414
+ p.add_argument("--max-cycles", type=int, help="stop after this many cycles (for tests)")
1415
+ command("state")
1416
+
1417
+ queue = sub.add_parser("queue").add_subparsers(dest="queue_command", required=True, parser_class=_Parser)
1418
+ p = command("add", queue)
1419
+ p.add_argument("--pr", type=int, required=True)
1420
+ p.add_argument("--head-sha", required=True)
1421
+ p.add_argument("--title", help="the PR title (default: read from gh; `PR #<n>` when gh fails)")
1422
+ p = command("set", queue)
1423
+ p.add_argument("--pr", type=int, required=True)
1424
+ p.add_argument("--state", choices=QUEUE_STATES)
1425
+ p.add_argument("--head-sha")
1426
+ p.add_argument("--why-waiting")
1427
+ p.add_argument("--waits-on", type=_waits_on)
1428
+ p = command("remove", queue)
1429
+ p.add_argument("--pr", type=int, required=True)
1430
+ p = command("ready", queue)
1431
+ p.add_argument("--pr", type=int, required=True)
1432
+ command("next", queue)
1433
+ p = command("merged", queue)
1434
+ p.add_argument("--pr", type=int, required=True)
1435
+ p.add_argument("--merge-sha", required=True)
1436
+
1437
+ deploy = sub.add_parser("deploy-check").add_subparsers(dest="deploy_command", required=True, parser_class=_Parser)
1438
+ p = command("set", deploy)
1439
+ p.add_argument("--pr", type=int, required=True)
1440
+ p.add_argument("--run", type=int, help="the deploy run id")
1441
+ p.add_argument("--health", choices=DEPLOY_HEALTH)
1442
+
1443
+ p = command("deploy-verify")
1444
+ p.add_argument("--pr", type=int, required=True)
1445
+ p.add_argument("--merge-sha", required=True, help="the full merge commit SHA")
1446
+ p.add_argument("--config-path", required=True, help="the repo root, or its .caddis/coordinator.md")
1447
+ p.add_argument("--docs-only", action="store_true", help="a docs-only merge: D1, D2 and D5 only")
1448
+
1449
+ ledger = sub.add_parser("ledger").add_subparsers(dest="ledger_command", required=True, parser_class=_Parser)
1450
+ p = command("record", ledger)
1451
+ p.add_argument("--what", required=True)
1452
+ p.add_argument("--decision", choices=LEDGER_DECISIONS, required=True)
1453
+ p.add_argument("--by", choices=LEDGER_BY, required=True)
1454
+ p.add_argument("--reason", required=True)
1455
+ p.add_argument("--window", help="the session window the decision was made in")
1456
+ p.add_argument("--request-id")
1457
+ return top
1458
+
1459
+
1460
+ def _emit(args: argparse.Namespace, value: object, text: str) -> None:
1461
+ print(json.dumps(value, indent=2) if args.json else text)
1462
+
1463
+
1464
+ def _dispatch(args: argparse.Namespace) -> int:
1465
+ if args.command == "repo-id":
1466
+ rid = repo_id_for(Path(args.path))
1467
+ _emit(args, {"repo": rid}, rid)
1468
+ return EXIT_OK
1469
+ if args.command == "session":
1470
+ found = session_lookup(_claude_agents(), session_id=args.session_id, name=args.name)
1471
+ _emit(args, found, f"pid {found['pid']} ({found['name']})")
1472
+ return EXIT_OK
1473
+ if args.command == "config":
1474
+ if args.config_command == "init":
1475
+ path = init_config(Path(args.path))
1476
+ _emit(args, {"path": str(path)}, str(path))
1477
+ else:
1478
+ loaded = load_config(Path(args.path))
1479
+ _emit(args, loaded, json.dumps(loaded, indent=2))
1480
+ return EXIT_OK
1481
+ rid = args.repo or repo_id_for(Path.cwd())
1482
+ _repo_dir(rid) # refuse a bad id before anything is written
1483
+
1484
+ if args.command == "lock":
1485
+ if args.lock_command == "acquire":
1486
+ ok, holder = acquire_lock(rid, args.pid, args.session_id, args.name)
1487
+ if not ok:
1488
+ _emit(args, {"acquired": False, "holder": holder}, f"refused: {holder['error']}" if holder.get("error")
1489
+ else f"refused: held by pid {holder.get('pid')} ({holder.get('name')})")
1490
+ return EXIT_LOCK_REFUSED
1491
+ _claim_state(rid, {"name": args.name, "sessionId": args.session_id, "pid": args.pid})
1492
+ _emit(args, {"acquired": True, "holder": None}, "acquired")
1493
+ return EXIT_OK
1494
+ release_lock(rid, args.pid)
1495
+ _emit(args, {"released": True}, "released")
1496
+ return EXIT_OK
1497
+ if args.command == "heartbeat":
1498
+ if not state_path(rid).exists(): # checked first so an unknown repo id leaves no folder or lock file behind
1499
+ raise ValueError(f"no coordinator state for repo {rid!r}")
1500
+ heartbeat(rid)
1501
+ _emit(args, {"ok": True}, "ok")
1502
+ return EXIT_OK
1503
+ if args.command == "watch":
1504
+ return watch(rid, args.pid, args.interval, args.max_cycles)
1505
+ if args.command == "intake":
1506
+ results = intake(rid, datetime.datetime.now(datetime.timezone.utc), _gh_head_of(rid))
1507
+ lines = [f"{r['id']}: {r['result']} {r['reason']}".rstrip() for r in results]
1508
+ _emit(args, results, "\n".join(lines) or "no new requests")
1509
+ return EXIT_OK
1510
+ if args.command == "state":
1511
+ state = read_state(rid)
1512
+ if state is None:
1513
+ raise ValueError(f"no coordinator state for repo {rid!r}")
1514
+ _emit(args, state, json.dumps(state, indent=2))
1515
+ return EXIT_OK
1516
+ if args.command == "queue" and args.queue_command == "next":
1517
+ state = read_state(rid)
1518
+ if state is None:
1519
+ raise ValueError(f"no coordinator state for repo {rid!r}")
1520
+ item = queue_next(state)
1521
+ _emit(args, {"next": item}, f"PR #{item['pr']}" if item else "none")
1522
+ return EXIT_OK
1523
+ if args.command == "deploy-verify":
1524
+ root = _deploy_root(args.config_path)
1525
+ out = deploy_verify(rid, load_config(root), args.pr, args.merge_sha, docs_only=args.docs_only,
1526
+ runner=_deploy_run, repo_root=root)
1527
+ marks = " ".join(f"{name}={mark}" for name, mark in out["checks"].items())
1528
+ _emit(args, out, f"{out['health']}: {marks} ({out['reason']})")
1529
+ return EXIT_OK if out["health"] == "ok" else EXIT_ERROR
1530
+ if args.command == "deploy-check":
1531
+ deploy_check_set(rid, args.pr, deploy_run=args.run, health=args.health)
1532
+ elif args.command == "ledger":
1533
+ ledger_record(rid, what=args.what, decision=args.decision, by=args.by, reason=args.reason,
1534
+ window=args.window, request_id=args.request_id)
1535
+ elif args.queue_command == "add":
1536
+ queue_add(rid, args.pr, args.head_sha, args.title if args.title is not None else _gh_title(rid, args.pr))
1537
+ elif args.queue_command == "set":
1538
+ queue_set(rid, args.pr, state=args.state, head_sha=args.head_sha, why_waiting=args.why_waiting,
1539
+ waits_on=args.waits_on)
1540
+ elif args.queue_command == "ready":
1541
+ queue_set(rid, args.pr, state="ready")
1542
+ elif args.queue_command == "merged":
1543
+ if not queue_merged(rid, args.pr, args.merge_sha):
1544
+ _emit(args, {"ok": True, "result": "already recorded"}, "already recorded")
1545
+ return EXIT_OK
1546
+ else:
1547
+ queue_remove(rid, args.pr)
1548
+ _emit(args, {"ok": True}, "ok")
1549
+ return EXIT_OK
1550
+
1551
+
1552
+ def main(argv: list[str] | None = None) -> int:
1553
+ """Run one subcommand. Exit 0 on success, 1 on an error (one line on stderr), 2 on a usage error, 3 when the
1554
+ lock is refused."""
1555
+ try:
1556
+ parser = _parser()
1557
+ args = parser.parse_args(argv)
1558
+ if args.command == "deploy-check" and args.run is None and args.health is None:
1559
+ parser.error("deploy-check set needs --run or --health")
1560
+ if args.command == "deploy-verify" and not _SHA.fullmatch(args.merge_sha):
1561
+ parser.error("deploy-verify --merge-sha must be a full 40-character SHA")
1562
+ if args.command == "lock" and args.lock_command == "acquire" and args.pid <= 0:
1563
+ parser.error("lock acquire --pid must be a positive process id")
1564
+ if args.command == "watch":
1565
+ if args.pid <= 0:
1566
+ parser.error("watch --pid must be a positive process id")
1567
+ if not 1 <= args.interval <= 86400: # also refuses NaN and inf (time.sleep(inf) raises)
1568
+ parser.error("watch --interval must be 1 to 86400 seconds")
1569
+ if args.max_cycles is not None and args.max_cycles <= 0:
1570
+ parser.error("watch --max-cycles must be 1 or more")
1571
+ except _UsageError as exc:
1572
+ print(f"usage error: {exc}", file=sys.stderr)
1573
+ return EXIT_USAGE
1574
+ except SystemExit as exc: # -h prints help and exits 0
1575
+ return EXIT_OK if exc.code in (0, None) else EXIT_USAGE
1576
+ try:
1577
+ return _dispatch(args)
1578
+ except (OSError, ValueError, subprocess.SubprocessError) as exc:
1579
+ message = " ".join(str(exc).split()) or type(exc).__name__
1580
+ print(f"error: {message}", file=sys.stderr)
1581
+ return EXIT_ERROR
1582
+ except Exception as exc: # last resort, e.g. a hand-edited state file: one line, no traceback
1583
+ message = " ".join(str(exc).split())
1584
+ print(f"error: unexpected {type(exc).__name__}" + (f": {message}" if message else ""), file=sys.stderr)
1585
+ return EXIT_ERROR
1586
+
1587
+
1588
+ if __name__ == "__main__":
1589
+ sys.exit(main())