@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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.127",
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
@@ -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 != "caddis_exit":
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
- full = governed_argv(argv)
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 = time.monotonic()
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
- output = ""
490
- try:
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
- output, _ = proc.communicate(
493
- input=prompt if stdin_prompt else None,
494
- timeout=timeout_s,
495
- )
496
- except subprocess.TimeoutExpired as first_timeout:
497
- kill_governed(proc)
498
- timed_out = True
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
- output, _ = proc.communicate(timeout=30)
501
- except subprocess.TimeoutExpired as second_timeout:
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
- captured = second_timeout.output or first_timeout.output or ""
504
- output = (
505
- captured.decode("utf-8", errors="replace")
506
- if isinstance(captured, bytes)
507
- else captured
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=output,
686
+ output=b"".join(chunks).decode("utf-8", errors="replace"),
516
687
  timed_out=timed_out,
517
- duration_s=time.monotonic() - monotonic_started,
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
- results = []
560
- for command in cmds:
561
- try:
562
- completed = subprocess.run(
563
- governed_argv(command),
564
- cwd=cwd,
565
- env=env,
566
- capture_output=True,
567
- text=True,
568
- encoding="utf-8",
569
- errors="replace",
570
- timeout=timeout_s,
571
- )
572
- except subprocess.TimeoutExpired:
573
- results.append(
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=-1,
782
+ returncode=EXIT_NOT_RUN,
577
783
  passed=False,
578
- tail=f"timed out after {timeout_s}s",
784
+ tail=msg,
579
785
  )
580
- )
581
- continue
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=shlex.join(command),
590
- returncode=completed.returncode,
591
- passed=passed,
592
- tail=output[-2000:],
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
- run = run_agy(agy_argv, repo, prompt, False, remaining, child_env(os.environ, "agy", model))
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
- gates = run_gates(
1365
- gate_commands(repo, worktree, cfg),
1366
- worktree,
1367
- gate_env(repo, dict(os.environ)),
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 == RESULT_LANE_FAILED:
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
- print(f"NEXT: {packet['next']}")
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
- $sourceNssm = 'C:\Tools\nssm\nssm.exe'
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
- "$PY" -m "$tool" "$@" || rc=$?
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
- npm run 2>/dev/null | grep -q " lint" && { echo "[gate] npm run lint"; npm run lint --silent || fail=1; }
461
- npm run 2>/dev/null | grep -q " typecheck" && { echo "[gate] npm run typecheck"; npm run typecheck --silent || fail=1; }
462
- npm run 2>/dev/null | grep -q " test" && { echo "[gate] npm test"; npm test --silent || fail=1; }
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
- echo "[gate] doc coverage"
473
- "$DOC_PY" scripts/check_doc_coverage.py --check || fail=1
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 os, stat
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
 
@@ -1,7 +1,7 @@
1
1
  {
2
- "poolVersion": "1.3.127",
2
+ "poolVersion": "1.3.128",
3
3
  "bundles": {
4
- "antigravity-plugin": "1.3.127",
4
+ "antigravity-plugin": "1.3.128",
5
5
  "antigravity-plugin-extras": "1.3.40",
6
6
  "codex": "unknown"
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caddis/cli",
3
- "version": "0.4.41",
3
+ "version": "0.4.42",
4
4
  "description": "Install, update and diagnose the caddis dev harness across every coding agent you use (Claude Code, agy/Antigravity, and more).",
5
5
  "keywords": [
6
6
  "caddis",