Files
lvmh/plugin/selftest.md
T

153 lines
8.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# lvmh-agent selftest
How the "BULLETPROOF" constraints were verified for `lvmh-agent.ts`.
Line numbers refer to `lvmh-agent.ts` in this directory.
## Validation runs (all executed, 2026-08-18)
| check | command | result |
| --- | --- | --- |
| Typecheck (strict, real pi types) | `npx --package typescript@5.9 tsc --noEmit -p .` in `plugin/` | **exit 0**, no errors |
| Offline behavior vs mini-daemon | `node plugin/smoke.ts` | **25/25 ok** (run 3× consecutive, all green) |
| Real pi end-to-end vs mini-daemon | `node plugin/e2e.ts` | **13/13 ok** (one real LLM call via `-p`) |
| Real pi + dead host | `LVMH_URL=ws://127.0.0.1:9/agent/ws LVMH_TOKEN=x pi -e ./plugin/lvmh-agent.ts -p "Reply with exactly: ok"` | replies `ok`, **exit 0**, plugin silently retries (log only, growing backoff), stdout clean |
Environment: Node v26.7.0, pi 0.84.1, TypeScript 5.9.
## Constraint checklist
### 1. Never crash pi / never throw from a handler
Every `pi.on` subscription goes through `sub()` (L183204): the handler body
runs inside `try/catch`; if it returns a promise, rejections are swallowed via
`.then(undefined, …)`; the wrapper itself always returns `undefined`. pi can
therefore never observe a throw or an unhandled rejection from this extension,
regardless of which of the 13 registered handlers fails (L459617).
Handler-internal risk points and their guards:
| code path | guard |
| --- | --- |
| `log()` (L7582) | `try/catch` around `fs.appendFile`; callback discards errors |
| `nextSeq`/map/emit payload building | pure data code; `mapMessage` (L113) and `textOfContent` (L91) treat every field as `unknown` and type-check before use |
| `enqueue`/`flush` (L206240) | `JSON.stringify` in `try/catch` (circular args → skip frame, L224); `ws.send` in `try/catch` → treated as disconnect + reconnect scheduled (L230236) |
| `connect()` (L288) | constructor in `try/catch` (invalid URL → backoff, L293); all four socket callbacks (`onopen` L297, `onmessage` L312, `onclose` L325) wrap their bodies in `try/catch`; stale-socket identity guard `if (ws !== socket) return` so an old socket's late callbacks can never clobber the new socket's state |
| `onMessage` (L382) | `JSON.parse` in `try/catch` (L385); non-object frames, wrong `v`, unknown types ignored |
| `deliverPrompt` (L400) | `pi.sendUserMessage(..., {deliverAs:"steer"})` in `try/catch` (L406); empty or non-string message ignored; foreign `sessionId` ignored |
| `doAbort` (L411) | `pi.abort` (if present) else `lastCtx.abort()` in `try/catch` |
| `buildSnapshot` (L421) | all `sessionManager` reads in `try/catch` with fallbacks (L433: ctx.cwd / Date.now) |
| `session_start` (L459) | `getSessionId()` in `try/catch`; no id ⇒ mirroring disabled for that session, no crash |
| `session_shutdown` (L490) | `clearTimeout` and bye-send/close both in `try/catch` (L494, L500) |
| `onWelcome` (L349) | validates `lastSeq` (`Number.isFinite`, else 0, L355); foreign-session welcome ignored (L350) |
That is **14 explicit `try/catch` blocks** plus the universal `sub()` wrapper.
Static pass over every function: no code path can throw to pi; nothing
`await`s — all I/O is fire-and-forget async (`fs.appendFile`, undici WS), so
nothing can block or hang the event loop either. The only synchronous work is
JSON serialization of outbound frames (bounded by message size).
### 2. Zero npm dependencies
Imports: `node:fs`, `node:os`, `node:path` (L2931), type-only import of
`@earendil-works/pi-coding-agent` (L33, erased at runtime), and the Node ≥ 22
global `WebSocket` (undici). Verified by `grep import` and by the e2e run
against real pi. `typeof WebSocket === "undefined"` guard at L156 makes the
extension inert (not broken) on older Node.
### 3. Bounded queue + drop-oldest + buffer_overflow
- Send queue cap 1000: `enqueue()` L206215 (`while` shift-oldest, every drop
counted and logged).
- Replay buffer cap 10000 (persisted-kind events only; `message_update` is
excluded per protocol): `emit()` L241252.
- Drops are never silent: `droppedEvents > 0` ⇒ `emit("buffer_overflow",
{dropped})` immediately after the next `welcome` is processed (L373376).
- Persisted-kind events dropped from the send queue are still in the replay
buffer and get recovered by replay; only `message_update` deltas can be
lost. `onWelcome` additionally filters persisted frames out of the stale
queue before merging replay (L367368) so replay + queue can never
double-send an event.
- Smoke scenario 4 proves it: 10 050 persisted events fired while
disconnected → reconnect → `buffer_overflow {dropped: n>0}` observed on the
wire, replay delivered, no duplicate seq.
### 4. WS send failures never propagate
`flush()` L230236: `ws.send` in `try/catch` → log, `handleDisconnect()`,
`scheduleReconnect()`. Same treatment in `session_shutdown` bye-send (L500).
Proven by smoke scenario 4 (server destroys sockets under load) and e2e.
### 5. Reconnect: backoff + jitter, cap 30s, forever, never throws
`scheduleReconnect()` L274286: `min(1000·2^attempt, 30000) + jitter[0,1000)`;
attempt counter reset only on a successful `welcome` (L356). Timer is
`unref()`'d (L283) so it can never keep pi's event loop alive. Dead-host run
above shows pi fully functional with the plugin retrying silently
(`~/.pi/lvmh-agent.log`: attempts #0,#1,#2 at ~1s/~2s/~3s spacing).
### 6. Log file only, console clean
Single sink `~/.pi/lvmh-agent.log` via `log()` L7582 (async append, truncated
to 500 chars/line, swallows all errors). `grep -n "console\." lvmh-agent.ts`
returns nothing; e2e asserts the plugin adds nothing to pi's stdout
("e2e plugin keeps stdout clean").
### 7. session_shutdown / unload: close, flush nothing, exit fast
`session_shutdown` handler L490519: synchronous, sends best-effort
`bye {reason:"shutdown"}` only if the socket is OPEN, `close()`s it, clears
the timer, sets `stopped` (blocks `emit`, `connect`, `scheduleReconnect`, and
`flush`). No flush, no awaits. Smoke scenario 6: connection count → 0, no
reconnect for the following 2.6s. E2E: `bye` frame observed on the wire, no
reconnect after.
### 8. Daemon down at startup ⇒ pi starts and runs perfectly
Proven twice: (a) smoke scenario 5 — factory loads against a closed port,
handlers never throw, event loop stays responsive (100ms timer completes
<500ms); (b) real pi run above — prompt answered, exit 0.
### 9. Protocol conformance details
- Envelope `v/sessionId/seq/ts/type` on every frame; `hello`/`bye`/`welcome`
use `seq: 0` (L339, L505).
- Per-session monotonic seq survives reconnects (module-level `seqCounters`,
L70); after a process restart, the counter is pushed past `welcome.lastSeq`
so seqs are never reused against a warm daemon (L360364).
- Message mapping (`mapMessage` L113): `role`, deterministic `id`
(`role-timestamp`), concatenated `text`, `thinking` or null,
`toolCalls[{id,name,argsJson}]`, `toolCallId` for toolResults — verified by
smoke "2 message_end mapping" and e2e user/assistant message_end.
- `message_update` carries only the text delta (diffed against the previously
sent prefix, L547556); non-prefix text (model retry) resends full text.
- Truncation to 2000 chars for `partial` and `resultPreview` (L576, L584).
- `agent_end.usage` sums `input`/`output`/`cost.total` across the run's
assistant messages; omitted entirely if none had usage (L595613).
- Multi-session: extension instance is re-created per session bind; on
`session_start` with a new session id the old socket/timers/queues are reset
(L475488) and seq counters stay per-session (module map).
## Known non-issues (documented, not plugin bugs)
- **pi print-mode exit hang**: `pi -p` with *any* extension loaded
(reproduced with an empty noop extension, no lvmh involvement) sometimes
does not exit after answering — a pi quirk, independent of this plugin. The
plugin releases all of its resources on `session_shutdown` (proven by `bye`,
socket close, and no reconnect in e2e). The e2e harness treats this as
informational.
- Sync flush of a large replay burst (~10k frames ≈ 1MB) takes single-digit
milliseconds of JSON serialization; it cannot hang the loop but is the
largest synchronous chunk the plugin can produce.
## Files
| file | role |
| --- | --- |
| `lvmh-agent.ts` | the extension (only file pi loads) |
| `README.md` | install / env / behavior |
| `tsconfig.json` | strict typecheck against real pi types (`paths`, `typeRoots`) |
| `pi-types.d.ts` | commented minimal fallback if pi's dist types move |
| `mini-daemon.ts` | dev-only hand-rolled WS daemon for tests |
| `smoke.ts` | dev-only offline test (25 checks) |
| `e2e.ts` | dev-only real-pi test (13 checks) |