# AGENTS.md — sm121 Rust CLI (`edition 2024`) for launching vLLM/SGLang inference containers across a DGX Spark cluster via SSH (`russh`). Docker default, tmux alternative. ## Memory discipline (the web process runs 24/7 on a 4 GB Pi) The add-on image builds the launcher **with `--features mimalloc`** (the CI Dockerfile does this; a plain `cargo build` stays on the default allocator). The 2026-10-13/14 daily Pi OOMs (`dmesg oom-kill … task=sm121`, anon-rss ~2.7 GiB) were rooted in the report panel's file watcher: `recipe_dirs()` always includes the cwd (`.`), the container cwd is `/`, and notify's recursive walk followed the `/sys` `subsystem` symlink loops — an endless walk (~4.6 GB/h allocation churn) the allocator ratcheted into RSS until OOM. Fixes: `watchable_recipe_dirs()` (watch only real recipe workspaces — never `/`), one shared HA REST client (a reqwest+rustls build per probe tick was another ~2 GB/h), the lazy report refresh (full re-collect only on debounced file events; the 30 s tick only re-runs the stale cross-check, riding the dashboard's fleet bus — no extra SSH), the SSH key parsed once and cached (`ssh.rs`), and mimalloc as the backstop. Post-fix RSS is ~40 MiB flat (was 270 MiB/hour growth). Heap hunting: `cargo build --release --features dhat` + clean SIGTERM exit dumps a per-callsite retained-heap `dhat-heap.json` (summarize with `/tmp/dhat-sum.py`); the profiler must be created in `main` before anything allocates. Note: axum graceful shutdown hangs on live SSE — web closes connections on the signal instead. ## Monitoring discipline (agent rule — long operations fail early) Do **not** "sleep N minutes, then check". `serve`/`bench`/image builds and the staging hooks fail in the first seconds or minutes (a missing mount, a bad rev, PEP-668 pip, "not found on any node"), so a 10-15 minute fixed sleep wastes the whole window and buries the failure — and a long `sleep` in one tool call aborts the call, losing the log position. - Start the operation in the background (`nohup … > /tmp/-.log 2>&1 &`), then **poll in short cycles (10-30 s)** and `break` on the first signal: - failure markers in the log — `Error:`, `error:`, `FATAL`, `Traceback`, `No such file`, `not found on any node`, `died`; - or the phase's success marker (`script:`, `started on`, `API ready`, `benchmark_complete`). - Print the log tail **inside the polling loop** (`tail -1`), so progress is visible while it runs; on failure grep the log for the error and report it — never sleep again after a failure. - Prefer the tool's own wait/follow mode when it has one (`sm121 serve` without `--no-wait` waits for readiness and follows liveness; `sm121 logs -f`), and use `--no-wait` only when you intend to poll the readiness URL yourself. - Cap the total wait (a readiness wait is minutes, a first-time staging pass is ~40 min — poll for that long, don't `sleep 2400`). On timeout, report the tail instead of sleeping more. - Same for `make check`, benches and image pushes: background + short polls. ## Dev quick-ref ## Dev quick-ref | Action | Command | |--------|---------| | Build | `cargo build` | | All gates | `make check` (fmt → clippy → test → ≤500 lines) | | Dry-run | `cargo run -- -d serve --recipe deepseek-v4-flash` | | Tests | `cargo test` (65, all passing) | | l10n | `locales/{en,ru,es,it,fr,de}/main.ftl` — Fluent FTL files | See [`DEVELOPMENT.md`](DEVELOPMENT.md) for architecture, module layout, and dev workflow. ## Commands ``` serve/stop/bench/logs/health/doctor/exec/power/tmux/init-host-keys/config/web/report/runlock/validate repos/promote/archive/unarchive ``` `validate` — offline recipe-contract gate (no cluster, no SSH): checks every `recipes//recipe.toml` against the launcher contract — `docker_image_dir` must exist under `docker-images/`, `port` must be the cluster default 30000, no floating `:latest` on engine images (`alpine` test images exempt), models must float (no `@revision`), `required_nodes` within 1..=8. Active recipes: violations are **errors** (exit 1). Archived lanes are grandfathered — their errors downgrade to **warnings** (advisory, always exit 0). Also runs as a `cargo test` over the repo's own `recipes/`, so `make check` fails if a new recipe breaks the contract. Rules live in `src/validate.rs`. `serve` records the launch in the recipe's run ledger (`recipes//.lock`, gitignored) and then **waits for the engine to actually come up** — polls `GET /v1/models` on the head node up to 30 min (`--no-wait` skips); a run that never reaches readiness is closed with its uptime and the serve fails. Follow mode watches liveness (30 s probes; 2 misses = died): a dead engine — or an ended log stream with a dead engine — fails loudly and closes the run. `stop` stops ALL nodes in parallel, every node runs the verifying command (polls up to 60 s that no labeled container remains) and failures are aggregated: one straggler cannot leave the other nodes un-stopped, and the error names the nodes that did not verify. The verified stop closes the run in the ledger. `--no-wait` opts out of the per-node verification. `report` — the table of tested recipes: markdown (default) or an interactive HTML table via `--serve`/`--open`. It shows each recipe's run-ledger state (● running + uptime — live in the HTML, launch count) and supports archive curation: `--archive ` / `--unarchive ` flip the recipe's `archived` flag (comment-preserving), archived recipes leave the output, `--archived` shows them instead. `stop` waits until the containers are actually stopped and removed (polls up 60 s per node) — an immediate `serve` on the same nodes would otherwise race the dying containers' GPU release. `runlock` — offline run-ledger management (the per-recipe `.lock`): `list` shows every recipe with an open (running) entry, `close ` closes the open run without any node verification. `stop` closes the ledger only after verifying every node of the cluster, so a node that died mid-run (or a killed serve) leaves the run open — `sm121 report --serve` cross-checks the ledger against a live fleet probe and renders such rows as orange "stale" (tooltip: `sm121 runlock close `) instead of green "up" when all nodes of the run's cluster are reachable but no running container of the recipe exists; an unreachable node is inconclusive and keeps the green (the fix for a confirmed stale row is `runlock close`). `bench` runs `tool-eval-bench` (from PATH) against a live serve and lands `_