153 lines
8.6 KiB
Markdown
153 lines
8.6 KiB
Markdown
# 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()` (L183–204): 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 (L459–617).
|
||
|
||
Handler-internal risk points and their guards:
|
||
|
||
| code path | guard |
|
||
| --- | --- |
|
||
| `log()` (L75–82) | `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` (L206–240) | `JSON.stringify` in `try/catch` (circular args → skip frame, L224); `ws.send` in `try/catch` → treated as disconnect + reconnect scheduled (L230–236) |
|
||
| `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` (L29–31), 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()` L206–215 (`while` shift-oldest, every drop
|
||
counted and logged).
|
||
- Replay buffer cap 10000 (persisted-kind events only; `message_update` is
|
||
excluded per protocol): `emit()` L241–252.
|
||
- Drops are never silent: `droppedEvents > 0` ⇒ `emit("buffer_overflow",
|
||
{dropped})` immediately after the next `welcome` is processed (L373–376).
|
||
- 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 (L367–368) 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()` L230–236: `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()` L274–286: `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()` L75–82 (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 L490–519: 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 (L360–364).
|
||
- 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, L547–556); 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 (L595–613).
|
||
- 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
|
||
(L475–488) 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) |
|