@caddis/cli 0.4.72 → 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 +123 -6
- 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 +5 -2
- package/bundles/antigravity-plugin/skills/coordinate/SKILL.md +25 -4
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +37 -2
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -23,12 +23,12 @@ from collections.abc import Callable
|
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
|
|
25
25
|
try:
|
|
26
|
-
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
|
|
27
27
|
except ModuleNotFoundError as exc:
|
|
28
28
|
if exc.name != "caddis_file_lock":
|
|
29
29
|
raise
|
|
30
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
|
|
31
|
+
from caddis_file_lock import _try_lock, _unlock, atomic_write, locked_path, retry_denied
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
_TIMEOUT = 10
|
|
@@ -132,6 +132,14 @@ def _lock_path(rid: str) -> Path:
|
|
|
132
132
|
return _repo_dir(rid) / "coordinator.lock.json"
|
|
133
133
|
|
|
134
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
|
+
|
|
135
143
|
def read_state(rid: str) -> dict | None:
|
|
136
144
|
"""Return the last good parse of `coordinator.json`; None when it is missing or was never good.
|
|
137
145
|
|
|
@@ -367,6 +375,17 @@ def acquire_lock(rid: str, pid: int, session_id: str, name: str) -> tuple[bool,
|
|
|
367
375
|
return True, None
|
|
368
376
|
|
|
369
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
|
+
|
|
370
389
|
def release_lock(rid: str, pid: int) -> None:
|
|
371
390
|
"""Remove the lock if `pid` holds it; do nothing otherwise."""
|
|
372
391
|
with locked_path(_lock_path(rid)) as path:
|
|
@@ -943,6 +962,7 @@ def session_lookup(sessions: list[dict], *, session_id: str | None = None, name:
|
|
|
943
962
|
|
|
944
963
|
CONFIG_KEYS = ("schema", "default_branch", "merge_method", "required_checks", "not_required", "docs_only",
|
|
945
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`
|
|
946
966
|
CONFIG_SCHEMA = 1
|
|
947
967
|
_PLACEHOLDER = "<fill:"
|
|
948
968
|
_FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})[ \t]*([^\s`]*)") # a Markdown code fence and its info string
|
|
@@ -998,7 +1018,8 @@ def load_config(repo_root: Path) -> dict:
|
|
|
998
1018
|
"""Read `<repo_root>/.caddis/coordinator.md` and return its json block.
|
|
999
1019
|
|
|
1000
1020
|
Raise FileNotFoundError when the file is missing, ValueError when there is no json block, a key is missing,
|
|
1001
|
-
`schema` is not 1,
|
|
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)."""
|
|
1002
1023
|
path = config_path(repo_root)
|
|
1003
1024
|
try:
|
|
1004
1025
|
text = path.read_text(encoding="utf-8")
|
|
@@ -1016,9 +1037,24 @@ def load_config(repo_root: Path) -> dict:
|
|
|
1016
1037
|
unfilled = _placeholders(config)
|
|
1017
1038
|
if unfilled:
|
|
1018
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}")
|
|
1019
1043
|
return config
|
|
1020
1044
|
|
|
1021
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
|
+
|
|
1022
1058
|
def init_config(repo_root: Path) -> Path:
|
|
1023
1059
|
"""Copy the template to `<repo_root>/.caddis/coordinator.md` when it is absent. Never overwrite."""
|
|
1024
1060
|
path = config_path(repo_root)
|
|
@@ -1078,10 +1114,79 @@ def watch_cycle(rid: str, pid: int, now: datetime.datetime, head_of: Callable[[i
|
|
|
1078
1114
|
return {"stop": None, "results": results, "error": None}
|
|
1079
1115
|
|
|
1080
1116
|
|
|
1081
|
-
|
|
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:
|
|
1082
1146
|
"""Run `watch_cycle` every `interval` seconds. Print one JSON line (flushed) for each cycle with results or a
|
|
1083
1147
|
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.
|
|
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:
|
|
1085
1190
|
head_of = _gh_head_of(rid)
|
|
1086
1191
|
cycles = 0
|
|
1087
1192
|
last_error: str | None = None
|
|
@@ -1098,6 +1203,9 @@ def watch(rid: str, pid: int, interval: float, max_cycles: int | None = None) ->
|
|
|
1098
1203
|
if max_cycles is not None and cycles >= max_cycles:
|
|
1099
1204
|
return EXIT_OK
|
|
1100
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
|
|
1101
1209
|
except KeyboardInterrupt:
|
|
1102
1210
|
return EXIT_OK
|
|
1103
1211
|
|
|
@@ -1405,6 +1513,7 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
1405
1513
|
p.add_argument("--name", required=True)
|
|
1406
1514
|
p = command("release", lock)
|
|
1407
1515
|
p.add_argument("--pid", type=int, required=True)
|
|
1516
|
+
command("holder", lock)
|
|
1408
1517
|
|
|
1409
1518
|
command("heartbeat")
|
|
1410
1519
|
command("intake")
|
|
@@ -1412,6 +1521,8 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
1412
1521
|
p.add_argument("--pid", type=int, required=True, help="the coordinator session pid that holds the lock")
|
|
1413
1522
|
p.add_argument("--interval", type=float, default=30.0, help="seconds between cycles (default 30)")
|
|
1414
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)")
|
|
1415
1526
|
command("state")
|
|
1416
1527
|
|
|
1417
1528
|
queue = sub.add_parser("queue").add_subparsers(dest="queue_command", required=True, parser_class=_Parser)
|
|
@@ -1491,6 +1602,12 @@ def _dispatch(args: argparse.Namespace) -> int:
|
|
|
1491
1602
|
_claim_state(rid, {"name": args.name, "sessionId": args.session_id, "pid": args.pid})
|
|
1492
1603
|
_emit(args, {"acquired": True, "holder": None}, "acquired")
|
|
1493
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
|
|
1494
1611
|
release_lock(rid, args.pid)
|
|
1495
1612
|
_emit(args, {"released": True}, "released")
|
|
1496
1613
|
return EXIT_OK
|
|
@@ -1501,7 +1618,7 @@ def _dispatch(args: argparse.Namespace) -> int:
|
|
|
1501
1618
|
_emit(args, {"ok": True}, "ok")
|
|
1502
1619
|
return EXIT_OK
|
|
1503
1620
|
if args.command == "watch":
|
|
1504
|
-
return watch(rid, args.pid, args.interval, args.max_cycles)
|
|
1621
|
+
return watch(rid, args.pid, args.interval, args.max_cycles, takeover=args.takeover)
|
|
1505
1622
|
if args.command == "intake":
|
|
1506
1623
|
results = intake(rid, datetime.datetime.now(datetime.timezone.utc), _gh_head_of(rid))
|
|
1507
1624
|
lines = [f"{r['id']}: {r['result']} {r['reason']}".rstrip() for r in results]
|
|
@@ -482,6 +482,17 @@ def render_resume(session: str, session_id: str, workstream: str, prompt: str, r
|
|
|
482
482
|
f"{_fenced(shown)}\n{start}\n")
|
|
483
483
|
|
|
484
484
|
|
|
485
|
+
def render_coordinator_resume(session: str, session_id: str, workstream: str) -> str:
|
|
486
|
+
"""The resume block for the repo's coordinator: three lines to type, no prompt and no start line.
|
|
487
|
+
|
|
488
|
+
`/caddis:coordinate start` reads the coordinator's relay and tasks itself, so a pasted prompt would only race it."""
|
|
489
|
+
return (f"=== RESUME ===\nSession: {session} ({session_id})\nWorkstream: {workstream}\n"
|
|
490
|
+
"This session is the repo's coordinator. To resume, type these three lines in this window, one at a time:\n"
|
|
491
|
+
"/clear\n/rename coordinator\n/caddis:coordinate start\n"
|
|
492
|
+
f"Do not paste a resume prompt; /caddis:coordinate start reads .caddis/relay/{workstream}.md "
|
|
493
|
+
"and its tasks itself.\n")
|
|
494
|
+
|
|
495
|
+
|
|
485
496
|
# ---- CLI --------------------------------------------------------------------------------------
|
|
486
497
|
|
|
487
498
|
def _load_acks(fleet_dir: Path) -> list[Ack]:
|
|
@@ -491,6 +502,28 @@ def _load_acks(fleet_dir: Path) -> list[Ack]:
|
|
|
491
502
|
return [parse_ack(p.read_text(encoding="utf-8")) for p in sorted(acks_dir.glob("*.md"))]
|
|
492
503
|
|
|
493
504
|
|
|
505
|
+
def _coordinator_relay_problem(root: Path, name: str) -> str | None:
|
|
506
|
+
"""None when `<root>/.caddis/coordinator.md` loads and its `relay` is `name`, else one line that says why not.
|
|
507
|
+
|
|
508
|
+
The coordinator block says `/caddis:coordinate start` reads `.caddis/relay/<name>.md` itself; start reads the
|
|
509
|
+
config's `relay`, so the block is true only when the two are the same name."""
|
|
510
|
+
try:
|
|
511
|
+
import caddis_coordinate
|
|
512
|
+
except ModuleNotFoundError as exc:
|
|
513
|
+
if exc.name != "caddis_coordinate":
|
|
514
|
+
raise
|
|
515
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
516
|
+
import caddis_coordinate
|
|
517
|
+
try:
|
|
518
|
+
relay = caddis_coordinate.load_config(root).get("relay")
|
|
519
|
+
except (OSError, ValueError) as exc: # FileNotFoundError is an OSError
|
|
520
|
+
return f"--coordinator needs a valid .caddis/coordinator.md with relay {name!r}: {' '.join(str(exc).split())}"
|
|
521
|
+
if relay != name:
|
|
522
|
+
return (f"--coordinator: --name {name!r} is not the coordinator config relay {relay!r}"
|
|
523
|
+
if relay else f"--coordinator: .caddis/coordinator.md has no relay (needs relay {name!r})")
|
|
524
|
+
return None
|
|
525
|
+
|
|
526
|
+
|
|
494
527
|
def _resume_block_cli(args: argparse.Namespace) -> int:
|
|
495
528
|
try:
|
|
496
529
|
import caddis_workstreams
|
|
@@ -500,15 +533,23 @@ def _resume_block_cli(args: argparse.Namespace) -> int:
|
|
|
500
533
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
501
534
|
import caddis_workstreams
|
|
502
535
|
root = args.root.resolve()
|
|
536
|
+
if args.coordinator:
|
|
537
|
+
problem = _coordinator_relay_problem(root, args.name)
|
|
538
|
+
if problem:
|
|
539
|
+
sys.stderr.write(f"resume-block failed: {problem}\n")
|
|
540
|
+
return caddis_exit.BLOCKED
|
|
503
541
|
try:
|
|
504
542
|
text, digest = caddis_workstreams.read_relay(root, args.name)
|
|
505
543
|
if digest is None:
|
|
506
544
|
raise ResumeError(f"relay {args.name!r} not found")
|
|
507
|
-
prompt = resume_prompt(text)
|
|
545
|
+
prompt = "" if args.coordinator else resume_prompt(text)
|
|
508
546
|
except (ValueError, OSError) as exc: # ResumeError is a ValueError; a bad relay name is one too
|
|
509
547
|
sys.stderr.write(f"resume-block failed: {exc}\n")
|
|
510
548
|
return caddis_exit.BLOCKED
|
|
511
549
|
session, known = session_name(args.session_id, root)
|
|
550
|
+
if args.coordinator:
|
|
551
|
+
print(render_coordinator_resume(session, args.session_id, args.name), end="")
|
|
552
|
+
return caddis_exit.CLEAN
|
|
512
553
|
role = args.role or (role_of(session) if known else args.name)
|
|
513
554
|
if not _SAFE_NAME.match(role): # the role goes unquoted into the start line
|
|
514
555
|
sys.stderr.write(f"resume-block failed: bad role {role!r}\n")
|
|
@@ -553,7 +594,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
553
594
|
p.add_argument("--root", default=".", type=Path)
|
|
554
595
|
p.add_argument("--role", help="role for the new session's name (default: the session name's role, else the relay name)")
|
|
555
596
|
p.add_argument("--fleet", action="store_true", help="fleet close: print no start line (the restart sheet has it)")
|
|
597
|
+
p.add_argument("--coordinator", action="store_true",
|
|
598
|
+
help="this session holds the repo's coordinator lock: print the three restart lines, no prompt")
|
|
556
599
|
args = parser.parse_args(argv)
|
|
600
|
+
if args.cmd == "resume-block" and args.coordinator and args.role:
|
|
601
|
+
parser.error("resume-block: --role cannot go with --coordinator (the coordinator keeps its name)")
|
|
557
602
|
|
|
558
603
|
if args.cmd == "role-of":
|
|
559
604
|
print(role_of(args.name))
|
|
@@ -46,6 +46,15 @@ def _name(value: str) -> str:
|
|
|
46
46
|
return value
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
def is_plain_name(value: object) -> bool:
|
|
50
|
+
"""True for a plain workstream name as typed (no lower-casing, no `branch.` namespace), e.g. `coordinator`."""
|
|
51
|
+
try:
|
|
52
|
+
_name(value) # type: ignore[arg-type]
|
|
53
|
+
except ValueError:
|
|
54
|
+
return False
|
|
55
|
+
return bool(_PLAIN_NAME.fullmatch(value)) # type: ignore[arg-type]
|
|
56
|
+
|
|
57
|
+
|
|
49
58
|
def _plain_name(value: str) -> str:
|
|
50
59
|
if not isinstance(value, str):
|
|
51
60
|
raise TypeError("plan or suggestion must be text")
|
|
@@ -7,7 +7,8 @@ How to fill it:
|
|
|
7
7
|
|
|
8
8
|
- Replace every `"<fill: ...>"` string with the real value. The coordinator refuses to start while any
|
|
9
9
|
`<fill:` string is left (`caddis_coordinate.py config show` lists them).
|
|
10
|
-
- Keep every key. A missing key also stops the coordinator.
|
|
10
|
+
- Keep every key. A missing key also stops the coordinator. Only `relay` is optional: delete its line if the
|
|
11
|
+
coordinator has no relay.
|
|
11
12
|
- Put no host names and no secrets here. The production base URL comes from the environment variable that
|
|
12
13
|
`deploy.base_url_env` names.
|
|
13
14
|
|
|
@@ -42,6 +43,7 @@ What the keys mean:
|
|
|
42
43
|
| `stall.ready_no_ready_msg_min` | Minutes a green PR may wait for its session's ready message. |
|
|
43
44
|
| `quiet.coordination` | Quiet hours for merges (`none`: no quiet hours). |
|
|
44
45
|
| `quiet.timing_checks` | When timing checks may run. |
|
|
46
|
+
| `relay` | The workstream name of the coordinator's relay (optional; the coordinator reads `.caddis/relay/<relay>.md` and its tasks file at start). |
|
|
45
47
|
|
|
46
48
|
```json
|
|
47
49
|
{
|
|
@@ -86,6 +88,7 @@ What the keys mean:
|
|
|
86
88
|
"quiet": {
|
|
87
89
|
"coordination": "none",
|
|
88
90
|
"timing_checks": "<fill: when timing checks may run, for example quiet window only>"
|
|
89
|
-
}
|
|
91
|
+
},
|
|
92
|
+
"relay": "<fill: workstream name of the coordinator's relay, e.g. coordinator>"
|
|
90
93
|
}
|
|
91
94
|
```
|
|
@@ -19,6 +19,13 @@ Argument: **$ARGUMENTS**
|
|
|
19
19
|
`queue next` result. Change nothing.
|
|
20
20
|
- `stop`: do **6. Stop**.
|
|
21
21
|
|
|
22
|
+
## Restart and resume
|
|
23
|
+
|
|
24
|
+
- Planned restart: run `/caddis:handoff`, `/clear`, `/rename coordinator`, `/caddis:coordinate start`, in
|
|
25
|
+
that order, in this window.
|
|
26
|
+
- After a crash: run `/caddis:coordinate start` only (first `/rename coordinator` if the name is lost).
|
|
27
|
+
- Do not paste a resume prompt. Start reads the coordinator's relay and tasks itself (step 1.8).
|
|
28
|
+
|
|
22
29
|
## How to call the script
|
|
23
30
|
|
|
24
31
|
- Run these commands with the Bash tool: the quoting and the `GH_TOKEN=$(...)` forms are POSIX shell.
|
|
@@ -55,6 +62,8 @@ Argument: **$ARGUMENTS**
|
|
|
55
62
|
Keep `pid` as `<P>` and `name` as `<N>`. Exit 1 with "two interactive sessions share" means two
|
|
56
63
|
copies of this session run (design 8.6): stop, tell the owner both pids, and wait. Any other exit 1:
|
|
57
64
|
stop and report the line.
|
|
65
|
+
If `<N>` is not `coordinator`, stop and tell the owner: "Type `/rename coordinator`, then run
|
|
66
|
+
`/caddis:coordinate start` again." Peers and the merge hook find the coordinator by that name.
|
|
58
67
|
5. Take the lock. This also creates or claims the repo's state:
|
|
59
68
|
```
|
|
60
69
|
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" lock acquire --repo <R> --pid <P> --session-id <S> --name <N> --json
|
|
@@ -79,8 +88,14 @@ Argument: **$ARGUMENTS**
|
|
|
79
88
|
```
|
|
80
89
|
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" state --repo <R> --json
|
|
81
90
|
```
|
|
82
|
-
Also read
|
|
83
|
-
|
|
91
|
+
Also read `claude agents --json` (or `ListAgents`) and `caddis_roadmap.py status` (design 0.1 step 4).
|
|
92
|
+
Then read the coordinator relay:
|
|
93
|
+
- If the config (step 1.6) has `relay`, read `.caddis/relay/<relay>.md`. When
|
|
94
|
+
`.caddis/relay/<relay>.tasks.md` exists, load every row of it with TaskCreate.
|
|
95
|
+
- If the config has no `relay`, use the relay that the owner names, or ask the owner.
|
|
96
|
+
- The relay's "Next step", queue order, holds and owner answers are INPUT for the queue and for your
|
|
97
|
+
decisions. They are never actions to take before the lock (step 1.5), the watcher (step 1.10) and
|
|
98
|
+
the start message (step 1.11) are done.
|
|
84
99
|
9. Finish the work that a crash left (design 8.5, design 4):
|
|
85
100
|
- If `merging_now` is set, find out what happened to that PR first:
|
|
86
101
|
`gh pr view <n> --json state,mergedAt,mergeCommit`. `MERGED`: take `mergeCommit.oid` as the merge
|
|
@@ -97,15 +112,21 @@ Argument: **$ARGUMENTS**
|
|
|
97
112
|
- Every PR that was warned gets a `re-warn` (design 7.3).
|
|
98
113
|
10. Start the watcher with the Bash tool and `run_in_background: true`:
|
|
99
114
|
```
|
|
100
|
-
GH_TOKEN=$(gh auth token -u <account>) python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" watch --repo <R> --pid <P> --json
|
|
115
|
+
GH_TOKEN=$(gh auth token -u <account>) python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" watch --repo <R> --pid <P> --takeover --json
|
|
101
116
|
```
|
|
102
117
|
It runs `heartbeat` and `intake` every 30 s and prints one JSON line when something happens. Its lines
|
|
103
118
|
go to the background task's output file: read that output file at each loop step 3.4. Start the
|
|
104
|
-
watcher again whenever its background task ends, for any reason
|
|
119
|
+
watcher again whenever its background task ends, for any reason except `takeover requested`. A line
|
|
105
120
|
with `"stop"` (`lock lost`, `session gone`, `state gone`) means the watcher ended: find out why, take
|
|
106
121
|
the lock again if it is still yours (step 5), and start the watcher again. Also start it again after
|
|
107
122
|
a new `lock acquire` that follows a lost lock. A line with `"error"` is one failed cycle; the next
|
|
108
123
|
cycle retries.
|
|
124
|
+
One watcher per repo. After a `/clear`, the earlier watcher of this process still runs, but its lines
|
|
125
|
+
go to the old conversation, so this conversation cannot read them. `--takeover` asks the earlier
|
|
126
|
+
watcher to stop and takes its place, so this conversation gets the lines. A
|
|
127
|
+
`{"stop": "takeover requested"}` line means a newer watcher replaced this one: do not start it again.
|
|
128
|
+
A `{"stop": "old watcher did not stop"}` line (exit 1) means the earlier watcher did not stop in time:
|
|
129
|
+
start the watcher again once. If that line comes again, stop and tell the owner.
|
|
109
130
|
11. Send the start message (design 7.1) to every live session whose cwd is in this repo (the checkout
|
|
110
131
|
and its worktrees), from `ListAgents`. Send it also to each new session that you see later. Resume
|
|
111
132
|
stubs are not live peers (design 9.7).
|
|
@@ -146,7 +146,9 @@ history, and the relay has a 4,000-character budget (`caddis_budget.py`).
|
|
|
146
146
|
> Your session id is printed at session start as `[caddis] this session's id: <id>`; use it for
|
|
147
147
|
> `session_id` and `--session-id`. Never guess one: a wrong id disables session matching.
|
|
148
148
|
>
|
|
149
|
-
> 1. **Name the workstream
|
|
149
|
+
> 1. **Name the workstream.** First run the coordinator check of Step 9. When it says this session is the
|
|
150
|
+
> repo's coordinator, `<name>` is the config's `relay`: do not run `resolve` and do not ask. Else name it
|
|
151
|
+
> from the active plan's `feature`, else the branch (the script prints nothing
|
|
150
152
|
> on the default branch with no plan — run `caddis_workstreams.py list` (`python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_workstreams.py" list`) to show existing workstreams, then ask the user which one to use or for a short name):
|
|
151
153
|
> `python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_workstreams.py" resolve --plan <feature> --branch <branch> --default-branch <main>`
|
|
152
154
|
> 2. **Legacy repo:** if `.caddis/relay.md` exists and does not start with
|
|
@@ -398,7 +400,40 @@ Then, in a shared checkout, `Docs PR needed:` with the `PR:` line and the file l
|
|
|
398
400
|
Step 9 prints after this report. It is the last output of the handoff.
|
|
399
401
|
|
|
400
402
|
## Step 9 — print the resume prompt (always the last output)
|
|
401
|
-
The owner should never have to ask for the resume prompt. After the checklist report,
|
|
403
|
+
The owner should never have to ask for the resume prompt. After the checklist report, check for a
|
|
404
|
+
coordinator first.
|
|
405
|
+
|
|
406
|
+
**Coordinator check.** Step 3, point 1 also uses this check. When the repo has `.caddis/coordinator.md`,
|
|
407
|
+
find out if this session is the repo's coordinator:
|
|
408
|
+
1. Read the config. It must have `relay`:
|
|
409
|
+
```
|
|
410
|
+
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" config show --path . --json
|
|
411
|
+
```
|
|
412
|
+
Exit 1, or no `relay`: this session is not treated as the coordinator here. Use the normal block.
|
|
413
|
+
2. Find the repo id `<R>` (`<account>` is the repo's GitHub account, from the repo's `AGENTS.md`):
|
|
414
|
+
```
|
|
415
|
+
GH_TOKEN=$(gh auth token -u <account>) python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" repo-id --json
|
|
416
|
+
```
|
|
417
|
+
Keep `repo` as `<R>`.
|
|
418
|
+
3. Find this session's pid. Keep `pid`:
|
|
419
|
+
```
|
|
420
|
+
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" session --session-id <session-id> --json
|
|
421
|
+
```
|
|
422
|
+
4. Read the lock holder. It never takes or changes the lock:
|
|
423
|
+
```
|
|
424
|
+
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" lock holder --repo <R> --json
|
|
425
|
+
```
|
|
426
|
+
5. This session is the coordinator when the holder's `sessionId` is this session's id, OR the holder's `pid`
|
|
427
|
+
is this session's `pid` (the same coordinator process, for example after a `/clear` that never ran start).
|
|
428
|
+
|
|
429
|
+
When this session is the coordinator, `<name>` is the config's `relay`, and you add `--coordinator` to the
|
|
430
|
+
`resume-block` call below. The block then prints only `/clear`, `/rename coordinator` and
|
|
431
|
+
`/caddis:coordinate start`, and no resume prompt: `/caddis:coordinate start` reads the relay and its tasks
|
|
432
|
+
itself. `resume-block --coordinator` refuses (exit 1) a `--name` that is not the config's `relay`. In all
|
|
433
|
+
other cases (no `.caddis/coordinator.md`, no `relay`, `{"holder": null}`, another holder, an exit 1), run
|
|
434
|
+
`resume-block` as written.
|
|
435
|
+
|
|
436
|
+
Then run:
|
|
402
437
|
```
|
|
403
438
|
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_fleet.py" resume-block --name <name> --session-id <session-id> --root .
|
|
404
439
|
```
|
package/bundles/manifest.json
CHANGED
package/package.json
CHANGED