@yemi33/minions 0.1.2115 → 0.1.2117

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.
package/engine/shared.js CHANGED
@@ -65,6 +65,16 @@ const COOLDOWNS_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
65
65
  // process-lifetime (state/pid/ownerToken). See ENGINE_DEFAULTS.abandonedReconciliationVersion
66
66
  // for the first consumer.
67
67
  const ENGINE_STATE_PATH = path.join(ENGINE_DIR, 'state.json');
68
+ // Phase 9.3: wrap raw safeWrite callers for kb-checkpoint, kb-swept,
69
+ // kb-sweep-state, and test-results so they go through mutateJsonFileLocked
70
+ // (the same path mutateControl / mutateEngineState / mutateCooldowns use).
71
+ // This sets them up for SQL routing via _SMALL_STATE_MUTATE_ROUTES later, and
72
+ // in the case of kb-sweep-state.json closes a real cross-process race (the
73
+ // dashboard process and the detached kb-sweep-runner child both write it).
74
+ const KB_CHECKPOINT_PATH = path.join(ENGINE_DIR, 'kb-checkpoint.json');
75
+ const KB_SWEPT_PATH = path.join(ENGINE_DIR, 'kb-swept.json');
76
+ const KB_SWEEP_STATE_PATH = path.join(ENGINE_DIR, 'kb-sweep-state.json');
77
+ const TEST_RESULTS_PATH = path.join(ENGINE_DIR, 'test-results.json');
68
78
  const PR_LINKS_PATH = path.join(MINIONS_DIR, 'engine', 'pr-links.json');
69
79
  const PINNED_ITEMS_PATH = path.join(MINIONS_DIR, 'engine', 'kb-pins.json');
70
80
  const LOG_PATH = path.join(MINIONS_DIR, 'engine', 'log.json');
@@ -536,6 +546,22 @@ function _routeJsonReadToSql(p) {
536
546
  const store = require('./small-state-store');
537
547
  return { value: store.readPrLinks() };
538
548
  }
549
+ if (norm.endsWith('/engine/cooldowns.json')) {
550
+ const store = require('./small-state-store');
551
+ return { value: store.readCooldowns() };
552
+ }
553
+ if (norm.endsWith('/engine/pending-rebases.json')) {
554
+ const store = require('./small-state-store');
555
+ return { value: store.readPendingRebases() };
556
+ }
557
+ if (norm.endsWith('/engine/cc-sessions.json')) {
558
+ const store = require('./small-state-store');
559
+ return { value: store.readCcSessions() };
560
+ }
561
+ if (norm.endsWith('/engine/doc-sessions.json')) {
562
+ const store = require('./small-state-store');
563
+ return { value: store.readDocSessions() };
564
+ }
539
565
  // Per-project work-items.json — match `/projects/<name>/work-items.json`.
540
566
  // When SQL has no rows for the scope AND the JSON file is absent on
541
567
  // disk, preserve the legacy "file missing → null" semantic. This guards
@@ -565,9 +591,11 @@ function _routeJsonReadToSql(p) {
565
591
  const store = require('./pull-requests-store');
566
592
  return { value: store.readPullRequestsForScope('central') };
567
593
  }
568
- } catch {
569
- // SQLite unavailable / store load failurefall through to disk read.
570
- 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;
571
599
  }
572
600
  return null;
573
601
  }
