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