pi-crew 0.11.11 → 0.11.13

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 (50) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/dist/index.mjs +551 -519
  3. package/package.json +1 -1
  4. package/src/config/config-validation.ts +9 -0
  5. package/src/config/defaults.ts +7 -0
  6. package/src/config/env-vars.ts +24 -0
  7. package/src/config/types.ts +11 -0
  8. package/src/extension/registration/tool-registration.ts +7 -0
  9. package/src/extension/registration/verify-gate-tool.ts +263 -0
  10. package/src/extension/team-tool/run-deadline.ts +22 -1
  11. package/src/extension/team-tool/run.ts +38 -2
  12. package/src/extension/team-tool-types.ts +4 -0
  13. package/src/extension/team-tool.ts +31 -3
  14. package/src/prompt/inbox-poll.ts +141 -3
  15. package/src/runtime/broker/crew-broker.ts +48 -2
  16. package/src/runtime/broker/protocol/lock-busy.ts +47 -1
  17. package/src/runtime/broker/protocol/request-parsers.ts +8 -1
  18. package/src/runtime/budget-enforcement.ts +36 -20
  19. package/src/runtime/child-pi/child-pi-constants.ts +24 -0
  20. package/src/runtime/child-pi/child-pi-kill.ts +59 -2
  21. package/src/runtime/child-pi/child-pi-rpc.ts +1021 -0
  22. package/src/runtime/child-pi/child-pi-steering.ts +10 -8
  23. package/src/runtime/child-pi/child-pi-streams.ts +69 -19
  24. package/src/runtime/child-pi/child-pi.ts +29 -30
  25. package/src/runtime/child-pi/owned-process.ts +434 -0
  26. package/src/runtime/child-pi/postmortem-registry.ts +134 -0
  27. package/src/runtime/classifier/ambient-noise-gate.ts +126 -0
  28. package/src/runtime/classifier/classifier-service.ts +277 -61
  29. package/src/runtime/dispatch-batch.ts +32 -0
  30. package/src/runtime/live-session/live-agent-control.ts +2 -0
  31. package/src/runtime/live-session/live-agent-manager.ts +72 -2
  32. package/src/runtime/live-session/live-session-runtime.ts +435 -40
  33. package/src/runtime/merge-loop.ts +63 -35
  34. package/src/runtime/recovery/crash-recovery.ts +141 -3
  35. package/src/runtime/recovery/retry-executor.ts +29 -0
  36. package/src/runtime/run-tracker.ts +28 -0
  37. package/src/runtime/stale-reconciler.ts +36 -1
  38. package/src/runtime/task-runner.ts +103 -0
  39. package/src/runtime/verification/classifier-pre-gate.ts +262 -0
  40. package/src/runtime/verification/verification-gates.ts +5 -1
  41. package/src/runtime/verification/verify-gate.ts +394 -0
  42. package/src/schema/config-schema.ts +13 -0
  43. package/src/state/contracts.ts +18 -0
  44. package/src/state/coordination/mailbox.ts +203 -55
  45. package/src/state/stores/sqlite-run-state.ts +378 -0
  46. package/src/state/stores/state-store.ts +134 -6
  47. package/src/ui/agents-jobs-browser.ts +154 -8
  48. package/src/ui/events-state-source.ts +358 -0
  49. package/src/ui/run-snapshot-cache.ts +87 -3
  50. package/src/ui/widget/widget-model.ts +13 -2