@@ -1323,13 +1351,64 @@ function withFileLock(lockPath, fn, {
1323
1351
  throw lastErr;
1324
1352
  }
1325
1353
 
1354
+ // Route table for small-state files migrated to SQL stores. Each entry
1355
+ // declares the SQL store function to invoke and the expected default JSON
1356
+ // shape (for the "ensure file exists" fallback below).
1357
+ const _SMALL_STATE_MUTATE_ROUTES = {
1358
+ 'cooldowns.json': { fn: 'applyCooldownsMutation', mirror: '_mirrorCooldownsJson', defaultShape: 'object' },
1359
+ 'pending-rebases.json': { fn: 'applyPendingRebasesMutation', mirror: '_mirrorPendingRebasesJson', defaultShape: 'array' },
1360
+ 'cc-sessions.json': { fn: 'applyCcSessionsMutation', mirror: '_mirrorCcSessionsJson', defaultShape: 'array' },
1361
+ 'doc-sessions.json': { fn: 'applyDocSessionsMutation', mirror: '_mirrorDocSessionsJson', defaultShape: 'object' },
1362
+ 'pr-links.json': { fn: 'applyPrLinksMutation', mirror: '_mirrorPrLinksJson', defaultShape: 'object' },
1363
+ };
1364
+
1326
1365
  function _tryRouteMutateToSql(filePath, mutateFn, onWrote) {
1327
1366
  const baseName = path.basename(filePath);
1328
- if (baseName !== 'work-items.json' && baseName !== 'pull-requests.json') return null;
1367
+ const smallRoute = _SMALL_STATE_MUTATE_ROUTES[baseName];
1368
+ if (baseName !== 'work-items.json' && baseName !== 'pull-requests.json' && !smallRoute) return null;
1329
1369
  const fpNorm = String(filePath).replace(/\\/g, '/');
1330
1370
  const minionsNorm = String(MINIONS_DIR).replace(/\\/g, '/');
1331
1371
  const insideMinionsDir = fpNorm.startsWith(minionsNorm + '/') || fpNorm === minionsNorm + '/' + baseName;
1332
1372
  if (!insideMinionsDir) return null;
1373
+
1374
+ // Small-state files live exclusively under <MINIONS_DIR>/engine/<baseName>.
1375
+ // Don't hijack writes to ad-hoc paths that happen to share the basename.
1376
+ if (smallRoute) {
1377
+ if (!fpNorm.endsWith('/engine/' + baseName)) return null;
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, () => {
1392
+ const out = store[smallRoute.fn]((data) => {
1393
+ const next = mutateFn(data);
1394
+ if (onWrote) onWrote();
1395
+ return next;
1396
+ });
1397
+ const result = out && Object.prototype.hasOwnProperty.call(out, 'result') ? out.result : undefined;
1398
+ try { store[smallRoute.mirror](filePath); } catch { /* mirror best-effort */ }
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 */ }
1407
+ }
1408
+ return { routed: result };
1409
+ });
1410
+ }
1411
+
1333
1412
  let result;
