mjolnir-cli 0.2.0__py3-none-any.whl

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.
Files changed (80) hide show
  1. mjolnir_cli/__init__.py +8 -0
  2. mjolnir_cli/__main__.py +4 -0
  3. mjolnir_cli/accuracy.py +333 -0
  4. mjolnir_cli/autoresearch.py +539 -0
  5. mjolnir_cli/benchy.py +475 -0
  6. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4/config.yaml +78 -0
  7. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DFLASH2/config.yaml +103 -0
  8. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DSPARK/config.yaml +102 -0
  9. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA2/config.yaml +84 -0
  10. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA4hd256/config.yaml +104 -0
  11. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR/config.yaml +86 -0
  12. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3/config.yaml +93 -0
  13. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DFLASH2/config.yaml +123 -0
  14. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DSPARK/config.yaml +124 -0
  15. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/.env +2 -0
  16. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/config.yaml +98 -0
  17. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/.env +4 -0
  18. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/config.yaml +98 -0
  19. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/.env +4 -0
  20. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/config.yaml +102 -0
  21. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/.env +3 -0
  22. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/config.yaml +98 -0
  23. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/draft_vocab.json +1 -0
  24. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/kv_scales.json +35197 -0
  25. mjolnir_cli/bundled/configs/Qwen/Qwen3.8_chat_template.jinja +456 -0
  26. mjolnir_cli/bundled/docker/litellm/docker-compose.yaml +113 -0
  27. mjolnir_cli/bundled/docker/litellm/litellm.env.example +51 -0
  28. mjolnir_cli/bundled/docker/litellm/litellm_config.template.example.yaml +192 -0
  29. mjolnir_cli/bundled/docker/perses/README.md +89 -0
  30. mjolnir_cli/bundled/docker/perses/dashboards/global-datasource.yaml +17 -0
  31. mjolnir_cli/bundled/docker/perses/dashboards/performance_statistics.yaml +771 -0
  32. mjolnir_cli/bundled/docker/perses/dashboards/project.yaml +13 -0
  33. mjolnir_cli/bundled/docker/perses/dashboards/query_statistics.yaml +399 -0
  34. mjolnir_cli/bundled/docker/perses/docker-compose.yaml +51 -0
  35. mjolnir_cli/bundled/docker/perses/perses.yml +16 -0
  36. mjolnir_cli/bundled/docker/perses/prometheus.template.yaml +20 -0
  37. mjolnir_cli/bundled/scripts/hw/setup-thor.sh +1287 -0
  38. mjolnir_cli/cachectl.py +146 -0
  39. mjolnir_cli/charts.py +1687 -0
  40. mjolnir_cli/cli.py +1480 -0
  41. mjolnir_cli/clidoc.py +170 -0
  42. mjolnir_cli/config.py +346 -0
  43. mjolnir_cli/dockerctl.py +533 -0
  44. mjolnir_cli/drivers/fa2_driver.py +89 -0
  45. mjolnir_cli/drivers/fi_driver.py +134 -0
  46. mjolnir_cli/e2e.py +302 -0
  47. mjolnir_cli/fonts/InterDisplay-Bold.ttf +0 -0
  48. mjolnir_cli/fonts/InterDisplay-Medium.ttf +0 -0
  49. mjolnir_cli/fonts/InterDisplay-Regular.ttf +0 -0
  50. mjolnir_cli/fonts/InterDisplay-SemiBold.ttf +0 -0
  51. mjolnir_cli/fonts/OFL.txt +92 -0
  52. mjolnir_cli/gate.py +233 -0
  53. mjolnir_cli/history.py +159 -0
  54. mjolnir_cli/interactive.py +383 -0
  55. mjolnir_cli/legs.py +109 -0
  56. mjolnir_cli/litellm.py +254 -0
  57. mjolnir_cli/perses.py +214 -0
  58. mjolnir_cli/picker.py +70 -0
  59. mjolnir_cli/readme.py +1020 -0
  60. mjolnir_cli/stack.py +340 -0
  61. mjolnir_cli/tasks.py +122 -0
  62. mjolnir_cli/theme.py +281 -0
  63. mjolnir_cli/thor.py +108 -0
  64. mjolnir_cli/verify/__init__.py +2 -0
  65. mjolnir_cli/verify/cli.py +816 -0
  66. mjolnir_cli/verify/docker.py +334 -0
  67. mjolnir_cli/verify/kvcache.py +180 -0
  68. mjolnir_cli/verify/legs.py +134 -0
  69. mjolnir_cli/verify/ncu.py +141 -0
  70. mjolnir_cli/verify/profile.py +312 -0
  71. mjolnir_cli/verify/report.py +177 -0
  72. mjolnir_cli/verify/results.py +157 -0
  73. mjolnir_cli/verify/spec.py +279 -0
  74. mjolnir_cli/verify/stages.py +817 -0
  75. mjolnir_cli-0.2.0.dist-info/METADATA +87 -0
  76. mjolnir_cli-0.2.0.dist-info/RECORD +80 -0
  77. mjolnir_cli-0.2.0.dist-info/WHEEL +4 -0
  78. mjolnir_cli-0.2.0.dist-info/entry_points.txt +2 -0
  79. mjolnir_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
  80. mjolnir_cli-0.2.0.dist-info/licenses/NOTICE +64 -0