@@ -0,0 +1,378 @@
1
+ /**
2
+ * U8 (upgrade-spec 2026-10-09) — sqlite run-state backend. OPT-IN; the JSON
3
+ * file backend stays the default until sqlite is proven in production.
4
+ *
5
+ * What this module owns:
6
+ * - One sqlite file per run at `<stateRoot>/state.sqlite` (node:sqlite
7
+ * DatabaseSync — stdlib only, no new dependency; requires Node >= 22.5,
8
+ * repo pins >= 22.19).
9
+ * - Schema (spec-mandated, deliberately simple):
10
+ * tasks(id TEXT PRIMARY KEY, record TEXT NOT NULL) — one row per task
11
+ * manifest(key TEXT PRIMARY KEY, value TEXT NOT NULL) — kv; manifest row key "manifest"
12
+ * worker_status(task_id TEXT PRIMARY KEY, record TEXT NOT NULL) — worker-status CRUD
13
+ * seq(name TEXT PRIMARY KEY, value INTEGER NOT NULL) — monotonic sequences
14
+ * - EVERY batch write runs inside ONE explicit transaction (BEGIN IMMEDIATE
15
+ * … COMMIT / ROLLBACK). A crash (or error) mid-batch can never leave half
16
+ * a batch on disk — verified by the kill -9 crash test
17
+ * (test/integration/state-store-sqlite-crash.test.ts), pattern
18
+ * live-proven in /tmp/pi-crew-verify10-crash/ with journal_mode=wal +
19
+ * synchronous=1 (both set on open).
20
+ *
21
+ * What this module does NOT own (unchanged by U8):
22
+ * - events.jsonl stays the audit spine — never routed through this store.
23
+ * - manifest.json / tasks.json keep being written by state-store.ts as
24
+ * read-side MIRRORS for consumers that still read the JSON files
25
+ * directly (ui, prune, resume …). In sqlite mode the db is authoritative
26
+ * for loadRunManifestById; the mirrors are eventually-consistent copies.
27
+ * - JSON-backend behavior when PI_CREW_STATE_BACKEND is unset — the
28
+ * default path must stay byte-identical.
29
+ */
30
+ import * as fs from "node:fs";
31
+ import * as path from "node:path";
32
+ import type { StatementSync } from "node:sqlite";
33
+ import { DatabaseSync } from "node:sqlite";
34
+ import { getCrewEnv } from "../../config/env-vars.ts";
35
+ import type { TeamRunManifest, TeamTaskState } from "../types.ts";
36
+
37
+ /** File name of the sqlite run-state db inside the run's stateRoot. */
38
+ export const SQLITE_STATE_FILE = "state.sqlite";
39
+
40
+ /** manifest-table row key holding the run manifest record. */
41
+ const MANIFEST_KEY = "manifest";
42
+
43
+ /**
44
+ * U8 backend select: PI_CREW_STATE_BACKEND=sqlite opts a process into the
45
+ * sqlite run-state backend. Unset or any other value keeps the JSON backend
46
+ * (fail-safe default). Read via getCrewEnv so the env-vars registry gate
47
+ * stays green.
48
+ */
49
+ export function isSqliteStateBackend(): boolean {
50
+ return getCrewEnv("PI_CREW_STATE_BACKEND")?.trim().toLowerCase() === "sqlite";
51
+ }
52
+
53
+ /** Absolute path of the sqlite run-state db for a stateRoot. */
54
+ export function sqliteDbPath(stateRoot: string): string {
55
+ return path.join(stateRoot, SQLITE_STATE_FILE);
56
+ }
57
+
58
+ /** A worker-status row: the owning task id plus its opaque JSON record. */
59
+ export interface SqliteWorkerStatusRecord {
60
+ taskId: string;
61
+ record: unknown;
62
+ }
63
+
64
+ /**
65
+ * Synchronous sqlite-backed run-state store (DatabaseSync — matches the sync
66
+ * save APIs of state-store.ts; the async save variants call the same sync
67
+ * core). One instance = one open db handle; get one via
68
+ * {@link getSqliteRunStateStore} (process-wide cache) rather than `new`.
69
+ */
70
+ export class SqliteRunStateStore {
71
+ private db: DatabaseSync;
72
+ private inTx = false;
73
+ private readonly stmtUpsertManifest: StatementSync;
74
+ private readonly stmtGetManifest: StatementSync;
75
+ private readonly stmtDeleteManifest: StatementSync;
76
+ private readonly stmtInsertTask: StatementSync;
77
+ private readonly stmtDeleteTasks: StatementSync;
78
+ private readonly stmtSelectTasks: StatementSync;
79
+ private readonly stmtUpsertWorkerStatus: StatementSync;
80
+ private readonly stmtSelectWorkerStatus: StatementSync;
81
+ private readonly stmtDeleteWorkerStatus: StatementSync;
82
+ private readonly stmtBumpSeq: StatementSync;
83
+ private readonly stmtInitSeq: StatementSync;
84
+ private readonly stmtGetSeq: StatementSync;
85
+ private closed = false;
86
+
87
+ constructor(stateRoot: string) {
88
+ // The stateRoot always exists by the time a save runs (createRunManifest
89
+ // mkdirs it first), but be defensive — sqlite does not create dirs.
90
+ fs.mkdirSync(stateRoot, { recursive: true });
91
+ this.db = new DatabaseSync(sqliteDbPath(stateRoot));
92
+ // Durability combo live-proven by the round-10 kill -9 harness
93
+ // (/tmp/pi-crew-verify10-crash/): WAL journal + synchronous=1
94
+ // (NORMAL). busy_timeout rides out cross-process writer contention
95
+ // (parent + child processes may both hold the run state).
96
+ this.db.exec("PRAGMA journal_mode=wal");
97
+ this.db.exec("PRAGMA synchronous=1");
98
+ this.db.exec("PRAGMA busy_timeout=5000");
99
+ this.db.exec(`
100
+ CREATE TABLE IF NOT EXISTS tasks (
101
+ id TEXT PRIMARY KEY,
102
+ record TEXT NOT NULL
103
+ );
104
+ CREATE TABLE IF NOT EXISTS manifest (
105
+ key TEXT PRIMARY KEY,
106
+ value TEXT NOT NULL
107
+ );
108
+ CREATE TABLE IF NOT EXISTS worker_status (
109
+ task_id TEXT PRIMARY KEY,
110
+ record TEXT NOT NULL
111
+ );
112
+ CREATE TABLE IF NOT EXISTS seq (
113
+ name TEXT PRIMARY KEY,
114
+ value INTEGER NOT NULL
115
+ );
116
+ `);
117
+ this.stmtUpsertManifest = this.db.prepare(
118
+ "INSERT INTO manifest (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
119
+ );
120
+ this.stmtGetManifest = this.db.prepare("SELECT value FROM manifest WHERE key = ?");
121
+ this.stmtDeleteManifest = this.db.prepare("DELETE FROM manifest WHERE key = ?");
122
+ this.stmtInsertTask = this.db.prepare(
123
+ "INSERT INTO tasks (id, record) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET record = excluded.record",
124
+ );
125
+ this.stmtDeleteTasks = this.db.prepare("DELETE FROM tasks");
126
+ this.stmtSelectTasks = this.db.prepare("SELECT id, record FROM tasks ORDER BY rowid");
127
+ this.stmtUpsertWorkerStatus = this.db.prepare(
128
+ "INSERT INTO worker_status (task_id, record) VALUES (?, ?) ON CONFLICT(task_id) DO UPDATE SET record = excluded.record",
129
+ );
130
+ this.stmtSelectWorkerStatus = this.db.prepare("SELECT task_id, record FROM worker_status ORDER BY rowid");
131
+ this.stmtDeleteWorkerStatus = this.db.prepare("DELETE FROM worker_status WHERE task_id = ?");
132
+ this.stmtBumpSeq = this.db.prepare("UPDATE seq SET value = value + 1 WHERE name = ?");
133
+ this.stmtInitSeq = this.db.prepare("INSERT INTO seq (name, value) VALUES (?, 0) ON CONFLICT(name) DO NOTHING");
134
+ this.stmtGetSeq = this.db.prepare("SELECT value FROM seq WHERE name = ?");
135
+ }
136
+
137
+ /** Run `fn` inside ONE write transaction — all-or-nothing per batch. */
138
+ private tx<T>(fn: () => T): T {
139
+ if (this.inTx) throw new Error("sqlite-run-state: nested transaction refused");
140
+ this.db.exec("BEGIN IMMEDIATE");
141
+ this.inTx = true;
142
+ try {
143
+ const result = fn();
144
+ this.db.exec("COMMIT");
145
+ return result;
146
+ } catch (err) {
147
+ try {
148
+ this.db.exec("ROLLBACK");
149
+ } catch {
150
+ /* connection-level failure — surface the original error */
151
+ }
152
+ throw err;
153
+ } finally {
154
+ this.inTx = false;
155
+ }
156
+ }
157
+
158
+ /** Run `fn` inside ONE read transaction — a stable snapshot under WAL. */
159
+ private readTx<T>(fn: () => T): T {
160
+ if (this.inTx) throw new Error("sqlite-run-state: nested transaction refused");
161
+ this.db.exec("BEGIN");
162
+ this.inTx = true;
163
+ try {
164
+ const result = fn();
165
+ this.db.exec("COMMIT");
166
+ return result;
167
+ } catch (err) {
168
+ try {
169
+ this.db.exec("ROLLBACK");
170
+ } catch {
171
+ /* surface the original error */
172
+ }
173
+ throw err;
174
+ } finally {
175
+ this.inTx = false;
176
+ }
177
+ }
178
+
179
+ /** Upsert the run manifest — ONE transaction (1-row batch). */
180
+ saveManifest(manifest: TeamRunManifest): void {
181
+ const record = JSON.stringify(manifest);
182
+ this.tx(() => {
183
+ this.stmtUpsertManifest.run(MANIFEST_KEY, record);
184
+ });
185
+ }
186
+
187
+ /**
188
+ * Replace the whole task set (same whole-file semantics as tasks.json)
189
+ * — ONE transaction.
190
+ */
191
+ saveTasks(tasks: TeamTaskState[]): void {
192
+ const rows = tasks.map((task) => [task.id, JSON.stringify(task)] as const);
193
+ this.tx(() => {
194
+ this.stmtDeleteTasks.run();
195
+ for (const [id, record] of rows) {
196
+ // rows are stringified BEFORE BEGIN so a serialization error
197
+ // cannot open a transaction at all (let alone half-apply one).
198
+ this.stmtInsertTask.run(id, record);
199
+ }
200
+ });
201
+ }
202
+
203
+ /**
204
+ * THE U8 batch primitive: manifest + tasks commit together in ONE
205
+ * transaction. Crash mid-batch → neither lands; observers never see a
206
+ * half batch (new manifest with stale tasks or the reverse).
207
+ */
208
+ saveManifestAndTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]): void {
209
+ const manifestRecord = JSON.stringify(manifest);
210
+ const rows = tasks.map((task) => [task.id, JSON.stringify(task)] as const);
211
+ this.tx(() => {
212
+ this.stmtUpsertManifest.run(MANIFEST_KEY, manifestRecord);
213
+ this.stmtDeleteTasks.run();
214
+ for (const [id, record] of rows) {
215
+ this.stmtInsertTask.run(id, record);
216
+ }
217
+ });
218
+ }
219
+
220
+ /** Load the manifest record (undefined when never saved). */
221
+ loadManifest(): TeamRunManifest | undefined {
222
+ return this.readTx(() => {
223
+ const row = this.stmtGetManifest.get(MANIFEST_KEY) as { value: string } | undefined;
224
+ return row ? (JSON.parse(row.value) as TeamRunManifest) : undefined;
225
+ });
226
+ }
227
+
228
+ /** Load all task records in insertion order. */
229
+ loadTasks(): TeamTaskState[] {
230
+ return this.readTx(() => {
231
+ const rows = this.stmtSelectTasks.all() as Array<{ id: string; record: string }>;
232
+ return rows.map((row) => JSON.parse(row.record) as TeamTaskState);
233
+ });
234
+ }
235
+
236
+ /**
237
+ * Load manifest + tasks from ONE consistent snapshot — the sqlite-mode
238
+ * counterpart of loadRunManifestById's manifest/tasks file pair, minus
239
+ * the torn-pair window.
240
+ */
241
+ loadManifestAndTasks(): { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined {
242
+ return this.readTx(() => {
243
+ const manifestRow = this.stmtGetManifest.get(MANIFEST_KEY) as { value: string } | undefined;
244
+ if (!manifestRow) return undefined;
245
+ const manifest = JSON.parse(manifestRow.value) as TeamRunManifest;
246
+ const rows = this.stmtSelectTasks.all() as Array<{ id: string; record: string }>;
247
+ return { manifest, tasks: rows.map((row) => JSON.parse(row.record) as TeamTaskState) };
248
+ });
249
+ }
250
+
251
+ /**
252
+ * Worker-status CRUD (U8: worker-status joins tasks + manifest in the
253
+ * same file). Batch upsert — ONE transaction for the whole array.
254
+ */
255
+ saveWorkerStatuses(records: SqliteWorkerStatusRecord[]): void {
256
+ const rows = records.map((entry) => [entry.taskId, JSON.stringify(entry.record ?? null)] as const);
257
+ this.tx(() => {
258
+ for (const [taskId, record] of rows) {
259
+ this.stmtUpsertWorkerStatus.run(taskId, record);
260
+ }
261
+ });
262
+ }
263
+
264
+ /** Single worker-status upsert — convenience wrapper over the batch. */
265
+ upsertWorkerStatus(taskId: string, record: unknown): void {
266
+ this.saveWorkerStatuses([{ taskId, record }]);
267
+ }
268
+
269
+ /** Remove one worker-status row. */
270
+ deleteWorkerStatus(taskId: string): void {
271
+ this.tx(() => {
272
+ this.stmtDeleteWorkerStatus.run(taskId);
273
+ });
274
+ }
275
+
276
+ /** Load every worker-status row in insertion order. */
277
+ loadWorkerStatuses(): SqliteWorkerStatusRecord[] {
278
+ return this.readTx(() => {
279
+ const rows = this.stmtSelectWorkerStatus.all() as Array<{ task_id: string; record: string }>;
280
+ return rows.map((row) => ({ taskId: row.task_id, record: JSON.parse(row.record) }));
281
+ });
282
+ }
283
+
284
+ /** Allocate the next value of a named monotonic sequence — ONE tx. */
285
+ nextSeq(name: string): number {
286
+ return this.tx(() => {
287
+ this.stmtInitSeq.run(name);
288
+ this.stmtBumpSeq.run(name);
289
+ const row = this.stmtGetSeq.get(name) as { value: number } | undefined;
290
+ if (!row) throw new Error(`sqlite-run-state: seq '${name}' missing after upsert`);
291
+ return Number(row.value);
292
+ });
293
+ }
294
+
295
+ /** True when the manifest row has never been written. */
296
+ isEmptyRun(): boolean {
297
+ return this.loadManifest() === undefined;
298
+ }
299
+
300
+ /** Drop the manifest + tasks records (test/teardown helper; worker-status and seq are kept). */
301
+ clearRunState(): void {
302
+ this.tx(() => {
303
+ this.stmtDeleteManifest.run(MANIFEST_KEY);
304
+ this.stmtDeleteTasks.run();
305
+ });
306
+ }
307
+
308
+ /** Current journal mode (test observability — expect "wal"). */
309
+ journalMode(): string {
310
+ const row = this.db.prepare("PRAGMA journal_mode").get() as { journal_mode: string } | undefined;
311
+ return row?.journal_mode ?? "";
312
+ }
313
+
314
+ close(): void {
315
+ if (this.closed) return;
316
+ this.closed = true;
317
+ this.db.close();
318
+ }
319
+
320
+ get isClosed(): boolean {
321
+ return this.closed;
322
+ }
323
+ }
324
+
325
+ // ── Process-wide handle cache ─────────────────────────────────────────────
326
+ // Saves are frequent (persistSingleTaskUpdate ~500ms); reopen+pragma+prepare
327
+ // per call would waste the hot path. Cache one open store per stateRoot,
328
+ // LRU-bounded (each entry pins a file handle; runs are few in prod but
329
+ // tests create many tmp dirs) — a HIT refreshes recency so a hot run is
330
+ // never evicted+reopened while cold roots linger (review MINOR-3).
331
+ const SQLITE_STORE_CACHE_MAX = 32;
332
+ const sqliteStores = new Map<string, SqliteRunStateStore>();
333
+
334
+ function evictOldestSqliteStore(): void {
335
+ while (sqliteStores.size > SQLITE_STORE_CACHE_MAX) {
336
+ const oldest = sqliteStores.keys().next().value;
337
+ if (oldest === undefined) break;
338
+ const store = sqliteStores.get(oldest);
339
+ sqliteStores.delete(oldest);
340
+ store?.close();
341
+ }
342
+ }
343
+
344
+ /** Get (or open) the cached sqlite run-state store for a stateRoot. */
345
+ export function getSqliteRunStateStore(stateRoot: string): SqliteRunStateStore {
346
+ const existing = sqliteStores.get(stateRoot);
347
+ if (existing && !existing.isClosed) {
348
+ // LRU: move-to-back on hit (Map keeps insertion order; eviction takes
349
+ // the FRONT = least-recently-touched).
350
+ sqliteStores.delete(stateRoot);
351
+ sqliteStores.set(stateRoot, existing);
352
+ return existing;
353
+ }
354
+ const store = new SqliteRunStateStore(stateRoot);
355
+ sqliteStores.delete(stateRoot);
356
+ sqliteStores.set(stateRoot, store);
357
+ evictOldestSqliteStore();
358
+ return store;
359
+ }
360
+
361
+ /** Close + forget the cached store for a stateRoot (no-op when absent). */
362
+ export function closeSqliteRunStateStore(stateRoot: string): void {
363
+ const store = sqliteStores.get(stateRoot);
364
+ if (!store) return;
365
+ sqliteStores.delete(stateRoot);
366
+ store.close();
367
+ }
368
+
369
+ /** @internal — test/teardown: close every cached store. */
370
+ export function __test__closeAllSqliteStores(): void {
371
+ for (const store of sqliteStores.values()) store.close();
372
+ sqliteStores.clear();
373
+ }
374
+
375
+ /** @internal — test observability: current cache size. */
376
+ export function __test__sqliteStoreCacheSize(): number {
377
+ return sqliteStores.size;
378
+ }
@@ -25,6 +25,7 @@ import type { RunModelContext, TeamRunManifest, TeamTaskState } from "../types.t
25
25
  import { CURRENT_SCHEMA_VERSION } from "../types.ts";
