@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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.126",
3
+ "version": "1.3.128",
4
4
  "description": "caddis — agent-agnostic dev harness as an Antigravity (agy) plugin: TDD skills + commands, single-sourced with the Claude Code plugin."
5
5
  }
@@ -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) -> int:
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, args.allow_uncapped)
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