phase: 33_gateway_route_switching

Phase 33 completion pass is done. Final report:

## Phase 33 — completion report

**All 8 task files complete** (`.agents/phases/complete/33_gateway_route_switching/`), full gate green, docs verified.

### Work in this pass

1. **`tests/test_routectl.py` added** (17 tests, all green) — exercises the *real* helper binary unprivileged against a fake tree (`--root`), the real agent parser, the captured gateway fixtures, and stubbed `nginx`/`haproxy`/`systemctl` on `PATH`. Pins the full protocol (locate → state → dry-run → temp-file/fsync/`os.replace` write → checker → restore on checker failure → reload-never-restart → re-read proof → sidecar record), all three route shapes (haproxy `server`, nginx `proxy_pass`, nginx stream `map` via its sink-value id), `drift` / unknown-id / `map-default` refusals, `list`/`verify`, and the `--serve` peer gate (`SO_PEERCRED` uid check, one JSON line per request, exact body shape).

2. **Real helper defect fixed** — `deploy/dashboards-routectl.py`: `main()`'s early-return paths returned `({"ok": False, …}, EXIT_*)` tuples, so `sys.exit()` printed a Python repr instead of a JSON line (usage errors, agent-import refusal). Now prints `json.dumps(…)` and returns only the code, keeping the one-JSON-line contract.

