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