pi-legion 3.0.3 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +263 -2
  2. package/CONTRIBUTING.md +4 -17
  3. package/README.md +29 -22
  4. package/agents/worker.md +1 -1
  5. package/docs/contracts/display-liveness-v1.md +373 -0
  6. package/docs/contracts/drop-non-pi-kinds-v4.md +198 -0
  7. package/docs/contracts/esc-abort-v1.md +4 -4
  8. package/docs/contracts/esc-no-retire-v3.md +2 -2
  9. package/docs/contracts/launch-admission-queue-v1.md +839 -0
  10. package/docs/contracts/status-panel-v1.md +174 -0
  11. package/docs/inprocess-supervisor.md +37 -4
  12. package/docs/legion-v2-contract.md +18 -2
  13. package/docs/performance-2026-10-19.md +199 -0
  14. package/docs/spawn-mode-visibility-v1.md +206 -0
  15. package/docs/vexillatio-contract.md +3 -1
  16. package/index.ts +633 -84
  17. package/package.json +4 -2
  18. package/skills/pi-legion/SKILL.md +2 -3
  19. package/src/agents/agents.ts +17 -10
  20. package/src/agents/overrides.ts +0 -1
  21. package/src/agents/presets.ts +14 -40
  22. package/src/agents/settings.ts +26 -77
  23. package/src/agents/step-model.ts +7 -22
  24. package/src/api.ts +1 -35
  25. package/src/extension/event-bus.ts +80 -8
  26. package/src/extension/legion-commands.ts +43 -17
  27. package/src/extension/legion-doctor.ts +1 -1
  28. package/src/extension/legion-stop.ts +39 -13
  29. package/src/extension/legion-tools.ts +21 -4
  30. package/src/extension/liveness.ts +200 -0
  31. package/src/extension/notify.ts +6 -6
  32. package/src/extension/playbook.ts +6 -6
  33. package/src/extension/recycle.ts +6 -2
  34. package/src/extension/runtime.ts +422 -18
  35. package/src/extension/slash.ts +0 -21
  36. package/src/extension/spawn-render.ts +183 -0
  37. package/src/extension/summary.ts +1 -1
  38. package/src/legion/db.ts +165 -1
  39. package/src/legion/events.ts +81 -16
  40. package/src/legion/nodes.ts +13 -2
  41. package/src/legion/rebuild.ts +7 -1
  42. package/src/legion/usage.ts +58 -4
  43. package/src/runs/orchestrator.ts +1165 -62
  44. package/src/runs/worktree.ts +190 -21
  45. package/src/shared/ignored-kind.ts +57 -0
  46. package/src/shared/progress.ts +11 -0
  47. package/src/shared/session.ts +199 -19
  48. package/src/shared/types.ts +50 -66
  49. package/src/supervisor/admission-queue.ts +516 -0
  50. package/src/supervisor/inprocess-supervisor.ts +108 -33
  51. package/src/supervisor/proc-memory.ts +67 -0
  52. package/src/supervisor/routing-supervisor.ts +58 -1
  53. package/src/tui/live-overlay.ts +14 -0
  54. package/src/tui/session-messages-file.ts +100 -0
  55. package/src/tui/status.ts +103 -21
  56. package/src/tui/tree-view.ts +36 -3
  57. package/src/tui/viz/layout.ts +54 -3
  58. package/src/tui/viz/model.ts +67 -14
  59. package/src/tui/viz/palette.ts +11 -0
  60. package/src/tui/viz/panels.ts +222 -8
  61. package/src/tui/viz/railway.ts +28 -25
  62. package/src/tui/viz/view.ts +110 -23
  63. package/src/backends/legacy/client.ts +0 -638
  64. package/src/backends/legacy/cursor-chat.ts +0 -348
  65. package/src/backends/legacy/runner.ts +0 -101
  66. package/src/backends/legacy/types.ts +0 -138
  67. package/src/runs/kind.ts +0 -260