26
26
  import { unregisterActiveRun } from "./active-run-registry.ts";
27
27
  import { extractTaskArray, loadTasksWithRecovery, loadTasksWithRecoveryAsync, quarantineCorruptFile } from "./manifest-io.ts";
28
+ import { closeSqliteRunStateStore, getSqliteRunStateStore, isSqliteStateBackend, sqliteDbPath } from "./sqlite-run-state.ts";
28
29
 
29
30
  export { loadManifestWithRecovery, loadTasksWithRecovery } from "./manifest-io.ts";
30
31
 
@@ -210,7 +211,12 @@ function resolveRunStateRoot(cwd: string, runId: string): string | undefined {
210
211
  // semantics: ENOENT is fine, only symlink/escape violations throw) — so
211
212
  // existence must be probed explicitly. Without this, the primary root
212
213
  // always "wins" with a phantom path and the fallback can never run.
213
- if (!fs.existsSync(path.join(scopedPath, DEFAULT_PATHS.state.manifestFile))) continue;
214
+ // U8 (2026-10-10): a sqlite-backend run also accepts state.sqlite as a
215
+ // birth marker — a crash between the sqlite commit and the manifest.json
216
+ // mirror write must not make the run invisible to by-ID resolution.
217
+ if (!fs.existsSync(path.join(scopedPath, DEFAULT_PATHS.state.manifestFile)) && !fs.existsSync(sqliteDbPath(scopedPath))) {
218
+ continue;
219
+ }
214
220
  try {
215
221
  resolveRealContainedPath(runsRoot, runId);
216
222
  } catch {
@@ -554,6 +560,13 @@ export function saveRunManifest(manifest: TeamRunManifest, options?: { allowTerm
554
560
  // it, broker loadRunManifestById is a cross-process reader, and the
555
561
  // statSync-based cache repopulation below needs the real post-write
556
562
  // mtime/size). The 50ms coalesce window broke all three.
563
+ // U8 (2026-10-10): sqlite backend — the db is the authoritative manifest
564
+ // store (1 tx per save); the atomicWriteJson below becomes a read-side
565
+ // mirror for consumers that still read manifest.json directly. Load paths
566
+ // read the db; events.jsonl is untouched either way.
567
+ if (isSqliteStateBackend()) {
568
+ getSqliteRunStateStore(manifest.stateRoot).saveManifest(effective);
569
+ }
557
570
  atomicWriteJson(manifestPath, effective);
558
571
  // FIX: Re-populate cache with actual mtime/size so loadRunManifestById
559
572
  // doesn't miss the cache on next read. Without this, every load until
@@ -587,6 +600,11 @@ export async function saveRunManifestAsync(manifest: TeamRunManifest, options?:
587
600
  invalidateRunCache(manifest.stateRoot);
588
601
  const manifestPath = path.join(manifest.stateRoot, "manifest.json");
589
602
  const effective = preserveDiskTerminalStatus(manifest, options?.allowTerminalExit);
603
+ // U8 (2026-10-10): sqlite backend — db is authoritative (1 tx); the json
604
+ // write below is the read-side mirror. See saveRunManifest for rationale.
605
+ if (isSqliteStateBackend()) {
606
+ getSqliteRunStateStore(manifest.stateRoot).saveManifest(effective);
607
+ }
590
608
  await atomicWriteJsonAsync(manifestPath, effective);
591
609
  // FIX: Re-populate cache with actual mtime/size. See saveRunManifest.
592
610
  // RACE GUARD: another concurrent async save (OPT-02) may unlink+rewrite
@@ -623,7 +641,7 @@ export async function saveRunManifestAsync(manifest: TeamRunManifest, options?:
623
641
  */
624
642
  function shouldPersistTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]): boolean {
625
643
  if (tasks.length > 0) return true;
626
- const existing = extractTaskArray(readJsonFile<unknown>(manifest.tasksPath));
644
+ const existing = loadExistingTasksForGuard(manifest);
627
645
  if (existing.length > 0) {
628
646
  logInternalError(
629
647
  "state-store",
@@ -638,6 +656,17 @@ function shouldPersistTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]):
638
656
  return true;
639
657
  }
640
658
 
659
+ /** U8 (2026-10-10): existing tasks for the ST-4 empty-over-nonempty guard —
660
+ * the sqlite db (authoritative) when that backend is enabled and populated,
661
+ * else tasks.json. */
662
+ function loadExistingTasksForGuard(manifest: TeamRunManifest): TeamTaskState[] {
663
+ if (isSqliteStateBackend() && fs.existsSync(sqliteDbPath(manifest.stateRoot))) {
664
+ const fromDb = getSqliteRunStateStore(manifest.stateRoot).loadTasks();
665
+ if (fromDb.length > 0) return fromDb;
666
+ }
667
+ return extractTaskArray(readJsonFile<unknown>(manifest.tasksPath));
668
+ }
669
+
641
670
  export function saveRunTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]): void {
642
671
  // ST-4: refuse to persist [] over a previously-non-empty tasks file.
643
672
  // Prevents the cascade where a corrupt tasks.json (→ [] on load) is then
@@ -654,6 +683,11 @@ export function saveRunTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]):
654
683
  return;
655
684
  }
