@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.
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_coordinate.py +1589 -0
- package/bundles/antigravity-plugin/scripts/caddis_upstream_check.py +107 -0
- package/bundles/antigravity-plugin/scripts/schemas/coordinator.schema.json +95 -0
- package/bundles/antigravity-plugin/scripts/schemas/request.schema.json +39 -0
- package/bundles/antigravity-plugin/scripts/templates/coordinator.md +91 -0
- package/bundles/antigravity-plugin/skills/_registry.md +2 -1
- package/bundles/antigravity-plugin/skills/brainstorming/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/coordinate/SKILL.md +339 -0
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +64 -1
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +14 -0
- package/bundles/antigravity-plugin/skills/usage-review/SKILL.md +9 -0
- package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +15 -375
- package/bundles/codex/.codex/skills/docs/writing-plans/SKILL.md +15 -375
- package/bundles/codex/.codex/skills/workflow/brainstorming/SKILL.md +1 -1
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -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())
|