@@ -0,0 +1,183 @@
1
+ /**
2
+ * TUI renderer for the `subagent` / `vexillatio` tool results.
3
+ *
4
+ * Pi never renders a tool result's `details` on its own: `renderResult` is the
5
+ * only consumer, and a tool registered without one falls back to the plain
6
+ * `content` text. So the arbitrated `spawnMode` (#209) stays invisible until a
7
+ * renderer names it — that is all this file does.
8
+ *
9
+ * The summary line is derived from `details`, never from `content`: `content`
10
+ * is model-facing, `details` is TUI-facing (`docs/design.md:258`). Keeping the
11
+ * line out of `content` keeps it out of the LLM context.
12
+ *
13
+ * Renderers are called on every repaint, so nothing here may throw: the host
14
+ * swallows a throwing renderer and falls back to the default block, silently
15
+ * erasing the very fact this file exists to show. Every `details` read is
16
+ * therefore optional-chained and shape-checked, AND both entry points are
17
+ * wrapped in `try/catch`: a shape check is no defence against a getter or a
18
+ * Proxy trap that throws (host: `tool-execution.js` catch → fallback card).
19
+ */
20
+
21
+ import { Text } from "@earendil-works/pi-tui";
22
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
23
+ import type { Theme } from "@earendil-works/pi-coding-agent";
24
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
25
+
26
+ /** How many `content` lines a collapsed card keeps before the expand hint. */
27
+ const COLLAPSED_LINES = 10;
28
+
29
+ const SPAWN_MODES = new Set(["rpc", "inprocess"]);
30
+
31
+ /**
32
+ * Binary control bytes that must never reach the terminal renderer.
33
+ *
34
+ * Same set as the host fallback (`shell.js:sanitizeBinaryOutput`, used by
35
+ * `render-utils.js:getTextOutput`): TAB, LF and CR are excluded on purpose —
36
+ * the carriage return is a line break here, see {@link resultLines}.
37
+ */
38
+ const BINARY_CONTROL = /[\x00-\x08\x0B\x0C\x0E-\x1F\uFFF9-\uFFFB]/g;
39
+
40
+ export interface SpawnRenderOptions {
41
+ expanded: boolean;
42
+ isPartial: boolean;
43
+ }
44
+
45
+ function isSpawnMode(value: unknown): value is "rpc" | "inprocess" {
46
+ return typeof value === "string" && SPAWN_MODES.has(value);
47
+ }
48
+
49
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
50
+ return typeof value === "object" && value !== null
51
+ ? (value as Record<string, unknown>)
52
+ : undefined;
53
+ }
54
+
55
+ /** One `name=mode` entry from a `spawns[]` array (launch-family details). */
56
+ function entryFromSpawn(value: unknown): string | undefined {
57
+ const spawn = asRecord(value);
58
+ if (!spawn) return undefined;
59
+ const name = spawn.name;
60
+ const mode = spawn.spawnMode;
61
+ if (typeof name !== "string" || !name) return undefined;
62
+ // An unknown/absent mode is skipped, never printed as a guess: a legacy
63
+ // handle must render as "no fact", not as `rpc`.
64
+ if (!isSpawnMode(mode)) return undefined;
65
+ return `${name}=${mode}`;
66
+ }
67
+
68
+ /** One `name=mode` entry from a `handles[]` object (vexillatio details). */
69
+ function entryFromHandle(value: unknown): string | undefined {
70
+ const handle = asRecord(value);
71
+ if (!handle) return undefined;
72
+ return entryFromSpawn(handle);
73
+ }
74
+
75
+ /**
76
+ * `spawn: scout-0=inprocess` or `spawn: worker-0=rpc, scout-1=inprocess`.
77
+ *
78
+ * Reads both documented detail shapes defensively:
79
+ * - launch family: `details.spawns[]` (`{ name, agent, spawnMode }`)
80
+ * - vexillatio: `details.handles[]` (`{ name, agent, sessionFile, spawnMode? }`)
81
+ *
82
+ * One entry per child, first occurrence wins: a result that carries the same
83
+ * handle in both arrays must not print `w=rpc, w=rpc`.
84
+ *
85
+ * Returns `undefined` when nothing survives — no details, wrong shape, or every
86
+ * entry's mode unknown. Unknown modes are omitted rather than defaulted.
87
+ *
88
+ * Never throws: a `details` object with a throwing getter (or a Proxy) would
89
+ * otherwise take the whole card down, because the host swallows renderer
90
+ * exceptions and paints its fallback instead.
91
+ */
92
+ export function spawnModeSummary(details: unknown): string | undefined {
93
+ try {
94
+ const record = asRecord(details);
95
+ if (!record) return undefined;
96
+ const entries: string[] = [];
97
+ const seen = new Set<string>();
98
+ const push = (entry: string | undefined): void => {
99
+ if (!entry || seen.has(entry)) return;
100
+ seen.add(entry);
101
+ entries.push(entry);
102
+ };
103
+ const spawns = record.spawns;
104
+ if (Array.isArray(spawns)) {
105
+ for (const spawn of spawns) push(entryFromSpawn(spawn));
106
+ }
107
+ // `handles` may be a `string[]` (launch-family) or an object array
108
+ // (vexillatio); string entries simply never match, so both shapes pass.
109
+ const handles = record.handles;
110
+ if (Array.isArray(handles)) {
111
+ for (const handle of handles) push(entryFromHandle(handle));
112
+ }
113
+ if (entries.length === 0) return undefined;
114
+ return `spawn: ${entries.join(", ")}`;
115
+ } catch {
116
+ // A hostile/throwing getter is not a rendering failure: no fact, no line.
117
+ return undefined;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * The model-facing text of a result, normalized the way it will be painted.
123
+ *
124
+ * Line counting has to happen on the text that actually renders, so
125
+ * normalizing runs BEFORE the split: a carriage return is how a progress
126
+ * stream redraws one line, not 20 rows — but `Text` treats `\r` as a line
127
+ * break, so leaving it in lets 20 segments paint 20 rows and bypass
128
+ * {@link COLLAPSED_LINES} with no hint. `\r` therefore becomes `\n` (so all
129
+ * 20 segments stay readable instead of being concatenated into one blob) and
130
+ * binary control bytes are dropped, matching the host fallback's intent
131
+ * (`render-utils.js:40` sanitizes binary output and removes raw `\r`).
132
+ */
133
+ function resultLines(result: AgentToolResult<unknown> | undefined): string[] {
134
+ const content = result?.content;
135
+ if (!Array.isArray(content)) return [];
136
+ const text = content
137
+ .flatMap((part) => {
138
+ if (asRecord(part)?.type !== "text") return [];
139
+ const value = asRecord(part)?.text;
140
+ return typeof value === "string" ? [value] : [];
141
+ })
142
+ .join("\n");
143
+ const normalized = stripTerminalSequences(text)
144
+ .replace(/\r\n/g, "\n")
145
+ .replace(/\r/g, "\n")
146
+ .replace(BINARY_CONTROL, "");
147
+ return normalized.split("\n");
148
+ }
149
+
150
+ /**
151
+ * Build the card: the spawn summary line (when known) followed by the result
152
+ * text, capped at {@link COLLAPSED_LINES} lines until `ctrl+o` expands it.
153
+ *
154
+ * Never throws by design; see the file comment.
155
+ */
156
+ export function renderSpawnResult(
157
+ result: AgentToolResult<unknown>,
158
+ opts: SpawnRenderOptions,
159
+ _theme: Theme,
160
+ ) {
161
+ const lines: string[] = [];
162
+ // Double insurance: `spawnModeSummary` is already total, but this call site
163
+ // must not depend on the callee's implementation staying that way (the host
164
+ // silences a throw and drops the card). The `details` READ is inside the
165
+ // guard too — a Proxy can throw on the property access itself.
166
+ let summary: string | undefined;
167
+ try {
168
+ summary = spawnModeSummary(result?.details);
169
+ } catch {
170
+ summary = undefined;
171
+ }
172
+ if (summary) lines.push(summary);
173
+ lines.push(...resultLines(result));
174
+ // `opts` is host-supplied; optional-chained so a partial/absent options
175
+ // object degrades to the collapsed view instead of throwing.
176
+ if (opts?.expanded || lines.length <= COLLAPSED_LINES) {
177
+ return new Text(lines.join("\n"), 0, 0);
178
+ }
179
+ const hidden = lines.length - COLLAPSED_LINES;
180
+ const visible = lines.slice(0, COLLAPSED_LINES);
181
+ visible.push(`... (${hidden} more lines, ctrl+o to expand)`);
182
+ return new Text(visible.join("\n"), 0, 0);
183
+ }
@@ -617,7 +617,7 @@ export function formatSubagentDetail(input: SummaryDetailInput): string {
617
617
  if (child.thinking !== undefined) stateBits.push(`thinking: ${child.thinking}`);
618
618
  const lines = [title, ` ${stateBits.join(" · ")}`];
619
619
 
620
- const runBits = [`run: ${run.runId}`, `supervisor: ${run.herdr.supervisor ?? "legacy"}`];
620
+ const runBits = [`run: ${run.runId}`, `supervisor: ${run.herdr.supervisor ?? "unknown"}`];
621
621
  lines.push(` ${runBits.join(" · ")}`);
622
622
 
623
623
  const startedAtMs = parseTimestamp(child.spawnedAt);
package/src/legion/db.ts CHANGED
@@ -5,6 +5,24 @@ import { createRequire } from "node:module";
5
5
  export const LEGION_DB_USER_VERSION = 1;
6
6
  export const LEGION_DB_BUSY_TIMEOUT_MS = 5_000;
7
7
 
8
+ /**
9
+ * Bound on one connection's compiled-statement cache.
10
+ *
11
+ * The ledger has ~29 distinct static SQL texts behind the 46 `prepare()` call
12
+ * sites (many sites share a text), plus a handful of dynamically-shaped
13
+ * families — `usage.ts`'s chunked `IN (?,?,…)` (stable chunk length at runtime)
14
+ * and `hygiene.ts`'s retention `NOT IN (…)` lists. 64 holds everything a
15
+ * connection reuses with 2x headroom, while an unbounded map keyed on SQL text
16
+ * would grow with every distinct chunk size ever requested.
17
+ */
18
+ export const LEGION_STMT_CACHE_LIMIT = 64;
19
+
20
+ /**
21
+ * `exec()` texts that can change the schema and therefore invalidate compiled
22
+ * statements. Deliberately matched anywhere in the text (see `attachStatementCache`).
23
+ */
24
+ const SCHEMA_MUTATION_PATTERN = /\b(?:ALTER|CREATE|DROP|REINDEX|VACUUM)\b/i;
25
+
8
26
  export type SqliteBindValue = string | number | bigint | null | Uint8Array;
9
27
  export type SqliteRow = Record<string, string | number | bigint | null | Uint8Array>;
10
28
 
@@ -41,6 +59,7 @@ const SQLITE_DRIVERS: readonly SqliteDriver[] = [
41
59
  ];
42
60
  let cachedSqliteConstructor: SqliteDatabaseConstructor | null | undefined;
43
61
  const transactionDepth = new WeakMap<DatabaseSync, number>();
62
+ const statementCaches = new WeakMap<DatabaseSync, Map<string, LegionStatementSync>>();
44
63
  let savepointSequence = 0;
45
64
 
46
65
  const SCHEMA_V1 = `
@@ -99,6 +118,10 @@ CREATE TABLE messages (
99
118
  created_at INTEGER NOT NULL
100
119
  );
101
120
  CREATE INDEX idx_messages_inbox ON messages(to_node, delivered, id);
121
+ -- Serves the per-sender rate-limit COUNT that sendMail runs inside its write
122
+ -- transaction (mail.ts): without it that COUNT is a full SCAN of messages, which
123
+ -- degrades monotonically because messages has no retention policy.
124
+ CREATE INDEX idx_messages_rate ON messages(from_node, created_at);
102
125
  `;
103
126
 
104
127
  /**
@@ -109,6 +132,54 @@ export function sqliteHasRow(row: SqliteRow | null | undefined): boolean {
109
132
  return row != null;
110
133
  }
111
134
 
135
+ /**
136
+ * The journal mode the ledger must actually run in (contract §4.1).
137
+ */
138
+ export const LEGION_DB_REQUIRED_JOURNAL_MODE = "wal";
139
+
140
+ /**
141
+ * Refuse a connection whose journal mode is not the one the ledger assumes.
142
+ *
143
+ * `PRAGMA journal_mode = WAL` is a *query*: it returns the mode now in effect,
144
+ * and that mode need not be `wal` — SQLite is entitled to answer with the mode
145
+ * it settled on rather than to fail, which is how a connection ends up on a
146
+ * rollback journal while the caller believes WAL is live. (Mounts without
147
+ * working shared memory and read-only media are the usual causes; probing
148
+ * `node:sqlite` here, those conditions happened to raise instead, and a
149
+ * conflicting lock raised too — so the silent path is not something this
150
+ * repository can reproduce on demand, which is exactly why the check is a
151
+ * precondition on the returned value rather than a test fixture.)
152
+ *
153
+ * Every durability argument the ledger makes (single-writer serialisation,
154
+ * readers that never block the writer, the WAL checkpoint that
155
+ * `runStartupHygiene` performs on each ledger it sweeps at startup, and the one
156
+ * `stopLegionRuntime` folds in before close) assumes WAL is actually live, so
157
+ * the reported value is treated as a precondition rather than a best effort.
158
+ * Failing at `openLegionDb` surfaces it at startup instead of as data loss.
159
+ *
160
+ * `:memory:` is the one deliberate exemption: SQLite does not implement WAL for
161
+ * in-memory databases and reports `memory` forever, which is correct there
162
+ * because the database cannot outlive the process. `memoryDatabase` is what
163
+ * distinguishes that benign case from a *file* that reported `memory` (a real
164
+ * failure — it means no rollback journal and no WAL).
165
+ */
166
+ export function assertLegionJournalMode(
167
+ mode: unknown,
168
+ memoryDatabase: boolean,
169
+ database: string = "legion.db",
170
+ ): void {
171
+ const observed = typeof mode === "string" ? mode : String(mode);
172
+ if (memoryDatabase && observed === "memory") return;
173
+ if (observed === LEGION_DB_REQUIRED_JOURNAL_MODE) return;
174
+ throw new Error(
175
+ `${database} is not in WAL mode: PRAGMA journal_mode = WAL reported "${observed}" ` +
176
+ `(expected "${LEGION_DB_REQUIRED_JOURNAL_MODE}"). WAL is a precondition of the ledger's ` +
177
+ "durability and reader/writer isolation, so this connection is refused rather than " +
178
+ "silently running on a rollback journal. A mount without working shared-memory " +
179
+ "support, or read-only media, is the usual cause.",
180
+ );
181
+ }
182
+
112
183
  /** Resolve the tree ledger location specified by contract §4.1. */
113
184
  export function legionDbPath(rootRunDir: string): string {
114
185
  if (typeof rootRunDir !== "string" || rootRunDir.trim().length === 0) {
@@ -120,6 +191,11 @@ export function legionDbPath(rootRunDir: string): string {
120
191
  /**
121
192
  * Open one supervisor's connection to the shared tree ledger. SQLite is loaded
122
193
  * lazily so importing the extension does not require a driver until v2 is used.
194
+ *
195
+ * The WAL switch is — unusually for a PRAGMA — a request, not a command, so its
196
+ * returned mode is verified rather than assumed; see
197
+ * {@link assertLegionJournalMode}. A file database that cannot reach `wal`
198
+ * throws here, at startup, instead of quietly running on a rollback journal.
123
199
  */
124
200
  export function openLegionDb(dbPath: string): DatabaseSync {
125
201
  if (typeof dbPath !== "string" || dbPath.trim().length === 0) {
@@ -131,9 +207,13 @@ export function openLegionDb(dbPath: string): DatabaseSync {
131
207
  const Database = sqliteDatabaseConstructor();
132
208
  const db = new Database(dbPath);
133
209
  try {
210
+ // Compiled statements are reused for every one of the call sites above.
211
+ attachStatementCache(db);
134
212
  db.exec(`PRAGMA busy_timeout = ${LEGION_DB_BUSY_TIMEOUT_MS};`);
135
213
  db.exec("PRAGMA foreign_keys = ON;");
136
- db.exec("PRAGMA journal_mode = WAL;");
214
+ // One query both requests WAL and reports the mode that actually resulted.
215
+ const journalMode = db.prepare("PRAGMA journal_mode = WAL;").get()?.journal_mode;
216
+ assertLegionJournalMode(journalMode, isMemoryLegionDb(db), dbPath);
137
217
  migrate(db);
138
218
  return db;
139
219
  } catch (error) {
@@ -142,6 +222,76 @@ export function openLegionDb(dbPath: string): DatabaseSync {
142
222
  }
143
223
  }
144
224
 
225
+ /**
226
+ * Serve every `prepare()` on this connection from a bounded per-connection LRU.
227
+ *
228
+ * The cache sits *inside* `prepare` rather than at the call sites: each call
229
+ * still enters the data layer (so instrumentation that counts
230
+ * `prepare(sql)` keeps counting), and all call sites get the reuse for free.
231
+ *
232
+ * Statements are keyed on the exact SQL text: two texts that differ only in
233
+ * whitespace are deliberately two entries, because SQL text is the only
234
+ * identity SQLite offers.
235
+ *
236
+ * Reuse is safe for data changes — `.get()`/`.all()`/`.run()` re-execute with
237
+ * the new bindings, so a cached statement always sees committed rows. It is NOT
238
+ * safe across a schema change: after `ALTER TABLE ... ADD COLUMN`, a cached
239
+ * `SELECT *` executed with `.all()` keeps reporting the schema it compiled
240
+ * against (`.get()` happens to refresh, which makes the hazard easy to miss).
241
+ * Any `exec()` whose text mentions a schema-mutating keyword therefore drops
242
+ * every cached statement, so the next `prepare()` recompiles against the new
243
+ * schema. That covers `migrate()`'s `SCHEMA_V1` (`CREATE TABLE/INDEX`),
244
+ * `hygiene.ts`'s `VACUUM`, and any future `ALTER`/`DROP`. `rebuild.ts` only
245
+ * issues `DELETE FROM`, which is a data change, so it needs no invalidation.
246
+ *
247
+ * Invalidation protects future `prepare()` calls; a handle the caller already
248
+ * holds stays live with the schema it compiled against, as it would without
249
+ * this cache.
250
+ */
251
+ function attachStatementCache(db: DatabaseSync): void {
252
+ if (statementCaches.has(db)) return;
253
+ const prepare = db.prepare as (sql: string, ...rest: unknown[]) => LegionStatementSync;
254
+ const exec = db.exec as (sql: string, ...rest: unknown[]) => void;
255
+ const close = db.close as (...rest: unknown[]) => void;
256
+ const cache = new Map<string, LegionStatementSync>();
257
+ statementCaches.set(db, cache);
258
+ db.prepare = (sql: string, ...rest: unknown[]): LegionStatementSync => {
259
+ const cached = cache.get(sql);
260
+ if (cached) {
261
+ // Re-insert so Map order stays least-recently-used first.
262
+ cache.delete(sql);
263
+ cache.set(sql, cached);
264
+ return cached;
265
+ }
266
+ const statement = prepare.call(db, sql, ...rest);
267
+ cache.set(sql, statement);
268
+ if (cache.size > LEGION_STMT_CACHE_LIMIT) {
269
+ const oldest = cache.keys().next();
270
+ if (!oldest.done) cache.delete(oldest.value);
271
+ }
272
+ return statement;
273
+ };
274
+ db.exec = (sql: string, ...rest: unknown[]): void => {
275
+ // Match anywhere in the text: `exec` runs multi-statement SQL (SCHEMA_V1)
276
+ // and over-matching only costs a recompile, while under-matching would
277
+ // serve statements compiled against a schema that no longer exists.
278
+ if (SCHEMA_MUTATION_PATTERN.test(sql)) cache.clear();
279
+ exec.call(db, sql, ...rest);
280
+ };
281
+ db.close = (...rest: unknown[]): void => {
282
+ // Drop our references before the driver finalizes them, so the cache
283
+ // cannot outlive the connection and nothing is released twice.
284
+ cache.clear();
285
+ statementCaches.delete(db);
286
+ close.call(db, ...rest);
287
+ };
288
+ }
289
+
290
+ /** Compiled statements a connection currently holds; 0 once it is closed. */
291
+ export function legionStatementCacheSize(db: DatabaseSync): number {
292
+ return statementCaches.get(db)?.size ?? 0;
293
+ }
294
+
145
295
  /** Run a short synchronous write transaction. Nested ledger helpers use savepoints. */
146
296
  export function withTransaction<T>(db: DatabaseSync, run: () => T): T {
147
297
  const depth = transactionDepth.get(db) ?? 0;
@@ -236,6 +386,20 @@ function sqliteDatabaseConstructor(): SqliteDatabaseConstructor {
236
386
  throw new AggregateError(failures, "no supported synchronous SQLite driver is available");
237
387
  }
238
388
 
389
+ /**
390
+ * True when this connection's `main` database lives in memory rather than on
391
+ * disk. `PRAGMA database_list` reports an empty `file` for `:memory:` and for
392
+ * the `file::memory:` URIs, and the resolved path for a real file — asking
393
+ * SQLite beats pattern-matching the path the caller passed, because the driver
394
+ * is what decides. A missing row means we cannot tell, so it is treated as a
395
+ * file: the WAL check then applies and the failure is loud rather than silent.
396
+ */
397
+ function isMemoryLegionDb(db: DatabaseSync): boolean {
398
+ const row = db.prepare("PRAGMA database_list;").get();
399
+ if (row == null) return false;
400
+ return String(row.file ?? "").length === 0;
401
+ }
402
+
239
403
  function readUserVersion(db: DatabaseSync): number {
240
404
  const row = db.prepare("PRAGMA user_version;").get();
241
405
  return Number(row?.user_version ?? 0);
@@ -7,6 +7,12 @@ export const LEGION_EVENT_TYPES = [
7
7
  "node_retired",
8
8
  "node_resumed",
9
9
  "node_blocked",
10
+ /**
11
+ * Written by hygiene's `markAbandoned` (#233). It was missing from this set
12
+ * while both the writer and `RETAINED_EVENT_TYPES` already treated it as a
13
+ * real event, so `mapEvent` threw on read and froze the db event cursor.
14
+ */
15
+ "node_abandoned",
10
16
  "phase_change",
11
17
  "phase_timeout",
12
18
  "mail_sent",
@@ -36,6 +42,13 @@ export interface ReadEventsOptions {
36
42
  limit?: number;
37
43
  }
38
44
 
45
+ export interface ReadEventRowsOptions {
46
+ nodeId?: string;
47
+ limit?: number;
48
+ /** Called for a row that cannot be decoded; the row is skipped, not thrown. */
49
+ onInvalidRow?: (error: unknown, rowId: number) => void;
50
+ }
51
+
39
52
  export function appendEvent<T>(
40
53
  db: DatabaseSync,
41
54
  nodeId: string,
@@ -73,23 +86,28 @@ export function readEventsAfter(
73
86
  cursor: number,
74
87
  options: ReadEventsOptions = {},
75
88
  ): LegionEvent[] {
76
- assertCursor(cursor);
77
- const limit = options.limit ?? 1_000;
78
- if (!Number.isSafeInteger(limit) || limit < 1) {
79
- throw new TypeError("event limit must be a positive safe integer");
89
+ return selectEventRows(db, cursor, options).map(mapEvent);
90
+ }
91
+
92
+ /**
93
+ * Per-row tolerant read: an undecodable row is reported through `onInvalidRow` and
94
+ * skipped instead of throwing, so one poisoned row cannot stall an event consumer.
95
+ * `readEventsAfter` stays strict for callers that must fail loudly.
96
+ */
97
+ export function readEventsAfterTolerant(
98
+ db: DatabaseSync,
99
+ cursor: number,
100
+ options: ReadEventRowsOptions = {},
101
+ ): LegionEvent[] {
102
+ const events: LegionEvent[] = [];
103
+ for (const row of selectEventRows(db, cursor, options)) {
104
+ try {
105
+ events.push(mapEvent(row));
106
+ } catch (error) {
107
+ options.onInvalidRow?.(error, Number(row.id));
108
+ }
80
109
  }
81
- const rows = options.nodeId
82
- ? db
83
- .prepare(
84
- "SELECT id, node_id, type, data, ts FROM events WHERE id > ? AND node_id = ? ORDER BY id LIMIT ?",
85
- )
86
- .all(cursor, options.nodeId, limit)
87
- : db
88
- .prepare(
89
- "SELECT id, node_id, type, data, ts FROM events WHERE id > ? ORDER BY id LIMIT ?",
90
- )
91
- .all(cursor, limit);
92
- return rows.map(mapEvent);
110
+ return events;
93
111
  }
94
112
 
95
113
  export function getEventById(db: DatabaseSync, id: number): LegionEvent | null {
@@ -102,6 +120,30 @@ export function getEventById(db: DatabaseSync, id: number): LegionEvent | null {
102
120
  return row ? mapEvent(row) : null;
103
121
  }
104
122
 
123
+ /**
124
+ * Highest persisted event id, or `0` for an empty log.
125
+ *
126
+ * #238: a caller that attaches to a ledger it did not just write uses this to
127
+ * seed `createDbEventSource`'s `cursor` at the current tail, so attach does not
128
+ * replay the whole history. The SQL deliberately does NOT begin with the
129
+ * `SELECT id, node_id, type, data, ts FROM events WHERE id > ?` prefix: the
130
+ * instrumented `DatabaseSync` doubles in `test/unit/legion-event-bus.test.ts`
131
+ * count event SELECTs by that exact prefix and assert exact counts, so a
132
+ * differently-shaped statement must stay outside that counter.
133
+ */
134
+ export function maxEventId(db: DatabaseSync): number {
135
+ const value = Number(
136
+ db.prepare("SELECT COALESCE(MAX(id), 0) AS id FROM events").get()?.id ?? 0,
137
+ );
138
+ // Only two shapes can reach here: 0 (empty log) or a positive safe integer.
139
+ // The column is an INTEGER PRIMARY KEY, a non-numeric write is refused with
140
+ // "datatype mismatch", and `node:sqlite` refuses to return an id it cannot
141
+ // represent exactly as a JS number ("Value is too large to be represented as a
142
+ // JavaScript number"). So this is a narrowing, not a recovery path — and a seed
143
+ // of 0 is safe regardless, because it only means "no history to skip".
144
+ return Number.isSafeInteger(value) && value > 0 ? value : 0;
145
+ }
146
+
105
147
  /** PRAGMA data_version changes only when another connection commits. */
106
148
  export function getDataVersion(db: DatabaseSync): number {
107
149
  return Number(db.prepare("PRAGMA data_version;").get()?.data_version ?? 0);
@@ -143,6 +185,29 @@ function mapEvent(row: SqliteRow): LegionEvent {
143
185
  };
144
186
  }
145
187
 
188
+ function selectEventRows(
189
+ db: DatabaseSync,
190
+ cursor: number,
191
+ options: ReadEventsOptions,
192
+ ): SqliteRow[] {
193
+ assertCursor(cursor);
194
+ const limit = options.limit ?? 1_000;
195
+ if (!Number.isSafeInteger(limit) || limit < 1) {
196
+ throw new TypeError("event limit must be a positive safe integer");
197
+ }
198
+ return options.nodeId
199
+ ? db
200
+ .prepare(
201
+ "SELECT id, node_id, type, data, ts FROM events WHERE id > ? AND node_id = ? ORDER BY id LIMIT ?",
202
+ )
203
+ .all(cursor, options.nodeId, limit)
204
+ : db
205
+ .prepare(
206
+ "SELECT id, node_id, type, data, ts FROM events WHERE id > ? ORDER BY id LIMIT ?",
207
+ )
208
+ .all(cursor, limit);
209
+ }
210
+
146
211
  function assertCursor(cursor: number): void {
147
212
  if (!Number.isSafeInteger(cursor) || cursor < 0) {
148
213
  throw new TypeError("event cursor must be a non-negative safe integer");
@@ -366,12 +366,23 @@ export function updateNode(
366
366
  return updated;
367
367
  }
368
368
 
369
+ /**
370
+ * Touch only `updated_at` for a live child. Empty-patch heartbeats must not
371
+ * run the full `updateNode` CASE ladder or a `SELECT *` read-back — orphan
372
+ * detection / `staleNodeHours` only need the timestamp (#236 P1).
373
+ */
369
374
  export function heartbeatNode(
370
375
  db: DatabaseSync,
371
376
  id: string,
372
377
  now: () => number = () => Date.now(),
373
- ): LegionNode {
374
- return updateNode(db, id, {}, now);
378
+ ): { id: string; updatedAt: number } {
379
+ const timestamp = now();
380
+ assertTimestamp(timestamp, "clock");
381
+ const result = db
382
+ .prepare("UPDATE nodes SET updated_at = ? WHERE id = ?")
383
+ .run(timestamp, id);
384
+ if (Number(result.changes) === 0) throw new Error(`node not found: ${id}`);
385
+ return { id, updatedAt: timestamp };
375
386
  }
376
387
 
377
388
  export function listNodesByStatus(
@@ -305,6 +305,7 @@ function collectSources(
305
305
  for (const child of run.children) {
306
306
  try {
307
307
  const childSource = sourceFromChild(currentId, child, file, run, clock);
308
+ if (!childSource) continue;
308
309
  const nestedRun = runs.find((entry) => nodeIdFromPath(entry.run.path) === childSource.id);
309
310
  if (nestedRun) {
310
311
  childSource.runId = nestedRun.run.runId;
@@ -336,7 +337,12 @@ function sourceFromChild(
336
337
  runFile: string,
337
338
  parentRun: RunRecord,
338
339
  clock: () => number,
339
- ): NodeSource {
340
+ ): NodeSource | undefined {
341
+ // #266 D3/§4.4: a `queued` child was never started — it has no ledger row, no
342
+ // run directory and only a placeholder session path. Rebuilding it into the
343
+ // tree would invent a node (and a phantom leaf) for a launch that may still be
344
+ // waiting or may have been dropped with the previous process.
345
+ if (child.state === "queued") return undefined;
340
346
  const slug = sanitizeNodeSlug(child.name);
341
347
  const id = nodeIdForChild(parentId, slug);
342
348
  let status: NodeSource["status"] = "settled";
@@ -16,6 +16,13 @@ export interface UpsertUsageInput {
16
16
  costUsd?: number;
17
17
  }
18
18
 
19
+ /**
20
+ * Chunk size for `WHERE node_id IN (...)` in {@link attachUsageTotals}.
21
+ * Stays under the common `SQLITE_MAX_VARIABLE_NUMBER` of 999 (some builds use
22
+ * 32766); contract trees are far smaller, but `getSubtree` is unbounded.
23
+ */
24
+ export const USAGE_IN_CHUNK = 500;
25
+
19
26
  export function upsertUsage(
20
27
  db: DatabaseSync,
21
28
  input: UpsertUsageInput,
@@ -61,23 +68,70 @@ export function usageTotalTokens(db: DatabaseSync, nodeId: string): number {
61
68
  return usage ? usage.tokensIn + usage.tokensOut : 0;
62
69
  }
63
70
 
71
+ /**
72
+ * Annotate a node snapshot with per-node usage totals (#235).
73
+ *
74
+ * Signature and output shape are unchanged from the previous N+1 path:
75
+ * - `root` is never annotated (even if a usage row exists)
76
+ * - `tokensTotal` / `tokensOut` appear only when strictly > 0
77
+ * - the input array / objects are not mutated
78
+ *
79
+ * Implementation: one (or chunked) `WHERE node_id IN (...)` query + Map lookup,
80
+ * instead of one `getUsage` prepare/execute per node.
81
+ */
64
82
  export function attachUsageTotals<T extends { id: string }>(
65
83
  db: DatabaseSync,
66
84
  nodes: T[],
67
85
  ): Array<T & { tokensTotal?: number; tokensOut?: number }> {
86
+ type Annotated = T & { tokensTotal?: number; tokensOut?: number };
87
+ if (nodes.length === 0) return [];
88
+
89
+ const ids: string[] = [];
90
+ const seen = new Set<string>();
91
+ for (const node of nodes) {
92
+ if (node.id === "root") continue;
93
+ assertNodeId(node.id);
94
+ if (seen.has(node.id)) continue;
95
+ seen.add(node.id);
96
+ ids.push(node.id);
97
+ }
98
+
99
+ const usageById = loadUsageByNodeIds(db, ids);
100
+
68
101
  return nodes.map((node) => {
69
- if (node.id === "root") return { ...node } as T & { tokensTotal?: number; tokensOut?: number };
70
- const usage = getUsage(db, node.id);
71
- if (!usage) return { ...node } as T & { tokensTotal?: number; tokensOut?: number };
102
+ if (node.id === "root") return { ...node } as Annotated;
103
+ const usage = usageById.get(node.id);
104
+ if (!usage) return { ...node } as Annotated;
72
105
  const total = usage.tokensIn + usage.tokensOut;
73
106
  return {
74
107
  ...node,
75
108
  ...(total > 0 ? { tokensTotal: total } : {}),
76
109
  ...(usage.tokensOut > 0 ? { tokensOut: usage.tokensOut } : {}),
77
- } as T & { tokensTotal?: number; tokensOut?: number };
110
+ } as Annotated;
78
111
  });
79
112
  }
80
113
 
114
+ /** Batch-load usage rows for the given node ids (chunked for SQLite bind limits). */
115
+ function loadUsageByNodeIds(db: DatabaseSync, ids: string[]): Map<string, UsageSnapshot> {
116
+ const usageById = new Map<string, UsageSnapshot>();
117
+ if (ids.length === 0) return usageById;
118
+
119
+ for (let i = 0; i < ids.length; i += USAGE_IN_CHUNK) {
120
+ const chunk = ids.slice(i, i + USAGE_IN_CHUNK);
121
+ const placeholders = chunk.map(() => "?").join(",");
122
+ const rows = db
123
+ .prepare(
124
+ `SELECT node_id, tokens_in, tokens_out, cost_usd, updated_at FROM usage WHERE node_id IN (${placeholders})`,
125
+ )
126
+ .all(...chunk);
127
+ for (const row of rows) {
128
+ const usage = mapUsage(row);
129
+ usageById.set(usage.nodeId, usage);
130
+ }
131
+ }
132
+ return usageById;
133
+ }
134
+
81
135
  function mapUsage(row: SqliteRow): UsageSnapshot {
82
136
  return {
83
137
  nodeId: String(row.node_id),