@caddis/cli 0.4.41 → 0.4.42
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_governor.py +19 -3
- package/bundles/antigravity-plugin/scripts/caddis_heavy_slot.py +227 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +313 -66
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +39 -0
- package/bundles/antigravity-plugin/scripts/install-host-guard.ps1 +8 -1
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +258 -9
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -406,6 +406,19 @@ def job_process_ids(job: int) -> list[int]:
|
|
|
406
406
|
return [int(lst.ProcessIdList[i]) for i in range(lst.NumberOfProcessIdsInList)]
|
|
407
407
|
|
|
408
408
|
|
|
409
|
+
def query_named_job(name: str) -> tuple[float, list[int]]:
|
|
410
|
+
"""Read a named job from a separate process using query access only."""
|
|
411
|
+
if sys.platform != "win32" or not name:
|
|
412
|
+
raise GovernorError("a Windows job name is required")
|
|
413
|
+
job = kernel32.OpenJobObjectW(JOB_OBJECT_QUERY, False, name)
|
|
414
|
+
if not job:
|
|
415
|
+
raise _winerr("OpenJobObjectW(query)")
|
|
416
|
+
try:
|
|
417
|
+
return job_cpu_seconds(job), job_process_ids(job)
|
|
418
|
+
finally:
|
|
419
|
+
close_job(job)
|
|
420
|
+
|
|
421
|
+
|
|
409
422
|
def close_job(job: int) -> None:
|
|
410
423
|
"""Close a job handle. Never call it on the last handle of a job the calling process belongs to."""
|
|
411
424
|
kernel32.CloseHandle(job)
|
|
@@ -451,7 +464,8 @@ def _host_kind() -> str:
|
|
|
451
464
|
|
|
452
465
|
|
|
453
466
|
def exec_governed(argv: list[str], scope: str, budget: Path | None = None,
|
|
454
|
-
machine_budget: Path | None = None, allow_uncapped: bool = False
|
|
467
|
+
machine_budget: Path | None = None, allow_uncapped: bool = False,
|
|
468
|
+
job_name: str | None = None) -> int:
|
|
455
469
|
"""Run `argv` under the scope's limits and return its exit code.
|
|
456
470
|
|
|
457
471
|
On Windows this puts the CURRENT process into the job for the rest of its life, so it is only
|
|
@@ -470,7 +484,7 @@ def exec_governed(argv: list[str], scope: str, budget: Path | None = None,
|
|
|
470
484
|
parent = verified_parent_percent(os.environ)
|
|
471
485
|
rate = effective_cpu_rate(limits, parent)
|
|
472
486
|
machine_pct = effective_machine_percent(limits, parent)
|
|
473
|
-
name = new_job_name()
|
|
487
|
+
name = job_name or new_job_name()
|
|
474
488
|
job = create_job(limits, rate, name)
|
|
475
489
|
try:
|
|
476
490
|
assign_current_process(job)
|
|
@@ -518,6 +532,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
518
532
|
p_exec.add_argument("--scope", choices=SCOPES, required=True)
|
|
519
533
|
p_exec.add_argument("--budget", type=Path)
|
|
520
534
|
p_exec.add_argument("--machine-budget", type=Path, help=argparse.SUPPRESS) # additional ceiling only
|
|
535
|
+
p_exec.add_argument("--job-name", help=argparse.SUPPRESS)
|
|
521
536
|
p_exec.add_argument("--allow-uncapped", action="store_true",
|
|
522
537
|
help="where no cap can be applied (no systemd-run, macOS), run at lower priority anyway")
|
|
523
538
|
p_show = sub.add_parser("show", help="print the effective limits")
|
|
@@ -538,7 +553,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
538
553
|
if not command:
|
|
539
554
|
sys.stderr.write("usage: caddis_governor.py exec --scope session|lane [--budget PATH] -- <command...>\n")
|
|
540
555
|
return EXIT_USAGE
|
|
541
|
-
return exec_governed(command, args.scope, args.budget, args.machine_budget,
|
|
556
|
+
return exec_governed(command, args.scope, args.budget, args.machine_budget,
|
|
557
|
+
args.allow_uncapped, args.job_name)
|
|
542
558
|
|
|
543
559
|
|
|
544
560
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Heavy-run slot manager.
|
|
2
|
+
|
|
3
|
+
Limits concurrent test-heavy runs (test suites, gates) using non-blocking OS byte locks.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import contextlib
|
|
9
|
+
import datetime
|
|
10
|
+
import errno
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import BinaryIO, Callable, Iterator
|
|
17
|
+
|
|
18
|
+
if os.name == "nt":
|
|
19
|
+
import msvcrt
|
|
20
|
+
else:
|
|
21
|
+
import fcntl
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HeavySlotBusy(Exception):
|
|
25
|
+
"""Raised when all heavy-run slots remain busy after the wait budget expires."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, holder: str | None = None) -> None:
|
|
28
|
+
self.holder = holder
|
|
29
|
+
message = f"heavy-run slot busy: {holder}" if holder else "heavy-run slot busy"
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _try_lock(handle: BinaryIO) -> bool:
|
|
34
|
+
try:
|
|
35
|
+
if os.name == "nt":
|
|
36
|
+
handle.seek(0)
|
|
37
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
38
|
+
else:
|
|
39
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
40
|
+
return True
|
|
41
|
+
except OSError as exc:
|
|
42
|
+
if exc.errno not in (errno.EACCES, errno.EAGAIN, errno.EDEADLK):
|
|
43
|
+
raise
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _unlock(handle: BinaryIO) -> None:
|
|
48
|
+
try:
|
|
49
|
+
if os.name == "nt":
|
|
50
|
+
handle.seek(0)
|
|
51
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
52
|
+
else:
|
|
53
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
54
|
+
except OSError:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _read_holder(slot_path: Path) -> str | None:
|
|
59
|
+
try:
|
|
60
|
+
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
|
|
61
|
+
fd = os.open(slot_path, flags)
|
|
62
|
+
with os.fdopen(fd, "rb") as f:
|
|
63
|
+
if os.name == "nt":
|
|
64
|
+
# On Windows, byte 0 is locked by the holder process, so reading byte 0
|
|
65
|
+
# raises PermissionError. Seeking to 1 reads the remainder of the payload.
|
|
66
|
+
f.seek(1)
|
|
67
|
+
raw = f.read()
|
|
68
|
+
text = raw.decode("utf-8", errors="replace").strip()
|
|
69
|
+
return text or None
|
|
70
|
+
except Exception:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _resolve_slots(slots: int | None) -> int:
|
|
75
|
+
if slots is not None:
|
|
76
|
+
try:
|
|
77
|
+
val = int(slots)
|
|
78
|
+
if val < 0:
|
|
79
|
+
sys.stderr.write(f"CADDIS_HEAVY_SLOTS: invalid value {slots!r}; defaulting to 1\n")
|
|
80
|
+
return 1
|
|
81
|
+
return val
|
|
82
|
+
except (ValueError, TypeError):
|
|
83
|
+
sys.stderr.write(f"CADDIS_HEAVY_SLOTS: invalid value {slots!r}; defaulting to 1\n")
|
|
84
|
+
return 1
|
|
85
|
+
|
|
86
|
+
raw = os.environ.get("CADDIS_HEAVY_SLOTS")
|
|
87
|
+
if raw is None:
|
|
88
|
+
return 1
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
val = int(raw.strip())
|
|
92
|
+
if val < 0:
|
|
93
|
+
sys.stderr.write(f"CADDIS_HEAVY_SLOTS: invalid value {raw!r}; defaulting to 1\n")
|
|
94
|
+
return 1
|
|
95
|
+
return val
|
|
96
|
+
except ValueError:
|
|
97
|
+
sys.stderr.write(f"CADDIS_HEAVY_SLOTS: invalid value {raw!r}; defaulting to 1\n")
|
|
98
|
+
return 1
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _resolve_wait_s(wait_s: float | None) -> float:
|
|
102
|
+
if wait_s is not None:
|
|
103
|
+
try:
|
|
104
|
+
return max(0.0, float(wait_s))
|
|
105
|
+
except (ValueError, TypeError):
|
|
106
|
+
return 1200.0
|
|
107
|
+
|
|
108
|
+
raw = os.environ.get("CADDIS_HEAVY_WAIT_S")
|
|
109
|
+
if raw is None:
|
|
110
|
+
return 1200.0
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
return max(0.0, float(raw.strip()))
|
|
114
|
+
except ValueError:
|
|
115
|
+
return 1200.0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@contextlib.contextmanager
|
|
119
|
+
def heavy_slot(
|
|
120
|
+
kind: str,
|
|
121
|
+
wait_s: float | None = None,
|
|
122
|
+
*,
|
|
123
|
+
slots: int | None = None,
|
|
124
|
+
clock: Callable[[], float] = time.monotonic,
|
|
125
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
126
|
+
root: Path | str | None = None,
|
|
127
|
+
) -> Iterator[None]:
|
|
128
|
+
"""Acquire an OS-level byte-locked heavy execution slot.
|
|
129
|
+
|
|
130
|
+
Limits parallel test-heavy commands across processes and sessions.
|
|
131
|
+
"""
|
|
132
|
+
if os.environ.get("CADDIS_HEAVY_SLOT_HELD") == "1":
|
|
133
|
+
yield
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
num_slots = _resolve_slots(slots)
|
|
137
|
+
if num_slots == 0:
|
|
138
|
+
yield
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
timeout_s = _resolve_wait_s(wait_s)
|
|
142
|
+
if root is not None:
|
|
143
|
+
slot_dir = Path(root)
|
|
144
|
+
else:
|
|
145
|
+
# Windows %TEMP% is already per user. On POSIX /tmp is shared, and the first user to create a
|
|
146
|
+
# shared folder would lock every other user out (they would see "busy" for ever), so each
|
|
147
|
+
# user gets their own folder: the limit is per account, as the guide says.
|
|
148
|
+
suffix = f"-{os.getuid()}" if hasattr(os, "getuid") else ""
|
|
149
|
+
slot_dir = Path(tempfile.gettempdir()) / f"caddis-heavy{suffix}"
|
|
150
|
+
slot_dir.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
|
|
152
|
+
deadline = clock() + timeout_s
|
|
153
|
+
acquired_handle: BinaryIO | None = None
|
|
154
|
+
last_holder: str | None = None
|
|
155
|
+
|
|
156
|
+
while True:
|
|
157
|
+
holders: list[str] = []
|
|
158
|
+
for i in range(num_slots):
|
|
159
|
+
slot_file = slot_dir / f"slot-{i}"
|
|
160
|
+
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_BINARY", 0)
|
|
161
|
+
handle = None
|
|
162
|
+
try:
|
|
163
|
+
fd = os.open(slot_file, flags, 0o666)
|
|
164
|
+
handle = os.fdopen(fd, "r+b")
|
|
165
|
+
if _try_lock(handle):
|
|
166
|
+
acquired_handle = handle
|
|
167
|
+
break
|
|
168
|
+
else:
|
|
169
|
+
h = _read_holder(slot_file)
|
|
170
|
+
if h:
|
|
171
|
+
holders.append(h)
|
|
172
|
+
handle.close()
|
|
173
|
+
handle = None
|
|
174
|
+
except OSError:
|
|
175
|
+
if handle is not None:
|
|
176
|
+
try:
|
|
177
|
+
handle.close()
|
|
178
|
+
except OSError:
|
|
179
|
+
pass
|
|
180
|
+
handle = None
|
|
181
|
+
|
|
182
|
+
if acquired_handle is not None:
|
|
183
|
+
break
|
|
184
|
+
|
|
185
|
+
if holders:
|
|
186
|
+
last_holder = ", ".join(holders)
|
|
187
|
+
|
|
188
|
+
now = clock()
|
|
189
|
+
remaining = deadline - now
|
|
190
|
+
if remaining <= 0:
|
|
191
|
+
raise HeavySlotBusy(last_holder)
|
|
192
|
+
|
|
193
|
+
sleep(min(2.0, remaining))
|
|
194
|
+
|
|
195
|
+
now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
196
|
+
holder_text = f"{os.getpid()} {kind} {now_iso}"
|
|
197
|
+
payload = f"\n{holder_text}\n".encode("utf-8")
|
|
198
|
+
acquired_handle.seek(0)
|
|
199
|
+
acquired_handle.write(payload)
|
|
200
|
+
acquired_handle.truncate(len(payload))
|
|
201
|
+
acquired_handle.flush()
|
|
202
|
+
|
|
203
|
+
prev_held = os.environ.get("CADDIS_HEAVY_SLOT_HELD")
|
|
204
|
+
os.environ["CADDIS_HEAVY_SLOT_HELD"] = "1"
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
yield
|
|
208
|
+
finally:
|
|
209
|
+
if prev_held is None:
|
|
210
|
+
os.environ.pop("CADDIS_HEAVY_SLOT_HELD", None)
|
|
211
|
+
else:
|
|
212
|
+
os.environ["CADDIS_HEAVY_SLOT_HELD"] = prev_held
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
acquired_handle.seek(0)
|
|
216
|
+
acquired_handle.truncate(0)
|
|
217
|
+
acquired_handle.flush()
|
|
218
|
+
except OSError:
|
|
219
|
+
pass
|
|
220
|
+
finally:
|
|
221
|
+
try:
|
|
222
|
+
_unlock(acquired_handle)
|
|
223
|
+
finally:
|
|
224
|
+
try:
|
|
225
|
+
acquired_handle.close()
|
|
226
|
+
except OSError:
|
|
227
|
+
pass
|
|
@@ -6,9 +6,11 @@ from __future__ import annotations
|
|
|
6
6
|
|
|
7
7
|
import importlib.util
|
|
8
8
|
import hashlib
|
|
9
|
+
from functools import lru_cache
|
|
9
10
|
import json
|
|
10
11
|
import os
|
|
11
12
|
import re
|
|
13
|
+
import secrets
|
|
12
14
|
import shlex
|
|
13
15
|
import shutil
|
|
14
16
|
import signal
|
|
@@ -16,17 +18,20 @@ import subprocess
|
|
|
16
18
|
import sys
|
|
17
19
|
import tempfile
|
|
18
20
|
import time
|
|
21
|
+
import threading
|
|
19
22
|
from dataclasses import dataclass
|
|
20
23
|
from datetime import datetime, timezone
|
|
21
24
|
from pathlib import Path
|
|
22
25
|
|
|
23
26
|
try:
|
|
24
27
|
import caddis_exit
|
|
28
|
+
from caddis_heavy_slot import HeavySlotBusy, heavy_slot
|
|
25
29
|
except ModuleNotFoundError as exc:
|
|
26
|
-
if exc.name
|
|
30
|
+
if exc.name not in {"caddis_exit", "caddis_heavy_slot"}:
|
|
27
31
|
raise
|
|
28
32
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
|
|
29
33
|
import caddis_exit
|
|
34
|
+
from caddis_heavy_slot import HeavySlotBusy, heavy_slot
|
|
30
35
|
|
|
31
36
|
import caddis_routing
|
|
32
37
|
|
|
@@ -39,13 +44,17 @@ EXIT_FAIL = caddis_exit.BLOCKED
|
|
|
39
44
|
EXIT_USAGE = caddis_exit.ERROR
|
|
40
45
|
EXIT_CONFIG = caddis_exit.NOT_RUN
|
|
41
46
|
EXIT_ALL_OUT = caddis_exit.NOT_RUN
|
|
47
|
+
EXIT_NOT_RUN = caddis_exit.NOT_RUN
|
|
42
48
|
RESULT_OK = "OK"
|
|
43
49
|
RESULT_LANE_FAILED = "LANE-FAILED"
|
|
50
|
+
RESULT_STALLED = "LANE-STALLED"
|
|
44
51
|
RESULT_OUT_OF_BUDGET = "OUT-OF-BUDGET"
|
|
45
52
|
RESULT_CONFIG_ERROR = "CONFIG-ERROR"
|
|
46
53
|
RESULT_RESOURCE = "FAILED-RESOURCE"
|
|
47
54
|
MAX_ATTEMPTS = 2
|
|
48
55
|
DEFAULT_TIMEOUT_S = 3600
|
|
56
|
+
DEFAULT_IDLE_TIMEOUT_S = 900
|
|
57
|
+
IDLE_SAMPLE_S = 15
|
|
49
58
|
LANE_IDS = ("claude", "glm", "codex-sol", "codex-astra", "agy")
|
|
50
59
|
TASK_TYPES = ("backend", "hard", "ui", "tests", "docs")
|
|
51
60
|
LANES_TMP = Path(tempfile.gettempdir()) / "caddis-lanes"
|
|
@@ -154,6 +163,17 @@ class LaneRun:
|
|
|
154
163
|
duration_s: float
|
|
155
164
|
pid: int
|
|
156
165
|
started: float
|
|
166
|
+
stalled: bool = False
|
|
167
|
+
idle_seconds: int = 0
|
|
168
|
+
processes: list[dict] | None = None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass
|
|
172
|
+
class LaneProbe:
|
|
173
|
+
mtime_ns: int
|
|
174
|
+
cpu_seconds: float | None
|
|
175
|
+
pids: frozenset[int] | None
|
|
176
|
+
processes: list[dict]
|
|
157
177
|
|
|
158
178
|
|
|
159
179
|
@dataclass
|
|
@@ -320,13 +340,14 @@ def governor_path() -> Path:
|
|
|
320
340
|
)
|
|
321
341
|
|
|
322
342
|
|
|
323
|
-
def governed_argv(argv: list[str]) -> list[str]:
|
|
343
|
+
def governed_argv(argv: list[str], job_name: str | None = None) -> list[str]:
|
|
324
344
|
return [
|
|
325
345
|
sys.executable,
|
|
326
346
|
str(governor_path()),
|
|
327
347
|
"exec",
|
|
328
348
|
"--scope",
|
|
329
349
|
"lane",
|
|
350
|
+
*(["--job-name", job_name] if job_name else []),
|
|
330
351
|
"--",
|
|
331
352
|
*argv,
|
|
332
353
|
]
|
|
@@ -455,6 +476,88 @@ def release_lock(lock: Path) -> None:
|
|
|
455
476
|
_release_acquisition_guard(guard_fd)
|
|
456
477
|
|
|
457
478
|
|
|
479
|
+
def newest_file_mtime(cwd: Path) -> int:
|
|
480
|
+
newest = 0
|
|
481
|
+
files = 0
|
|
482
|
+
pending = [cwd]
|
|
483
|
+
while pending and files < 20000:
|
|
484
|
+
directory = pending.pop()
|
|
485
|
+
with os.scandir(directory) as entries:
|
|
486
|
+
for entry in entries:
|
|
487
|
+
if entry.is_dir(follow_symlinks=False):
|
|
488
|
+
if entry.name == ".git" or (
|
|
489
|
+
directory == cwd / ".caddis" and entry.name == "orchestrator"
|
|
490
|
+
):
|
|
491
|
+
continue
|
|
492
|
+
pending.append(Path(entry.path))
|
|
493
|
+
elif entry.is_file(follow_symlinks=False):
|
|
494
|
+
files += 1
|
|
495
|
+
newest = max(newest, entry.stat(follow_symlinks=False).st_mtime_ns)
|
|
496
|
+
if files >= 20000:
|
|
497
|
+
break
|
|
498
|
+
return newest
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def default_probe(pid: int, cwd: Path, job_name: str | None) -> LaneProbe:
|
|
502
|
+
mtime = newest_file_mtime(cwd)
|
|
503
|
+
if sys.platform == "win32":
|
|
504
|
+
governor = _governor_probe_module()
|
|
505
|
+
cpu, pids = governor.query_named_job(job_name)
|
|
506
|
+
names = _windows_process_names(pids)
|
|
507
|
+
elif sys.platform.startswith("linux"):
|
|
508
|
+
ticks = os.sysconf("SC_CLK_TCK")
|
|
509
|
+
members = []
|
|
510
|
+
for path in Path("/proc").iterdir():
|
|
511
|
+
if not path.name.isdecimal():
|
|
512
|
+
continue
|
|
513
|
+
try:
|
|
514
|
+
stat = (path / "stat").read_text(encoding="utf-8")
|
|
515
|
+
fields = stat[stat.rfind(")") + 2 :].split()
|
|
516
|
+
if int(fields[3]) == pid:
|
|
517
|
+
members.append((int(path.name), stat[stat.find("(") + 1 : stat.rfind(")")],
|
|
518
|
+
(int(fields[11]) + int(fields[12])) / ticks))
|
|
519
|
+
except (OSError, ValueError, IndexError):
|
|
520
|
+
continue
|
|
521
|
+
pids = [item[0] for item in members]
|
|
522
|
+
cpu = sum(item[2] for item in members)
|
|
523
|
+
names = {item[0]: item[1] for item in members}
|
|
524
|
+
else:
|
|
525
|
+
return LaneProbe(mtime, None, None, [])
|
|
526
|
+
return LaneProbe(mtime, cpu, frozenset(pids),
|
|
527
|
+
[{"pid": member, "name": names.get(member, "unknown")} for member in sorted(pids)])
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
@lru_cache(maxsize=1)
|
|
531
|
+
def _governor_probe_module():
|
|
532
|
+
return _load_file_module("_caddis_governor_probe", governor_path())
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _windows_process_names(pids: list[int]) -> dict[int, str]:
|
|
536
|
+
import ctypes
|
|
537
|
+
from ctypes import wintypes
|
|
538
|
+
|
|
539
|
+
kernel32 = ctypes.windll.kernel32
|
|
540
|
+
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
541
|
+
kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
542
|
+
kernel32.QueryFullProcessImageNameW.argtypes = [wintypes.HANDLE, wintypes.DWORD,
|
|
543
|
+
wintypes.LPWSTR, ctypes.POINTER(wintypes.DWORD)]
|
|
544
|
+
kernel32.QueryFullProcessImageNameW.restype = wintypes.BOOL
|
|
545
|
+
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
546
|
+
names = {}
|
|
547
|
+
for pid in pids:
|
|
548
|
+
handle = kernel32.OpenProcess(0x1000, False, pid)
|
|
549
|
+
if not handle:
|
|
550
|
+
continue
|
|
551
|
+
try:
|
|
552
|
+
buffer = ctypes.create_unicode_buffer(32768)
|
|
553
|
+
size = wintypes.DWORD(len(buffer))
|
|
554
|
+
if kernel32.QueryFullProcessImageNameW(handle, 0, buffer, ctypes.byref(size)):
|
|
555
|
+
names[pid] = Path(buffer.value).name
|
|
556
|
+
finally:
|
|
557
|
+
kernel32.CloseHandle(handle)
|
|
558
|
+
return names
|
|
559
|
+
|
|
560
|
+
|
|
458
561
|
def run_lane(
|
|
459
562
|
argv: list[str],
|
|
460
563
|
cwd: Path,
|
|
@@ -462,14 +565,19 @@ def run_lane(
|
|
|
462
565
|
stdin_prompt: bool,
|
|
463
566
|
timeout_s: int,
|
|
464
567
|
env: dict,
|
|
568
|
+
idle_timeout_s: int = DEFAULT_IDLE_TIMEOUT_S,
|
|
569
|
+
clock=time.monotonic,
|
|
570
|
+
sleep=time.sleep,
|
|
571
|
+
probe=default_probe,
|
|
465
572
|
) -> LaneRun:
|
|
466
|
-
|
|
573
|
+
job_name = f"Local\\caddis-governor-{os.getpid()}-{secrets.token_hex(8)}" if sys.platform == "win32" else None
|
|
574
|
+
full = governed_argv(argv, job_name)
|
|
467
575
|
if not stdin_prompt:
|
|
468
576
|
full.append(prompt)
|
|
469
577
|
validate_codex_argv(full)
|
|
470
578
|
|
|
471
579
|
started = time.time()
|
|
472
|
-
monotonic_started =
|
|
580
|
+
monotonic_started = clock()
|
|
473
581
|
popen_options = {}
|
|
474
582
|
if sys.platform != "win32":
|
|
475
583
|
popen_options["start_new_session"] = True
|
|
@@ -479,47 +587,117 @@ def run_lane(
|
|
|
479
587
|
stdin=subprocess.PIPE if stdin_prompt else subprocess.DEVNULL,
|
|
480
588
|
stdout=subprocess.PIPE,
|
|
481
589
|
stderr=subprocess.STDOUT,
|
|
482
|
-
text=True,
|
|
483
|
-
encoding="utf-8",
|
|
484
|
-
errors="replace",
|
|
485
590
|
env=env,
|
|
486
591
|
**popen_options,
|
|
487
592
|
)
|
|
488
|
-
timed_out = False
|
|
489
|
-
|
|
490
|
-
|
|
593
|
+
timed_out = stalled = False
|
|
594
|
+
idle_seconds = 0
|
|
595
|
+
processes = None
|
|
596
|
+
chunks: list[bytes] = []
|
|
597
|
+
byte_count = [0]
|
|
598
|
+
|
|
599
|
+
def read_output() -> None:
|
|
600
|
+
assert proc.stdout is not None
|
|
491
601
|
try:
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
602
|
+
while chunk := proc.stdout.read1(65536):
|
|
603
|
+
chunks.append(chunk)
|
|
604
|
+
byte_count[0] += len(chunk)
|
|
605
|
+
except OSError:
|
|
606
|
+
pass # the pipe can close when the governed process is killed
|
|
607
|
+
|
|
608
|
+
reader = threading.Thread(target=read_output, daemon=True)
|
|
609
|
+
reader.start()
|
|
610
|
+
try:
|
|
611
|
+
if stdin_prompt:
|
|
612
|
+
assert proc.stdin is not None
|
|
613
|
+
stdin_pipe = proc.stdin
|
|
614
|
+
|
|
615
|
+
def write_prompt() -> None:
|
|
616
|
+
# On its own thread: a child that never reads its stdin would block this write for
|
|
617
|
+
# ever, and the wall-clock and idle limits below must still apply to it.
|
|
618
|
+
try:
|
|
619
|
+
stdin_pipe.write(prompt.encode("utf-8"))
|
|
620
|
+
except (BrokenPipeError, OSError, ValueError):
|
|
621
|
+
pass
|
|
622
|
+
finally:
|
|
623
|
+
try:
|
|
624
|
+
stdin_pipe.close()
|
|
625
|
+
except (BrokenPipeError, OSError, ValueError):
|
|
626
|
+
pass
|
|
627
|
+
|
|
628
|
+
threading.Thread(target=write_prompt, daemon=True).start()
|
|
629
|
+
previous = None
|
|
630
|
+
previous_bytes = byte_count[0]
|
|
631
|
+
last_active = monotonic_started
|
|
632
|
+
last_sample = monotonic_started
|
|
633
|
+
next_sample = monotonic_started + IDLE_SAMPLE_S
|
|
634
|
+
if idle_timeout_s:
|
|
499
635
|
try:
|
|
500
|
-
|
|
501
|
-
except
|
|
636
|
+
previous = probe(proc.pid, cwd, job_name)
|
|
637
|
+
except Exception:
|
|
638
|
+
pass
|
|
639
|
+
while proc.poll() is None:
|
|
640
|
+
now = clock()
|
|
641
|
+
if now - monotonic_started >= timeout_s:
|
|
642
|
+
timed_out = True
|
|
502
643
|
kill_governed(proc)
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
644
|
+
break
|
|
645
|
+
until_sample = max(next_sample - now, 0) if idle_timeout_s else 0.25
|
|
646
|
+
sleep(min(0.25, until_sample, timeout_s - (now - monotonic_started)))
|
|
647
|
+
if proc.poll() is not None or not idle_timeout_s:
|
|
648
|
+
continue
|
|
649
|
+
now = clock()
|
|
650
|
+
if now < next_sample:
|
|
651
|
+
continue
|
|
652
|
+
next_sample = now + IDLE_SAMPLE_S
|
|
653
|
+
try:
|
|
654
|
+
current = probe(proc.pid, cwd, job_name)
|
|
655
|
+
except Exception:
|
|
656
|
+
current = None
|
|
657
|
+
if current is None or previous is None:
|
|
658
|
+
last_active = now # unknown activity must not cause a false stall
|
|
659
|
+
else:
|
|
660
|
+
elapsed = max(now - last_sample, 0)
|
|
661
|
+
active = (byte_count[0] != previous_bytes or
|
|
662
|
+
current.mtime_ns != previous.mtime_ns or
|
|
663
|
+
current.pids != previous.pids or
|
|
664
|
+
(current.cpu_seconds is not None and previous.cpu_seconds is not None and
|
|
665
|
+
current.cpu_seconds - previous.cpu_seconds > elapsed / 60))
|
|
666
|
+
if active:
|
|
667
|
+
last_active = now
|
|
668
|
+
previous, previous_bytes, last_sample = current, byte_count[0], now
|
|
669
|
+
if now - last_active >= idle_timeout_s and now - monotonic_started < timeout_s:
|
|
670
|
+
stalled = True
|
|
671
|
+
idle_seconds = int(now - last_active)
|
|
672
|
+
processes = current.processes
|
|
673
|
+
kill_governed(proc)
|
|
674
|
+
break
|
|
675
|
+
try:
|
|
676
|
+
proc.wait(timeout=30)
|
|
677
|
+
except subprocess.TimeoutExpired:
|
|
678
|
+
kill_governed(proc)
|
|
679
|
+
reader.join(timeout=30)
|
|
509
680
|
except BaseException:
|
|
510
681
|
kill_governed(proc)
|
|
511
682
|
raise
|
|
512
683
|
|
|
513
684
|
return LaneRun(
|
|
514
685
|
returncode=proc.returncode,
|
|
515
|
-
output=
|
|
686
|
+
output=b"".join(chunks).decode("utf-8", errors="replace"),
|
|
516
687
|
timed_out=timed_out,
|
|
517
|
-
duration_s=
|
|
688
|
+
duration_s=clock() - monotonic_started,
|
|
518
689
|
pid=proc.pid,
|
|
519
690
|
started=started,
|
|
691
|
+
stalled=stalled,
|
|
692
|
+
idle_seconds=idle_seconds,
|
|
693
|
+
processes=processes,
|
|
520
694
|
)
|
|
521
695
|
|
|
522
696
|
|
|
697
|
+
|
|
698
|
+
# ci_watch compares its `run_agy` argument with this by identity (see the idle watchdog note there).
|
|
699
|
+
_LANE_RUNNER = run_lane
|
|
700
|
+
|
|
523
701
|
def gate_commands(repo_root: Path, worktree: Path, cfg: dict) -> list[list[str]]:
|
|
524
702
|
configured = cfg.get("gate_cmds")
|
|
525
703
|
if isinstance(configured, list) and configured:
|
|
@@ -556,43 +734,65 @@ def run_gates(
|
|
|
556
734
|
env: dict,
|
|
557
735
|
timeout_s: int = 1800,
|
|
558
736
|
) -> list[GateResult]:
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
737
|
+
try:
|
|
738
|
+
with heavy_slot("gates"):
|
|
739
|
+
results = []
|
|
740
|
+
for command in cmds:
|
|
741
|
+
try:
|
|
742
|
+
completed = subprocess.run(
|
|
743
|
+
governed_argv(command),
|
|
744
|
+
cwd=cwd,
|
|
745
|
+
env=env,
|
|
746
|
+
capture_output=True,
|
|
747
|
+
text=True,
|
|
748
|
+
encoding="utf-8",
|
|
749
|
+
errors="replace",
|
|
750
|
+
timeout=timeout_s,
|
|
751
|
+
)
|
|
752
|
+
except subprocess.TimeoutExpired:
|
|
753
|
+
results.append(
|
|
754
|
+
GateResult(
|
|
755
|
+
cmd=shlex.join(command),
|
|
756
|
+
returncode=-1,
|
|
757
|
+
passed=False,
|
|
758
|
+
tail=f"timed out after {timeout_s}s",
|
|
759
|
+
)
|
|
760
|
+
)
|
|
761
|
+
continue
|
|
762
|
+
|
|
763
|
+
output = _combined_output(completed)
|
|
764
|
+
passed = completed.returncode == 0 or (
|
|
765
|
+
completed.returncode == 5 and "pytest" in " ".join(command)
|
|
766
|
+
)
|
|
767
|
+
results.append(
|
|
768
|
+
GateResult(
|
|
769
|
+
cmd=shlex.join(command),
|
|
770
|
+
returncode=completed.returncode,
|
|
771
|
+
passed=passed,
|
|
772
|
+
tail=output[-2000:],
|
|
773
|
+
)
|
|
774
|
+
)
|
|
775
|
+
return results
|
|
776
|
+
except HeavySlotBusy as exc:
|
|
777
|
+
msg = f"heavy-run slot busy: {exc.holder}" if exc.holder else "heavy-run slot busy"
|
|
778
|
+
if cmds:
|
|
779
|
+
return [
|
|
574
780
|
GateResult(
|
|
575
781
|
cmd=shlex.join(command),
|
|
576
|
-
returncode
|
|
782
|
+
returncode=EXIT_NOT_RUN,
|
|
577
783
|
passed=False,
|
|
578
|
-
tail=
|
|
784
|
+
tail=msg,
|
|
579
785
|
)
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
output = _combined_output(completed)
|
|
584
|
-
passed = completed.returncode == 0 or (
|
|
585
|
-
completed.returncode == 5 and "pytest" in " ".join(command)
|
|
586
|
-
)
|
|
587
|
-
results.append(
|
|
786
|
+
for command in cmds
|
|
787
|
+
]
|
|
788
|
+
return [
|
|
588
789
|
GateResult(
|
|
589
|
-
cmd=
|
|
590
|
-
returncode=
|
|
591
|
-
passed=
|
|
592
|
-
tail=
|
|
790
|
+
cmd="",
|
|
791
|
+
returncode=EXIT_NOT_RUN,
|
|
792
|
+
passed=False,
|
|
793
|
+
tail=msg,
|
|
593
794
|
)
|
|
594
|
-
|
|
595
|
-
return results
|
|
795
|
+
]
|
|
596
796
|
|
|
597
797
|
|
|
598
798
|
def oss_review_path() -> Path | None:
|
|
@@ -788,9 +988,17 @@ def load_lanes_config(repo_root: Path) -> dict:
|
|
|
788
988
|
lanes = data.get("lanes", {})
|
|
789
989
|
if not isinstance(lanes, dict):
|
|
790
990
|
raise LaneConfigError(f"[lanes] in {config_path} is not a table")
|
|
991
|
+
idle_timeout(lanes)
|
|
791
992
|
return lanes
|
|
792
993
|
|
|
793
994
|
|
|
995
|
+
def idle_timeout(cfg: dict, override: int | None = None) -> int:
|
|
996
|
+
value = cfg.get("idle_timeout_s", DEFAULT_IDLE_TIMEOUT_S) if override is None else override
|
|
997
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
998
|
+
raise LaneConfigError(f"invalid [lanes] idle_timeout_s: {value!r}")
|
|
999
|
+
return value
|
|
1000
|
+
|
|
1001
|
+
|
|
794
1002
|
def resolve_models(cfg: dict) -> dict[str, str]:
|
|
795
1003
|
models = DEFAULT_MODELS.copy()
|
|
796
1004
|
for key, value in cfg.get("models", {}).items():
|
|
@@ -937,9 +1145,12 @@ def classify_exit(
|
|
|
937
1145
|
output: str,
|
|
938
1146
|
timed_out: bool,
|
|
939
1147
|
guard_killed: bool = False,
|
|
1148
|
+
stalled: bool = False,
|
|
940
1149
|
) -> str:
|
|
941
1150
|
if guard_killed:
|
|
942
1151
|
return RESULT_RESOURCE
|
|
1152
|
+
if stalled:
|
|
1153
|
+
return RESULT_STALLED
|
|
943
1154
|
if timed_out:
|
|
944
1155
|
return RESULT_LANE_FAILED
|
|
945
1156
|
if any(re.search(pattern, output, re.I) for pattern in CONFIG_PATTERNS[lane]):
|
|
@@ -1109,7 +1320,10 @@ def ci_watch(
|
|
|
1109
1320
|
"--dangerously-skip-permissions", "--output-format", "text", "--print",
|
|
1110
1321
|
]
|
|
1111
1322
|
try:
|
|
1112
|
-
|
|
1323
|
+
# A CI watcher legitimately sits idle while CI runs, so the idle watchdog is off for it;
|
|
1324
|
+
# the wall-clock limit (`remaining`) still applies.
|
|
1325
|
+
watcher = (lambda *a: run_lane(*a, idle_timeout_s=0)) if run_agy is _LANE_RUNNER else run_agy
|
|
1326
|
+
run = watcher(agy_argv, repo, prompt, False, remaining, child_env(os.environ, "agy", model))
|
|
1113
1327
|
except OSError as exc:
|
|
1114
1328
|
print(f"[caddis-lanes] ci-watch: agy could not start ({exc}); watching with gh", file=sys.stderr)
|
|
1115
1329
|
break
|
|
@@ -1202,6 +1416,7 @@ def build_prompt(
|
|
|
1202
1416
|
"- Never use --no-verify. Never read or edit .env files or any secret. Never "
|
|
1203
1417
|
"run anything against production.\n"
|
|
1204
1418
|
"- Do not implement any other phase.\n"
|
|
1419
|
+
"- Run every command in the foreground and wait for it to finish. Start no background tasks, watchers or dev servers.\n"
|
|
1205
1420
|
"Resource budget (enforced by the machine, not by this text):\n"
|
|
1206
1421
|
f"- You run inside a Windows Job Object: {limits_line}. Exceeding it slows "
|
|
1207
1422
|
"you down; it does not stop the limit.\n"
|
|
@@ -1361,11 +1576,27 @@ def _run_post_checks(
|
|
|
1361
1576
|
before: dict[str, str | None],
|
|
1362
1577
|
) -> tuple[str, int]:
|
|
1363
1578
|
worktree = Path(packet["worktree"])
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1579
|
+
# The heavy-run slot covers the project's own test commands only. The review is a network call and
|
|
1580
|
+
# must not hold a slot that another gate is waiting for.
|
|
1581
|
+
try:
|
|
1582
|
+
with heavy_slot("gates"):
|
|
1583
|
+
gates = run_gates(
|
|
1584
|
+
gate_commands(repo, worktree, cfg),
|
|
1585
|
+
worktree,
|
|
1586
|
+
gate_env(repo, dict(os.environ)),
|
|
1587
|
+
)
|
|
1588
|
+
except HeavySlotBusy as exc:
|
|
1589
|
+
msg = f"heavy-run slot busy: {exc.holder}" if exc.holder else "heavy-run slot busy"
|
|
1590
|
+
packet.update(
|
|
1591
|
+
{
|
|
1592
|
+
"gates": [{"cmd": "gates", "returncode": EXIT_NOT_RUN, "passed": False, "tail": msg}],
|
|
1593
|
+
"gates_passed": False,
|
|
1594
|
+
"next": "stop",
|
|
1595
|
+
}
|
|
1596
|
+
)
|
|
1597
|
+
packet.pop("_freeze_before", None)
|
|
1598
|
+
return "stop", EXIT_NOT_RUN
|
|
1599
|
+
|
|
1369
1600
|
review = run_review(worktree, dict(os.environ))
|
|
1370
1601
|
pythons = list(before)
|
|
1371
1602
|
if pythons:
|
|
@@ -1411,6 +1642,7 @@ def _run_command(
|
|
|
1411
1642
|
packet_path: Path,
|
|
1412
1643
|
) -> int:
|
|
1413
1644
|
cfg = load_lanes_config(repo)
|
|
1645
|
+
idle_timeout_s = idle_timeout(cfg, args.idle_timeout)
|
|
1414
1646
|
block = phase_block_text(plan, args.phase)
|
|
1415
1647
|
findings_text = ""
|
|
1416
1648
|
if args.findings is not None:
|
|
@@ -1484,6 +1716,7 @@ def _run_command(
|
|
|
1484
1716
|
prompt_via_stdin(lane),
|
|
1485
1717
|
args.timeout,
|
|
1486
1718
|
child_env(dict(os.environ), lane, model),
|
|
1719
|
+
idle_timeout_s=idle_timeout_s,
|
|
1487
1720
|
)
|
|
1488
1721
|
killed = guard_killed_since(lane_run.started, {lane_run.pid})
|
|
1489
1722
|
result = classify_exit(
|
|
@@ -1492,6 +1725,7 @@ def _run_command(
|
|
|
1492
1725
|
lane_run.output,
|
|
1493
1726
|
lane_run.timed_out,
|
|
1494
1727
|
killed,
|
|
1728
|
+
lane_run.stalled,
|
|
1495
1729
|
)
|
|
1496
1730
|
packet["lanes_tried"].append(
|
|
1497
1731
|
{
|
|
@@ -1502,9 +1736,18 @@ def _run_command(
|
|
|
1502
1736
|
"returncode": lane_run.returncode,
|
|
1503
1737
|
"duration_s": lane_run.duration_s,
|
|
1504
1738
|
"timed_out": lane_run.timed_out,
|
|
1739
|
+
"stalled": lane_run.stalled,
|
|
1740
|
+
"idle_seconds": lane_run.idle_seconds,
|
|
1505
1741
|
}
|
|
1506
1742
|
)
|
|
1507
1743
|
packet["result"] = result
|
|
1744
|
+
if lane_run.stalled:
|
|
1745
|
+
packet.update({
|
|
1746
|
+
"stall": "idle",
|
|
1747
|
+
"idle_seconds": lane_run.idle_seconds,
|
|
1748
|
+
"tail": f"idle for {lane_run.idle_seconds}s; process tree before kill: "
|
|
1749
|
+
f"{json.dumps(lane_run.processes or [])}",
|
|
1750
|
+
})
|
|
1508
1751
|
if result == RESULT_RESOURCE:
|
|
1509
1752
|
packet["next"] = "stop"
|
|
1510
1753
|
write_packet(packet_path, packet)
|
|
@@ -1518,12 +1761,15 @@ def _run_command(
|
|
|
1518
1761
|
write_packet(packet_path, packet)
|
|
1519
1762
|
print(f"NEXT: stop (config error on {lane})")
|
|
1520
1763
|
return EXIT_CONFIG
|
|
1521
|
-
if result
|
|
1764
|
+
if result in (RESULT_LANE_FAILED, RESULT_STALLED):
|
|
1522
1765
|
packet["next"] = (
|
|
1523
1766
|
"takeover" if args.attempt >= MAX_ATTEMPTS else "redo"
|
|
1524
1767
|
)
|
|
1525
1768
|
write_packet(packet_path, packet)
|
|
1526
|
-
|
|
1769
|
+
if result == RESULT_STALLED:
|
|
1770
|
+
print(f"NEXT: {packet['next']} (lane idle {lane_run.idle_seconds}s; see packet)")
|
|
1771
|
+
else:
|
|
1772
|
+
print(f"NEXT: {packet['next']}")
|
|
1527
1773
|
return EXIT_FAIL
|
|
1528
1774
|
break
|
|
1529
1775
|
else:
|
|
@@ -1840,6 +2086,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1840
2086
|
run.add_argument("--lane")
|
|
1841
2087
|
run.add_argument("--findings", type=Path)
|
|
1842
2088
|
run.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_S)
|
|
2089
|
+
run.add_argument("--idle-timeout", type=int)
|
|
1843
2090
|
run.add_argument("--allow-load", action="store_true")
|
|
1844
2091
|
|
|
1845
2092
|
check = subparsers.add_parser("check")
|
|
@@ -36,6 +36,7 @@ FIX_HINTS: dict[str, str] = {
|
|
|
36
36
|
"deny-rules": "run /caddis:setup-project-ai to add the missing deny rules",
|
|
37
37
|
"gitignore": "run /caddis:setup-project-ai to add the missing .caddis ignore entries",
|
|
38
38
|
"misfiled": "move the file into a caddis document folder, or delete it",
|
|
39
|
+
"host-guard": "see docs/guide/resource-protection.md (install-host-guard.ps1)",
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
# Probed contract versions (docs/analysis/*-contract.md). WARN when the installed binary drifts past
|
|
@@ -452,6 +453,38 @@ def keys_line(env: Mapping[str, str]) -> tuple[str, str]:
|
|
|
452
453
|
return ("INFO", "keys: could not check")
|
|
453
454
|
|
|
454
455
|
|
|
456
|
+
def host_guard_findings(
|
|
457
|
+
run: Callable[..., object] | None = None,
|
|
458
|
+
budget_path: Path | None = None,
|
|
459
|
+
) -> list[tuple[str, str]]:
|
|
460
|
+
"""Check host guard installation on Windows. Levels: WARN (never raises)."""
|
|
461
|
+
if sys.platform != "win32":
|
|
462
|
+
return []
|
|
463
|
+
|
|
464
|
+
run = run or subprocess.run
|
|
465
|
+
out: list[tuple[str, str]] = []
|
|
466
|
+
|
|
467
|
+
try:
|
|
468
|
+
res = run(["sc", "query", "caddis-host-guard"], capture_output=True, text=True)
|
|
469
|
+
rc = getattr(res, "returncode", 0)
|
|
470
|
+
if rc != 0: # 1060 is "no such service"; any other failure also means "cannot confirm it runs"
|
|
471
|
+
out.append(("WARN", f"caddis-host-guard service not found — {FIX_HINTS['host-guard']}"))
|
|
472
|
+
except Exception:
|
|
473
|
+
return []
|
|
474
|
+
|
|
475
|
+
if budget_path is None:
|
|
476
|
+
prog_data = os.environ.get("ProgramData", r"C:\ProgramData")
|
|
477
|
+
budget_path = Path(prog_data) / "caddis" / "host-budget.toml"
|
|
478
|
+
|
|
479
|
+
try:
|
|
480
|
+
if not budget_path.is_file():
|
|
481
|
+
out.append(("WARN", f"{budget_path} missing — {FIX_HINTS['host-guard']}"))
|
|
482
|
+
except Exception:
|
|
483
|
+
pass
|
|
484
|
+
|
|
485
|
+
return out
|
|
486
|
+
|
|
487
|
+
|
|
455
488
|
# ── project checks (read-only — WARN or INFO, never FAIL) ─────────────────────
|
|
456
489
|
def deny_rule_findings(dest: Path) -> list[tuple[str, str]]:
|
|
457
490
|
dest = Path(dest)
|
|
@@ -593,6 +626,12 @@ def run(dest: Path, quiet: bool) -> int:
|
|
|
593
626
|
hard = 1
|
|
594
627
|
print(f" {k_level:4} {k_text}")
|
|
595
628
|
|
|
629
|
+
host = host_guard_findings()
|
|
630
|
+
if host:
|
|
631
|
+
print("-- host")
|
|
632
|
+
for level, text in host:
|
|
633
|
+
print(f" {level:4} {text}")
|
|
634
|
+
|
|
596
635
|
print("-- project")
|
|
597
636
|
proj_findings = deny_rule_findings(dest) + gitignore_findings(dest) + misfiled_findings(dest)
|
|
598
637
|
if proj_findings:
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
param(
|
|
3
3
|
[switch]$Apply,
|
|
4
4
|
[string]$NssmSha256,
|
|
5
|
+
# Where the NSSM binary to copy lives. Default: the first nssm.exe on PATH, else C:\Tools\nssm\nssm.exe.
|
|
6
|
+
[string]$NssmPath,
|
|
5
7
|
[ValidateSet('report', 'enforce')]
|
|
6
8
|
[string]$Mode = 'report'
|
|
7
9
|
)
|
|
@@ -9,7 +11,12 @@ param(
|
|
|
9
11
|
Set-StrictMode -Version Latest
|
|
10
12
|
$ErrorActionPreference = 'Stop'
|
|
11
13
|
|
|
12
|
-
$
|
|
14
|
+
if ($NssmPath) {
|
|
15
|
+
$sourceNssm = $NssmPath
|
|
16
|
+
} else {
|
|
17
|
+
$foundNssm = Get-Command 'nssm.exe' -ErrorAction SilentlyContinue
|
|
18
|
+
if ($foundNssm) { $sourceNssm = $foundNssm.Source } else { $sourceNssm = 'C:\Tools\nssm\nssm.exe' }
|
|
19
|
+
}
|
|
13
20
|
$serviceName = 'caddis-host-guard'
|
|
14
21
|
$programRoot = Join-Path $env:ProgramFiles 'caddis'
|
|
15
22
|
$nssmDir = Join-Path $programRoot 'nssm'
|
|
@@ -377,6 +377,225 @@ def check_venv(target: Path, install: bool, dry: bool) -> list[str]:
|
|
|
377
377
|
return notes
|
|
378
378
|
|
|
379
379
|
|
|
380
|
+
GATE_STEP_PY = r'''#!/usr/bin/env python3
|
|
381
|
+
"""caddis_gate_step.py — Per-step gate runner with timeout and process tree cleanup.
|
|
382
|
+
|
|
383
|
+
Runs under the PROJECT's interpreter, which may be older than 3.11: no `tomllib` at import time
|
|
384
|
+
(the config file is skipped, with one stderr line, when it is missing) and annotations are not evaluated.
|
|
385
|
+
"""
|
|
386
|
+
from __future__ import annotations
|
|
387
|
+
|
|
388
|
+
import argparse
|
|
389
|
+
import os
|
|
390
|
+
import signal
|
|
391
|
+
import subprocess
|
|
392
|
+
import sys
|
|
393
|
+
import time
|
|
394
|
+
from pathlib import Path
|
|
395
|
+
|
|
396
|
+
try:
|
|
397
|
+
import tomllib
|
|
398
|
+
except ImportError: # Python < 3.11
|
|
399
|
+
tomllib = None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def format_elapsed(seconds: float) -> str:
|
|
403
|
+
total_s = max(0, int(round(seconds)))
|
|
404
|
+
if total_s < 60:
|
|
405
|
+
return f"{total_s}s"
|
|
406
|
+
m = total_s // 60
|
|
407
|
+
s = total_s % 60
|
|
408
|
+
return f"{m}m{s:02d}s"
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def with_faulthandler(argv: list[str], name: str, env: dict[str, str] | None = None) -> list[str]:
|
|
412
|
+
argv = list(argv)
|
|
413
|
+
if name != "pytest":
|
|
414
|
+
return argv
|
|
415
|
+
if any("faulthandler_timeout" in str(arg) for arg in argv):
|
|
416
|
+
return argv
|
|
417
|
+
env_opts = (env.get("PYTEST_ADDOPTS") if env is not None else os.environ.get("PYTEST_ADDOPTS")) or ""
|
|
418
|
+
if "faulthandler_timeout" in env_opts:
|
|
419
|
+
return argv
|
|
420
|
+
return argv + ["-o", "faulthandler_timeout=300"]
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def kill_process_tree(proc: subprocess.Popen) -> None:
|
|
424
|
+
if sys.platform == "win32":
|
|
425
|
+
try:
|
|
426
|
+
subprocess.run(
|
|
427
|
+
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
428
|
+
capture_output=True,
|
|
429
|
+
timeout=10,
|
|
430
|
+
)
|
|
431
|
+
except Exception:
|
|
432
|
+
pass
|
|
433
|
+
try:
|
|
434
|
+
proc.kill()
|
|
435
|
+
except Exception:
|
|
436
|
+
pass
|
|
437
|
+
try:
|
|
438
|
+
proc.wait(timeout=5)
|
|
439
|
+
except Exception:
|
|
440
|
+
pass
|
|
441
|
+
else:
|
|
442
|
+
try:
|
|
443
|
+
pgid = os.getpgid(proc.pid)
|
|
444
|
+
except Exception:
|
|
445
|
+
pgid = proc.pid
|
|
446
|
+
try:
|
|
447
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
448
|
+
except Exception:
|
|
449
|
+
pass
|
|
450
|
+
try:
|
|
451
|
+
proc.wait(timeout=5)
|
|
452
|
+
except subprocess.TimeoutExpired:
|
|
453
|
+
try:
|
|
454
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
455
|
+
except Exception:
|
|
456
|
+
pass
|
|
457
|
+
try:
|
|
458
|
+
proc.wait(timeout=5)
|
|
459
|
+
except Exception:
|
|
460
|
+
pass
|
|
461
|
+
except Exception:
|
|
462
|
+
pass
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def main(argv: list[str] | None = None) -> int:
|
|
466
|
+
if argv is None:
|
|
467
|
+
argv = sys.argv[1:]
|
|
468
|
+
|
|
469
|
+
if "--" in argv:
|
|
470
|
+
sep_idx = argv.index("--")
|
|
471
|
+
runner_args = argv[:sep_idx]
|
|
472
|
+
cmd = argv[sep_idx + 1:]
|
|
473
|
+
else:
|
|
474
|
+
runner_args = argv
|
|
475
|
+
cmd = []
|
|
476
|
+
|
|
477
|
+
parser = argparse.ArgumentParser(description="caddis quality gate step runner")
|
|
478
|
+
parser.add_argument("--name", required=True, help="Step name")
|
|
479
|
+
parser.add_argument("--timeout", type=int, required=True, help="Default timeout in seconds")
|
|
480
|
+
parser.add_argument("cmd", nargs="*", help="Command to run (if -- not used)")
|
|
481
|
+
|
|
482
|
+
args = parser.parse_args(runner_args)
|
|
483
|
+
if not cmd:
|
|
484
|
+
cmd = args.cmd
|
|
485
|
+
if cmd and cmd[0] == "--":
|
|
486
|
+
cmd = cmd[1:]
|
|
487
|
+
|
|
488
|
+
if not cmd:
|
|
489
|
+
print("[gate] error: no command specified to run", file=sys.stderr)
|
|
490
|
+
return 1
|
|
491
|
+
|
|
492
|
+
name = args.name
|
|
493
|
+
timeout = args.timeout
|
|
494
|
+
|
|
495
|
+
# Config: read .caddis/config.toml in the current directory if present
|
|
496
|
+
cfg_path = Path(".caddis/config.toml")
|
|
497
|
+
gate_cfg: dict = {}
|
|
498
|
+
if cfg_path.is_file() and tomllib is None:
|
|
499
|
+
print("[gate] note: .caddis/config.toml not read (needs Python 3.11 or later)", file=sys.stderr)
|
|
500
|
+
elif cfg_path.is_file():
|
|
501
|
+
try:
|
|
502
|
+
with open(cfg_path, "rb") as f:
|
|
503
|
+
raw_cfg = tomllib.load(f)
|
|
504
|
+
if isinstance(raw_cfg, dict):
|
|
505
|
+
g = raw_cfg.get("gate", {})
|
|
506
|
+
if isinstance(g, dict):
|
|
507
|
+
gate_cfg = g
|
|
508
|
+
except Exception as exc:
|
|
509
|
+
print(f"[gate] warning: malformed .caddis/config.toml ({exc})", file=sys.stderr)
|
|
510
|
+
|
|
511
|
+
# Section [gate]: timeout_<name> overrides --timeout for that step name
|
|
512
|
+
candidate_keys = (
|
|
513
|
+
f"timeout_{name}",
|
|
514
|
+
f"timeout_{name.replace('-', '_')}",
|
|
515
|
+
f"timeout_{name.replace('_', '-')}",
|
|
516
|
+
)
|
|
517
|
+
for k in candidate_keys:
|
|
518
|
+
if k in gate_cfg:
|
|
519
|
+
val = gate_cfg[k]
|
|
520
|
+
if isinstance(val, int) and not isinstance(val, bool) and val > 0:
|
|
521
|
+
timeout = val
|
|
522
|
+
else:
|
|
523
|
+
print(
|
|
524
|
+
f"[gate] warning: invalid {k}={val!r} in config (must be positive integer)",
|
|
525
|
+
file=sys.stderr,
|
|
526
|
+
)
|
|
527
|
+
break
|
|
528
|
+
|
|
529
|
+
# Env var CADDIS_GATE_TIMEOUT_<NAME upper, - replaced by _> overrides config
|
|
530
|
+
env_var_name = f"CADDIS_GATE_TIMEOUT_{name.upper().replace('-', '_')}"
|
|
531
|
+
if env_var_name in os.environ:
|
|
532
|
+
raw_val = os.environ[env_var_name].strip()
|
|
533
|
+
try:
|
|
534
|
+
env_timeout = int(raw_val)
|
|
535
|
+
if env_timeout > 0:
|
|
536
|
+
timeout = env_timeout
|
|
537
|
+
else:
|
|
538
|
+
print(
|
|
539
|
+
f"[gate] warning: invalid {env_var_name}={raw_val!r} (must be positive integer)",
|
|
540
|
+
file=sys.stderr,
|
|
541
|
+
)
|
|
542
|
+
except ValueError:
|
|
543
|
+
print(
|
|
544
|
+
f"[gate] warning: invalid {env_var_name}={raw_val!r} (must be integer)",
|
|
545
|
+
file=sys.stderr,
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# pytest_args (list of strings) are appended to pytest command ONLY when --name is pytest
|
|
549
|
+
if name == "pytest":
|
|
550
|
+
pytest_args = gate_cfg.get("pytest_args")
|
|
551
|
+
if isinstance(pytest_args, list):
|
|
552
|
+
for pa in pytest_args:
|
|
553
|
+
if isinstance(pa, str):
|
|
554
|
+
cmd.append(pa)
|
|
555
|
+
else:
|
|
556
|
+
cmd.append(str(pa))
|
|
557
|
+
|
|
558
|
+
# Pytest hang dump
|
|
559
|
+
cmd = with_faulthandler(cmd, name, os.environ)
|
|
560
|
+
|
|
561
|
+
# Print start line
|
|
562
|
+
print(f"[gate] {name}: start (limit {timeout}s)", flush=True)
|
|
563
|
+
|
|
564
|
+
start_time = time.monotonic()
|
|
565
|
+
kwargs: dict = {}
|
|
566
|
+
if sys.platform == "win32":
|
|
567
|
+
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
568
|
+
else:
|
|
569
|
+
kwargs["start_new_session"] = True
|
|
570
|
+
|
|
571
|
+
try:
|
|
572
|
+
proc = subprocess.Popen(cmd, **kwargs)
|
|
573
|
+
except Exception as exc:
|
|
574
|
+
print(f"[gate] {name}: failed to start {cmd[0]}: {exc}", file=sys.stderr)
|
|
575
|
+
return 1
|
|
576
|
+
|
|
577
|
+
try:
|
|
578
|
+
proc.wait(timeout=timeout)
|
|
579
|
+
except subprocess.TimeoutExpired:
|
|
580
|
+
kill_process_tree(proc)
|
|
581
|
+
print(
|
|
582
|
+
f"[gate] {name}: TIMEOUT after {timeout}s - killed the process tree",
|
|
583
|
+
file=sys.stderr,
|
|
584
|
+
flush=True,
|
|
585
|
+
)
|
|
586
|
+
return 124
|
|
587
|
+
|
|
588
|
+
elapsed = time.monotonic() - start_time
|
|
589
|
+
rc = proc.returncode
|
|
590
|
+
print(f"[gate] {name}: done in {format_elapsed(elapsed)} (exit {rc})", flush=True)
|
|
591
|
+
return rc
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
if __name__ == "__main__":
|
|
595
|
+
sys.exit(main())
|
|
596
|
+
'''
|
|
597
|
+
|
|
598
|
+
|
|
380
599
|
# Enforced "green before push" gate. Installed into .git/hooks/pre-push. Pure POSIX sh
|
|
381
600
|
# so it runs under Git's bundled shell on Windows/Linux/macOS.
|
|
382
601
|
#
|
|
@@ -398,6 +617,13 @@ PRE_PUSH_HOOK = r"""#!/usr/bin/env sh
|
|
|
398
617
|
set -eu
|
|
399
618
|
echo "[caddis] pre-push quality gate"
|
|
400
619
|
fail=0
|
|
620
|
+
if command -v dirname >/dev/null 2>&1; then
|
|
621
|
+
GATE_RUN="$(dirname "$0")/caddis_gate_step.py"
|
|
622
|
+
elif [ -n "${0%/*}" ] && [ "${0%/*}" != "$0" ]; then
|
|
623
|
+
GATE_RUN="${0%/*}/caddis_gate_step.py"
|
|
624
|
+
else
|
|
625
|
+
GATE_RUN="./caddis_gate_step.py"
|
|
626
|
+
fi
|
|
401
627
|
|
|
402
628
|
# Prefer the project's virtualenv over PATH. Checked in venv-layout order for both
|
|
403
629
|
# Windows (Scripts/) and POSIX (bin/) so the same hook works on either.
|
|
@@ -424,10 +650,20 @@ py_sources_exist() {
|
|
|
424
650
|
|
|
425
651
|
py_gate() {
|
|
426
652
|
tool=$1; shift
|
|
653
|
+
timeout=300
|
|
654
|
+
case "$tool" in
|
|
655
|
+
ruff) timeout=300 ;;
|
|
656
|
+
mypy) timeout=600 ;;
|
|
657
|
+
pytest) timeout=1200 ;;
|
|
658
|
+
esac
|
|
427
659
|
if py_has "$tool"; then
|
|
428
|
-
echo "[gate] $tool $*"
|
|
429
660
|
rc=0
|
|
430
|
-
|
|
661
|
+
if [ -f "$GATE_RUN" ]; then
|
|
662
|
+
"$PY" "$GATE_RUN" --name "$tool" --timeout "$timeout" -- "$PY" -m "$tool" "$@" || rc=$?
|
|
663
|
+
else
|
|
664
|
+
echo "[gate] $tool $*"
|
|
665
|
+
"$PY" -m "$tool" "$@" || rc=$?
|
|
666
|
+
fi
|
|
431
667
|
if [ "$rc" -ne 0 ]; then
|
|
432
668
|
# pytest exit 5 = "no tests collected". A repo that has not written tests yet is not a
|
|
433
669
|
# failing repo, and blocking it would hit exactly the fresh-scaffold case this fix exists
|
|
@@ -457,9 +693,15 @@ if [ -f "pyproject.toml" ] || [ -f "requirements.txt" ]; then
|
|
|
457
693
|
fi
|
|
458
694
|
fi
|
|
459
695
|
if [ -f "package.json" ] && command -v npm >/dev/null 2>&1; then
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
696
|
+
if [ -f "$GATE_RUN" ] && [ -n "$PY" ]; then
|
|
697
|
+
npm run 2>/dev/null | grep -q " lint" && { "$PY" "$GATE_RUN" --name npm-lint --timeout 600 -- npm run lint --silent || fail=1; }
|
|
698
|
+
npm run 2>/dev/null | grep -q " typecheck" && { "$PY" "$GATE_RUN" --name npm-typecheck --timeout 600 -- npm run typecheck --silent || fail=1; }
|
|
699
|
+
npm run 2>/dev/null | grep -q " test" && { "$PY" "$GATE_RUN" --name npm-test --timeout 1200 -- npm test --silent || fail=1; }
|
|
700
|
+
else
|
|
701
|
+
npm run 2>/dev/null | grep -q " lint" && { echo "[gate] npm run lint"; npm run lint --silent || fail=1; }
|
|
702
|
+
npm run 2>/dev/null | grep -q " typecheck" && { echo "[gate] npm run typecheck"; npm run typecheck --silent || fail=1; }
|
|
703
|
+
npm run 2>/dev/null | grep -q " test" && { echo "[gate] npm test"; npm test --silent || fail=1; }
|
|
704
|
+
fi
|
|
463
705
|
fi
|
|
464
706
|
# Doc-coverage discipline (any stack). The checker exits non-zero ONLY on a hard invariant
|
|
465
707
|
# (missing route / dangling doc-map link); soft signals warn without failing. Auto-skips when the
|
|
@@ -469,8 +711,12 @@ if [ -f "scripts/check_doc_coverage.py" ]; then
|
|
|
469
711
|
DOC_PY="$PY"
|
|
470
712
|
if [ -z "$DOC_PY" ]; then DOC_PY=$(command -v python || command -v python3 || true); fi
|
|
471
713
|
if [ -n "$DOC_PY" ]; then
|
|
472
|
-
|
|
473
|
-
|
|
714
|
+
if [ -f "$GATE_RUN" ]; then
|
|
715
|
+
"$DOC_PY" "$GATE_RUN" --name doc-coverage --timeout 120 -- "$DOC_PY" scripts/check_doc_coverage.py --check || fail=1
|
|
716
|
+
else
|
|
717
|
+
echo "[gate] doc coverage"
|
|
718
|
+
"$DOC_PY" scripts/check_doc_coverage.py --check || fail=1
|
|
719
|
+
fi
|
|
474
720
|
fi
|
|
475
721
|
fi
|
|
476
722
|
if [ "$fail" -ne 0 ]; then
|
|
@@ -491,6 +737,7 @@ def install_git_hooks(target: Path, force: bool, dry: bool) -> list[str]:
|
|
|
491
737
|
return ["pre-push gate: .git is a worktree pointer — skipped (install manually if wanted)"]
|
|
492
738
|
hooks_dir = git / "hooks"
|
|
493
739
|
dest = hooks_dir / "pre-push"
|
|
740
|
+
step_dest = hooks_dir / "caddis_gate_step.py"
|
|
494
741
|
if dest.exists() and not force:
|
|
495
742
|
_hook_txt = dest.read_text(encoding="utf-8", errors="ignore")
|
|
496
743
|
managed = "caddis" in _hook_txt or "claudster" in _hook_txt # recognise pre-rename hooks too
|
|
@@ -498,12 +745,14 @@ def install_git_hooks(target: Path, force: bool, dry: bool) -> list[str]:
|
|
|
498
745
|
if not dry:
|
|
499
746
|
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
500
747
|
dest.write_text(PRE_PUSH_HOOK, encoding="utf-8", newline="\n")
|
|
748
|
+
step_dest.write_text(GATE_STEP_PY, encoding="utf-8", newline="\n")
|
|
501
749
|
try:
|
|
502
|
-
import
|
|
750
|
+
import stat
|
|
503
751
|
dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
752
|
+
step_dest.chmod(step_dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
504
753
|
except Exception:
|
|
505
754
|
pass
|
|
506
|
-
notes.append("pre-push gate: installed .git/hooks/pre-push (green-before-push enforced)")
|
|
755
|
+
notes.append("pre-push gate: installed .git/hooks/pre-push and caddis_gate_step.py (green-before-push enforced)")
|
|
507
756
|
return notes
|
|
508
757
|
|
|
509
758
|
|
package/bundles/manifest.json
CHANGED
package/package.json
CHANGED