@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
|
@@ -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")
|