@yemi33/minions 0.1.2116 → 0.1.2118

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.
@@ -88,16 +88,45 @@ function getDb() {
88
88
  try {
89
89
  _installExperimentalWarningFilter();
90
90
  const { DatabaseSync } = require('node:sqlite');
91
- _db = new DatabaseSync(dbPath);
92
- _dbPath = dbPath;
93
- // WAL mode lets the engine writer and dashboard reader hit the same
94
- // file from two processes without lock contention. NORMAL synchronous
95
- // is the recommended WAL pairing (durable enough; one OS-level fsync
96
- // per checkpoint instead of per write).
97
- _db.exec('PRAGMA journal_mode = WAL');
98
- _db.exec('PRAGMA foreign_keys = ON');
99
- _db.exec('PRAGMA synchronous = NORMAL');
100
- _db.exec('PRAGMA busy_timeout = 5000');
91
+ // Concurrent cold-openers (parallel child processes in tests; engine
92
+ // + dashboard cold-starting together) can race on the exclusive lock
93
+ // that SQLite needs to either create the file or upgrade
94
+ // journal_mode to WAL on the first connection. `busy_timeout` only
95
+ // applies to PRAGMAs and queries AFTER the connection is open, so
96
+ // `new DatabaseSync(dbPath)` itself and the initial WAL PRAGMA can
97
+ // still return SQLITE_BUSY. Retry the whole open+PRAGMA sequence
98
+ // with backoff so a brief lock contention isn't a hard failure.
99
+ // Phase 9.4 removed the JSON fallback that previously hid this
100
+ // race without retry, parallel test runs fail intermittently with
101
+ // "small-state-store: SQLite unavailable (... database is locked)".
102
+ const maxAttempts = 20;
103
+ let lastErr = null;
104
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
105
+ try {
106
+ _db = new DatabaseSync(dbPath);
107
+ // busy_timeout MUST be set before any other PRAGMA so subsequent
108
+ // statements wait instead of failing immediately on contention.
109
+ _db.exec('PRAGMA busy_timeout = 5000');
110
+ _db.exec('PRAGMA journal_mode = WAL');
111
+ _db.exec('PRAGMA foreign_keys = ON');
112
+ _db.exec('PRAGMA synchronous = NORMAL');
113
+ _dbPath = dbPath;
114
+ lastErr = null;
115
+ break;
116
+ } catch (e) {
117
+ lastErr = e;
118
+ const msg = String(e && e.message || '');
119
+ const isBusy = /database is locked|SQLITE_BUSY|SQLITE_LOCKED/i.test(msg);
120
+ if (!isBusy || attempt === maxAttempts - 1) throw e;
121
+ if (_db) { try { _db.close(); } catch { /* best-effort */ } _db = null; }
122
+ // Synchronous backoff: 10ms, 20ms, 40ms... capped at 250ms.
123
+ // Total worst-case wait ~3s, still under the 5s busy_timeout.
124
+ const delayMs = Math.min(250, 10 * (1 << Math.min(attempt, 5)));
125
+ const end = Date.now() + delayMs;
126
+ while (Date.now() < end) { /* busy-wait — sub-300ms total */ }
127
+ }
128
+ }
129
+ if (lastErr) throw lastErr;
101
130
  const { runMigrations } = require('./migrate');
102
131
  runMigrations(_db);
103
132
  return _db;
@@ -31,7 +31,11 @@ const path = require('path');
31
31
  const fs = require('fs');
32
32
 
33
33
  function _resolveMinionsDir() {
34
- const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
34
+ // Canonical order: MINIONS_TEST_DIR wins over MINIONS_HOME (matches
35
+ // engine/db/index.js + migrations 002/003/004). Reversing this lets a
36
+ // test that sets both env vars open the DB at MINIONS_TEST_DIR but
37
+ // backfill its tables from MINIONS_HOME — cross-directory corruption.
38
+ const envHome = process.env.MINIONS_TEST_DIR || process.env.MINIONS_HOME;
35
39
  if (envHome) return envHome;
36
40
  try { return require('../../shared').MINIONS_DIR; } catch { return null; }
37
41
  }
@@ -15,7 +15,11 @@ const path = require('path');
15
15
  const fs = require('fs');
16
16
 
17
17
  function _resolveMinionsDir() {
18
- const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
18
+ // Canonical order: MINIONS_TEST_DIR wins over MINIONS_HOME (matches
19
+ // engine/db/index.js + migrations 002/003/004). Reversing this lets a
20
+ // test that sets both env vars open the DB at MINIONS_TEST_DIR but
21
+ // backfill its tables from MINIONS_HOME — cross-directory corruption.
22
+ const envHome = process.env.MINIONS_TEST_DIR || process.env.MINIONS_HOME;
19
23
  if (envHome) return envHome;
20
24
  try { return require('../../shared').MINIONS_DIR; } catch { return null; }
21
25
  }
@@ -14,7 +14,11 @@ const path = require('path');
14
14
  const fs = require('fs');
15
15
 
16
16
  function _resolveMinionsDir() {
17
- const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
17
+ // Canonical order: MINIONS_TEST_DIR wins over MINIONS_HOME (matches
18
+ // engine/db/index.js + migrations 002/003/004). Reversing this lets a
19
+ // test that sets both env vars open the DB at MINIONS_TEST_DIR but
20
+ // backfill its tables from MINIONS_HOME — cross-directory corruption.
21
+ const envHome = process.env.MINIONS_TEST_DIR || process.env.MINIONS_HOME;
18
22
  if (envHome) return envHome;
19
23
  try { return require('../../shared').MINIONS_DIR; } catch { return null; }
20
24
  }
@@ -36,9 +36,7 @@ function _parseRow(row) {
36
36
 
37
37
  function readDispatchSectioned() {
38
38
  const { getDb } = require('./db');
39
- let db;
40
- try { db = getDb(); }
41
- catch { return _readDispatchJsonFallback(); } // SQLite unavailable
39
+ const db = getDb();
42
40
 
43
41
  // All rows, all statuses. Matches the legacy dispatch.json reader's
44
42
  // semantics — returns the complete dispatch state regardless of how old
@@ -10,11 +10,11 @@ const queries = require('./queries');
10
10
  const { setCooldown, setCooldownFailure } = require('./cooldown');
11
11
  const dispatchEvents = require('./dispatch-events');
12
12
 
13
- const { safeJsonArr, mutateJsonFileLocked, mutateWorkItems,
13
+ const { safeJsonArr, mutateWorkItems,
14
14
  mutatePullRequests, getProjects, projectPrPath, log, ts, dateStamp,
15
15
  sidecarDispatchPrompt, deleteDispatchPromptSidecar,
16
16
  WI_STATUS, WORK_TYPE, DISPATCH_RESULT, ENGINE_DEFAULTS, AGENT_STATUS, FAILURE_CLASS, PR_STATUS } = shared;
17
- const { getConfig, DISPATCH_PATH, INBOX_DIR } = queries;
17
+ const { getConfig, INBOX_DIR } = queries;
18
18
 
19
19
  const MINIONS_DIR = shared.MINIONS_DIR;
20
20
 
@@ -58,72 +58,37 @@ function _sidecarChangedPrompts(dispatch, prevSnap) {
58
58
  }
59
59
  }
60
60
 
61
- // SQL-backed mutator (Phase 1). Same external contract as the legacy
62
- // JSON path: mutator receives a `{ pending, active, completed, review }`
63
- // object, mutates in place (or returns a replacement), and the changes
64
- // land transactionally in the dispatches table. The diff-then-apply trick
65
- // in dispatch-store preserves every field via the `data` JSON column, so
66
- // new dispatch fields the engine adds in future commits don't need any
67
- // migration — they round-trip automatically.
68
- //
69
- // Fallback path: if SQLite is unavailable (Node < 22.5, the DB init failed,
70
- // the table doesn't exist yet because the migration hasn't run) we fall
71
- // back to the legacy mutateJsonFileLocked flow against dispatch.json.
72
- // The migration leaves dispatch.json.pre-sql-<ts> on disk, so an operator
73
- // pinning to an older release can rename it back.
61
+ // SQL-backed mutator (Phase 9.4 SQL-only). mutator receives a
62
+ // `{ pending, active, completed, review }` object, mutates in place
63
+ // (or returns a replacement), and the changes land transactionally in
64
+ // the dispatches table. The diff-then-apply trick in dispatch-store
65
+ // preserves every field via the `data` JSON column, so new dispatch
66
+ // fields the engine adds in future commits don't need any migration —
67
+ // they round-trip automatically. SQLite failures propagate; the CLI
68
+ // shim in bin/minions.js guarantees node:sqlite is loadable.
74
69
  function mutateDispatch(mutator) {
75
- // Try the SQL path first.
76
- try {
77
- const store = require('./dispatch-store');
78
- const { wrote, result } = store.applyDispatchMutation((dispatch) => {
79
- dispatch.pending = Array.isArray(dispatch.pending) ? dispatch.pending : [];
80
- dispatch.active = Array.isArray(dispatch.active) ? dispatch.active : [];
81
- dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
82
- dispatch.review = Array.isArray(dispatch.review) ? dispatch.review : [];
83
- const prevSnap = _snapshotPrompts(dispatch);
84
- const next = mutator(dispatch) ?? dispatch;
85
- // Prompt-size guard: only scan items whose prompt changed (or new items),
86
- // so a 100-item status-flip doesn't re-byte-count every prompt.
87
- _sidecarChangedPrompts(next, prevSnap);
88
- return next;
89
- });
90
- if (wrote) {
91
- try { require('./queries').invalidateDispatchCache(); } catch {}
92
- try { require('./db-events').emitStateEvent('dispatch'); } catch { /* optional */ }
93
- // Mirror back to dispatch.json for tests + tools that fs.readFileSync
94
- // the file directly. SQL is the source of truth; the JSON file is
95
- // regenerated from SQL on every successful mutation, never independently
96
- // mutated. Cheap to delete once those direct-JSON readers are gone.
97
- try { store._mirrorJsonFromSql(); } catch { /* best-effort */ }
98
- }
99
- return result;
100
- } catch (e) {
101
- // Only fall back if the failure looks like a "DB not available" case,
102
- // not a programming error. Surface mutator exceptions to the caller.
103
- if (e && /SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
104
- // fall through to legacy JSON path below
105
- } else {
106
- throw e;
107
- }
108
- }
109
-
110
- // Legacy JSON path (pre-Phase 1 fallback). Same code as before.
111
- const defaultDispatch = { pending: [], active: [], completed: [] };
112
- const result = mutateJsonFileLocked(DISPATCH_PATH, (dispatch) => {
70
+ const store = require('./dispatch-store');
71
+ const { wrote, result } = store.applyDispatchMutation((dispatch) => {
113
72
  dispatch.pending = Array.isArray(dispatch.pending) ? dispatch.pending : [];
114
73
  dispatch.active = Array.isArray(dispatch.active) ? dispatch.active : [];
115
74
  dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
75
+ dispatch.review = Array.isArray(dispatch.review) ? dispatch.review : [];
116
76
  const prevSnap = _snapshotPrompts(dispatch);
117
77
  const next = mutator(dispatch) ?? dispatch;
78
+ // Prompt-size guard: only scan items whose prompt changed (or new items),
79
+ // so a 100-item status-flip doesn't re-byte-count every prompt.
118
80
  _sidecarChangedPrompts(next, prevSnap);
119
81
  return next;
120
- }, {
121
- defaultValue: defaultDispatch,
122
- onWrote: () => {
123
- try { require('./db-events').emitStateEvent('dispatch'); } catch { /* optional */ }
124
- },
125
82
  });
126
- try { require('./queries').invalidateDispatchCache(); } catch {}
83
+ if (wrote) {
84
+ try { require('./queries').invalidateDispatchCache(); } catch {}
85
+ try { require('./db-events').emitStateEvent('dispatch'); } catch { /* optional */ }
86
+ // Mirror back to dispatch.json for tests + tools that fs.readFileSync
87
+ // the file directly. SQL is the source of truth; the JSON file is
88
+ // regenerated from SQL on every successful mutation, never independently
89
+ // mutated. Cheap to delete once those direct-JSON readers are gone.
90
+ try { store._mirrorJsonFromSql(); } catch { /* best-effort */ }
91
+ }
127
92
  return result;
128
93
  }
129
94
 
@@ -123,9 +123,7 @@ function _resyncScopeIfJsonDiverged(db, scope) {
123
123
 
124
124
  function readPullRequestsForScope(scope) {
125
125
  const { getDb } = require('./db');
126
- let db;
127
- try { db = getDb(); }
128
- catch { return _readJsonArrayFallback(scope); }
126
+ const db = getDb();
129
127
 
130
128
  _resyncScopeIfJsonDiverged(db, scope);
131
129
 
@@ -170,23 +168,7 @@ function _enumerateJsonScopes() {
170
168
 
171
169
  function readAllPullRequests() {
172
170
  const { getDb } = require('./db');
173
- let db;
174
- try { db = getDb(); }
175
- catch {
176
- // Issue #3035: SQLite unavailable (Node 22.x without
177
- // --experimental-sqlite) — read every JSON scope on disk so the
178
- // aggregate readers (queries.getPullRequests, shared.getPrLinks)
179
- // continue to return real data instead of [].
180
- const out = [];
181
- for (const scope of _enumerateJsonScopes()) {
182
- for (const pr of _readJsonArrayFallback(scope)) {
183
- if (!pr || typeof pr !== 'object') continue;
184
- pr._scope = scope;
185
- out.push(pr);
186
- }
187
- }
188
- return out;
189
- }
171
+ const db = getDb();
190
172
 
191
173
  try { _resyncScopeIfJsonDiverged(db, 'central'); } catch {}
192
174
  const knownScopes = db.prepare('SELECT DISTINCT scope FROM pull_requests').all().map(r => r.scope);
@@ -273,11 +255,7 @@ function _applyPullRequestsDiff(db, diff) {
273
255
 
274
256
  function applyPullRequestsMutation(scope, mutator) {
275
257
  const { getDb, withTransaction } = require('./db');
276
- let db;
277
- try { db = getDb(); }
278
- catch (e) {
279
- throw new Error(`engine/pull-requests-store: SQLite unavailable (${e.message}); cannot mutate pull_requests`);
280
- }
258
+ const db = getDb();
281
259
 
282
260
  return withTransaction(db, () => {
283
261
  _resyncScopeIfJsonDiverged(db, scope);
package/engine/shared.js CHANGED
@@ -591,9 +591,11 @@ function _routeJsonReadToSql(p) {
591
591
  const store = require('./pull-requests-store');
592
592
  return { value: store.readPullRequestsForScope('central') };
593
593
  }
594
- } catch {
595
- // SQLite unavailable / store load failurefall through to disk read.
596
- return null;
594
+ } catch (e) {
595
+ // Phase 9.4: store/load failures (not SQLite-unavailablethe CLI shim
596
+ // in bin/minions.js guarantees node:sqlite is loadable) propagate up so
597
+ // the caller can decide whether to retry or surface.
598
+ throw e;
597
599
  }
598
600
  return null;
599
601
  }
@@ -1373,34 +1375,38 @@ function _tryRouteMutateToSql(filePath, mutateFn, onWrote) {
1373
1375
  // Don't hijack writes to ad-hoc paths that happen to share the basename.
1374
1376
  if (smallRoute) {
1375
1377
  if (!fpNorm.endsWith('/engine/' + baseName)) return null;
1376
- let result;
1377
- try {
1378
- const store = require('./small-state-store');
1378
+ const store = require('./small-state-store');
1379
+ // Hold the JSON file lock across the SQL transaction AND the mirror
1380
+ // write. The SQL transaction itself is cross-process serialized via
1381
+ // BEGIN IMMEDIATE inside the SQL store, but the JSON mirror is a
1382
+ // separate read-from-SQL → atomic-rename that can race across
1383
+ // processes: an earlier mirror's stale snapshot can land AFTER a
1384
+ // later mirror's complete snapshot and lose committed rows from the
1385
+ // on-disk JSON file. Pre-Phase-9.4 this was masked by the JSON
1386
+ // fallback (concurrent writers serialized through the same lock at
1387
+ // the bottom of mutateJsonFileLocked); after the fallback removal,
1388
+ // the bare mirror races. Locking around the SQL+mirror block puts
1389
+ // those two operations back into one cross-process critical section.
1390
+ const lockPath = `${filePath}.lock`;
1391
+ return withFileLock(lockPath, () => {
1379
1392
  const out = store[smallRoute.fn]((data) => {
1380
1393
  const next = mutateFn(data);
1381
1394
  if (onWrote) onWrote();
1382
1395
  return next;
1383
1396
  });
1384
- result = out && Object.prototype.hasOwnProperty.call(out, 'result') ? out.result : undefined;
1397
+ const result = out && Object.prototype.hasOwnProperty.call(out, 'result') ? out.result : undefined;
1385
1398
  try { store[smallRoute.mirror](filePath); } catch { /* mirror best-effort */ }
1386
- } catch (e) {
1387
- // SQLite unavailable / table missing — fall through to JSON path.
1388
- const msg = e && e.message ? String(e.message) : '';
1389
- if (/SQLite unavailable|no such table|not a database|node:sqlite/i.test(msg)) {
1390
- return null;
1399
+ if (!fs.existsSync(filePath)) {
1400
+ try {
1401
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
1402
+ const fallback = smallRoute.defaultShape === 'array'
1403
+ ? (Array.isArray(result) ? result : [])
1404
+ : (result && typeof result === 'object' && !Array.isArray(result) ? result : {});
1405
+ safeWrite(filePath, fallback);
1406
+ } catch { /* best-effort */ }
1391
1407
  }
1392
- throw e;
1393
- }
1394
- if (!fs.existsSync(filePath)) {
1395
- try {
1396
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
1397
- const fallback = smallRoute.defaultShape === 'array'
1398
- ? (Array.isArray(result) ? result : [])
1399
- : (result && typeof result === 'object' && !Array.isArray(result) ? result : {});
1400
- safeWrite(filePath, fallback);
1401
- } catch { /* best-effort */ }
1402
- }
1403
- return { routed: result };
1408
+ return { routed: result };
1409
+ });
1404
1410
  }
1405
1411
 
1406
1412
  let result;
@@ -3206,32 +3212,20 @@ const WATCH_ACTION_TYPE = {
3206
3212
 
3207
3213
  /**
3208
3214
  * Phase 7 — small state file mutators. Each routes through the
3209
- * small-state-store, mirrors back to its JSON file, and emits a topic
3210
- * event on real writes. SQLite failure falls through to the legacy
3211
- * mutateJsonFileLocked path.
3215
+ * small-state-store and mirrors back to its JSON file, then emits a topic
3216
+ * event on real writes. SQL-canonical (Phase 9.4): SQLite failures
3217
+ * propagate; the CLI shim in bin/minions.js guarantees `node:sqlite` is
3218
+ * loadable on every supported Node version.
3212
3219
  */
3213
- function _smallStateMutator({ filePath, applyMutation, mirror, topic, defaultValue }) {
3220
+ function _smallStateMutator({ filePath, applyMutation, mirror, topic }) {
3214
3221
  return (mutator) => {
3215
- try {
3216
- const store = require('./small-state-store');
3217
- const { wrote, result } = store[applyMutation]((obj) => mutator(obj) || obj);
3218
- if (wrote) {
3219
- try { if (mirror && typeof store[mirror] === 'function') store[mirror](filePath); } catch { /* mirror best-effort */ }
3220
- try { require('./db-events').emitStateEvent(topic); } catch { /* optional */ }
3221
- }
3222
- return result;
3223
- } catch (e) {
3224
- if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) throw e;
3222
+ const store = require('./small-state-store');
3223
+ const { wrote, result } = store[applyMutation]((obj) => mutator(obj) || obj);
3224
+ if (wrote) {
3225
+ try { if (mirror && typeof store[mirror] === 'function') store[mirror](filePath); } catch { /* mirror best-effort */ }
3226
+ try { require('./db-events').emitStateEvent(topic); } catch { /* optional */ }
3225
3227
  }
3226
- return mutateJsonFileLocked(filePath, (data) => {
3227
- if (data == null) data = defaultValue();
3228
- return mutator(data) || data;
3229
- }, {
3230
- defaultValue: defaultValue(),
3231
- onWrote: () => {
3232
- try { require('./db-events').emitStateEvent(topic); } catch { /* optional */ }
3233
- },
3234
- });
3228
+ return result;
3235
3229
  };
3236
3230
  }
3237
3231
 
@@ -3287,31 +3281,18 @@ function _qaDualWriteEnabled() {
3287
3281
  function _qaMutator({ filePath, applyMutation, mirror, topic }) {
3288
3282
  return (mutator) => {
3289
3283
  return withFileLock(filePath + '.lock', () => {
3290
- try {
3291
- const store = require('./small-state-store');
3292
- const { wrote, result } = store[applyMutation]((arr) => {
3293
- if (!Array.isArray(arr)) arr = [];
3294
- return mutator(arr) || arr;
3295
- });
3296
- if (wrote) {
3297
- if (_qaDualWriteEnabled()) {
3298
- try { if (mirror && typeof store[mirror] === 'function') store[mirror](filePath); } catch { /* mirror best-effort */ }
3299
- }
3300
- try { require('./db-events').emitStateEvent(topic); } catch { /* optional */ }
3284
+ const store = require('./small-state-store');
3285
+ const { wrote, result } = store[applyMutation]((arr) => {
3286
+ if (!Array.isArray(arr)) arr = [];
3287
+ return mutator(arr) || arr;
3288
+ });
3289
+ if (wrote) {
3290
+ if (_qaDualWriteEnabled()) {
3291
+ try { if (mirror && typeof store[mirror] === 'function') store[mirror](filePath); } catch { /* mirror best-effort */ }
3301
3292
  }
3302
- return result;
3303
- } catch (e) {
3304
- if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) throw e;
3305
- return mutateJsonFileLocked(filePath, (data) => {
3306
- if (!Array.isArray(data)) data = [];
3307
- return mutator(data) || data;
3308
- }, {
3309
- defaultValue: [],
3310
- onWrote: () => {
3311
- try { require('./db-events').emitStateEvent(topic); } catch { /* optional */ }
3312
- },
3313
- });
3293
+ try { require('./db-events').emitStateEvent(topic); } catch { /* optional */ }
3314
3294
  }
3295
+ return result;
3315
3296
  }, { timeoutMs: 5000, retries: 3 });
3316
3297
  };
3317
3298
  }
@@ -3334,36 +3315,20 @@ const mutateQaSessions = _qaMutator({
3334
3315
  * Route a watches mutation through the SQL store. Same shape as
3335
3316
  * mutateWorkItems / mutatePullRequests: mutator receives the watches
3336
3317
  * array, mutates in place or returns a replacement, and the store
3337
- * diffs by id. Falls back to the legacy mutateJsonFileLocked path on
3338
- * SQLite failure.
3318
+ * diffs by id. SQL-canonical (Phase 9.4); SQLite failures propagate.
3339
3319
  */
3340
3320
  function mutateWatches(mutator) {
3341
3321
  const watchesPath = path.join(MINIONS_DIR, 'engine', 'watches.json');
3342
- try {
3343
- const store = require('./watches-store');
3344
- const { wrote, result } = store.applyWatchesMutation((arr) => {
3345
- if (!Array.isArray(arr)) arr = [];
3346
- return mutator(arr) || arr;
3347
- });
3348
- if (wrote) {
3349
- try { store._mirrorJsonFromSql(watchesPath); } catch { /* mirror best-effort */ }
3350
- try { require('./db-events').emitStateEvent('watches'); } catch { /* optional */ }
3351
- }
3352
- return result;
3353
- } catch (e) {
3354
- if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
3355
- throw e;
3356
- }
3357
- }
3358
- return mutateJsonFileLocked(watchesPath, (data) => {
3359
- if (!Array.isArray(data)) data = [];
3360
- return mutator(data) || data;
3361
- }, {
3362
- defaultValue: [],
3363
- onWrote: () => {
3364
- try { require('./db-events').emitStateEvent('watches'); } catch { /* optional */ }
3365
- },
3322
+ const store = require('./watches-store');
3323
+ const { wrote, result } = store.applyWatchesMutation((arr) => {
3324
+ if (!Array.isArray(arr)) arr = [];
3325
+ return mutator(arr) || arr;
3366
3326
  });
3327
+ if (wrote) {
3328
+ try { store._mirrorJsonFromSql(watchesPath); } catch { /* mirror best-effort */ }
3329
+ try { require('./db-events').emitStateEvent('watches'); } catch { /* optional */ }
3330
+ }
3331
+ return result;
3367
3332
  }
3368
3333
 
3369
3334
  /**
@@ -3371,37 +3336,20 @@ function mutateWatches(mutator) {
3371
3336
  * mirror. Same shape as mutateWorkItems / mutatePullRequests: mutator
3372
3337
  * receives the full legacy-shape metrics object, mutates in place or
3373
3338
  * returns a replacement, and the store diffs by (kind, key) row.
3374
- *
3375
- * Falls back to the legacy mutateJsonFileLocked path on SQLite failure
3376
- * so a node:sqlite-broken install keeps recording metrics.
3339
+ * SQL-canonical (Phase 9.4); SQLite failures propagate.
3377
3340
  */
3378
3341
  function mutateMetrics(mutator) {
3379
3342
  const metricsPath = path.join(MINIONS_DIR, 'engine', 'metrics.json');
3380
- try {
3381
- const store = require('./metrics-store');
3382
- const { wrote, result } = store.applyMetricsMutation((m) => {
3383
- if (!m || typeof m !== 'object') m = {};
3384
- return mutator(m) || m;
3385
- });
3386
- if (wrote) {
3387
- try { store._mirrorJsonFromSql(metricsPath); } catch { /* mirror best-effort */ }
3388
- try { require('./db-events').emitStateEvent('metrics'); } catch { /* optional */ }
3389
- }
3390
- return result;
3391
- } catch (e) {
3392
- if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
3393
- throw e;
3394
- }
3395
- }
3396
- return mutateJsonFileLocked(metricsPath, (metrics) => {
3397
- if (!metrics || typeof metrics !== 'object') metrics = {};
3398
- return mutator(metrics) || metrics;
3399
- }, {
3400
- defaultValue: {},
3401
- onWrote: () => {
3402
- try { require('./db-events').emitStateEvent('metrics'); } catch { /* optional */ }
3403
- },
3343
+ const store = require('./metrics-store');
3344
+ const { wrote, result } = store.applyMetricsMutation((m) => {
3345
+ if (!m || typeof m !== 'object') m = {};
3346
+ return mutator(m) || m;
3404
3347
  });
3348
+ if (wrote) {
3349
+ try { store._mirrorJsonFromSql(metricsPath); } catch { /* mirror best-effort */ }
3350
+ try { require('./db-events').emitStateEvent('metrics'); } catch { /* optional */ }
3351
+ }
3352
+ return result;
3405
3353
  }
3406
3354
 
3407
3355
  /** Update per-agent review metrics (prsApproved/prsRejected). Only writes for configured agents. */
@@ -5167,23 +5115,11 @@ function addPrLink(prId, itemId, { project = null, url = '', prNumber = null } =
5167
5115
  links[effectivePrId] = [...mergedCurrent];
5168
5116
  return links;
5169
5117
  };
5170
- // Phase 9.1: pr-links is SQL-canonical via small-state-store; the JSON file
5171
- // is a dual-write mirror. SQLite failures fall through to the legacy JSON
5172
- // path so older installs without --experimental-sqlite still work.
5173
- let routedViaSql = false;
5174
- try {
5175
- const store = require('./small-state-store');
5176
- store.applyPrLinksMutation(mutator);
5177
- try { store._mirrorPrLinksJson(); } catch { /* mirror best-effort */ }
5178
- routedViaSql = true;
5179
- } catch (e) {
5180
- if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
5181
- throw e;
5182
- }
5183
- }
5184
- if (!routedViaSql) {
5185
- mutateJsonFileLocked(PR_LINKS_PATH, mutator, { defaultValue: {} });
5186
- }
5118
+ // Phase 9.4: pr-links is SQL-only via small-state-store; the JSON file
5119
+ // is a write-only mirror artifact for legacy direct-disk readers.
5120
+ const store = require('./small-state-store');
5121
+ store.applyPrLinksMutation(mutator);
5122
+ try { store._mirrorPrLinksJson(); } catch { /* mirror best-effort */ }
5187
5123
 
5188
5124
  if (!project) return;
5189
5125
  const prPath = projectPrPath(project);
@@ -5575,42 +5511,27 @@ function listProcessReachable(rootPids, allProcesses = null) {
5575
5511
  * @param {Function} mutator - Receives the array, mutates in place or returns new value
5576
5512
  */
5577
5513
  function mutateWorkItems(filePath, mutator) {
5578
- // Phase 2 SQL path → Phase 9 SQL-only. Route through work-items-store so
5579
- // SQL is the canonical (and only) store; the legacy JSON mirror was
5580
- // retired in Phase 9.
5581
- //
5582
- // The SQL store identifies records by `scope` (central or project name)
5583
- // derived from the file path's last two segments. Ad-hoc file paths
5584
- // outside the MINIONS_DIR layout (e.g. tests using createTmpDir()) can't
5585
- // be mapped to a stable scope, so we short-circuit to the legacy
5586
- // JSON path for those. Production callers always use
5587
- // shared.projectWorkItemsPath(p) / MINIONS_DIR/work-items.json.
5588
- //
5589
- // SQLite failures fall through to the legacy JSON path below — keeps a
5590
- // node:sqlite-broken install fully functional.
5514
+ // Phase 9.4 SQL-only. Route through work-items-store; SQL is the canonical
5515
+ // (and only) store. The legacy JSON path below remains ONLY for ad-hoc
5516
+ // file paths outside the MINIONS_DIR layout (e.g. tests using
5517
+ // createTmpDir()) that can't be mapped to a stable scope. Production
5518
+ // callers always use shared.projectWorkItemsPath(p) / MINIONS_DIR/work-items.json.
5591
5519
  const fpNorm = String(filePath).replace(/\\/g, '/');
5592
5520
  const minionsNorm = String(MINIONS_DIR).replace(/\\/g, '/');
5593
5521
  const insideMinionsDir = fpNorm.startsWith(minionsNorm + '/') || fpNorm === minionsNorm + '/work-items.json';
5594
5522
  if (insideMinionsDir) {
5595
- try {
5596
- const store = require('./work-items-store');
5597
- const scope = store.scopeForFilePath(filePath);
5598
- const { wrote, result } = store.applyWorkItemsMutation(scope, (items) => {
5599
- if (!Array.isArray(items)) items = [];
5600
- return mutator(items) || items;
5601
- });
5602
- if (wrote) {
5603
- try { store._mirrorJsonFromSql(scope, filePath); } catch { /* mirror best-effort */ }
5604
- try { require('./db-events').emitStateEvent('work_items'); } catch { /* optional */ }
5605
- }
5606
- try { require('./queries').invalidateWorkItemsCache(); } catch { /* queries not loaded */ }
5607
- return result;
5608
- } catch (e) {
5609
- if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
5610
- throw e;
5611
- }
5612
- // Fall through to the legacy JSON path on SQLite errors only.
5523
+ const store = require('./work-items-store');
5524
+ const scope = store.scopeForFilePath(filePath);
5525
+ const { wrote, result } = store.applyWorkItemsMutation(scope, (items) => {
5526
+ if (!Array.isArray(items)) items = [];
5527
+ return mutator(items) || items;
5528
+ });
5529
+ if (wrote) {
5530
+ try { store._mirrorJsonFromSql(scope, filePath); } catch { /* mirror best-effort */ }
5531
+ try { require('./db-events').emitStateEvent('work_items'); } catch { /* optional */ }
5613
5532
  }
5533
+ try { require('./queries').invalidateWorkItemsCache(); } catch { /* queries not loaded */ }
5534
+ return result;
5614
5535
  }
5615
5536
 
5616
5537
  const result = mutateJsonFileLocked(filePath, (data) => {
@@ -5648,31 +5569,24 @@ function reopenWorkItem(wi) {
5648
5569
  * @param {Function} mutator - Receives the array, mutates in place or returns new value
5649
5570
  */
5650
5571
  function mutatePullRequests(filePath, mutator) {
5651
- // Phase 3 SQL path → Phase 9 SQL-only. Route through pull-requests-store
5652
- // when filePath sits under MINIONS_DIR. Ad-hoc tmp paths (legacy tests
5653
- // using createTmpDir) and SQLite failures fall through to the JSON path.
5572
+ // Phase 9.4 SQL-only. Route through pull-requests-store when filePath sits
5573
+ // under MINIONS_DIR. Ad-hoc tmp paths (legacy tests using createTmpDir)
5574
+ // still fall through to the JSON path.
5654
5575
  const fpNorm = String(filePath).replace(/\\/g, '/');
5655
5576
  const minionsNorm = String(MINIONS_DIR).replace(/\\/g, '/');
5656
5577
  const insideMinionsDir = fpNorm.startsWith(minionsNorm + '/') || fpNorm === minionsNorm + '/pull-requests.json';
5657
5578
  if (insideMinionsDir) {
5658
- try {
5659
- const store = require('./pull-requests-store');
5660
- const scope = store.scopeForFilePath(filePath);
5661
- const { wrote, result } = store.applyPullRequestsMutation(scope, (prs) => {
5662
- if (!Array.isArray(prs)) prs = [];
5663
- return mutator(prs) || prs;
5664
- });
5665
- if (wrote) {
5666
- try { store._mirrorJsonFromSql(scope, filePath); } catch { /* mirror best-effort */ }
5667
- try { require('./db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
5668
- }
5669
- return result;
5670
- } catch (e) {
5671
- if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
5672
- throw e;
5673
- }
5674
- // Fall through to legacy JSON path on SQLite errors only.
5579
+ const store = require('./pull-requests-store');
5580
+ const scope = store.scopeForFilePath(filePath);
5581
+ const { wrote, result } = store.applyPullRequestsMutation(scope, (prs) => {
5582
+ if (!Array.isArray(prs)) prs = [];
5583
+ return mutator(prs) || prs;
5584
+ });
5585
+ if (wrote) {
5586
+ try { store._mirrorJsonFromSql(scope, filePath); } catch { /* mirror best-effort */ }
5587
+ try { require('./db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
5675
5588
  }
5589
+ return result;
5676
5590
  }
5677
5591
 
5678
5592
  return mutateJsonFileLocked(filePath, (data) => {
@@ -94,9 +94,7 @@ function _readScheduleRunsFromSql(db) {
94
94
 
95
95
  function readScheduleRuns() {
96
96
  const { getDb } = require('./db');
97
- let db;
98
- try { db = getDb(); }
99
- catch { return _readJson(_resolveFilePath('schedule-runs.json')) || {}; }
97
+ const db = getDb();
100
98
  _resyncScheduleRunsIfDiverged(db);
101
99
  const out = _readScheduleRunsFromSql(db);
102
100
  if (Object.keys(out).length === 0) {
@@ -207,9 +205,7 @@ function _readPipelineRunsFromSql(db) {
207
205
 
208
206
  function readPipelineRuns() {
209
207
  const { getDb } = require('./db');
210
- let db;
211
- try { db = getDb(); }
212
- catch { return _readJson(_resolveFilePath('pipeline-runs.json')) || {}; }
208
+ const db = getDb();
213
209
  _resyncPipelineRunsIfDiverged(db);
214
210
  const out = _readPipelineRunsFromSql(db);
215
211
  if (Object.keys(out).length === 0) {
@@ -329,9 +325,7 @@ function _readManagedProcessesFromSql(db) {
329
325
 
330
326
  function readManagedProcesses() {
331
327
  const { getDb } = require('./db');
332
- let db;
333
- try { db = getDb(); }
334
- catch { return _readJson(_resolveFilePath('managed-processes.json')) || { specs: [] }; }
328
+ const db = getDb();
335
329
  _resyncManagedProcessesIfDiverged(db);
336
330
  const out = _readManagedProcessesFromSql(db);
337
331
  if (out.specs.length === 0) {
@@ -449,9 +443,7 @@ function _readWorktreePoolFromSql(db) {
449
443
 
450
444
  function readWorktreePool() {
451
445
  const { getDb } = require('./db');
452
- let db;
453
- try { db = getDb(); }
454
- catch { return _readJson(_resolveFilePath('worktree-pool.json')) || { entries: [] }; }
446
+ const db = getDb();
455
447
  _resyncWorktreePoolIfDiverged(db);
456
448
  const out = _readWorktreePoolFromSql(db);
457
449
  if (out.entries.length === 0) {
@@ -581,9 +573,7 @@ function _readQaRunsFromSqlOnly(db) {
581
573
 
582
574
  function readQaRuns() {
583
575
  const { getDb } = require('./db');
584
- let db;
585
- try { db = getDb(); }
586
- catch { return _readJson(_resolveFilePath('qa-runs.json')) || []; }
576
+ const db = getDb();
587
577
  _resyncQaRunsIfDiverged(db);
588
578
  const out = _readQaRunsFromSqlOnly(db);
589
579
  if (out.length === 0) {
@@ -741,9 +731,7 @@ function _readQaSessionsFromSqlOnly(db) {
741
731
 
742
732
  function readQaSessions() {
743
733
  const { getDb } = require('./db');
744
- let db;
745
- try { db = getDb(); }
746
- catch { return _readJson(_resolveFilePath('qa-sessions.json')) || []; }
734
+ const db = getDb();
747
735
  _resyncQaSessionsIfDiverged(db);
748
736
  const out = _readQaSessionsFromSqlOnly(db);
749
737
  if (out.length === 0) {
@@ -875,9 +863,7 @@ function _readPrLinksFromSql(db) {
875
863
 
876
864
  function readPrLinks() {
877
865
  const { getDb } = require('./db');
878
- let db;
879
- try { db = getDb(); }
880
- catch { return _readJson(_resolveFilePath('pr-links.json')) || {}; }
866
+ const db = getDb();
881
867
  try { _resyncPrLinksIfDiverged(db); }
882
868
  catch { /* table may be missing on a stale install — fall back to JSON */ }
883
869
  let out;
@@ -984,9 +970,7 @@ function _readCooldownsFromSql(db) {
984
970
 
985
971
  function readCooldowns() {
986
972
  const { getDb } = require('./db');
987
- let db;
988
- try { db = getDb(); }
989
- catch { return _readJson(_resolveFilePath('cooldowns.json')) || {}; }
973
+ const db = getDb();
990
974
  try { _resyncCooldownsIfDiverged(db); }
991
975
  catch { /* table may be missing on stale install */ }
992
976
  let out;
@@ -1095,9 +1079,7 @@ function _readPendingRebasesFromSql(db) {
1095
1079
 
1096
1080
  function readPendingRebases() {
1097
1081
  const { getDb } = require('./db');
1098
- let db;
1099
- try { db = getDb(); }
1100
- catch { return _readJson(_resolveFilePath('pending-rebases.json')) || []; }
1082
+ const db = getDb();
1101
1083
  try { _resyncPendingRebasesIfDiverged(db); }
1102
1084
  catch { /* table missing */ }
1103
1085
  let out;
@@ -1197,9 +1179,7 @@ function _readCcSessionsFromSql(db) {
1197
1179
 
1198
1180
  function readCcSessions() {
1199
1181
  const { getDb } = require('./db');
1200
- let db;
1201
- try { db = getDb(); }
1202
- catch { return _readJson(_resolveFilePath('cc-sessions.json')) || []; }
1182
+ const db = getDb();
1203
1183
  try { _resyncCcSessionsIfDiverged(db); }
1204
1184
  catch { /* table missing */ }
1205
1185
  let out;
@@ -1307,9 +1287,7 @@ function _readDocSessionsFromSql(db) {
1307
1287
 
1308
1288
  function readDocSessions() {
1309
1289
  const { getDb } = require('./db');
1310
- let db;
1311
- try { db = getDb(); }
1312
- catch { return _readJson(_resolveFilePath('doc-sessions.json')) || {}; }
1290
+ const db = getDb();
1313
1291
  try { _resyncDocSessionsIfDiverged(db); }
1314
1292
  catch { /* table missing */ }
1315
1293
  let out;
@@ -75,9 +75,7 @@ function _readJsonArrayFallback(scope) {
75
75
 
76
76
  function readWorkItemsForScope(scope) {
77
77
  const { getDb } = require('./db');
78
- let db;
79
- try { db = getDb(); }
80
- catch { return _readJsonArrayFallback(scope); }
78
+ const db = getDb();
81
79
 
82
80
  _resyncScopeIfJsonDiverged(db, scope);
83
81
 
@@ -88,10 +86,9 @@ function readWorkItemsForScope(scope) {
88
86
  `).all(scope);
89
87
 
90
88
  if (rows.length === 0) {
91
- // Same fallback shape as dispatch-store: SQL empty AND JSON has
92
- // content means a test seeded via fs.writeFileSync (legacy helper)
93
- // or a fresh install pre-migration. Returning the JSON keeps those
94
- // call sites working without touching every helper.
89
+ // SQL empty AND JSON has content means a test seeded via fs.writeFileSync
90
+ // (legacy helper) or a fresh install pre-migration. Returning the JSON
91
+ // keeps those call sites working without touching every helper.
95
92
  const fallback = _readJsonArrayFallback(scope);
96
93
  if (fallback.length > 0) return fallback;
97
94
  return [];
@@ -161,29 +158,9 @@ function _enumerateJsonScopes() {
161
158
  // Read all rows across all scopes — used by queries.getWorkItems which
162
159
  // needs to surface central + every project's items in a single shot,
163
160
  // tagged with their source scope.
164
- //
165
- // Issue #3035: SQL-unavailable installs (Node 22.x without
166
- // --experimental-sqlite, prior to v0.1.2113) returned [] from this path
167
- // because `getDb()` throws and the legacy `return null` signal was
168
- // swallowed by `queries.getWorkItems`'s `|| []`. Mirror the per-scope
169
- // reader's JSON-fallback shape so the aggregate API stays useful on
170
- // Node versions that don't have node:sqlite enabled.
171
161
  function readAllWorkItems() {
172
162
  const { getDb } = require('./db');
173
- let db;
174
- try { db = getDb(); }
175
- catch {
176
- // SQLite unavailable — read every JSON scope on disk directly.
177
- const out = [];
178
- for (const scope of _enumerateJsonScopes()) {
179
- for (const wi of _readJsonArrayFallback(scope)) {
180
- if (!wi || typeof wi !== 'object') continue;
181
- wi._source = scope;
182
- out.push(wi);
183
- }
184
- }
185
- return out;
186
- }
163
+ const db = getDb();
187
164
 
188
165
  // Pick up any external JSON edits for every scope SQL knows about.
189
166
  // Also resync 'central' explicitly so first-time reads on a JSON-only
@@ -326,11 +303,7 @@ function _hydrateScopeFromJson(db, scope) {
326
303
 
327
304
  function applyWorkItemsMutation(scope, mutator) {
328
305
  const { getDb, withTransaction } = require('./db');
329
- let db;
330
- try { db = getDb(); }
331
- catch (e) {
332
- throw new Error(`engine/work-items-store: SQLite unavailable (${e.message}); cannot mutate work_items`);
333
- }
306
+ const db = getDb();
334
307
 
335
308
  return withTransaction(db, () => {
336
309
  // Re-hydrate SQL from JSON if the file was touched outside the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2116",
3
+ "version": "0.1.2118",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"