1334
1413
  if (baseName === 'work-items.json') {
1335
1414
  result = mutateWorkItems(filePath, (arr) => {
@@ -1451,6 +1530,45 @@ function mutateCooldowns(mutator) {
1451
1530
  }, { defaultValue: {}, skipWriteIfUnchanged: true });
1452
1531
  }
1453
1532
 
1533
+ // Phase 9.3 (W-mp48wxqw / SQL-canonicalization prep): lock-safe RMW wrappers
1534
+ // for the small JSON state files that were still going through raw safeWrite.
1535
+ // Pattern mirrors mutateControl / mutateEngineState / mutateCooldowns — so a
1536
+ // future SQL migration is a mechanical _SMALL_STATE_MUTATE_ROUTES addition.
1537
+
1538
+ // KB classification checkpoint counter (consolidation.js post-classify).
1539
+ function mutateKbCheckpoint(mutator) {
1540
+ return mutateJsonFileLocked(KB_CHECKPOINT_PATH, (data) => {
1541
+ if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
1542
+ return mutator(data) || data;
1543
+ }, { defaultValue: {}, skipWriteIfUnchanged: true });
1544
+ }
1545
+
1546
+ // KB last-sweep summary (kb-sweep.js post-sweep, dashboard reader).
1547
+ function mutateKbSwept(mutator) {
1548
+ return mutateJsonFileLocked(KB_SWEPT_PATH, (data) => {
1549
+ if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
1550
+ return mutator(data) || data;
1551
+ }, { defaultValue: {}, skipWriteIfUnchanged: true });
1552
+ }
1553
+
1554
+ // KB sweep state machine. Written by BOTH the dashboard process AND the
1555
+ // detached kb-sweep-runner child — wrapping under mutateJsonFileLocked makes
1556
+ // cross-process writes file-locked (previously a real race via raw safeWrite).
1557
+ function mutateKbSweepState(mutator) {
1558
+ return mutateJsonFileLocked(KB_SWEEP_STATE_PATH, (data) => {
1559
+ if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
1560
+ return mutator(data) || data;
1561
+ }, { defaultValue: {}, skipWriteIfUnchanged: true });
1562
+ }
1563
+
1564
+ // Test results history (capped at TEST_RESULTS_CAP by cleanup.js).
1565
+ function mutateTestResults(mutator) {
1566
+ return mutateJsonFileLocked(TEST_RESULTS_PATH, (data) => {
1567
+ if (!Array.isArray(data)) data = [];
1568
+ return mutator(data) || data;
1569
+ }, { defaultValue: [], skipWriteIfUnchanged: true });
1570
+ }
1571
+
1454
1572
  let _uidCounter = 0;
1455
1573
 
1456
1574
  /**
@@ -3094,32 +3212,20 @@ const WATCH_ACTION_TYPE = {
3094
3212
 
3095
3213
  /**
3096
3214
  * Phase 7 — small state file mutators. Each routes through the
3097
- * small-state-store, mirrors back to its JSON file, and emits a topic
3098
- * event on real writes. SQLite failure falls through to the legacy
3099
- * 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.
3100
3219
  */
3101
- function _smallStateMutator({ filePath, applyMutation, mirror, topic, defaultValue }) {
3220
+ function _smallStateMutator({ filePath, applyMutation, mirror, topic }) {
3102
3221
  return (mutator) => {
3103
- try {
3104
- const store = require('./small-state-store');
3105
- const { wrote, result } = store[applyMutation]((obj) => mutator(obj) || obj);
3106
- if (wrote) {
3107
- try { if (mirror && typeof store[mirror] === 'function') store[mirror](filePath); } catch { /* mirror best-effort */ }
3108
- try { require('./db-events').emitStateEvent(topic); } catch { /* optional */ }
3109
- }
3110
- return result;
3111
- } catch (e) {
3112
- 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 */ }
3113
3227
  }
3114
- return mutateJsonFileLocked(filePath, (data) => {
3115
- if (data == null) data = defaultValue();
3116
- return mutator(data) || data;
3117
- }, {
3118
- defaultValue: defaultValue(),
3119
- onWrote: () => {
3120
- try { require('./db-events').emitStateEvent(topic); } catch { /* optional */ }
3121
- },
3122
- });
3228
+ return result;
3123
3229
  };
3124
3230
  }
3125
3231
 
@@ -3175,31 +3281,18 @@ function _qaDualWriteEnabled() {
3175
3281
  function _qaMutator({ filePath, applyMutation, mirror, topic }) {
3176
3282
  return (mutator) => {
3177
3283
  return withFileLock(filePath + '.lock', () => {
3178
- try {
3179
- const store = require('./small-state-store');
3180
- const { wrote, result } = store[applyMutation]((arr) => {
3181
- if (!Array.isArray(arr)) arr = [];
3182
- return mutator(arr) || arr;
3183
- });
3184
- if (wrote) {
3185
- if (_qaDualWriteEnabled()) {
3186
- try { if (mirror && typeof store[mirror] === 'function') store[mirror](filePath); } catch { /* mirror best-effort */ }
3187
- }
3188
- 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 */ }
3189
3292
  }
3190
- return result;
3191
- } catch (e) {
3192
- if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) throw e;
3193
- return mutateJsonFileLocked(filePath, (data) => {
3194
- if (!Array.isArray(data)) data = [];
3195
- return mutator(data) || data;
3196
- }, {
3197
- defaultValue: [],
3198
- onWrote: () => {
3199
- try { require('./db-events').emitStateEvent(topic); } catch { /* optional */ }
3200
- },
3201
- });
3293
+ try { require('./db-events').emitStateEvent(topic); } catch { /* optional */ }
3202
3294
  }
3295
+ return result;
3203
3296
  }, { timeoutMs: 5000, retries: 3 });
3204
3297
  };
3205
3298
  }