mjolnir_cli/stack.py ADDED
@@ -0,0 +1,340 @@
1
+ """Generic docker-compose stack driver — the shared machinery for every
2
+ side-stack the CLI manages (litellm, perses, ...).
3
+
4
+ A *stack* is a docker-compose project (``docker/<name>/docker-compose.yaml``)
5
+ driven by the CLI under the ``mjolnir-<name>`` compose project. Stacks are
6
+ *independent* of the vLLM server: ``<name> down`` never touches vLLM, and
7
+ ``serve down`` never touches a stack. They reach the host (e.g. the vLLM
8
+ port) through ``host.docker.internal`` (host-gateway), never through a
9
+ shared network.
10
+
11
+ What lives here (the part every stack would otherwise re-implement):
12
+
13
+ * the path conventions — data dir (``$MJOLNIR_DATA/<name>``, overridable
14
+ per stack), env file (``<name>.env`` in the data dir), compose file
15
+ (repo-relative)
16
+ * env-file parsing + the merge order (env file < injected < defaults)
17
+ * the compose plumbing — v2 check, argv building, ``up -d`` (with the
18
+ best-effort rollback on failure), ``down`` (the "nothing to stop" check
19
+ + purge), ``ps`` parsing (the NDJSON quirk), ``logs``
20
+ * envsubst-style template rendering
21
+ * the up-wait (health-URL poll)
22
+
23
+ Adding a new stack = ``docker/<name>/docker-compose.yaml`` (+ templates) + a
24
+ thin ``src/mjolnir/<name>.py`` (a ``StackSpec`` + glue: what to render, the
25
+ health URL, the banner) + a typer group in ``cli.py``.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ import re
33
+ import shutil
34
+ import subprocess
35
+ import time
36
+ import urllib.request
37
+ from dataclasses import dataclass
38
+ from pathlib import Path
39
+
40
+ from mjolnir_cli.config import resource_path
41
+
42
+ # envsubst-style variables: ${NAME} or bare $NAME
43
+ VAR_RE = re.compile(r"\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?")
44
+
45
+
46
+ class StackError(RuntimeError):
47
+ """The compose driver failed (up failed, health poll timed out, ...)."""
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class StackSpec:
52
+ """Declarative description of one compose stack.
53
+
54
+ name the CLI command group + the ``mjolnir-<name>`` project
55
+ compose_rel the compose file, relative to the repo root
56
+ env_file_var env var overriding the env-file location
57
+ data_dir_var env var overriding the data-dir location
58
+ data_dir_env the env var the compose file interpolates for the data
59
+ dir (``${...}``); set so the value always reaches compose
60
+ (defaults to ``data_dir_var``)
61
+ data_subdirs dirs under the data dir created on up / wiped on
62
+ ``down --purge``
63
+ port_env env var holding the host port of the stack's main service
64
+ default_port its default
65
+ wait_timeout_s default timeout for the up health-wait
66
+ """
67
+
68
+ name: str
69
+ compose_rel: Path
70
+ env_file_var: str = ""
71
+ data_dir_var: str = ""
72
+ data_dir_env: str = ""
73
+ data_subdirs: tuple[str, ...] = ()
74
+ port_env: str = ""
75
+ default_port: int = 0
76
+ wait_timeout_s: float = 600.0
77
+
78
+ @property
79
+ def project(self) -> str:
80
+ return f"mjolnir-{self.name}"
81
+
82
+ def data_dir(self) -> Path:
83
+ """Stack data root: ``$<data_dir_var>`` or
84
+ ``$MJOLNIR_DATA/<name>`` (default ``~/.local/share/mjolnir/<name>``)."""
85
+ env = self.data_dir_var and os.environ.get(self.data_dir_var)
86
+ if not env:
87
+ mjolnir_data = os.environ.get("MJOLNIR_DATA", str(Path.home() / ".local" / "share" / "mjolnir"))
88
+ env = str(Path(mjolnir_data).expanduser() / self.name)
89
+ return Path(env).expanduser()
90
+
91
+ def env_file_path(self) -> Path:
92
+ """The user's env file: ``$<env_file_var>`` or
93
+ ``<data_dir>/<name>.env``."""
94
+ env = self.env_file_var and os.environ.get(self.env_file_var)
95
+ if env:
96
+ return Path(env).expanduser()
97
+ return self.data_dir() / f"{self.name}.env"
98
+
99
+ def compose_file(self) -> Path:
100
+ return resource_path(self.compose_rel)
101
+
102
+
103
+ # ── env file + rendering ────────────────────────────────────────────────────
104
+
105
+
106
+ def load_env_file(path: Path) -> dict[str, str]:
107
+ """Parse a ``KEY=VALUE`` env file (comments/blank lines ignored, optional
108
+ single/double quotes around the value)."""
109
+ out: dict[str, str] = {}
110
+ if not path.exists():
111
+ return out
112
+ for line in path.read_text().splitlines():
113
+ line = line.strip()
114
+ if not line or line.startswith("#") or "=" not in line:
115
+ continue
116
+ k, _, v = line.partition("=")
117
+ k, v = k.strip(), v.strip()
118
+ if len(v) >= 2 and v[0] == v[-1] and v[0] in "'\"":
119
+ v = v[1:-1]
120
+ if k:
121
+ out[k] = v
122
+ return out
123
+
124
+
125
+ def resolve_env(spec: StackSpec, extra: dict[str, str] | None = None) -> dict[str, str]:
126
+ """Merged env for rendering + compose interpolation:
127
+ user env file < extra (injected by the stack's glue) < stack defaults
128
+ (data dir, main port)."""
129
+ env = load_env_file(spec.env_file_path())
130
+ if extra:
131
+ env.update(extra)
132
+ if spec.data_dir_var:
133
+ # the value the compose file interpolates ($LITELLM_DATA_DIR, ...)
134
+ env.setdefault(spec.data_dir_env or spec.data_dir_var, str(spec.data_dir()))
135
+ if spec.port_env:
136
+ env.setdefault(spec.port_env, str(spec.default_port))
137
+ return env
138
+
139
+
140
+ def render(template_text: str, env: dict[str, str]) -> tuple[str, list[str]]:
141
+ """envsubst-style substitution; returns (rendered, missing-vars).
142
+ Comment lines keep their placeholders (they may document ${...})."""
143
+
144
+ def sub(m: re.Match) -> str:
145
+ return env.get(m.group(1), m.group(0))
146
+
147
+ rendered = VAR_RE.sub(sub, template_text)
148
+ missing: list[str] = []
149
+ for line in rendered.splitlines():
150
+ if line.lstrip().startswith("#"):
151
+ continue
152
+ for m in VAR_RE.finditer(line):
153
+ if m.group(1) not in missing:
154
+ missing.append(m.group(1))
155
+ return rendered, missing
156
+
157
+
158
+ # ── compose plumbing ────────────────────────────────────────────────────────
159
+
160
+
161
+ def _docker() -> str:
162
+ if shutil.which("docker") is None:
163
+ raise StackError("docker not found on PATH")
164
+ return "docker"
165
+
166
+
167
+ def compose_ok() -> None:
168
+ p = subprocess.run(["docker", "compose", "version", "--short"], capture_output=True, text=True)
169
+ if p.returncode != 0:
170
+ raise StackError("docker compose v2 plugin not found (needed: sudo apt install docker-compose-plugin)")
171
+
172
+
173
+ def compose_argv(spec: StackSpec, extra: list[str], env: dict[str] | None = None) -> list[str]:
174
+ """The full ``docker compose ...`` argv for one subcommand."""
175
+ return [_docker(), "compose", "-f", str(spec.compose_file()), "-p", spec.project, *extra]
176
+
177
+
178
+ def _run_compose(spec: StackSpec, extra: list[str], env: dict[str], **kw) -> subprocess.CompletedProcess:
179
+ compose_ok()
180
+ return subprocess.run(compose_argv(spec, extra, env), env={**os.environ, **env}, **kw)
181
+
182
+
183
+ def project_services(spec: StackSpec) -> dict[str, dict]:
184
+ """``{service: {name, state, health, status}}`` from ``compose ps -a``
185
+ (empty dict when the project has no containers)."""
186
+ p = _run_compose(spec, ["ps", "-a", "--format", "json"], resolve_env(spec), capture_output=True, text=True)
187
+ if p.returncode != 0 or not p.stdout.strip():
188
+ return {}
189
+ # compose v5 emits NDJSON (one object per line), older v2 an array
190
+ rows: list[dict] = []
191
+ for line in p.stdout.splitlines():
192
+ line = line.strip()
193
+ if not line:
194
+ continue
195
+ try:
196
+ obj = json.loads(line)
197
+ except json.JSONDecodeError:
198
+ continue
199
+ rows.extend(obj if isinstance(obj, list) else [obj])
200
+ out: dict[str, dict] = {}
201
+ for r in rows:
202
+ name = r.get("Name") or ""
203
+ svc = r.get("Service")
204
+ if not svc:
205
+ base = name[len(spec.project) + 1 :] if name.startswith(spec.project + "-") else name
206
+ svc = base.rsplit("-", 1)[0] if base.endswith("-1") else base
207
+ out[svc] = {"name": name, "state": r.get("State"), "health": r.get("Health"), "status": r.get("Status")}
208
+ return out
209
+
210
+
211
+ def probe(url: str, timeout_s: float = 5.0) -> str:
212
+ """GET ``url`` once → ``"ok"`` (200) | ``"HTTP <code>"`` | ``"unreachable"``."""
213
+ try:
214
+ with urllib.request.urlopen(url, timeout=timeout_s) as r:
215
+ return "ok" if r.status == 200 else f"HTTP {r.status}"
216
+ except Exception: # noqa: BLE001
217
+ return "unreachable"
218
+
219
+
220
+ # ── up / down / status / logs ────────────────────────────────────────────────
221
+
222
+
223
+ def up(
224
+ spec: StackSpec,
225
+ s: object,
226
+ prepare: object = None,
227
+ *,
228
+ wait: bool = True,
229
+ wait_timeout_s: float | None = None,
230
+ dry_run: bool = False,
231
+ health: object = None,
232
+ banner: object = None,
233
+ compose_wait: bool = False,
234
+ ) -> None:
235
+ """Start the stack.
236
+
237
+ ``prepare(s) -> extra env vars`` — the stack's glue renders its files
238
+ here (and may print notes). ``health(env) -> URL`` must answer 200 before
239
+ up returns (with ``wait``). ``banner(env) -> [lines]`` is the success
240
+ output. ``compose_wait`` adds ``--wait`` to ``up -d`` (only useful when
241
+ the services define healthchecks; the URL poll always does the real
242
+ waiting). A failed up rolls the stack back down (no partial state)."""
243
+ wait_timeout_s = spec.wait_timeout_s if wait_timeout_s is None else wait_timeout_s
244
+ extra = prepare(s) if prepare is not None else None
245
+ env = resolve_env(spec, extra)
246
+ up_args = ["up", "-d"] + (["--wait"] if (wait and compose_wait) else [])
247
+ if dry_run:
248
+ print(" ".join(compose_argv(spec, up_args, env)))
249
+ return
250
+ for sub in spec.data_subdirs:
251
+ (spec.data_dir() / sub).mkdir(parents=True, exist_ok=True)
252
+ p = _run_compose(spec, up_args, env, text=True, stderr=subprocess.PIPE)
253
+ if p.returncode != 0:
254
+ err = (p.stderr or "").strip()
255
+ # best-effort rollback: a failed up never leaves partial state
256
+ _run_compose(spec, ["down"], env, capture_output=True, text=True)
257
+ raise StackError(f"docker compose up failed ({p.returncode}); rolled the stack back down:\n{err}")
258
+ if wait and health is not None:
259
+ url = health(env)
260
+ t0 = time.time()
261
+ while time.time() - t0 < wait_timeout_s:
262
+ if probe(url) == "ok":
263
+ break
264
+ time.sleep(3)
265
+ else:
266
+ raise StackError(
267
+ f"{spec.name} not responsive at {url} after {wait_timeout_s:.0f}s — check: mjolnir {spec.name} logs"
268
+ )
269
+ if banner is not None:
270
+ for line in banner(env):
271
+ print(line)
272
+
273
+
274
+ def down(spec: StackSpec, purge: bool = False, dry_run: bool = False, keep_note: str = "stack state survives") -> int:
275
+ """Stop the stack. Data kept under the data dir by default; ``purge``
276
+ wipes the ``data_subdirs``."""
277
+ env = resolve_env(spec)
278
+ if not dry_run and not project_services(spec):
279
+ print(f"nothing to stop (no {spec.name} stack running)")
280
+ return 0
281
+ if dry_run:
282
+ print(" ".join(compose_argv(spec, ["down"], env)))
283
+ if purge:
284
+ print(
285
+ f"(purge: rm -rf {' '.join(str(spec.data_dir() / s) for s in spec.data_subdirs)}"
286
+ " — via a throwaway root container)"
287
+ )
288
+ return 0
289
+ _run_compose(spec, ["down"], env, text=True)
290
+ if purge:
291
+ _purge_data(spec)
292
+ print(f"stopped + purged the stack data ({', '.join(str(spec.data_dir() / s) for s in spec.data_subdirs)})")
293
+ else:
294
+ print(f"stopped (data kept under {spec.data_dir()} — {keep_note})")
295
+ return 0
296
+
297
+
298
+ def _purge_data(spec: StackSpec) -> None:
299
+ """Wipe the stack's ``data_subdirs``.
300
+
301
+ The services run as their images' baked-in nonroot uid (perses 65532,
302
+ prometheus 65534) and create ``0750`` subdirs under the bind-mounted
303
+ data dirs that the host user can't traverse or delete — a host-side
304
+ ``shutil.rmtree`` would silently fail to empty them. Instead, wipe them
305
+ in a throwaway **root** container (root bypasses the permissions).
306
+ Falls back to a best-effort host rmtree if docker can't run."""
307
+ data_dir = spec.data_dir()
308
+ rm = " ".join(f"/d/{sub}" for sub in spec.data_subdirs)
309
+ try:
310
+ subprocess.run(
311
+ ["docker", "run", "--rm", "-v", f"{data_dir}:/d", "busybox", "sh", "-c", f"rm -rf {rm}"],
312
+ check=True,
313
+ capture_output=True,
314
+ )
315
+ return
316
+ except Exception: # noqa: BLE001 — fall back to host rmtree
317
+ pass
318
+ for sub in spec.data_subdirs:
319
+ shutil.rmtree(data_dir / sub, ignore_errors=True)
320
+
321
+
322
+ def status(spec: StackSpec, extra: dict | None = None) -> dict:
323
+ """``{project, services}`` + the stack's glue-specific fields."""
324
+ st: dict = {"project": spec.project, "services": project_services(spec)}
325
+ if extra:
326
+ st.update(extra)
327
+ return st
328
+
329
+
330
+ def logs(spec: StackSpec, follow: bool = False, tail: int = 100, service: str | None = None) -> int:
331
+ """Tail one service's logs (default: the service named ``spec.name``)."""
332
+ env = resolve_env(spec)
333
+ args = compose_argv(spec, ["logs", "--tail", str(tail)], env)
334
+ if follow:
335
+ args.append("-f")
336
+ args.append(service or spec.name)
337
+ try:
338
+ return subprocess.call(args, env={**os.environ, **env})
339
+ except KeyboardInterrupt:
340
+ return 0
mjolnir_cli/tasks.py ADDED
@@ -0,0 +1,122 @@
1
+ """Image canary tasks + the clean-window gate.
2
+
3
+ The canaries are the sm_110 gate probes and the Thor-authored kernel tests
4
+ that ship with the image build (``docker/vllm-thor/``); ``mjolnir image
5
+ gates`` runs them in a fresh container against an image (no server needed;
6
+ GPU tests self-skip on a box without CUDA). Kernel correctness and
7
+ benchmarks live in ``mjolnir verify``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import os
14
+ import shlex
15
+ import sys
16
+ from dataclasses import dataclass
17
+
18
+ from mjolnir_cli.config import Settings, find_repo_root
19
+ from mjolnir_cli.dockerctl import _docker
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Task:
24
+ name: str
25
+ script: str # repo-relative path
26
+ desc: str
27
+ gpu: bool = True
28
+
29
+
30
+ TASKS: list[Task] = [
31
+ Task(
32
+ "gates",
33
+ "docker/vllm-thor/kernel_test_sm110_gates.py",
34
+ "sm_110 gate-probe canary (T6 GDN, T9 FA4, T10 draft-CG, T11 hd256)",
35
+ ),
36
+ Task(
37
+ "fi-update",
38
+ "docker/vllm-thor/kernel_test_fi_update.py",
39
+ "fused draft-decode FI kernel (advance plan vs numpy reference)",
40
+ ),
41
+ Task(
42
+ "dspark-nc",
43
+ "docker/vllm-thor/kernel_test_dspark_draft_nonncausal.py",
44
+ "DSpark non-causal draft cudagraph (gate/route/capture-replay/determinism)",
45
+ ),
46
+ Task(
47
+ "functional",
48
+ "docker/vllm-thor/functional_check_55519.py",
49
+ "55519 draft-group warning gate check (no GPU)",
50
+ gpu=False,
51
+ ),
52
+ ]
53
+
54
+ BY_NAME = {t.name: t for t in TASKS}
55
+
56
+
57
+ def task_container(task: Task) -> str:
58
+ return f"mjolnir-task-{task.name}-{os.getpid()}"
59
+
60
+
61
+ def build_docker(task: Task, s: Settings) -> list[str]:
62
+ script = find_repo_root() / task.script
63
+ cmd = [_docker(), "run", "--rm", "--name", task_container(task)]
64
+ if task.gpu:
65
+ cmd += ["--gpus", "all"]
66
+ cmd += ["--entrypoint", "python3", "-v", f"{script}:/tmp/{script.name}:ro"]
67
+ return cmd + [s.image, f"/tmp/{script.name}"]
68
+
69
+
70
+ def run_task(s: Settings, name: str, script_args: list[str], dry_run: bool = False) -> int:
71
+ """Run one canary task (or ``gates`` = the gate probe) in a fresh container."""
72
+ task = BY_NAME.get(name)
73
+ if task is None:
74
+ print(f"[mjolnir] unknown task '{name}'. Known: {', '.join(BY_NAME)}", file=sys.stderr)
75
+ return 4
76
+ try:
77
+ script = find_repo_root() / task.script
78
+ except FileNotFoundError as e:
79
+ print(f"[mjolnir] {e}", file=sys.stderr)
80
+ return 4
81
+ if not script.exists():
82
+ print(f"[mjolnir] ERROR: script not found: {script}", file=sys.stderr)
83
+ return 4
84
+ cmd = build_docker(task, s) + list(script_args)
85
+ if dry_run:
86
+ print(" ".join(shlex.quote(c) for c in cmd))
87
+ return 0
88
+ print(f"[mjolnir] running: {' '.join(cmd)}", file=sys.stderr)
89
+ from mjolnir_cli.dockerctl import run_once
90
+
91
+ return run_once(cmd, task_container(task))
92
+
93
+
94
+ def _run_gate(s: Settings, script_args: list[str], dry_run: bool) -> int:
95
+ """Run the clean-window gate on the host (no container)."""
96
+ from mjolnir_cli.gate import wait_for_idle, server_load
97
+ import time as _time
98
+
99
+ ap = argparse.ArgumentParser()
100
+ ap.add_argument("--metrics-url", default=s.metrics_url)
101
+ ap.add_argument("--confirm", type=int, default=6)
102
+ ap.add_argument("--timeout", type=float, default=3600)
103
+ ap.add_argument("--once", action="store_true")
104
+ args = ap.parse_args(script_args)
105
+
106
+ if args.once:
107
+ load = server_load(args.metrics_url)
108
+ if load is None:
109
+ print("UNREACHABLE (vLLM metrics endpoint not up)")
110
+ return 2
111
+ clean = load == (0.0, 0.0)
112
+ print(f"now: running={load[0]:g} waiting={load[1]:g} -> {'CLEAN' if clean else 'BUSY'}")
113
+ return 0 if clean else 1
114
+ if dry_run:
115
+ print(f"(host) wait_for_idle({args.metrics_url}, confirm={args.confirm})")
116
+ return 0
117
+ ts = wait_for_idle(args.metrics_url, confirm=args.confirm, timeout_s=args.timeout)
118
+ if ts is None:
119
+ print(f"TIMEOUT: no {args.confirm}x consecutive 0/0 after {args.timeout:.0f}s", file=sys.stderr)
120
+ return 3
121
+ print(f"CLEAN WINDOW OPEN ({args.confirm} consecutive 0/0 samples) at t={_time.time() - ts:.2f}s ago")
122
+ return 0