3. **Documentation consistency fixes** (task 08 criterion 6):
   - `README.md`: garbled verdict-colour sentence on the switch ("green and to the position") → "in the **on** position and tinted `--gate-on`" (the existence-pair contract).
   - `README.md`: audit sentence now names all three event kinds — `route-disabled` / `route-enabled` / **`route-denied`** (the code stores one per refused call; dry runs store none).
   - `page-gateway.js`: replaced the stale pre-33 module docstring ("the switch is not wired") with the two-arm `GATEWAY_ALLOW_WRITES` contract.
   - `AGENTS.md`: every phase-33 claim re-verified against code (the ten gates, the 35 s/30 s budget pair, `gateway.writes_enabled`, `/api/summary`'s `disabled`, the 1.4.0 agent bullet, the gap list) — no changes needed.

### Verification
- `uv run pytest --cov`: **1970 passed**, 18 skipped — coverage **98.74%** (floor 94%)
- `ruff` clean · `pyright` 0 errors · `bash .agents/validate.sh` **exit 0** (incl. `node --check` on every page script)
- Dev server running; all 10 sources `ok`; bundles exported

### Live infrastructure
**No live changes made.** The live drill remains unexecuted per `data/logs/NOTES-33-switching.md` (no operator approval; checker binaries unavailable in the podman proxy units). All routes stay enabled, config hashes unchanged, and the sandbox rehearsal + backup tree are in place for the operator's own `DANGER` call. All changes are in the working tree only.
This commit is contained in:
2026-11-01 11:50:51 -05:00
parent 0271387053
commit 86c8bf4992
91 changed files with 7568 additions and 519 deletions
@@ -0,0 +1,54 @@
# Phase 34 — Radiance is a first-class engine: a third `/llm` panel that says what its /metrics and /stats publish, nothing invented
**Source:** operator request, 2026-10-24 — "add support for a new llm engine 'radiance' which is running on driveripper now. Check it out at http://driveripper.reeselink.com:8000/ and come up with a plan." + the two decisions taken with them in that conversation: **radiance replaced vLLM at driveripper:8000** (the dev config's `VLLM_BASE_URL` points at the engine that is now radiance, so the dev config moves to `RADIANCE_BASE_URL`; vLLM *code* stays — the API shape is additive-only and other deployments may run vLLM), and **the per-position spec-acceptance curve is in** (seven positions, each its own acceptance ratio).
**Story:** n/a
**Context:** `src/dashboards/sources/llm.py` (the gufo/vLLM field maps, the rule-2 throughput precedence, `HISTORY_METRICS`, `_sessions()`, `fleet_totals()`, `SEVERITY_CELLS`), `src/dashboards/config.py` (the `*_BASE_URL`/`*_API_KEY` engine settings, `any_source_enabled`), `src/dashboards/collectors.py` (`fetch_engine`, the best-effort `/slots` fetch, `llm_jobs`), `src/dashboards/api.py` (the `llm_router` 404 gate, `/api/config`'s llm block, the summary's llm block, the history defaults), `src/dashboards/alerts.py` (the cell-driven `_engine_alerts`), `src/dashboards/severity.py` (the only threshold table), `src/dashboards/web/static/page-llm.js` + `pages/llm.html` (the `only:` scoping, strip cells, slot grid), `tests/test_llm_source.py` / `tests/test_collectors.py` / `tests/test_api.py` / `tests/test_config.py` / `tests/test_live_fleet.py`, `scripts/capture-fixtures.sh`, `.env.dev` / `.env.dev.example`, `README.md`.
## Objective
The dashboard treats radiance (a vLLM-derived engine, v1.15.1, model `juggernaut`, on `driveripper:8000`) as a **first-class third engine** — `llm:radiance` — with its own field map pinned by a verbatim capture, its native windowed throughput gauges winning per rule 2, its `/stats` endpoint as the session source (radiance's answer to gufo's `/slots`), and a radiance strip on `/llm` (queue cap, version/uptime, per-card VRAM, the 7-bar spec-acceptance curve) — while gufo and vLLM keep their exact payloads, history names, and behaviour.
## Scope decisions taken with the operator (binding for this phase)
| # | Decision | Consequence |
|---|----------|-------------|
| 1 | **Radiance replaced vLLM at driveripper:8000.** | `.env.dev`'s `VLLM_BASE_URL` line becomes `RADIANCE_BASE_URL` (same address). The vLLM engine **code and env var stay** — `VLLM_BASE_URL` still configures a vLLM engine for other deployments; only the dev fleet's config moves. `AGENTS.md`'s fleet table says `vLLM :8000` for driveripper; that is the one stale fact the final pass fixes. |
| 2 | **The per-position spec-acceptance curve is in.** | `radiance:draft_position_acceptance_ratio{position="0".."6"}` renders as a 7-bar hand-rolled SVG on the radiance strip, `role="img"` + `aria-label`, pinned in `tests/test_api.py` beside every other chart. Snapshot-only: the seven per-position ratios do **not** get history series (a bounded store, rule 9); the overall `draft_acceptance_ratio` does. |
| 3 | **Engine internals the operator cannot act on are out.** | `link`/`passes`/`steps*`/`ckpt*`/`hit_cuts`/`released`/`kv_stream*`/`lookup*`/`linear*`/`attn_cached`/`client_disconnect`/`stream_overflow` stay out of the payload (flavour text is not helpful information). What the payload carries: native + measured throughputs, KV usage + max tokens/bytes, queue depth/cap/admission-rejected, preemptions, the prefix hit-rate gauge, overall + per-position spec acceptance, the decode-step gauges, `requests[]` sessions, per-card VRAM, `model`/`version`/`uptime_s`/`startup_s`, lifetime `tokens_total`. |
| 4 | **No new thresholds, no new write path.** | The only rated radiance cell is KV pressure, via the **existing** `llm.kv_cache_usage_pct` row (radiance publishes the same 0-1 ratio the vLLM map already feeds it). Spec acceptance is an efficiency number, not a verdict — uncoloured. No radiance endpoint accepts writes; the app's only upstream write stays `POST /api/ups/command` (invariant 1 unchanged). No new dependency, no new `/api` route — the served-route walk and the public-surface pin are untouched. |
## Dependencies
- `23_llm_rate_responsiveness` (complete) — the per-cadence `RateEstimator` and the refused-window → `n/a` + `Δ<window>` semantics this phase reuses for the measured Δ of radiance's vLLM-shaped counters.
- `13_llm_page_and_decode_rate` (complete) — the engine-panel/strip shape, the `only:` scoping, the served-byte pins this phase extends.
- `24_dev_mtls_rollout` (complete) — the direct-dial fleet; radiance is dialled the same plain way the other engines are (no token, no certificate — the operator's address answers unauthenticated).
- Independent of `33_gateway_route_switching` (todo, in progress) — orthogonal module; no shared file besides `api.py`'s already-present llm routes, which this phase does not touch.
## Tasks
1. `01_capture_radiance_ground_truth.md` — `capture-fixtures.sh` records radiance's `/metrics`, `/stats` and `/v1/models` verbatim into `tests/fixtures/`, and the fixture name-set is pinned hermetically.
2. `02_config_and_the_engine_registry.md` — `RADIANCE_BASE_URL` / `RADIANCE_API_KEY`, one `llm_engines()` registry replacing the five hardcoded gufo/vllm enumeration sites, the dev config moves to radiance.
3. `03_the_radiance_field_map.md` — `RADIANCE_SERIES`/`RADIANCE_HISTOGRAMS`, `parse_radiance_metrics()`, the radiance `_sessions()` arm, the additive history series, `fleet_totals`' seams copy.
4. `04_the_radiance_job.md` — `fetch_engine` learns the best-effort `/stats` fetch, the `llm:radiance` job.
5. `05_the_llm_page_learns_radiance.md` — the radiance strip (queue cap, version/uptime, per-card VRAM, the 7-bar spec curve), the `only:` scopes, `requests[]` rows, the empty-state text.
6. `06_docs_and_live_verify.md` — the live read-only test, the fixture re-capture, README/AGENTS.md pass, the live-data screenshots.
## Testing & Quality
- Unit/integration: the radiance parse against the verbatim fixtures (name-set drift pin, rule-2 precedence in both directions, absent → `n/a`, the refused-window arm, severity pass-through, the per-position shape, both session arms, `fleet_totals` with a radiance payload), the collector's best-effort `/stats` (a dead `/stats` keeps the `/metrics` poll healthy — invariant 4), the registry (one place lists the engines), the API surface (`/api/config.llm.radiance`, the 404 gate, the history defaults, the summary), and the live read-only test.
- Coverage: **>90%** on new/modified code; the whole suite 100%; `ruff` + `pyright` clean.
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's — pre-existing failures included) and achieves **>90%** code coverage on new/modified code.
- Every UI change is verified in a real browser against live data, screenshot in `data/logs/` (AGENTS.md rule 10) — with gufo and radiance both answering, the cross-engine fleet block finally renders on the dev fleet, which is the phase's own proof that the sum is honest.
## Documentation & Consistency
- The final pass reviews `README.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by this phase**. For `AGENTS.md` it fixes only *stale statements* (a dead command, a changed rule, an outdated fact) and **never adds or expands its content** (a file that states its own update policy, e.g. "updated rarely, if ever", wins).
- Tasks update `README.md` immediately when their own changes are user-visible (setup, commands, configuration, features, conventions), and `AGENTS.md` only when a statement in it became stale.
## Completion Criteria
- [ ] `RADIANCE_BASE_URL` set and `GUFO_BASE_URL` set: `curl -s http://127.0.0.1:8090/api/llm/snapshot | jq .engines | jq 'map(.engine)'` lists `gufo`, `radiance` (and `vllm` only if configured), and `fleet.engines_live` is 2; `/api/config.llm.radiance.enabled` is `true`.
- [ ] `RADIANCE_BASE_URL` unset: `/api/llm/snapshot` answers `404 {"configured": false}` with no dial (spy-pinned), and no `llm:radiance` job exists in the hub.
- [ ] `tests/fixtures/metrics_radiance.txt` and `tests/fixtures/stats_radiance.json` exist, are verbatim captures, and a hermetic test fails if the fixture's `radiance:*`/`vllm:*` name set drifts from `RADIANCE_SERIES`/`RADIANCE_HISTOGRAMS`.
- [ ] The radiance panel on `/llm` shows the native `Δ<window>`-labelled throughputs when the gauge is absent, the KV bar with its severity colour, the 7-bar spec curve with its `aria-label`, the `requests[]` rows when a request is live and the counts-otherwise arm at rest — screenshot in `data/logs/`.
- [ ] `uv run pytest -m live --live` includes a radiance test that passes read-only against the running engine.
- [ ] full test suite **100% passing** (every test in the project, not just this phase's), coverage >90%; `ruff` + `pyright` clean; `bash .agents/validate.sh` green.
- [ ] README.md is accurate and consistent with the whole project; AGENTS.md has no stale statements (verified in the final pass — the `vLLM :8000` fleet fact included).
- [ ] no behaviour change in completed work: the gufo and vLLM payloads, history names, alerts, and served pages are byte-identical where this phase does not touch them (the suite's existing pins untouched).
@@ -0,0 +1,34 @@
# Task 01 — Ground truth first: radiance's /metrics, /stats and /v1/models recorded verbatim
**Phase:** `34_radiance_llm_engine` · **Story:** n/a
## Objective
`scripts/capture-fixtures.sh` records radiance's three endpoints verbatim into `tests/fixtures/`, driven by the same env the app reads (AGENTS.md rule 7: field maps are captured from the running engine, never trusted to a README or a memory of an upstream), and a hermetic test pins the recorded `radiance:*`/`vLLM-compat` name set so a metric rename fails the suite before the dashboard does.
## Work
1. `.env.dev` — the engine block: `VLLM_BASE_URL=http://driveripper.reeselink.com:8000` becomes `RADIANCE_BASE_URL=http://driveripper.reeselink.com:8000` with a comment line (the operator's decision of 2026-10-24: radiance replaced vLLM at that address; the vLLM engine code stays for other deployments). `.env.dev` is gitignored — this edit is what makes the live capture below dial the right thing.
2. `scripts/capture-fixtures.sh` — the engine section learns radiance, the same way it reads the engine URLs (nothing hard-coded, so a capture can never come from a different topology than the dashboard polls):
- `[ -n "${RADIANCE_BASE_URL:-}" ] && ENGINES+=("radiance:${RADIANCE_BASE_URL%/}/metrics")` (the existing `--with-llm` gate stays the only switch).
- The radiance entry captures **three** files, not one: `metrics_radiance.txt` (the `/metrics` body, verbatim), `stats_radiance.json` (the `/stats` body, verbatim JSON), `models_radiance.json` (the `/v1/models` body — small, and the model's `capabilities`/`max_model_len` are the only place the engine says what it can do). Each gets the same verbatim-header comment the other fixtures carry: recorded by the script, volatile values move between captures, the contract asserts **shape and name set, never the numbers** (the `nut_*.vars` precedent).
- The `requests[]` shape: the capture at rest records `requests: []` (the engine was idle at first contact). The header comment says so, and the script prints a hint line (`requests[] was empty — re-run with a request in flight to record its shape`) when it sees `in_flight == 0` in the stats body. Task 03's session arm must handle the empty shape; if the operator runs a re-capture with live traffic before task 03 lands, the fixture carries the real rows and task 03's defensive arm is tested against both.
- `--with-llm`'s `ENGINES` loop currently appends `<name>:<url>/metrics` only; restructure it so each engine entry is `<name> <url>` and the loop writes the `<name>.txt` metrics file plus, for radiance only, the two JSON sidecars (gufo keeps its separate `/slots` pass exactly as today).
3. `tests/test_llm_source.py` — a fixture-loader following the existing skip pattern (`tests/conftest.py` skips a contract test when its fixture is missing, naming the capture command in the skip message — the new test skips the same way). With the fixtures present it asserts, against `metrics_radiance.txt`:
- the sorted set of series names in the file equals the sorted set `RADIANCE_SERIES` names + `RADIANCE_HISTOGRAMS` names that the fixture actually carries (the drift pin; a renamed `radiance:*` series fails this test, the way the gufo/vLLM captures are pinned);
- every name in the map that the fixture does not carry is listed in a module-level `RADIANCE_OPTIONAL` set (names that exist on other radiance builds), so "absent here" and "absent everywhere" are different things in the test (rule 2's absence arms need to be tested against real absence, not a typo).
- a second loader for `stats_radiance.json`: the top-level key set is pinned as a frozenset (shape, not values), `cards[]` is a non-empty list of dicts with `index`/`name`/`vram_used`/`vram_total`, `requests` is a list, and `draft_positions` is a list of dicts with `reached`/`accepted`/`rate`.
## Testing & Quality
- Unit: the fixture-loader + name-set + shape tests above (they are the unit — the fixtures are the input contract).
- Coverage: **>90%** on this task's new/modified code (the script is shell; its behaviour is pinned by `tests/test_deploy_scripts.py`'s existing capture-script tests — extend one of them to assert the radiance branch exists and writes the three files when `RADIANCE_BASE_URL` is set, using the script's existing sandbox/fake-curl pattern).
- The live run itself (operator's machine, this dev fleet): `set -a; . ./.env.dev; set +a && ./scripts/capture-fixtures.sh --with-llm` produces the three files with the recorded headers.
## Completion Criteria
- [ ] `./scripts/capture-fixtures.sh --with-llm` (with `.env.dev` sourced) writes `tests/fixtures/metrics_radiance.txt`, `stats_radiance.json`, `models_radiance.json`, each with a verbatim header; the `requests[]`-empty hint printed at rest.
- [ ] `tests/fixtures/metrics_radiance.txt` carries both families on one file: `grep -c '^radiance:' tests/fixtures/metrics_radiance.txt` > 0 and `grep -c '^vllm:' tests/fixtures/metrics_radiance.txt` > 0 (the vLLM-derived shape is part of the contract).
- [ ] The hermetic name-set/shape tests pass (and skip with the capture command named when the fixtures are absent).
- [ ] `tests/test_deploy_scripts.py`'s capture-script pin covers the radiance branch.
- [ ] full test suite **100% passing** (every test in the project, not just this task's).
- [ ] README.md updated for anything user-visible this task changes (the capture script's `--with-llm` line now records three engine endpoints); AGENTS.md touched only if a statement in it became stale (the phase's final pass re-checks both)
- [ ] no behavior change in completed work (gufo's `/slots` pass and the agent/UPS captures behave exactly as before)
@@ -0,0 +1,38 @@
# Task 02 — One registry: `RADIANCE_BASE_URL` and the five hardcoded engine lists become one function
**Phase:** `34_radiance_llm_engine` · **Story:** n/a
## Objective
Radiance is configured the way the other two engines are (a base URL, an optional API key, unset by default → "not configured", rule 3), and the **five** places that enumerate the gufo/vllm pair by hand — `collectors.llm_jobs`, the `llm_router` 404 gate, the history-route defaults, `/api/config`'s llm block, and the summary's `llm.enabled` — all read one `llm_engines()` registry, so the next engine is a one-line addition and no enumeration site can silently keep a two-engine world.
## Work
1. `src/dashboards/config.py`
- Beside the existing engine settings: `radiance_url: str = _base_url("RADIANCE_BASE_URL")` and `radiance_api_key: str = _env("RADIANCE_API_KEY")` — the same shapes, the same comment idiom (unset = "this deployment does not run it", never a red panel).
- `any_source_enabled` gains `or bool(self.radiance_url)`.
- `def llm_engines(self) -> list[tuple[str, str, str, str]]` — ordered `(key, display, url, api_key)` for the three engines, in **stable display order** `gufo, vllm, radiance` (the UI's card order and the history defaults follow it; the order is part of the contract, pinned in a test), with a comment that the list is the single place an engine's presence is decided. A `bool(self.radiance_url)`-style `configured` test lives at the call sites (`url` non-empty), matching how `nut_enabled`/`gateway_enabled` work — no new property for nothing.
2. `src/dashboards/collectors.py` — `llm_jobs` rewrites its two `if` blocks over `settings.llm_engines()`: one `Job(f"llm:{key}", "llm", display, settings.llm_interval, …)` per **configured** engine; `with_slots=True` when `key == "gufo"`, `with_stats=True` when `key == "radiance"` (the flag plumbing lands in task 04; this task introduces the flag on `fetch_engine` as a no-op parameter so `llm_jobs` reads its final shape now).
3. `src/dashboards/api.py`
- The `llm_router` 404 gate (today `if not (ctx.settings.gufo_url or ctx.settings.vllm_url)`) becomes "no configured engine" over the registry — same `404 {"configured": false, "detail": …}` shape, and the `detail` sentence lists all three variable names (`GUFO_BASE_URL` / `VLLM_BASE_URL` / `RADIANCE_BASE_URL`) so the not-configured arm names the fix.
- The `/api/llm/history` defaults (`(("gufo", ctx.settings.gufo_url), ("vllm", ctx.settings.vllm_url))`) become the registry's configured `(display, url)` pairs — the `engines` query-param filtering behaves exactly as today.
- The summary's `llm.enabled` (today `bool(ctx.settings.gufo_url or ctx.settings.vllm_url)`) becomes "any engine configured" over the registry.
- `/api/config`'s llm block gains a third key, **additively**: `"radiance": {"enabled": bool(settings.radiance_url), "base_url": strip_userinfo(settings.radiance_url), "auth": bool(settings.radiance_api_key)}` — the existing `gufo`/`vllm` keys keep their exact names and shape (the API shape is additive-only, LOCKED).
4. `.env.dev.example` — the engines block keeps the gufo line, keeps the vllm line (vLLM is still a supported engine for other deployments), and adds `export RADIANCE_BASE_URL=http://driveripper.example.net:8000` with the same documentation idiom. `README.md`'s env-var table row (`GUFO_BASE_URL / VLLM_BASE_URL …`) gains radiance in the same sentence shape (the "unset means *not deployed here*" clause applies unchanged).
5. `tests/test_config.py`
- `RADIANCE_BASE_URL` unset → `radiance_url == ""`, no job, `any_source_enabled` unchanged by it; set → job present, `any_source_enabled` true.
- `llm_engines()` pins: the exact ordered triple, the display names (`gufo`, `vLLM`, `radiance`), the api-key passthrough (a set `RADIANCE_API_KEY` travels into the tuple), and that an unset engine is **still listed** with an empty url (the registry is the full fleet, configured-ness is a url test — the `parse_targets` idiom).
6. `tests/test_api.py` — `/api/config.llm.radiance` absent-by-default pin (additive key, `enabled: false`); the 404 gate with only `RADIANCE_BASE_URL` set (no 404) and none set (404 + the three-variable detail sentence); the history defaults listing a configured radiance.
## Testing & Quality
- Unit/integration: the registry pins above; the 404 gate in all three single-engine configurations; the config block's additivity (a diff-pin: the gufo/vllm keys' bytes are unchanged when radiance is added).
- Coverage: **>90%** on `config.py` and the touched `api.py`/`collectors.py` paths; the whole suite 100%; `ruff` + `pyright` clean.
## Completion Criteria
- [ ] `RADIANCE_BASE_URL=… uv run python -c "from dashboards.config import load; e=[x for x in load().llm_engines() if x[0]=='radiance']; print(e)"` prints the one configured entry; unset, the entry is present with an empty url.
- [ ] `grep -n "gufo_url\|vllm_url" src/dashboards/collectors.py src/dashboards/api.py` shows no engine enumeration outside the registry (the settings fields themselves remain — they are the env surface).
- [ ] The 404 gate and the history defaults answer from the registry in all configurations (spy-pinned: an unconfigured radiance is never dialled — no client, no fetch).
- [ ] `/api/config` serves `llm.radiance` beside byte-identical `llm.gufo`/`llm.vllm`.
- [ ] full test suite **100% passing** (every test in the project, not just this task's).
- [ ] README.md updated for anything user-visible this task changes (the env-var table row); AGENTS.md touched only if a statement in it became stale (the phase's final pass re-checks both)
- [ ] no behavior change in completed work (a gufo-only or vLLM-only deployment's `/api/config`, 404, history and summary bodies are byte-identical to before, modulo the new additive `radiance` key in the config block)
@@ -0,0 +1,43 @@
# Task 03 — The radiance field map: rule-2 precedence, `/stats` as the session source, no invented number
**Phase:** `34_radiance_llm_engine` · **Story:** n/a
## Objective
`src/dashboards/sources/llm.py` parses a radiance poll the way it parses gufo and vLLM — a field map **built from the task-01 fixture** (rule 7), the native windowed throughput gauges winning over a measured Δ (rule 2's precedence, the vLLM-native-gauge case), `n/a` where the engine is silent (never `0`), the KV cell rated through the **existing** `llm.kv_cache_usage_pct` severity row, and `/stats` as the session source beside gufo's `/slots` and vLLM's counts-only arm.
## Work
1. `src/dashboards/sources/llm.py` — the map, **names taken from `tests/fixtures/metrics_radiance.txt`, not from memory** (the module docstring's capture ritual already says how; the vLLM-compat subset is part of the same file, so one `Prom` parse serves both families):
- `RADIANCE_SERIES` (UI key → (names, agg)), the radiance gauges plus the vLLM-shaped series this build carries: `decode_tokens_per_second_native` (`radiance:decode_tokens_per_second`, avg — the scheduler's recent-window aggregate, the thing that wins), `prompt_tokens_per_second_native` (`radiance:prefill_tokens_per_second`, avg), `num_requests_running` / `num_requests_waiting` (`vllm:…`, sum), `gpu_cache_usage` (`vllm:kv_cache_usage_perc`, max — the same 0-1 ratio the vLLM map feeds `SEVERITY_CELLS`, so `attach_cell_severity` rates it with **no new row**), `kv_cache_max_tokens` / `kv_cache_max_bytes` (`radiance:…`, max), `queue_depth` (`radiance:http_queue_depth`), `admission_rejected_total` (`radiance:http_admission_rejected_total`), `num_preemptions_total` (`vllm:num_preemptions_total`), `prompt_tokens_total` / `generation_tokens_total` (`vllm:…`, sum — the measured-Δ source), `gpu_prefix_cache_hit_rate` (`vllm:gpu_prefix_cache_hit_rate`, avg — a 0-1 gauge; **this build publishes no prefix counters**, so a hits/queries ratio would be an invented number: the gauge is the answer, source named, not derived — the documented asymmetry, the gufo `*_native` note's sibling), `spec_accept_rate` (`radiance:draft_acceptance_ratio`, avg — a 0-1 gauge, **reusing gufo's UI key** so the history name, the fleet block and the UI row all work without a new vocabulary), `decode_step_seconds_recent` / `decode_step_seconds_best` (`radiance:…`, max).
- `RADIANCE_HISTOGRAMS`: `time_to_first_token_seconds` (`vllm:time_to_first_token_seconds`), `time_per_output_token_seconds` (`vllm:time_per_output_token_seconds`, `vllm:inter_token_latency_seconds`), `e2e_request_latency_seconds` (`vllm:e2e_request_latency_seconds`) — the vLLM-shaped histograms the build carries. This build has **no** `request_queue_time_seconds`, so `queue_seconds` is `n/a` for radiance by absence, never back-filled.
- `RADIANCE_OPTIONAL` (task 01's set of map names absent from the fixture — e.g. the `vllm:num_requests_swapped`-class names) so the hermetic test distinguishes "not on this build" from "not anywhere".
2. `parse_radiance_metrics(text, rates, now, stats=None)` — the gufo/vLLM parse functions' idiom, with the **vLLM-side** throughput precedence (native gauge is the aggregate, so it wins):
- `decode_tokens_per_second` / `prompt_tokens_per_second`: the native gauge if present (source = the gauge's name, `derived=False`); else the measured Δ of the token counter **per label set** via the existing `_series_delta` (keyed by resolved name + labels — phase 13's carried-counter rule; a refused window is still an answer: `n/a` beside its honest `Δ<window>`); else `field(None, None)`. **No third estimate** (no `running × 1/tpot`): a gauge + a counter + a guess stacked on one cell is exactly the aggregation rule 2 exists to catch, and the engine's own page shows only the first two.
- `tokens_total` from the two `vllm:` token counters (prompt + generated, as the vLLM path does); `prefix_cache_hit_ratio` from the gauge (0-1, source named); `spec_positions` from the labelled series `radiance:draft_position_acceptance_ratio` via `parsed.by_label(…, "position")` — an **ordered** list of 7 `{position, ratio}` entries (0-1, in the order 0..6 the engine publishes; absent positions stay absent, not zeroed).
- `attach_cell_severity(out)` at the end, unchanged (the `gpu_cache_usage` key carries the verdict; **no** new severity row for spec acceptance — an efficiency number, decision 4).
3. `_sessions()` — the third arm, keyed on the `stats` dict task 04's fetch supplies:
- `stats` present: `running` / `waiting` / `queue_cap` / `requests` come from it (the engine's own summary — the numbers its own page prints). `requests` empty → `detail` = counts + cap, one honest sentence (the vLLM arm's idiom: "radiance reports counts, not sessions: N running of cap C. Per-request detail appears here while requests are in flight."). Non-empty → `list` = the rows verbatim (the fixture's shape is the contract; a row's fields render as-is, task 05), `source` = "radiance /stats".
- `stats` missing (the fetch failed — invariant 4: the poll itself is healthy) → fall back to the `/metrics` gauges `num_requests_running`/`num_requests_waiting` for the counts (they are real numbers, different provenance — the detail says `/stats` did not answer this poll), `queue_cap` `null` (never `0` — rule 2).
- `active` stays `metrics["num_requests_running"]["value"]` in every arm (one definition, the `/metrics` gauge).
4. `engine_result(engine, base_url, text, rates, *, slots=None, stats=None, now=None)` — the parser dispatch becomes the three-way map (gufo / vllm / radiance); a radiance poll attaches `payload["stats"]` = the small additive subset `{model, version, uptime_s, startup_s, total_requests, cards[]}` (each card `+ vram_used_pct` computed `used/total×100`, `derived: true` — the only derived number in the block; the raw bytes travel as the engine published them). The subset is **whitelisted from the fixture's shape**, so a radiance upgrade that renames a `/stats` key degrades to absent, not a crash (invariant 4's poll-body rule).
5. `HISTORY_METRICS` — **additive candidates only** (the existing keys and their candidate order are untouched): `queue_depth` → `("queue_depth",)`, `queue_cap` → `("queue_cap",)` (the `queue_cap` metrics key is the `/stats` passthrough), `decode_step_seconds_recent` → `("decode_step_seconds_recent",)`, `vram_used_pct` → `("vram_used_pct",)` (card 0, the engine's own view of the same card `/system` reads through the agent — different source, different host key, both honest). The existing keys already cover radiance: `decode/prompt_tokens_per_second` (the UI keys the precedence logic fills), `kv_cache_usage` (the `gpu_cache_usage` candidate), `requests_running`/`requests_waiting` (the `num_requests_*` candidates), `tokens_total`, `spec_accept_rate` (the gauge reuses gufo's key). The seven per-position ratios are **snapshot-only** (decision 2: a bounded store, rule 9).
6. `series_names(engine)` — the three-way branch; `fleet_totals(engines)` — the code needs **no change** (the aliases `num_requests_running`/`num_requests_waiting`, the `decode/prompt_tokens_per_second` keys, `tokens_total` and the `gpu_cache_usage` max all already cover a radiance payload); **only** the `seams` sentence is rewritten to state, briefly and honestly, the known asymmetries: gufo's prompt counter excludes cache-hit tokens; the summed throughput is per-engine provenance-labelled (native scheduler window vs measured Δ). It does not claim what radiance's `vllm:prompt_tokens_total` includes or excludes about cached tokens — the engine does not say, and neither does the dashboard (rule 2).
## Testing & Quality
- Unit/integration in `tests/test_llm_source.py` (fixture-driven, the conftest skip pattern from task 01):
- the fixture parse: every mapped key resolves (shape + source strings), the name set equals the fixture's (task 01's pin), `spec_positions` is the ordered 7, `stats`-arm sessions as specced (empty / non-empty / missing, three fakes), the `payload["stats"]` whitelist (a fixture with a renamed `version` key degrades to absent, the poll still returns), the radiance history-sample names (`llm.queue_depth`, `llm.queue_cap`, `llm.decode_step_seconds_recent`, `llm.vram_used_pct`, …) appear and the gufo/vLLM sample names are untouched.
- rule 2, three arms: gauge present → source is the gauge name, `derived` false; gauge line removed from the fixture copy → measured Δ, `derived` true, the window (or the refused-window `n/a` + `Δ` label, a fresh-estimator fake); both absent → `n/a`, no `0` anywhere; `queue_cap` with `stats` missing → `null`, not `0`.
- the severity pass-through: the `gpu_cache_usage` cell carries a `severity` verdict from the existing table at a fixture value above the warn bound; `spec_accept_rate` carries **no** severity key (decision 4 pinned both ways — an absence-only assertion, so it is asserted on the payload).
- `fleet_totals` with a gufo + radiance payload: the running/waiting sums, the true max KV, `engines_live` 2, the seams copy names the provenance labelling.
- the gufo and vLLM parse functions are byte-behaviour-unchanged (their existing pins untouched).
- Coverage: **>90%** on `sources/llm.py`; the whole suite 100%; `ruff` + `pyright` clean.
## Completion Criteria
- [ ] `parse_radiance_metrics` against the fixture returns every mapped key with its pinned source string; a series removed from a fixture copy answers `{"value": null}` — never `0`.
- [ ] the three throughput arms are proven in both directions (gauge wins / measured fills / `n/a` with the honest `Δ` label on a refused window).
- [ ] `spec_positions` is the ordered 7 from the fixture; `queue_cap` is `null` (not `0`) when `/stats` is absent; the KV cell carries the severity verdict and the spec cell carries none.
- [ ] `engine_result("radiance", …, stats=…)` carries the whitelisted `stats` block with the one computed `vram_used_pct`; a renamed stats key degrades to absent without raising.
- [ ] full test suite **100% passing** (every test in the project, not just this task's).
- [ ] README.md updated for anything user-visible this task changes (none expected — the payload surface is task 05's); AGENTS.md touched only if a statement in it became stale (the phase's final pass re-checks both)
- [ ] no behavior change in completed work (gufo and vLLM payloads, samples, alerts and pages behave exactly as before — the suite's existing pins untouched)
@@ -0,0 +1,33 @@
# Task 04 — The `llm:radiance` job: a `/stats` sidecar that can fail without failing the poll
**Phase:** `34_radiance_llm_engine` · **Story:** n/a
## Objective
`collectors.fetch_engine` fetches radiance's `/stats` **best-effort** — a dead or partial `/stats` degrades the session arm (task 03's missing-stats fallback) and never takes the `/metrics` poll down (invariant 4: a failed sidecar keeps the last good payload and marks nothing stale, while a dead `/metrics` is the real `UpstreamError`) — and `llm_jobs` (in the task-02 registry shape) builds the `llm:radiance` job with its own `Job` key, display name and the shared LLM cadence/estimator.
## Work
1. `src/dashboards/collectors.py` — `fetch_engine(…, with_slots=False, with_stats=False)`:
- The `with_stats` path, beside the existing `/slots` loop: one `GET {base_url}/stats` (no alias — radiance's own page uses exactly that path); on any `httpx.HTTPError` **or** a status ≥ 400 **or** a JSON that is not a dict, `stats` stays `None` and the function returns the metrics result with the missing-stats session arm. No `UpstreamError` is raised from this sidecar, no retry, no second dial — one best-effort read, the `/slots` idiom.
- The `UpstreamError` messages for the **metrics** dial keep the existing `strip_userinfo` redaction (invariant 6) — a `RADIANCE_API_KEY`-free URL never leaks, a tokenised one never leaks its token.
- `llm_jobs` (task 02's registry shape) passes `with_stats=True` for the radiance entry. No new `RateEstimator`: radiance rides the LLM cadence's existing estimator (task: `app.build_hub` is untouched — one estimator per measuring cadence stays the rule).
2. `src/dashboards/app.py` — nothing to change is the check: the `llm` jobs already flow from `llm_jobs` into the hub; confirm `llm:radiance` gets the LLM interval and the LLM-span estimator by the existing wiring (assert in a test, not by comment).
3. `tests/test_collectors.py`:
- a configured `RADIANCE_BASE_URL` (and no gufo/vllm) builds exactly one `llm:radiance` `Job` with `dashboard == "llm"`, display `radiance`, the LLM interval (the registry's shape from task 02, extended);
- a fake server with `/metrics` 200 and `/stats` 500 → the result is a healthy `Result` (payload present, `stats` absent, the session detail names the missing sidecar); `/stats` 200 with a non-JSON body → the same healthy degradation; `/stats` 200 with a valid dict → `stats` travels into the result;
- `/metrics` 500 with `/stats` 200 → the `UpstreamError` is the metrics one (the sidecar never masks the real failure);
- a tokenised `RADIANCE_BASE_URL` (`http://secret@host:8000`) whose metrics dial fails → the `UpstreamError` message contains no `secret` (the redaction pin, the existing engine-fetch precedent extended);
- the job list never dials an unconfigured engine (the existing spy pattern).
## Testing & Quality
- Unit/integration: the five pins above (healthy / degraded / masked / redacted / undialled).
- Coverage: **>90%** on the touched `collectors.py` paths; the whole suite 100%; `ruff` + `pyright` clean.
## Completion Criteria
- [ ] `set -a; . ./.env.dev; set +a` + `./scripts/dev-server.sh restart` → `./scripts/dev-server.sh status` shows `llm:radiance` ok (or its real failure) beside the other sources, and `/api/llm/snapshot` (authenticated) carries the radiance payload with the `stats` block.
- [ ] a faked dead `/stats` leaves the poll healthy (the test above, and the page shows the metrics panel with the counts-otherwise session arm).
- [ ] `grep -n "with_stats" src/dashboards/collectors.py` shows the flag feeding `fetch_engine` from `llm_jobs` only.
- [ ] full test suite **100% passing** (every test in the project, not just this task's).
- [ ] README.md updated for anything user-visible this task changes (none expected — no new env surface beyond task 02's); AGENTS.md touched only if a statement in it became stale (the phase's final pass re-checks both)
- [ ] no behavior change in completed work (the gufo `/slots` pass and the vLLM job behave exactly as before — the suite's existing pins untouched)
@@ -0,0 +1,39 @@
# Task 05 — `/llm` learns radiance: the strip, the 7-bar curve, and the honest empty arms
**Phase:** `34_radiance_llm_engine` · **Story:** n/a
## Objective
The radiance card on `/llm` shows the cells its payload carries — the native/measured throughputs with their provenance tags, the KV bar with its severity colour, the radiance strip (queue cap, build, uptime, per-card VRAM, the 7-bar spec-acceptance curve), and the `requests[]` rows when a request is live, the counts-otherwise sentence at rest — with every chart named for assistive tech and **no verdict colour** on any radiance cell except the KV bar (decision 4).
## Work
1. `src/dashboards/web/static/page-llm.js`
- The `only:` scopes extend, not new: `ttft`, `tpot`, `queue_mean`, `running` gain `'radiance'` (the build carries the vLLM-shaped histograms and the `num_requests_*` gauges — task 03's map fills the same cell keys); `spec_accept` becomes `['gufo', 'radiance']` (the gauge reuses gufo's key and is the same 0-1 scale, so the existing `%` row renders unchanged).
- New **radiance-only** strip cells (the `STRIP_CELLS` idiom, `only: ['radiance']`, each with a `tip` that tells the operator what the number is for):
- `cap` — "queue cap", the engine's `queue_cap` (the admission ceiling; `null` when `/stats` did not answer → the cell prints `n/a`, never `0`);
- `build` — `stats.version` (e.g. `1.15.1`), tip "The engine's own version string — a radiance upgrade changes it, the panel does not";
- `uptime` — `stats.uptime_s` through the existing `app.js` duration formatter;
- `vram` — card 0's `vram_used`/`vram_total` as `32.8 / 34.2 GiB` (GiB = 2³⁰, the only conversion on the page, labelled as such in the tip: "the engine's own view of the card; `/system`'s GPU row is the agent's view"), `n/a` when no card answered.
- The **7-bar spec curve** (decision 2): a small hand-rolled SVG on the radiance strip (the `charts.js` idiom, no library, no CDN — rule 5), one bar per position in `spec_positions` order (0..6), height ∝ the 0-1 ratio, drawn in a **neutral** token (the page's existing track/cool colours — never a severity colour, the value is an efficiency number, decision 4), with `role="img"` and an `aria-label` that names the chart and its range (e.g. `Spec-decode draft acceptance by position: 82% at position 0 falling to 65% at position 6`) — the label is **computed from the payload** (the "every chart names itself" rule, pinned below).
- The `requests[]` arm: non-empty `sessions.list` (source "radiance /stats") renders the rows as a compact table beside the counts (the gufo slot-grid idiom, shrunk — radiance rows are the engine's own fields, rendered **as-is**: the fixture's shape is the contract; a field that is a number prints `n/a`-free only when it is a number, anything else prints its string verbatim, no conversion, no invented key). Empty list → the `sessions.detail` sentence (task 03's counts + cap) in the existing detail slot.
- `SLOT_ENGINES` stays `['gufo']` (the slot grid is gufo's); the radiance rows live in the strip, not the slot grid.
2. `src/dashboards/web/pages/llm.html`
- The empty-state paragraph names all three variables: "Set `GUFO_BASE_URL` (llama.cpp / gufo), `VLLM_BASE_URL` and/or `RADIANCE_BASE_URL`."
- The fleet block's `fleet-note`/seams copy is payload-driven (`fleet.seams` from `fleet_totals`, task 03's rewritten sentence) — no new hardcoded copy.
3. `src/dashboards/web/static/dash.css` — only the tokens the 7-bar curve and the `requests` table need (reuse the existing neutral/cool/edge tokens; **no** new colour vocabulary — colour comes only from the payload's severity verdict, which for radiance is the KV bar alone).
4. `tests/test_api.py` — the served-byte pins extend: the radiance strip markup (the four new cells' `data-key`s), the curve's `role="img"` + `aria-label` joined to the existing chart-aria-label pin list (an absent label fails the test), and the requests-table container present-when-the-payload-carries-rows (a fixture-driven payload), absent-at-rest with the detail sentence.
5. Live verification (AGENTS.md rule 10): `./scripts/dev-server.sh restart` with `.env.dev` (gufo + radiance now both configured — the fleet block finally renders: two answering engines), sign in, `/llm`, screenshot the radiance card **at rest** (counts arm, curve, strip) into `data/logs/34-llm-radiance-rest.png`; if a request is in flight during the pass, a second screenshot with the rows visible.
## Testing & Quality
- Integration: the `test_api.py` pins above (markup, aria-label, both session arms) against fixture-driven payloads.
- Coverage: the UI is proven by the payload-contract pins + the screenshots (the JS-tests-none decision, PLAN.md); Python-side **>90%** on anything touched (none expected); the whole suite 100%; `ruff` + `pyright` clean.
## Completion Criteria
- [ ] The radiance card renders all task-03 cells; a payload with `stats` missing shows `n/a` on `cap`/`build`/`uptime`/`vram` (never `0`) and the counts-otherwise session sentence.
- [ ] The 7-bar curve is present with its computed `aria-label` (the pin fails on an absent label); no severity-colour token in its markup.
- [ ] The `only:` scopes: the vLLM-shaped rows (TTFT/TPOT/queue-time/running) and `spec accept` render on the radiance card from the fixture payload; no radiance row on the gufo card and no gufo row on the radiance card.
- [ ] `data/logs/34-llm-radiance-rest.png` exists, showing the card against live data with the fleet block rendered (two engines).
- [ ] full test suite **100% passing** (every test in the project, not just this task's).
- [ ] README.md updated for anything user-visible this task changes (the `/llm` page section, if it names the engines it draws); AGENTS.md touched only if a statement in it became stale (the phase's final pass re-checks both)
- [ ] no behavior change in completed work (the gufo and vLLM cards are byte-identical where this task does not touch them — the served-byte pins)
@@ -0,0 +1,35 @@
# Task 06 — Final pass: live read-only proof, fixture re-capture, README/AGENTS.md, the definition of done
**Phase:** `34_radiance_llm_engine` · **Story:** n/a
## Objective
The phase's verification pass: a read-only `live` test that drives the production radiance path against the running engine, the fixture re-capture with a diff read, the documentation pass (README in full; AGENTS.md stale-statements-only), and the definition-of-done checklist run against the dev server.
## Work
1. `tests/test_live_fleet.py` — the radiance test (the file's idiom: skipped unless `--live`, each test skips when its source is unset, **live tests must not write** — radiance exposes no write endpoint, so "read-only" is a list of GETs):
- skip when `RADIANCE_BASE_URL` is unset (the source gate, rule 3's live twin);
- dial the engine through the **production** path — `collectors.fetch_engine` with the LLM estimator (never a hand-rolled `client.get`), one poll;
- assert shape, not numbers: the payload's `engine == "radiance"`, the `stats` block's keys (task 03's whitelist), `spec_positions` length 7 when the fixture/running build publishes it, the session arm is one of the two specced arms, every cell that is present is either a number or `None` (never a fake `0` for an absent gauge — the live twin of rule 2), and `unparsed` is empty (a parse error on the live wire is drift, not a tolerance).
2. `./scripts/capture-fixtures.sh --with-llm` (`.env.dev` sourced) — re-record the radiance sidecars (and everything else), **read the diff**: a volatile value moving is expected (the headers say so); a renamed or new `radiance:*` series is a contract change — extend task 01's pin (and `RADIANCE_OPTIONAL` where a name is build-optional), never a silent ignore. If the earlier capture's `requests[]` was empty and a request is in flight now, re-capture so the fixture carries the real row shape, and re-run the session-arm tests against it.
3. `README.md` — the full final-pass review (the phase's documentation mandate): the env-var table (`RADIANCE_BASE_URL` / `RADIANCE_API_KEY` beside the engine rows, the "unset means *not deployed here*" clause), the LLM-engines section (a radiance paragraph in the gufo/vLLM idiom: what it is, the endpoints it serves, the native-gauge/measured-Δ provenance, the `/stats` sidecar), the capture-script section (three radiance endpoints under `--with-llm`), and every other place the engine count or the driveripper fact appears — fixing **every** inconsistency found, including pre-existing ones.
4. `AGENTS.md` — **stale statements only, no expansion** (its own update policy wins): the fleet table's driveripper line `vLLM :8000` → `radiance :8000` (the operator's decision of 2026-10-24); the `/llm` route row's source column (`gufo /metrics + /slots, vLLM /metrics`) gains `radiance /metrics + /stats`; `config.py`'s paragraph names the radiance settings beside the others; `sources/llm.py`'s bullet says the three field maps (gufo, vLLM, radiance) with the rule-2 precedence note already there. Nothing else — no phase narrative, no new rules.
5. The definition-of-done run, in order (AGENTS.md):
- `uv run pytest` green; `uv run pytest --cov` — coverage floor 94 % held, new/modified Python > 90 %; `uv run ruff check src tests agent && uv run pyright` clean; `uv pip list --outdated` empty (no dependency moved — confirm, do not assume).
- `set -a; . ./.env.dev; set +a && ./scripts/dev-server.sh restart` — `/api/health` sane (per-source ok/DOWN; `llm:radiance` answering), `./scripts/dev-server.sh status` read.
- `uv run pytest -m live --live` — the new radiance test passes read-only; the existing live tests (gufo, vLLM-where-configured, upsd, agents) still pass.
- Screenshots in `data/logs/`: the task-05 rest shot (and the in-flight shot if one was possible), and the overview page (`/`) showing the radiance engine's alert row when its KV bar is rated — the cell-driven `_engine_alerts` needs no code, only proof.
- `bash .agents/validate.sh` green.
## Testing & Quality
- The live test is the integration proof; the re-captured fixtures re-pin the hermetic suite; the documentation pass is checked by reading the two files against the project as it stands.
- Coverage: no new Python beyond the live test; the whole suite 100 %; `ruff` + `pyright` clean.
## Completion Criteria
- [ ] `uv run pytest -m live --live` includes the passing read-only radiance test (skipped, correctly, when unset — proven by one run without the env var).
- [ ] the fixture re-capture diff is read and either clean or folded into the pins (`RADIANCE_SERIES`/`RADIANCE_OPTIONAL`); the suite re-run is green.
- [ ] README.md: the engine section, env-var table, and capture-script section all name radiance; a full read found no remaining engine-count or driveripper stale statement.
- [ ] AGENTS.md: the three stale statements fixed (fleet line, route row, the two module bullets), nothing added or expanded beyond them.
- [ ] `./scripts/dev-server.sh status` shows `llm:radiance` answering; the `data/logs/` screenshots exist; `bash .agents/validate.sh` is green; the coverage floor held.
- [ ] full test suite **100% passing** (every test in the project, not just this task's) — the phase's "Complete" gate.