@@ -3222,36 +3315,20 @@ const mutateQaSessions = _qaMutator({
3222
3315
  * Route a watches mutation through the SQL store. Same shape as
3223
3316
  * mutateWorkItems / mutatePullRequests: mutator receives the watches
3224
3317
  * array, mutates in place or returns a replacement, and the store
3225
- * diffs by id. Falls back to the legacy mutateJsonFileLocked path on
3226
- * SQLite failure.
3318
+ * diffs by id. SQL-canonical (Phase 9.4); SQLite failures propagate.
3227
3319
  */
3228
3320
  function mutateWatches(mutator) {
3229
3321
  const watchesPath = path.join(MINIONS_DIR, 'engine', 'watches.json');
3230
- try {
3231
- const store = require('./watches-store');
3232
- const { wrote, result } = store.applyWatchesMutation((arr) => {
3233
- if (!Array.isArray(arr)) arr = [];
3234
- return mutator(arr) || arr;
3235
- });
3236
- if (wrote) {
3237
- try { store._mirrorJsonFromSql(watchesPath); } catch { /* mirror best-effort */ }
3238
- try { require('./db-events').emitStateEvent('watches'); } catch { /* optional */ }
3239
- }
3240
- return result;
3241
- } catch (e) {
3242
- if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
3243
- throw e;
3244
- }
3245
- }
3246
- return mutateJsonFileLocked(watchesPath, (data) => {
3247
- if (!Array.isArray(data)) data = [];
3248
- return mutator(data) || data;
3249
- }, {
3250
- defaultValue: [],
3251
- onWrote: () => {
3252
- try { require('./db-events').emitStateEvent('watches'); } catch { /* optional */ }
3253
- },
3322
+ const store = require('./watches-store');
3323
+ const { wrote, result } = store.applyWatchesMutation((arr) => {
3324
+ if (!Array.isArray(arr)) arr = [];
3325
+ return mutator(arr) || arr;
3254
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;
3255
3332
  }
3256
3333
 
3257
3334
  /**
@@ -3259,37 +3336,20 @@ function mutateWatches(mutator) {
3259
3336
  * mirror. Same shape as mutateWorkItems / mutatePullRequests: mutator
3260
3337
  * receives the full legacy-shape metrics object, mutates in place or
3261
3338
  * returns a replacement, and the store diffs by (kind, key) row.
3262
- *
3263
- * Falls back to the legacy mutateJsonFileLocked path on SQLite failure
3264
- * so a node:sqlite-broken install keeps recording metrics.
3339
+ * SQL-canonical (Phase 9.4); SQLite failures propagate.
3265
3340
  */
3266
3341
  function mutateMetrics(mutator) {
3267
3342
  const metricsPath = path.join(MINIONS_DIR, 'engine', 'metrics.json');
3268
- try {
3269
- const store = require('./metrics-store');
3270
- const { wrote, result } = store.applyMetricsMutation((m) => {
3271
- if (!m || typeof m !== 'object') m = {};
3272
- return mutator(m) || m;
3273
- });
3274
- if (wrote) {
3275
- try { store._mirrorJsonFromSql(metricsPath); } catch { /* mirror best-effort */ }
3276
- try { require('./db-events').emitStateEvent('metrics'); } catch { /* optional */ }
3277
- }
3278
- return result;
3279
- } catch (e) {
3280
- if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
3281
- throw e;
3282
- }
3283
- }
3284
- return mutateJsonFileLocked(metricsPath, (metrics) => {
3285
- if (!metrics || typeof metrics !== 'object') metrics = {};
3286
- return mutator(metrics) || metrics;
3287
- }, {
3288
- defaultValue: {},
3289
- onWrote: () => {
3290
- try { require('./db-events').emitStateEvent('metrics'); } catch { /* optional */ }
3291
- },
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;
3292
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;
3293
3353
  }
3294
3354
 
3295
3355
  /** Update per-agent review metrics (prsApproved/prsRejected). Only writes for configured agents. */
@@ -5055,23 +5115,11 @@ function addPrLink(prId, itemId, { project = null, url = '', prNumber = null } =
5055
5115
  links[effectivePrId] = [...mergedCurrent];
5056
5116
  return links;
5057
5117
  };
5058
- // Phase 9.1: pr-links is SQL-canonical via small-state-store; the JSON file
5059
- // is a dual-write mirror. SQLite failures fall through to the legacy JSON
5060
- // path so older installs without --experimental-sqlite still work.
5061
- let routedViaSql = false;
5062
- try {
5063
- const store = require('./small-state-store');
5064
- store.applyPrLinksMutation(mutator);
5065
- try { store._mirrorPrLinksJson(); } catch { /* mirror best-effort */ }
5066
- routedViaSql = true;
5067
- } catch (e) {
5068
- if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
5069
- throw e;
5070
- }
5071
- }
5072
- if (!routedViaSql) {
5073
- mutateJsonFileLocked(PR_LINKS_PATH, mutator, { defaultValue: {} });
5074
- }
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 */ }
5075
5123
 
5076
5124
  if (!project) return;
5077
5125
  const prPath = projectPrPath(project);
@@ -5463,42 +5511,27 @@ function listProcessReachable(rootPids, allProcesses = null) {
5463
5511
  * @param {Function} mutator - Receives the array, mutates in place or returns new value
5464
5512
  */
5465
5513
  function mutateWorkItems(filePath, mutator) {
5466
- // Phase 2 SQL path → Phase 9 SQL-only. Route through work-items-store so
5467
- // SQL is the canonical (and only) store; the legacy JSON mirror was
5468
- // retired in Phase 9.
5469
- //
5470
- // The SQL store identifies records by `scope` (central or project name)
5471
- // derived from the file path's last two segments. Ad-hoc file paths
5472
- // outside the MINIONS_DIR layout (e.g. tests using createTmpDir()) can't
5473
- // be mapped to a stable scope, so we short-circuit to the legacy
5474
- // JSON path for those. Production callers always use
5475
- // shared.projectWorkItemsPath(p) / MINIONS_DIR/work-items.json.
5476
- //
5477
- // SQLite failures fall through to the legacy JSON path below — keeps a
5478
- // 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.
5479
5519
  const fpNorm = String(filePath).replace(/\\/g, '/');
5480
5520
  const minionsNorm = String(MINIONS_DIR).replace(/\\/g, '/');
5481
5521
  const insideMinionsDir = fpNorm.startsWith(minionsNorm + '/') || fpNorm === minionsNorm + '/work-items.json';
5482
5522
  if (insideMinionsDir) {
5483
- try {
5484
- const store = require('./work-items-store');
5485
- const scope = store.scopeForFilePath(filePath);
5486
- const { wrote, result } = store.applyWorkItemsMutation(scope, (items) => {
5487
- if (!Array.isArray(items)) items = [];
5488
- return mutator(items) || items;
5489
- });
5490
- if (wrote) {
5491
- try { store._mirrorJsonFromSql(scope, filePath); } catch { /* mirror best-effort */ }
5492
- try { require('./db-events').emitStateEvent('work_items'); } catch { /* optional */ }
5493
- }
5494
- try { require('./queries').invalidateWorkItemsCache(); } catch { /* queries not loaded */ }
5495
- return result;
5496
- } catch (e) {
5497
- if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
5498
- throw e;
5499
- }
5500
- // 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 */ }
5501
5532
  }
5533
+ try { require('./queries').invalidateWorkItemsCache(); } catch { /* queries not loaded */ }
5534
+ return result;
5502
5535
  }
5503
5536
 
5504
5537
  const result = mutateJsonFileLocked(filePath, (data) => {
@@ -5536,31 +5569,24 @@ function reopenWorkItem(wi) {
5536
5569
  * @param {Function} mutator - Receives the array, mutates in place or returns new value
5537
5570
  */
5538
5571
  function mutatePullRequests(filePath, mutator) {
5539
- // Phase 3 SQL path → Phase 9 SQL-only. Route through pull-requests-store
5540
- // when filePath sits under MINIONS_DIR. Ad-hoc tmp paths (legacy tests
5541
- // 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.
5542
5575
  const fpNorm = String(filePath).replace(/\\/g, '/');
5543
5576
  const minionsNorm = String(MINIONS_DIR).replace(/\\/g, '/');
5544
5577
  const insideMinionsDir = fpNorm.startsWith(minionsNorm + '/') || fpNorm === minionsNorm + '/pull-requests.json';
5545
5578
  if (insideMinionsDir) {
5546
- try {
5547
- const store = require('./pull-requests-store');
5548
- const scope = store.scopeForFilePath(filePath);
5549
- const { wrote, result } = store.applyPullRequestsMutation(scope, (prs) => {
5550
- if (!Array.isArray(prs)) prs = [];
5551
- return mutator(prs) || prs;
5552
- });
5553
- if (wrote) {
5554
- try { store._mirrorJsonFromSql(scope, filePath); } catch { /* mirror best-effort */ }
5555
- try { require('./db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
5556
- }
5557
- return result;
5558
- } catch (e) {
5559
- if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
5560
- throw e;
5561
- }
5562
- // 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 */ }
5563
5588
  }
5589
+ return result;
5564
5590
  }
5565
5591
 
5566
5592
  return mutateJsonFileLocked(filePath, (data) => {
@@ -5975,6 +6001,10 @@ module.exports = {
5975
6001
  mutateEngineState, // W-mp60tw0u000j3931
5976
6002
  readEngineState, // W-mp60tw0u000j3931
5977
6003
  mutateCooldowns,
6004
+ mutateKbCheckpoint, // Phase 9.3
6005
+ mutateKbSwept, // Phase 9.3
6006
+ mutateKbSweepState, // Phase 9.3
6007
+ mutateTestResults, // Phase 9.3
5978
6008
  mutateWorkItems,
5979
6009
  reopenWorkItem,
5980
6010
  mutatePullRequests,