phase: 12_nut_self_test_verdict
Final state verified. Report: **Verified (phase 12 — all 3 tasks already complete; this was the verification pass)** - Re-ran the wire capture live: `ups.test.result` = `No test initiated` (apc) / `Done and passed` (cyberpower) — matches `LIVE_SELF_TEST_WORDS` + both fixtures; `/api/summary` has **no** self-test row, `/api/ups/snapshot` reads `cyberpower ok | Done and passed`, `apc unknown | …run a battery test`; both screenshots read back correct. - Fixed a defect found in prior work while checking the "unrelated live noise" note: `NutMonitor.collect()` reused one upsd socket after a *partially-read* reply, so a 4 s stall produced bogus `expected 'BEGIN LIST VAR', got 'RW apc battery.charge.low "10"'` errors (same hazard on `INSTCMD`, where a late `OK` could be read as a different command's success). `NutClient` now marks a socket whose reply stopped early as `UNUSABLE`, naming the command that stalled; a complete `ERR …` line still leaves it usable (the per-UPS tolerance `collect()` depends on). +3 hermetic pins against the fake upsd; live re-check (09:33–09:44) now logs one line per stall, no cascade. **Tests / lint / coverage (all green)** - `bash .agents/validate.sh` → **exit 0**; `uv run pytest --cov --cov-fail-under=94` → **1172 passed, 11 skipped**, coverage **98.63 %** (floor 94); `uv run ruff check src tests agent` clean; `uv run pyright` 0 errors; `node --check` clean. Modified modules: `sources/nut.py` 95 %, `collectors.py` 97 % (no new line uncovered). - `uv run pytest -m live --live` → **8 passed / 3 failed**, all three the pre-existing `driveripper` agent-transport failures AGENTS.md records (verified still true: unit `inactive`, no installed file, nothing on `:9120`). - `./scripts/dev-server.sh status`: `nut ok`, `llm:* ok`, `host/disks:deskwork|gateway ok`; the two `driveripper` jobs DOWN. **Completion criteria** - Live capture recorded + asserts `ok` — met (re-confirmed from the wire today). - `/ups` renders `ok`, overview silent, screenshots present — met. - pytest / cov / ruff / pyright / validate — met (above). - No other `health.checks[]` behaviour changed — met (diff confined to the `test_result` branch; all prior nut/ups-contract tests unmodified). **Documentation**: README verified consistent (self-test wording, config table cross-checked against `config.py`, all documented commands/flags exist) — no changes needed beyond phase tasks' edit. AGENTS.md fixes: stale test count (1140 → 1172), `nvme0n1` power-on hours 19 158 → 19 160, gap 4 store-error claim corrected (last stored `store-error` 2026-10-18 06:33, none in the previous 24 h), new `collectors.py`/`sources/nut.py` socket-hygiene note, new gap-1 bullet on the upsd stall, plus live evidence appended to `data/logs/NOTES-self-test.md`. **Deviations**: made no live *changes* (read-only probes only) — `driveripper`'s agent install needs root (`ops` has no installer/visudo grant) and a battery test stays the operator's call; the upsd 4 s stall itself is upstream-side. `uv pip list --outdated` still advisory (certifi/fastapi/starlette) — dependencies untouched this phase. **Next pending phase**: `13_llm_page_and_decode_rate`.
This commit is contained in:
@@ -213,8 +213,17 @@ Responsibilities, one line each:
|
||||
Events are stored under the **dashboard** name (`system`/`disks`/`llm`/`ups`), not the sample
|
||||
source (`host`) — a page filtering `/api/events` by `host` gets an empty feed forever
|
||||
(found on `/system` during phase 03 browser verification).
|
||||
- `collectors.py` — the fetch half. `NutMonitor` owns per-UPS transition bookkeeping. Failure
|
||||
messages name the **redacted** URL (`strip_userinfo` / `Target.display_url`), never the one
|
||||
- `collectors.py` — the fetch half. `NutMonitor` owns per-UPS transition bookkeeping (its
|
||||
self-test event takes its severity from `nut.self_test_outcome()` and not its own word list,
|
||||
so a passed test is never an amber line in the feed while the card beside it reads green).
|
||||
Its per-UPS tolerance for a failed `LIST CMD`/`LIST RW` is for a *complete* error reply only:
|
||||
one battery whose command list is denied loses its buttons and the poll goes on, while a
|
||||
**stalled** reply fails the poll, because `NutClient` marks a socket it could not read to its
|
||||
`END` as unusable (a reply still arriving would otherwise be taken for the *next* command's
|
||||
answer — the real cause of the `expected 'BEGIN LIST VAR', got 'RW apc …'` lines this fleet's
|
||||
event table used to fill with, and the same hazard on `INSTCMD`, where a late `OK` read as a
|
||||
different command's success would be a gate that lies).
|
||||
Failure messages name the **redacted** URL (`strip_userinfo` / `Target.display_url`), never the one
|
||||
that was dialled, because they become health records and event rows. `fetch_snapshot()` is
|
||||
the one agent `/snapshot` read that `system` and `disks` share, so a dead host says the
|
||||
same sentence on both pages and only the `timeout` differs (the disks poll has to outlast
|
||||
@@ -247,7 +256,13 @@ Responsibilities, one line each:
|
||||
`VLLM_HISTOGRAMS`), measured Δ rates, prefill speed as
|
||||
`mean(prompt tokens)/mean(prefill time)`, session list from gufo `/slots`.
|
||||
- `sources/nut.py` — upsd client, `UpsReading`, `VAR_MAP`, `assess()` (explicit checks, not
|
||||
a mystery score), `COMMANDS` catalog with risk tiers, `COMMAND_ALIASES` + `resolve_command()`
|
||||
a mystery score), `SELF_TEST_OUTCOMES` + `self_test_outcome()` (the `Last self-test` verdict
|
||||
follows the *outcome* of the driver's `*.test.result` word, not its phrasing: pass words →
|
||||
`ok` (including `Done and passed`, what the CyberPower here answers), fail words → `fail`,
|
||||
`No test initiated` / empty / no variable → `unknown` printing `NEVER_TESTED_DETAIL` so both
|
||||
"never tested" states say one sentence, and an unrecognised word → `warn`, because a driver
|
||||
inventing a verdict is drift worth noticing and the check prints its own word to be added),
|
||||
`COMMANDS` catalog with risk tiers, `COMMAND_ALIASES` + `resolve_command()`
|
||||
(drivers answer two names for the same action — `beeper.enable`/`beeper.on`,
|
||||
`beeper.disable`/`beeper.off` — so one entry is offered per action and any of its names hits
|
||||
that entry's verdict, never a gate the rendered name missed), `available_commands()` filtering,
|
||||
@@ -566,7 +581,11 @@ That scheme is the history API's key — treat it as a public interface.
|
||||
CyberPower reports `battery.mfr.date: CPS` (not a date → `unknown`, never a fake age),
|
||||
**neither driver reports a battery temperature or the NUT 2.8 `battery.charger.status`**
|
||||
(so the battery glyph's state has to come from `on_battery`, and `charger_status` must stay
|
||||
optional), and `ups.test.result: No test initiated` is the factory state on both.
|
||||
optional), and **`ups.test.result` is where the two boxes disagree**: the APC still answers
|
||||
the factory `No test initiated` while the CyberPower answers `Done and passed` (a test has
|
||||
run on it since the phase-05 capture; both words re-read from the wire on 2026-10-19 and
|
||||
pinned as `LIVE_SELF_TEST_WORDS` in `tests/test_nut.py`, which fails if the fixture and the
|
||||
capture ever drift).
|
||||
Both UPSes are recorded verbatim (`LIST UPS`/`LIST VAR`/`LIST CMD`, serials scrubbed) as
|
||||
`tests/fixtures/nut_<ups>.vars` by `./scripts/capture-fixtures.sh`, and the wire quotes a
|
||||
field only when it must — `VAR apc ups.status "OL"`, not three quoted fields.
|
||||
@@ -588,7 +607,8 @@ That scheme is the history API's key — treat it as a public interface.
|
||||
What changed by 2026-10-19 is that `deskwork`'s **installed** service runs as
|
||||
`dashboards-agent`, which is in the `disk` group (`getent group disk` → `disk:dashboards-agent`)
|
||||
on an agent installed after the SMART pass: `/disks` shows real numbers there —
|
||||
`smartctl_available: true`, `nvme0n1` healthy at 52 °C, 19 158 power-on hours, 11 % of rated
|
||||
`smartctl_available: true`, `nvme0n1` healthy at 52 °C, 19 160 power-on hours (an odometer,
|
||||
so that figure is a capture date, not a fact about the drive), 11 % of rated
|
||||
wear — so the wear bar and the alarm chips are drawn from live hardware, not only fixtures.
|
||||
`gateway` answers `smartctl_available: false` (no smartmontools in its VM: the *install it*
|
||||
half of the three-way flag), and `driveripper` answers nothing at all until its agent exists,
|
||||
@@ -636,7 +656,7 @@ set -a; . ./.env.dev; set +a # dev dials the fleet the prod way, no
|
||||
./scripts/dev-server.sh status # per-source ok/DOWN — unauthenticated: /api/health
|
||||
./scripts/dev-server.sh logs
|
||||
|
||||
uv run pytest # hermetic: 1140 passed + 11 live-skipped, no network
|
||||
uv run pytest # hermetic: 1172 passed + 11 live-skipped, no network
|
||||
uv run pytest --cov # coverage gate (98.6%; floor 94%)
|
||||
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
|
||||
uv run ruff check src tests agent && uv run pyright
|
||||
@@ -679,6 +699,17 @@ branch `main`); there is no CI on it, so `bash .agents/validate.sh` is the only
|
||||
reading the end of an attribute row instead of its annotated RAW_VALUE column.
|
||||
- Anything that needs a failure mode — upsd refusing a command, an engine 500ing, a host
|
||||
rebooting mid-poll, a counter reset — is a fake in `tests/conftest.py`.
|
||||
- **A verdict the operator sees is pinned on the surface that shows it, in both directions.**
|
||||
`tests/test_nut.py` grades the word; `tests/test_api.py`'s `self_test_app` harness holds the
|
||||
`nut` job's own payload (its collector is swapped, so a poll cannot overwrite what the test
|
||||
mutated) and rewrites the `Last self-test` check in the object `/api/ups/snapshot` serves:
|
||||
a pass (`Done and passed`) and a never-tested pack produce **no** `/api/summary` alert row,
|
||||
a fail produces exactly one `fail` row per UPS linking to `/ups`. The two directions ship
|
||||
together on purpose — an absence-only assertion is satisfied by deleting the check, which is
|
||||
not the fix the report asked for (phase 12). The same file pins that the `/ups` check list
|
||||
needs **no** markup for a new verdict: `<li class="${check.status}">` plus
|
||||
`ul.checks li.<verdict> .mark` in `dash.css` is the whole render path, and no page asset or
|
||||
stylesheet rule names one check (a second opinion about the driver's word is the bug).
|
||||
- **Page payloads that a rewrite must not break are pinned as a table.**
|
||||
`tests/card_contract.py` holds the dotted `/api/system/snapshot` paths the `/system` hero
|
||||
cards read (`CARD_METRICS`, `GPU_METRICS`, `HOST_SHAPES`); `tests/test_system_contract.py`
|
||||
@@ -1106,6 +1137,15 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
|
||||
- **Pre-existing upsd log noise:** every upsd start logs `Unknown upsmon type =` (20
|
||||
occurrences over 12 starts, oldest before this work). Harmless — `upsmon` logs into both
|
||||
UPSes right after it — but unexplained.
|
||||
- **One connection every few minutes answers nothing for >4 s.** Live upsd here stalls a
|
||||
single poll past `NUT_TIMEOUT_SECONDS` roughly every 2–4 minutes and then answers normally
|
||||
again; `nut` reads `ok` throughout, because the poller's job is one amber line plus the
|
||||
recovery, not a frozen page (invariant 4). What phase 12's verification pass fixed on our
|
||||
side was the **cascade** that used to follow it — the event table filling with
|
||||
`expected 'BEGIN LIST VAR', got 'RW apc battery.charge.low "10"'`, which blamed the wire
|
||||
protocol for a stall two commands earlier (see the `NutClient` bullet above). The stall
|
||||
itself is an upsd/driver-side question with no repo bug in it, and a battery test is not
|
||||
how to chase it.
|
||||
Editing `upsd.users` at all: never grant `actions = -`, and never write it blind — the
|
||||
running `upsmon` authenticates as its own account from that same file for both UPSes, so
|
||||
dropping it means no orderly power-down. Copy the untouched sections from the live bytes
|
||||
@@ -1118,16 +1158,16 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
|
||||
3. **UPS history depth.** Events and the numeric series are stored; long-window battery
|
||||
health (capacity fade over months) has the rollups but no dedicated view.
|
||||
4. **The dev DB is big enough to refuse a write now and then.** The dev store (~640 MB on
|
||||
2026-10-19, its WAL grown past 140 MB at its worst) logs `store-error: OperationalError:
|
||||
database is locked` a few times an
|
||||
hour across `system`/`llm`/`ups`; the poller handles it exactly as invariant 4 requires —
|
||||
the source goes red with the real reason, ages, and writes `recording` when the next poll
|
||||
lands — so nothing freezes, but those polls' samples are lost. The one cause this repo can
|
||||
prove is two servers on one file, which is why `scripts/dev-server.sh stop` waits for the
|
||||
old pid to die (its comment records the incident). A controlled 25 s external read
|
||||
transaction during this pass produced none, and neither did the dev-server log window open on
|
||||
2026-10-19 — the absence of a sample, not the absence of the pressure, since the file only
|
||||
keeps growing.
|
||||
2026-10-19, its WAL grown past 140 MB at its worst) logged `store-error: OperationalError:
|
||||
database is locked` a few times an hour across `system`/`llm`/`ups` through 2026-10-18; the
|
||||
poller handles it exactly as invariant 4 requires — the source goes red with the real reason,
|
||||
ages, and writes `recording` when the next poll lands — so nothing freezes, but those polls'
|
||||
samples are lost. The one cause this repo can prove is two servers on one file, which is why
|
||||
`scripts/dev-server.sh stop` waits for the old pid to die (its comment records the incident).
|
||||
A controlled 25 s external read transaction during the phase-09 pass produced none, and
|
||||
phase 12's re-check on 2026-10-19 found the 190 stored `store-error` rows all older than
|
||||
2026-10-18 06:33 with none in the previous 24 h of the running dev store — the absence of a
|
||||
sample, not the absence of the pressure, since the file only keeps growing.
|
||||
Retention/`wal_checkpoint` tuning for a long-running store is its own phase, not a
|
||||
phase-09 follow-up.
|
||||
5. **The login throttle keys on the socket peer address** (phase 11), and the one
|
||||
|
||||
Reference in New Issue
Block a user