@caddis/cli 0.4.39 → 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 +292 -11
- package/bundles/antigravity-plugin/scripts/install-host-guard.ps1 +8 -1
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +258 -9
- package/bundles/antigravity-plugin/settings.template.json +37 -0
- package/bundles/manifest.json +2 -2
- package/dist/cli.js +251 -130
- 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
|