656
685
 
686
+ // U8 (2026-10-10): sqlite backend — db is authoritative (1 tx, full task-set
687
+ // replace); the atomicWriteJson below is the read-side mirror.
688
+ if (isSqliteStateBackend()) {
689
+ getSqliteRunStateStore(manifest.stateRoot).saveTasks(tasks);
690
+ }
657
691
  atomicWriteJson(manifest.tasksPath, tasks, { compact: true });
658
692
  // FIX: Re-populate cache with actual mtime/size for manifest and tasks.
659
693
  // Note: We re-read manifest from disk to get its current mtime/size
@@ -774,6 +808,11 @@ export async function saveRunTasksAsync(manifest: TeamRunManifest, tasks: TeamTa
774
808
  } catch {
775
809
  return;
776
810
  }
811
+ // U8 (2026-10-10): sqlite backend — db is authoritative (1 tx); the async
812
+ // json write below is the read-side mirror.
813
+ if (isSqliteStateBackend()) {
814
+ getSqliteRunStateStore(manifest.stateRoot).saveTasks(tasks);
815
+ }
777
816
  await atomicWriteJsonAsync(manifest.tasksPath, tasks, { compact: true });
778
817
  }
779
818
 
@@ -806,6 +845,17 @@ async function saveManifestAndTasksAtomic(manifest: TeamRunManifest, tasks: Team
806
845
  // written before tasks. If a crash occurs between writes, manifest is
807
846
  // the older timestamp which stale-reconciler uses to detect inconsistency.
808
847
  invalidateRunCache(manifest.stateRoot);
848
+ // U8 (2026-10-10): sqlite backend — THE batch primitive. manifest +
849
+ // tasks commit together in ONE transaction (all-or-nothing): a crash
850
+ // mid-batch can never leave a new manifest beside stale tasks (or the
851
+ // reverse). The two atomic writes below become read-side mirrors for
852
+ // consumers that still read the json files directly; load paths read
853
+ // the db, so a crash between the tx and the mirrors still reopens clean.
854
+ if (isSqliteStateBackend()) {
855
+ getSqliteRunStateStore(manifest.stateRoot).saveManifestAndTasks(manifest, tasks);
856
+ manifestWritten = true;
857
+ tasksWritten = true;
858
+ }
809
859
  await atomicWriteJsonAsync(path.join(manifest.stateRoot, "manifest.json"), manifest);
810
860
  manifestWritten = true;
811
861
  await atomicWriteJsonAsync(manifest.tasksPath, tasks, { compact: true });
@@ -832,6 +882,16 @@ function saveManifestAndTasksAtomicSync(manifest: TeamRunManifest, tasks: TeamTa
832
882
  withRunLockSync(manifest, () => {
833
883
  // FIX: Invalidate cache BEFORE writes to prevent stale cache serving.
834
884
  invalidateRunCache(manifest.stateRoot);
885
+ // U8 (2026-10-10): sqlite backend — THE batch primitive (see the async
886
+ // twin above): manifest + tasks commit in ONE transaction, then the
887
+ // json writes below mirror for legacy readers. Flags flip after the
888
+ // AUTHORITATIVE tx commit so createRunManifest does not report a false
889
+ // failure when only a mirror write is interrupted.
890
+ if (isSqliteStateBackend()) {
891
+ getSqliteRunStateStore(manifest.stateRoot).saveManifestAndTasks(manifest, tasks);
892
+ manifestWritten = true;
893
+ tasksWritten = true;
894
+ }
835
895
  atomicWriteJson(path.join(manifest.stateRoot, "manifest.json"), manifest);
836
896
  manifestWritten = true;
837
897
  atomicWriteJson(manifest.tasksPath, tasks, { compact: true });
@@ -875,6 +935,19 @@ const DISK_TERMINAL_STATUSES: ReadonlySet<TeamRunManifest["status"]> = new Set([
875
935
  * just completed — pinned by resume-cancel.test.ts) are LEGITIMATE and pass
876
936
  * through; they are governed by canTransitionRunStatus at the updateRunStatus
877
937
  * layer. Returns the effective manifest to persist. */
938
+ /** U8 (2026-10-10): authoritative "disk" manifest for the terminal-preserve
939
+ * guard — the sqlite db when that backend is enabled (and populated), else
940
+ * manifest.json. Returns undefined / throws exactly like the previous inline
941
+ * file read when nothing readable exists (fresh create). */
942
+ function readRawDiskManifest(stateRoot: string): TeamRunManifest | undefined {
943
+ if (isSqliteStateBackend() && fs.existsSync(sqliteDbPath(stateRoot))) {
944
+ const fromDb = getSqliteRunStateStore(stateRoot).loadManifest();
945
+ if (fromDb) return fromDb;
946
+ }
947
+ const raw = fs.readFileSync(path.join(stateRoot, "manifest.json"), "utf-8");
948
+ return raw ? (JSON.parse(raw) as TeamRunManifest) : undefined;
949
+ }
950
+
878
951
  function preserveDiskTerminalStatus(manifest: TeamRunManifest, allowTerminalExit: boolean | undefined): TeamRunManifest {
879
952
  if (allowTerminalExit) return manifest;
880
953
  // Terminal→terminal re-decisions pass through (governed by
@@ -882,11 +955,11 @@ function preserveDiskTerminalStatus(manifest: TeamRunManifest, allowTerminalExit
882
955
  // ONLY to the erase class: a NON-terminal incoming status over terminal disk.
883
956
  if (DISK_TERMINAL_STATUSES.has(manifest.status)) return manifest;
884
957
  try {
885
- const manifestPath = path.join(manifest.stateRoot, "manifest.json");
886
958
  // Raw read (no cache): cross-process cancel writes must be seen NOW.
887
- const raw = fs.readFileSync(manifestPath, "utf-8");
888
- const disk = JSON.parse(raw) as TeamRunManifest;
889
- if (!DISK_TERMINAL_STATUSES.has(disk.status) || disk.status === manifest.status) return manifest;
959
+ // U8: under the sqlite backend the db is authoritative and can be one
960
+ // write ahead of the manifest.json mirror after a crash.
961
+ const disk = readRawDiskManifest(manifest.stateRoot);
962
+ if (!disk || !DISK_TERMINAL_STATUSES.has(disk.status) || disk.status === manifest.status) return manifest;
890
963
  return { ...manifest, status: disk.status, summary: disk.summary, updatedAt: disk.updatedAt };
891
964
  } catch {
892
965
  return manifest; // no disk manifest yet (create) or unreadable — normal write
@@ -1001,6 +1074,10 @@ export async function unloadRun(stateRoot: string): Promise<void> {
1001
1074
  // some in-process reader has a stale reference, the next cache lookup
1002
1075
  // misses (generation mismatch) and re-reads from disk.
1003
1076
  invalidateRunCache(stateRoot);
1077
+ // U8 (2026-10-10): release the cached sqlite handle for this run so a
1078
+ // later access reopens fresh (and close() checkpoints + drops the WAL
1079
+ // sidecars). No-op for the json backend and for never-opened roots.
1080
+ closeSqliteRunStateStore(stateRoot);
1004
1081
  }
1005
1082
 
1006
1083
  /**
@@ -1062,6 +1139,34 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
1062
1139
  const manifestPath = path.join(stateRoot, "manifest.json");
1063
1140
  const tasksPath = path.join(stateRoot, "tasks.json");
1064
1141
 
1142
+ // U8 (2026-10-10): sqlite backend — the db is authoritative. Read manifest
1143
+ // + tasks from ONE consistent snapshot; manifest.json/tasks.json are
1144
+ // read-side mirrors and may be one write behind after a crash (that is
1145
+ // the durability win over the two-file json backend). Legacy runs (no db
1146
+ // or empty db — created before the backend was enabled) fall through to
1147
+ // the unchanged JSON path below. The manifest cache is intentionally
1148
+ // bypassed here: a fresh db snapshot is cheap and always consistent.
1149
+ if (isSqliteStateBackend() && fs.existsSync(sqliteDbPath(stateRoot))) {
1150
+ const loaded = getSqliteRunStateStore(stateRoot).loadManifestAndTasks();
1151
+ if (loaded) {
1152
+ if (loaded.manifest.schemaVersion !== CURRENT_SCHEMA_VERSION) {
1153
+ logInternalError(
1154
+ "state-store",
1155
+ new Error(
1156
+ `Manifest schemaVersion mismatch: expected ${CURRENT_SCHEMA_VERSION}, got ${loaded.manifest.schemaVersion}. Run ${runId} may be incompatible.`,
1157
+ ),
1158
+ undefined,
1159
+ "warn",
1160
+ );
1161
+ }
1162
+ if (!validateRunManifestPaths(cwd, runId, loaded.manifest, stateRoot, tasksPath)) return undefined;
1163
+ return { manifest: loaded.manifest, tasks: loaded.tasks };
1164
+ }
1165
+ // No manifest row yet: either a json-created legacy run (mirror exists,
1166
+ // JSON path below serves it) or a pre-birth crash (nothing anywhere —
1167
+ // the JSON path below also returns undefined). Fall through.
1168
+ }
1169
+
1065
1170
  let manifestStat: fs.Stats;
1066
1171
  try {
1067
1172
  manifestStat = statManifestWithWindowsRetry(manifestPath);
@@ -1223,6 +1328,29 @@ export async function loadRunManifestByIdAsync(
1223
1328
  const manifestPath = path.join(stateRoot, "manifest.json");
1224
1329
  const tasksPath = path.join(stateRoot, "tasks.json");
1225
1330
 
1331
+ // U8 (2026-10-10): sqlite backend — async twin of the sync read-through
1332
+ // above: db is authoritative (one consistent snapshot), mirrors and legacy
1333
+ // json runs fall through to the unchanged JSON path below.
1334
+ if (isSqliteStateBackend() && fs.existsSync(sqliteDbPath(stateRoot))) {
1335
+ const loaded = getSqliteRunStateStore(stateRoot).loadManifestAndTasks();
1336
+ if (loaded) {
1337
+ if (loaded.manifest.schemaVersion !== CURRENT_SCHEMA_VERSION) {
1338
+ logInternalError(
1339
+ "state-store",
1340
+ new Error(
1341
+ `Manifest schemaVersion mismatch: expected ${CURRENT_SCHEMA_VERSION}, got ${loaded.manifest.schemaVersion}. Run ${runId} may be incompatible.`,
1342
+ ),
1343
+ undefined,
1344
+ "warn",
1345
+ );
1346
+ }
1347
+ if (!validateRunManifestPaths(cwd, runId, loaded.manifest, stateRoot, tasksPath)) return undefined;
1348
+ return { manifest: loaded.manifest, tasks: loaded.tasks };
1349
+ }
1350
+ // No manifest row yet — fall through to the JSON path (legacy run or
1351
+ // pre-birth crash; see the sync twin above).
1352
+ }
1353
+
1226
1354
  let manifestStat: fs.Stats;
1227
1355
  try {
1228
1356
  manifestStat = await fs.promises.stat(manifestPath);