@yemi33/minions 0.1.2115 → 0.1.2116

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/bin/minions.js CHANGED
@@ -34,6 +34,27 @@
34
34
  * minions uninstall --confirm Remove Minions and uninstall package
35
35
  */
36
36
 
37
+ // Phase 9.4 (issue #3035 hardening): self-reexec on Node 22.x without
38
+ // --experimental-sqlite so the CLI PARENT process can load `node:sqlite`
39
+ // for in-process store reads (status, doctor, etc.). On Node 24+ this is a
40
+ // no-op (sqlite is unflagged). Must run BEFORE any `require('engine/*')` —
41
+ // engine modules pull in `node:sqlite` transitively via `engine/db`.
42
+ (function _ensureSqliteFlagSelfReexec() {
43
+ const major = parseInt(String(process.versions.node).split('.')[0], 10);
44
+ if (!Number.isFinite(major) || major < 22 || major >= 24) return;
45
+ const opts = String(process.env.NODE_OPTIONS || '');
46
+ if (opts.includes('--experimental-sqlite')) return;
47
+ if (process.env.MINIONS_FORCE_JSON === '1') return; // explicit opt-out
48
+ const { spawnSync } = require('child_process');
49
+ const nextOpts = (opts + ' --experimental-sqlite').trim();
50
+ const res = spawnSync(
51
+ process.execPath,
52
+ ['--experimental-sqlite', __filename, ...process.argv.slice(2)],
53
+ { stdio: 'inherit', env: { ...process.env, NODE_OPTIONS: nextOpts } }
54
+ );
55
+ process.exit(res.status == null ? 1 : res.status);
56
+ })();
57
+
37
58
  const fs = require('fs');
38
59
  const path = require('path');
39
60
  const os = require('os');
package/engine/cleanup.js CHANGED
@@ -1410,12 +1410,19 @@ async function runCleanup(config, verbose = false) {
1410
1410
  } catch (e) { log('warn', 'clean stale PID files: ' + e.message); }
1411
1411
 
1412
1412
  // 13. Prune test-results.json — keep last 200 entries
1413
+ // Phase 9.3: route through shared.mutateTestResults so the read+trim+write
1414
+ // is atomic under the file lock (previously a safeJsonArr → safeWrite race).
1413
1415
  try {
1414
1416
  const testResultsPath = path.join(ENGINE_DIR, 'test-results.json');
1415
- const results = shared.safeJsonArr(testResultsPath);
1416
1417
  const TEST_RESULTS_CAP = 200;
1417
- if (results.length > TEST_RESULTS_CAP) {
1418
- safeWrite(testResultsPath, results.slice(-TEST_RESULTS_CAP));
1418
+ // Fast path: skip lock acquisition entirely when the file is already
1419
+ // under cap. The atomic re-check inside the mutator handles the race.
1420
+ const probe = shared.safeJsonArr(testResultsPath);
1421
+ if (probe.length > TEST_RESULTS_CAP) {
1422
+ shared.mutateTestResults((curr) => {
1423
+ if (curr.length > TEST_RESULTS_CAP) return curr.slice(-TEST_RESULTS_CAP);
1424
+ return undefined; // already trimmed by a concurrent writer
1425
+ });
1419
1426
  }
1420
1427
  } catch { /* optional — file may not exist */ }
1421
1428
 
@@ -849,7 +849,7 @@ function classifyToKnowledgeBase(items, config) {
849
849
  const dir = path.join(KNOWLEDGE_DIR, cat);
850
850
  if (fs.existsSync(dir)) count += fs.readdirSync(dir).length;
851
851
  }
852
- safeWrite(path.join(ENGINE_DIR, 'kb-checkpoint.json'), JSON.stringify({ count, updatedAt: ts() }));
852
+ shared.mutateKbCheckpoint(() => ({ count, updatedAt: ts() }));
853
853
  } catch (err) { log('warn', `KB checkpoint: ${err.message}`); }
854
854
  }
855
855
 
@@ -35,17 +35,24 @@ function runMigrations(db) {
35
35
  )
36
36
  `);
37
37
 
38
- const currentRow = db.prepare('SELECT COALESCE(MAX(version), 0) AS v FROM schema_version').get();
39
- const current = Number(currentRow.v) || 0;
40
38
  const migrations = _loadMigrations();
41
39
 
42
40
  for (const m of migrations) {
43
41
  if (typeof m.version !== 'number' || typeof m.up !== 'function') {
44
42
  throw new Error(`engine/db/migrate: migration missing { version, up }: ${JSON.stringify(Object.keys(m))}`);
45
43
  }
46
- if (m.version <= current) continue;
47
- db.exec('BEGIN');
44
+ // Concurrent-startup safety: take the SQLite write lock BEFORE re-reading
45
+ // schema_version. With plain `BEGIN` (deferred), two processes booting at
46
+ // the same time can both observe schema_version=N, both decide to apply
47
+ // N+1, and one then fails with "table already exists" while the other
48
+ // succeeds — leaving the loser stuck on `_dbInitError` for the lifetime
49
+ // of its process. `BEGIN IMMEDIATE` serializes the version check itself
50
+ // so the second arrival simply sees the updated schema_version and skips.
51
+ db.exec('BEGIN IMMEDIATE');
48
52
  try {
53
+ const currentRow = db.prepare('SELECT COALESCE(MAX(version), 0) AS v FROM schema_version').get();
54
+ const current = Number(currentRow.v) || 0;
55
+ if (m.version <= current) { db.exec('COMMIT'); continue; }
49
56
  // eslint-disable-next-line no-console
50
57
  console.log(`[db-migrate] Applying v${m.version}: ${m.description || '(no description)'}`);
51
58
  m.up(db, { fs, path });
@@ -0,0 +1,116 @@
1
+ // engine/db/migrations/011-remaining-state.js
2
+ //
3
+ // Phase 9.2: finish the JSON→SQL migration. Four remaining engine state
4
+ // files move into their own tables; the JSON files are kept as dual-write
5
+ // mirrors for back-compat.
6
+ //
7
+ // engine/cooldowns.json -> cooldowns
8
+ // engine/pending-rebases.json -> pending_rebases
9
+ // engine/cc-sessions.json -> cc_sessions
10
+ // engine/doc-sessions.json -> doc_sessions
11
+
12
+ const path = require('path');
13
+ const fs = require('fs');
14
+
15
+ function _resolveMinionsDir() {
16
+ const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
17
+ if (envHome) return envHome;
18
+ try { return require('../../shared').MINIONS_DIR; } catch { return null; }
19
+ }
20
+
21
+ function _readJsonOr(filePath, fallback) {
22
+ try {
23
+ const raw = fs.readFileSync(filePath, 'utf8');
24
+ return JSON.parse(raw);
25
+ } catch { return fallback; }
26
+ }
27
+
28
+ module.exports = {
29
+ version: 11,
30
+ description: 'cooldowns + pending_rebases + cc_sessions + doc_sessions',
31
+ up(db) {
32
+ db.exec(`
33
+ CREATE TABLE cooldowns (
34
+ key TEXT PRIMARY KEY,
35
+ data TEXT NOT NULL,
36
+ updated_at INTEGER NOT NULL
37
+ );
38
+
39
+ CREATE TABLE pending_rebases (
40
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
41
+ data TEXT NOT NULL,
42
+ updated_at INTEGER NOT NULL
43
+ );
44
+
45
+ CREATE TABLE cc_sessions (
46
+ id TEXT PRIMARY KEY,
47
+ data TEXT NOT NULL,
48
+ updated_at INTEGER NOT NULL
49
+ );
50
+
51
+ CREATE TABLE doc_sessions (
52
+ key TEXT PRIMARY KEY,
53
+ data TEXT NOT NULL,
54
+ updated_at INTEGER NOT NULL
55
+ );
56
+ `);
57
+
58
+ const minionsDir = _resolveMinionsDir();
59
+ if (!minionsDir) return;
60
+ const now = Date.now();
61
+ let inserted = 0;
62
+
63
+ // ── cooldowns ──────────────────────────────────────────────────────────
64
+ {
65
+ const raw = _readJsonOr(path.join(minionsDir, 'engine', 'cooldowns.json'), null);
66
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
67
+ const ins = db.prepare(`INSERT INTO cooldowns (key, data, updated_at) VALUES (?, ?, ?)`);
68
+ for (const [k, v] of Object.entries(raw)) {
69
+ try { ins.run(String(k), JSON.stringify(v), now); inserted += 1; }
70
+ catch { /* duplicate / corrupt — skip */ }
71
+ }
72
+ }
73
+ }
74
+
75
+ // ── pending_rebases ────────────────────────────────────────────────────
76
+ {
77
+ const raw = _readJsonOr(path.join(minionsDir, 'engine', 'pending-rebases.json'), null);
78
+ if (Array.isArray(raw)) {
79
+ const ins = db.prepare(`INSERT INTO pending_rebases (data, updated_at) VALUES (?, ?)`);
80
+ for (const entry of raw) {
81
+ if (!entry || typeof entry !== 'object') continue;
82
+ try { ins.run(JSON.stringify(entry), now); inserted += 1; }
83
+ catch { /* corrupt — skip */ }
84
+ }
85
+ }
86
+ }
87
+
88
+ // ── cc_sessions ────────────────────────────────────────────────────────
89
+ {
90
+ const raw = _readJsonOr(path.join(minionsDir, 'engine', 'cc-sessions.json'), null);
91
+ if (Array.isArray(raw)) {
92
+ const ins = db.prepare(`INSERT INTO cc_sessions (id, data, updated_at) VALUES (?, ?, ?)`);
93
+ for (const entry of raw) {
94
+ if (!entry || typeof entry !== 'object' || !entry.id) continue;
95
+ try { ins.run(String(entry.id), JSON.stringify(entry), now); inserted += 1; }
96
+ catch { /* duplicate id — skip */ }
97
+ }
98
+ }
99
+ }
100
+
101
+ // ── doc_sessions ───────────────────────────────────────────────────────
102
+ {
103
+ const raw = _readJsonOr(path.join(minionsDir, 'engine', 'doc-sessions.json'), null);
104
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
105
+ const ins = db.prepare(`INSERT INTO doc_sessions (key, data, updated_at) VALUES (?, ?, ?)`);
106
+ for (const [k, v] of Object.entries(raw)) {
107
+ try { ins.run(String(k), JSON.stringify(v), now); inserted += 1; }
108
+ catch { /* duplicate / corrupt — skip */ }
109
+ }
110
+ }
111
+ }
112
+
113
+ // eslint-disable-next-line no-console
114
+ console.log(`[db-migrate] v11: backfilled ${inserted} small-state rows; JSON files kept as dual-write mirrors`);
115
+ },
116
+ };
@@ -288,8 +288,10 @@ function _writeSweepState(state) {
288
288
  // can distinguish "still running" from "runner crashed". When this module is
289
289
  // imported by the detached runner, process.pid is the runner's pid — which
290
290
  // is exactly what we want.
291
+ // Phase 9.3: route through shared.mutateKbSweepState (file-locked RMW) so
292
+ // the dashboard process + the detached runner can't race each other.
291
293
  const augmented = { pid: process.pid, ...state };
292
- try { safeWrite(KB_SWEEP_STATE_PATH, JSON.stringify(augmented)); } catch { /* ignore */ }
294
+ try { shared.mutateKbSweepState(() => augmented); } catch { /* ignore */ }
293
295
  }
294
296
 
295
297
  /**
@@ -416,10 +418,10 @@ function reconcileSweepStateOnBoot(opts = {}) {
416
418
  error: `sweep abandoned: ${reason}`,
417
419
  reconciliationReason: reason,
418
420
  };
419
- // Direct safeWrite (NOT _writeSweepState) so the original pid is preserved
420
- // for forensics — _writeSweepState would overwrite it with this process's
421
- // pid, destroying the diagnostic value of the record.
422
- try { safeWrite(KB_SWEEP_STATE_PATH, JSON.stringify(failedState)); } catch { /* ignore */ }
421
+ // Direct mutateKbSweepState (NOT _writeSweepState) so the original pid is
422
+ // preserved for forensics — _writeSweepState would overwrite it with this
423
+ // process's pid, destroying the diagnostic value of the record.
424
+ try { shared.mutateKbSweepState(() => failedState); } catch { /* ignore */ }
423
425
  return {
424
426
  scanned: 1, released: 1, reason,
425
427
  prevStatus: state.status, prevPid: pid,
@@ -551,7 +553,7 @@ async function _runKbSweepImpl(opts = {}) {
551
553
  summary.summary = `${summary.hashDuplicatesArchived} hash-dup, ${summary.llmDuplicatesArchived} llm-dup, ${summary.staleRemoved} stale, ${summary.reclassified} reclassified, ${summary.rewritten} rewritten (${(summary.bytesBefore - summary.bytesAfter).toLocaleString()} bytes saved)`;
552
554
 
553
555
  if (!opts.dryRun) {
554
- try { safeWrite(path.join(ENGINE_DIR, 'kb-swept.json'), JSON.stringify({ timestamp: ts(), summary: summary.summary, detail: summary })); } catch { /* ignore */ }
556
+ try { shared.mutateKbSwept(() => ({ timestamp: ts(), summary: summary.summary, detail: summary })); } catch { /* ignore */ }
555
557
  try { queries.invalidateKnowledgeBaseCache(); } catch { /* ignore */ }
556
558
  }
557
559
  return summary;
@@ -587,7 +589,7 @@ function spawnSweepRunnerDetached(opts = {}) {
587
589
  const sweepToken = `${startedAt}-${Math.random().toString(36).slice(2, 8)}`;
588
590
 
589
591
  try {
590
- safeWrite(KB_SWEEP_STATE_PATH, JSON.stringify({
592
+ shared.mutateKbSweepState(() => ({
591
593
  status: 'starting', startedAt, startedAtIso: new Date().toISOString(),
592
594
  sweepToken, pid: null,
593
595
  }));
@@ -637,14 +639,18 @@ function spawnSweepRunnerDetached(opts = {}) {
637
639
  }
638
640
  if (logFdNum != null) try { fsLocal.closeSync(logFdNum); } catch { /* ignore */ }
639
641
 
642
+ // Phase 9.3: atomic starting→in-flight transition under the file lock
643
+ // (was previously a safeJson→safeWrite race).
640
644
  try {
641
- const current = safeJson(KB_SWEEP_STATE_PATH);
642
- if (current && current.status === 'starting' && current.sweepToken === sweepToken) {
643
- safeWrite(KB_SWEEP_STATE_PATH, JSON.stringify({
644
- status: 'in-flight', startedAt, startedAtIso: new Date().toISOString(),
645
- sweepToken, pid: proc.pid,
646
- }));
647
- }
645
+ shared.mutateKbSweepState((current) => {
646
+ if (current && current.status === 'starting' && current.sweepToken === sweepToken) {
647
+ return {
648
+ status: 'in-flight', startedAt, startedAtIso: new Date().toISOString(),
649
+ sweepToken, pid: proc.pid,
650
+ };
651
+ }
652
+ return undefined; // unchanged → skipWriteIfUnchanged suppresses the write
653
+ });
648
654
  } catch { /* best-effort */ }
649
655
 
650
656
  proc.unref();
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
@@ -1323,13 +1349,60 @@ function withFileLock(lockPath, fn, {
1323
1349
  throw lastErr;
1324
1350
  }
1325
1351
 
1352
+ // Route table for small-state files migrated to SQL stores. Each entry
1353
+ // declares the SQL store function to invoke and the expected default JSON
1354
+ // shape (for the "ensure file exists" fallback below).
1355
+ const _SMALL_STATE_MUTATE_ROUTES = {
1356
+ 'cooldowns.json': { fn: 'applyCooldownsMutation', mirror: '_mirrorCooldownsJson', defaultShape: 'object' },
1357
+ 'pending-rebases.json': { fn: 'applyPendingRebasesMutation', mirror: '_mirrorPendingRebasesJson', defaultShape: 'array' },
1358
+ 'cc-sessions.json': { fn: 'applyCcSessionsMutation', mirror: '_mirrorCcSessionsJson', defaultShape: 'array' },
1359
+ 'doc-sessions.json': { fn: 'applyDocSessionsMutation', mirror: '_mirrorDocSessionsJson', defaultShape: 'object' },
1360
+ 'pr-links.json': { fn: 'applyPrLinksMutation', mirror: '_mirrorPrLinksJson', defaultShape: 'object' },
1361
+ };
1362
+
1326
1363
  function _tryRouteMutateToSql(filePath, mutateFn, onWrote) {
1327
1364
  const baseName = path.basename(filePath);
1328
- if (baseName !== 'work-items.json' && baseName !== 'pull-requests.json') return null;
1365
+ const smallRoute = _SMALL_STATE_MUTATE_ROUTES[baseName];
1366
+ if (baseName !== 'work-items.json' && baseName !== 'pull-requests.json' && !smallRoute) return null;
1329
1367
  const fpNorm = String(filePath).replace(/\\/g, '/');
1330
1368
  const minionsNorm = String(MINIONS_DIR).replace(/\\/g, '/');
1331
1369
  const insideMinionsDir = fpNorm.startsWith(minionsNorm + '/') || fpNorm === minionsNorm + '/' + baseName;
1332
1370
  if (!insideMinionsDir) return null;
1371
+
1372
+ // Small-state files live exclusively under <MINIONS_DIR>/engine/<baseName>.
1373
+ // Don't hijack writes to ad-hoc paths that happen to share the basename.
1374
+ if (smallRoute) {
1375
+ if (!fpNorm.endsWith('/engine/' + baseName)) return null;
1376
+ let result;
1377
+ try {
1378
+ const store = require('./small-state-store');
1379
+ const out = store[smallRoute.fn]((data) => {
1380
+ const next = mutateFn(data);
1381
+ if (onWrote) onWrote();
1382
+ return next;
1383
+ });
1384
+ result = out && Object.prototype.hasOwnProperty.call(out, 'result') ? out.result : undefined;
1385
+ 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;
1391
+ }
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 };
1404
+ }
1405
+
1333
1406
  let result;
1334
1407
  if (baseName === 'work-items.json') {
1335
1408
  result = mutateWorkItems(filePath, (arr) => {
@@ -1451,6 +1524,45 @@ function mutateCooldowns(mutator) {
1451
1524
  }, { defaultValue: {}, skipWriteIfUnchanged: true });
1452
1525
  }
1453
1526
 
1527
+ // Phase 9.3 (W-mp48wxqw / SQL-canonicalization prep): lock-safe RMW wrappers
1528
+ // for the small JSON state files that were still going through raw safeWrite.
1529
+ // Pattern mirrors mutateControl / mutateEngineState / mutateCooldowns — so a
1530
+ // future SQL migration is a mechanical _SMALL_STATE_MUTATE_ROUTES addition.
1531
+
1532
+ // KB classification checkpoint counter (consolidation.js post-classify).
1533
+ function mutateKbCheckpoint(mutator) {
1534
+ return mutateJsonFileLocked(KB_CHECKPOINT_PATH, (data) => {
1535
+ if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
1536
+ return mutator(data) || data;
1537
+ }, { defaultValue: {}, skipWriteIfUnchanged: true });
1538
+ }
1539
+
1540
+ // KB last-sweep summary (kb-sweep.js post-sweep, dashboard reader).
1541
+ function mutateKbSwept(mutator) {
1542
+ return mutateJsonFileLocked(KB_SWEPT_PATH, (data) => {
1543
+ if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
1544
+ return mutator(data) || data;
1545
+ }, { defaultValue: {}, skipWriteIfUnchanged: true });
1546
+ }
1547
+
1548
+ // KB sweep state machine. Written by BOTH the dashboard process AND the
1549
+ // detached kb-sweep-runner child — wrapping under mutateJsonFileLocked makes
1550
+ // cross-process writes file-locked (previously a real race via raw safeWrite).
1551
+ function mutateKbSweepState(mutator) {
1552
+ return mutateJsonFileLocked(KB_SWEEP_STATE_PATH, (data) => {
1553
+ if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
1554
+ return mutator(data) || data;
1555
+ }, { defaultValue: {}, skipWriteIfUnchanged: true });
1556
+ }
1557
+
1558
+ // Test results history (capped at TEST_RESULTS_CAP by cleanup.js).
1559
+ function mutateTestResults(mutator) {
1560
+ return mutateJsonFileLocked(TEST_RESULTS_PATH, (data) => {
1561
+ if (!Array.isArray(data)) data = [];
1562
+ return mutator(data) || data;
1563
+ }, { defaultValue: [], skipWriteIfUnchanged: true });
1564
+ }
1565
+
1454
1566
  let _uidCounter = 0;
1455
1567
 
1456
1568
  /**
@@ -5975,6 +6087,10 @@ module.exports = {
5975
6087
  mutateEngineState, // W-mp60tw0u000j3931
5976
6088
  readEngineState, // W-mp60tw0u000j3931
5977
6089
  mutateCooldowns,
6090
+ mutateKbCheckpoint, // Phase 9.3
6091
+ mutateKbSwept, // Phase 9.3
6092
+ mutateKbSweepState, // Phase 9.3
6093
+ mutateTestResults, // Phase 9.3
5978
6094
  mutateWorkItems,
5979
6095
  reopenWorkItem,
5980
6096
  mutatePullRequests,
@@ -941,6 +941,438 @@ function _mirrorPrLinksJson(filePath) {
941
941
  } catch { /* mirror best-effort */ }
942
942
  }
943
943
 
944
+ // ─── cooldowns ─────────────────────────────────────────────────────────────
945
+ // Shape: { [key]: { timestamp, failures, ... } }
946
+ // SQL: row per key.
947
+
948
+ let _cooldownsHash = null;
949
+
950
+ function _hydrateCooldowns(db) {
951
+ const fp = _resolveFilePath('cooldowns.json');
952
+ const raw = _readJson(fp) || {};
953
+ db.prepare('DELETE FROM cooldowns').run();
954
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return;
955
+ const now = Date.now();
956
+ const ins = db.prepare('INSERT INTO cooldowns (key, data, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO NOTHING');
957
+ for (const [key, value] of Object.entries(raw)) {
958
+ ins.run(String(key), JSON.stringify(value), now);
959
+ }
960
+ }
961
+
962
+ function _resyncCooldownsIfDiverged(db) {
963
+ const fp = _resolveFilePath('cooldowns.json');
964
+ const currentHash = _fileContentHash(fp);
965
+ if (currentHash == null) return;
966
+ if (_cooldownsHash != null && currentHash === _cooldownsHash) return;
967
+ if (_cooldownsHash == null) {
968
+ const sqlHas = db.prepare('SELECT 1 FROM cooldowns LIMIT 1').get();
969
+ if (sqlHas) { _cooldownsHash = currentHash; return; }
970
+ }
971
+ _hydrateCooldowns(db);
972
+ _cooldownsHash = currentHash;
973
+ }
974
+
975
+ function _readCooldownsFromSql(db) {
976
+ const rows = db.prepare('SELECT key, data FROM cooldowns').all();
977
+ const out = {};
978
+ for (const row of rows) {
979
+ try { out[row.key] = JSON.parse(row.data); }
980
+ catch { /* skip malformed */ }
981
+ }
982
+ return out;
983
+ }
984
+
985
+ function readCooldowns() {
986
+ const { getDb } = require('./db');
987
+ let db;
988
+ try { db = getDb(); }
989
+ catch { return _readJson(_resolveFilePath('cooldowns.json')) || {}; }
990
+ try { _resyncCooldownsIfDiverged(db); }
991
+ catch { /* table may be missing on stale install */ }
992
+ let out;
993
+ try { out = _readCooldownsFromSql(db); }
994
+ catch { return _readJson(_resolveFilePath('cooldowns.json')) || {}; }
995
+ if (Object.keys(out).length === 0) {
996
+ const fallback = _readJson(_resolveFilePath('cooldowns.json'));
997
+ if (fallback && Object.keys(fallback).length > 0) return fallback;
998
+ return {};
999
+ }
1000
+ return out;
1001
+ }
1002
+
1003
+ function applyCooldownsMutation(mutator) {
1004
+ const { getDb, withTransaction } = require('./db');
1005
+ let db;
1006
+ try { db = getDb(); }
1007
+ catch (e) { throw new Error(`small-state-store: SQLite unavailable (${e.message})`); }
1008
+
1009
+ return withTransaction(db, () => {
1010
+ _resyncCooldownsIfDiverged(db);
1011
+ const before = _readCooldownsFromSql(db);
1012
+ const beforeSnap = JSON.parse(JSON.stringify(before));
1013
+ const next = mutator(before);
1014
+ const after = (next === undefined || next === null) ? before : next;
1015
+ if (!after || typeof after !== 'object' || Array.isArray(after)) {
1016
+ return { wrote: false, result: beforeSnap };
1017
+ }
1018
+ const afterIds = new Set(Object.keys(after));
1019
+ const beforeIds = new Set(Object.keys(beforeSnap));
1020
+ let wrote = false;
1021
+ const now = Date.now();
1022
+ const upsert = db.prepare(`
1023
+ INSERT INTO cooldowns (key, data, updated_at)
1024
+ VALUES (?, ?, ?)
1025
+ ON CONFLICT(key) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
1026
+ `);
1027
+ const del = db.prepare('DELETE FROM cooldowns WHERE key = ?');
1028
+ for (const id of afterIds) {
1029
+ if (!beforeIds.has(id) || JSON.stringify(beforeSnap[id]) !== JSON.stringify(after[id])) {
1030
+ upsert.run(id, JSON.stringify(after[id]), now);
1031
+ wrote = true;
1032
+ }
1033
+ }
1034
+ for (const id of beforeIds) {
1035
+ if (!afterIds.has(id)) { del.run(id); wrote = true; }
1036
+ }
1037
+ return { wrote, result: after };
1038
+ });
1039
+ }
1040
+
1041
+ function _mirrorCooldownsJson(filePath) {
1042
+ try {
1043
+ const shared = require('./shared');
1044
+ const { getDb } = require('./db');
1045
+ const obj = _readCooldownsFromSql(getDb());
1046
+ const target = filePath || _resolveFilePath('cooldowns.json');
1047
+ shared.safeWrite(target, obj);
1048
+ const h = _fileContentHash(target);
1049
+ if (h != null) _cooldownsHash = h;
1050
+ } catch { /* mirror best-effort */ }
1051
+ }
1052
+
1053
+ // ─── pending_rebases ───────────────────────────────────────────────────────
1054
+ // Shape: [ { prId, branch, projectName, mergedItemId, queuedAt, attempts, ... }, ... ]
1055
+ // SQL: row per array entry (rowid auto-increment preserves insertion order).
1056
+ // No stable natural key, so mutations are full clear+insert.
1057
+
1058
+ let _pendingRebasesHash = null;
1059
+
1060
+ function _hydratePendingRebases(db) {
1061
+ const fp = _resolveFilePath('pending-rebases.json');
1062
+ const raw = _readJson(fp) || [];
1063
+ db.prepare('DELETE FROM pending_rebases').run();
1064
+ if (!Array.isArray(raw)) return;
1065
+ const now = Date.now();
1066
+ const ins = db.prepare('INSERT INTO pending_rebases (data, updated_at) VALUES (?, ?)');
1067
+ for (const entry of raw) {
1068
+ if (!entry || typeof entry !== 'object') continue;
1069
+ ins.run(JSON.stringify(entry), now);
1070
+ }
1071
+ }
1072
+
1073
+ function _resyncPendingRebasesIfDiverged(db) {
1074
+ const fp = _resolveFilePath('pending-rebases.json');
1075
+ const currentHash = _fileContentHash(fp);
1076
+ if (currentHash == null) return;
1077
+ if (_pendingRebasesHash != null && currentHash === _pendingRebasesHash) return;
1078
+ if (_pendingRebasesHash == null) {
1079
+ const sqlHas = db.prepare('SELECT 1 FROM pending_rebases LIMIT 1').get();
1080
+ if (sqlHas) { _pendingRebasesHash = currentHash; return; }
1081
+ }
1082
+ _hydratePendingRebases(db);
1083
+ _pendingRebasesHash = currentHash;
1084
+ }
1085
+
1086
+ function _readPendingRebasesFromSql(db) {
1087
+ const rows = db.prepare('SELECT data FROM pending_rebases ORDER BY seq').all();
1088
+ const out = [];
1089
+ for (const row of rows) {
1090
+ try { out.push(JSON.parse(row.data)); }
1091
+ catch { /* skip malformed */ }
1092
+ }
1093
+ return out;
1094
+ }
1095
+
1096
+ function readPendingRebases() {
1097
+ const { getDb } = require('./db');
1098
+ let db;
1099
+ try { db = getDb(); }
1100
+ catch { return _readJson(_resolveFilePath('pending-rebases.json')) || []; }
1101
+ try { _resyncPendingRebasesIfDiverged(db); }
1102
+ catch { /* table missing */ }
1103
+ let out;
1104
+ try { out = _readPendingRebasesFromSql(db); }
1105
+ catch { return _readJson(_resolveFilePath('pending-rebases.json')) || []; }
1106
+ if (out.length === 0) {
1107
+ const fallback = _readJson(_resolveFilePath('pending-rebases.json'));
1108
+ if (Array.isArray(fallback) && fallback.length > 0) return fallback;
1109
+ return [];
1110
+ }
1111
+ return out;
1112
+ }
1113
+
1114
+ function applyPendingRebasesMutation(mutator) {
1115
+ const { getDb, withTransaction } = require('./db');
1116
+ let db;
1117
+ try { db = getDb(); }
1118
+ catch (e) { throw new Error(`small-state-store: SQLite unavailable (${e.message})`); }
1119
+
1120
+ return withTransaction(db, () => {
1121
+ _resyncPendingRebasesIfDiverged(db);
1122
+ const before = _readPendingRebasesFromSql(db);
1123
+ const beforeSnap = JSON.parse(JSON.stringify(before));
1124
+ const next = mutator(before);
1125
+ const after = (next === undefined || next === null) ? before : next;
1126
+ if (!Array.isArray(after)) {
1127
+ return { wrote: false, result: beforeSnap };
1128
+ }
1129
+ let wrote = false;
1130
+ if (JSON.stringify(before) !== JSON.stringify(after)) {
1131
+ const now = Date.now();
1132
+ db.prepare('DELETE FROM pending_rebases').run();
1133
+ const ins = db.prepare('INSERT INTO pending_rebases (data, updated_at) VALUES (?, ?)');
1134
+ for (const entry of after) {
1135
+ if (!entry || typeof entry !== 'object') continue;
1136
+ ins.run(JSON.stringify(entry), now);
1137
+ }
1138
+ wrote = true;
1139
+ }
1140
+ return { wrote, result: after };
1141
+ });
1142
+ }
1143
+
1144
+ function _mirrorPendingRebasesJson(filePath) {
1145
+ try {
1146
+ const shared = require('./shared');
1147
+ const { getDb } = require('./db');
1148
+ const arr = _readPendingRebasesFromSql(getDb());
1149
+ const target = filePath || _resolveFilePath('pending-rebases.json');
1150
+ shared.safeWrite(target, arr);
1151
+ const h = _fileContentHash(target);
1152
+ if (h != null) _pendingRebasesHash = h;
1153
+ } catch { /* mirror best-effort */ }
1154
+ }
1155
+
1156
+ // ─── cc_sessions ───────────────────────────────────────────────────────────
1157
+ // Shape: [ { id, sessionId, title, _promptHash, lastActiveAt, ... }, ... ]
1158
+ // SQL: row per id (each entry has a unique `id` tab key).
1159
+
1160
+ let _ccSessionsHash = null;
1161
+
1162
+ function _hydrateCcSessions(db) {
1163
+ const fp = _resolveFilePath('cc-sessions.json');
1164
+ const raw = _readJson(fp) || [];
1165
+ db.prepare('DELETE FROM cc_sessions').run();
1166
+ if (!Array.isArray(raw)) return;
1167
+ const now = Date.now();
1168
+ const ins = db.prepare('INSERT INTO cc_sessions (id, data, updated_at) VALUES (?, ?, ?) ON CONFLICT(id) DO NOTHING');
1169
+ for (const entry of raw) {
1170
+ if (!entry || typeof entry !== 'object' || !entry.id) continue;
1171
+ ins.run(String(entry.id), JSON.stringify(entry), now);
1172
+ }
1173
+ }
1174
+
1175
+ function _resyncCcSessionsIfDiverged(db) {
1176
+ const fp = _resolveFilePath('cc-sessions.json');
1177
+ const currentHash = _fileContentHash(fp);
1178
+ if (currentHash == null) return;
1179
+ if (_ccSessionsHash != null && currentHash === _ccSessionsHash) return;
1180
+ if (_ccSessionsHash == null) {
1181
+ const sqlHas = db.prepare('SELECT 1 FROM cc_sessions LIMIT 1').get();
1182
+ if (sqlHas) { _ccSessionsHash = currentHash; return; }
1183
+ }
1184
+ _hydrateCcSessions(db);
1185
+ _ccSessionsHash = currentHash;
1186
+ }
1187
+
1188
+ function _readCcSessionsFromSql(db) {
1189
+ const rows = db.prepare('SELECT data FROM cc_sessions ORDER BY updated_at, id').all();
1190
+ const out = [];
1191
+ for (const row of rows) {
1192
+ try { out.push(JSON.parse(row.data)); }
1193
+ catch { /* skip malformed */ }
1194
+ }
1195
+ return out;
1196
+ }
1197
+
1198
+ function readCcSessions() {
1199
+ const { getDb } = require('./db');
1200
+ let db;
1201
+ try { db = getDb(); }
1202
+ catch { return _readJson(_resolveFilePath('cc-sessions.json')) || []; }
1203
+ try { _resyncCcSessionsIfDiverged(db); }
1204
+ catch { /* table missing */ }
1205
+ let out;
1206
+ try { out = _readCcSessionsFromSql(db); }
1207
+ catch { return _readJson(_resolveFilePath('cc-sessions.json')) || []; }
1208
+ if (out.length === 0) {
1209
+ const fallback = _readJson(_resolveFilePath('cc-sessions.json'));
1210
+ if (Array.isArray(fallback) && fallback.length > 0) return fallback;
1211
+ return [];
1212
+ }
1213
+ return out;
1214
+ }
1215
+
1216
+ function applyCcSessionsMutation(mutator) {
1217
+ const { getDb, withTransaction } = require('./db');
1218
+ let db;
1219
+ try { db = getDb(); }
1220
+ catch (e) { throw new Error(`small-state-store: SQLite unavailable (${e.message})`); }
1221
+
1222
+ return withTransaction(db, () => {
1223
+ _resyncCcSessionsIfDiverged(db);
1224
+ const before = _readCcSessionsFromSql(db);
1225
+ const beforeSnap = JSON.parse(JSON.stringify(before));
1226
+ const next = mutator(before);
1227
+ const after = (next === undefined || next === null) ? before : next;
1228
+ if (!Array.isArray(after)) {
1229
+ return { wrote: false, result: beforeSnap };
1230
+ }
1231
+ const beforeById = new Map(beforeSnap.filter(e => e && e.id).map(e => [String(e.id), e]));
1232
+ const afterById = new Map(after.filter(e => e && e.id).map(e => [String(e.id), e]));
1233
+ let wrote = false;
1234
+ const now = Date.now();
1235
+ const upsert = db.prepare(`
1236
+ INSERT INTO cc_sessions (id, data, updated_at)
1237
+ VALUES (?, ?, ?)
1238
+ ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
1239
+ `);
1240
+ const del = db.prepare('DELETE FROM cc_sessions WHERE id = ?');
1241
+ for (const [id, entry] of afterById) {
1242
+ const prior = beforeById.get(id);
1243
+ if (!prior || JSON.stringify(prior) !== JSON.stringify(entry)) {
1244
+ upsert.run(id, JSON.stringify(entry), now);
1245
+ wrote = true;
1246
+ }
1247
+ }
1248
+ for (const id of beforeById.keys()) {
1249
+ if (!afterById.has(id)) { del.run(id); wrote = true; }
1250
+ }
1251
+ return { wrote, result: after };
1252
+ });
1253
+ }
1254
+
1255
+ function _mirrorCcSessionsJson(filePath) {
1256
+ try {
1257
+ const shared = require('./shared');
1258
+ const { getDb } = require('./db');
1259
+ const arr = _readCcSessionsFromSql(getDb());
1260
+ const target = filePath || _resolveFilePath('cc-sessions.json');
1261
+ shared.safeWrite(target, arr);
1262
+ const h = _fileContentHash(target);
1263
+ if (h != null) _ccSessionsHash = h;
1264
+ } catch { /* mirror best-effort */ }
1265
+ }
1266
+
1267
+ // ─── doc_sessions ──────────────────────────────────────────────────────────
1268
+ // Shape: { [filePath]: { sessionId, lastActiveAt, turnCount, ... } }
1269
+ // SQL: row per filePath key.
1270
+
1271
+ let _docSessionsHash = null;
1272
+
1273
+ function _hydrateDocSessions(db) {
1274
+ const fp = _resolveFilePath('doc-sessions.json');
1275
+ const raw = _readJson(fp) || {};
1276
+ db.prepare('DELETE FROM doc_sessions').run();
1277
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return;
1278
+ const now = Date.now();
1279
+ const ins = db.prepare('INSERT INTO doc_sessions (key, data, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO NOTHING');
1280
+ for (const [key, value] of Object.entries(raw)) {
1281
+ ins.run(String(key), JSON.stringify(value), now);
1282
+ }
1283
+ }
1284
+
1285
+ function _resyncDocSessionsIfDiverged(db) {
1286
+ const fp = _resolveFilePath('doc-sessions.json');
1287
+ const currentHash = _fileContentHash(fp);
1288
+ if (currentHash == null) return;
1289
+ if (_docSessionsHash != null && currentHash === _docSessionsHash) return;
1290
+ if (_docSessionsHash == null) {
1291
+ const sqlHas = db.prepare('SELECT 1 FROM doc_sessions LIMIT 1').get();
1292
+ if (sqlHas) { _docSessionsHash = currentHash; return; }
1293
+ }
1294
+ _hydrateDocSessions(db);
1295
+ _docSessionsHash = currentHash;
1296
+ }
1297
+
1298
+ function _readDocSessionsFromSql(db) {
1299
+ const rows = db.prepare('SELECT key, data FROM doc_sessions').all();
1300
+ const out = {};
1301
+ for (const row of rows) {
1302
+ try { out[row.key] = JSON.parse(row.data); }
1303
+ catch { /* skip malformed */ }
1304
+ }
1305
+ return out;
1306
+ }
1307
+
1308
+ function readDocSessions() {
1309
+ const { getDb } = require('./db');
1310
+ let db;
1311
+ try { db = getDb(); }
1312
+ catch { return _readJson(_resolveFilePath('doc-sessions.json')) || {}; }
1313
+ try { _resyncDocSessionsIfDiverged(db); }
1314
+ catch { /* table missing */ }
1315
+ let out;
1316
+ try { out = _readDocSessionsFromSql(db); }
1317
+ catch { return _readJson(_resolveFilePath('doc-sessions.json')) || {}; }
1318
+ if (Object.keys(out).length === 0) {
1319
+ const fallback = _readJson(_resolveFilePath('doc-sessions.json'));
1320
+ if (fallback && Object.keys(fallback).length > 0) return fallback;
1321
+ return {};
1322
+ }
1323
+ return out;
1324
+ }
1325
+
1326
+ function applyDocSessionsMutation(mutator) {
1327
+ const { getDb, withTransaction } = require('./db');
1328
+ let db;
1329
+ try { db = getDb(); }
1330
+ catch (e) { throw new Error(`small-state-store: SQLite unavailable (${e.message})`); }
1331
+
1332
+ return withTransaction(db, () => {
1333
+ _resyncDocSessionsIfDiverged(db);
1334
+ const before = _readDocSessionsFromSql(db);
1335
+ const beforeSnap = JSON.parse(JSON.stringify(before));
1336
+ const next = mutator(before);
1337
+ const after = (next === undefined || next === null) ? before : next;
1338
+ if (!after || typeof after !== 'object' || Array.isArray(after)) {
1339
+ return { wrote: false, result: beforeSnap };
1340
+ }
1341
+ const afterIds = new Set(Object.keys(after));
1342
+ const beforeIds = new Set(Object.keys(beforeSnap));
1343
+ let wrote = false;
1344
+ const now = Date.now();
1345
+ const upsert = db.prepare(`
1346
+ INSERT INTO doc_sessions (key, data, updated_at)
1347
+ VALUES (?, ?, ?)
1348
+ ON CONFLICT(key) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
1349
+ `);
1350
+ const del = db.prepare('DELETE FROM doc_sessions WHERE key = ?');
1351
+ for (const id of afterIds) {
1352
+ if (!beforeIds.has(id) || JSON.stringify(beforeSnap[id]) !== JSON.stringify(after[id])) {
1353
+ upsert.run(id, JSON.stringify(after[id]), now);
1354
+ wrote = true;
1355
+ }
1356
+ }
1357
+ for (const id of beforeIds) {
1358
+ if (!afterIds.has(id)) { del.run(id); wrote = true; }
1359
+ }
1360
+ return { wrote, result: after };
1361
+ });
1362
+ }
1363
+
1364
+ function _mirrorDocSessionsJson(filePath) {
1365
+ try {
1366
+ const shared = require('./shared');
1367
+ const { getDb } = require('./db');
1368
+ const obj = _readDocSessionsFromSql(getDb());
1369
+ const target = filePath || _resolveFilePath('doc-sessions.json');
1370
+ shared.safeWrite(target, obj);
1371
+ const h = _fileContentHash(target);
1372
+ if (h != null) _docSessionsHash = h;
1373
+ } catch { /* mirror best-effort */ }
1374
+ }
1375
+
944
1376
  // ─── Test seam ─────────────────────────────────────────────────────────────
945
1377
 
946
1378
  function _resetAllForTest() {
@@ -954,6 +1386,10 @@ function _resetAllForTest() {
954
1386
  try { db.exec('DELETE FROM qa_runs'); } catch { /* migration not applied */ }
955
1387
  try { db.exec('DELETE FROM qa_sessions'); } catch { /* migration not applied */ }
956
1388
  try { db.exec('DELETE FROM pr_links'); } catch { /* migration not applied */ }
1389
+ try { db.exec('DELETE FROM cooldowns'); } catch { /* migration not applied */ }
1390
+ try { db.exec('DELETE FROM pending_rebases'); } catch { /* migration not applied */ }
1391
+ try { db.exec('DELETE FROM cc_sessions'); } catch { /* migration not applied */ }
1392
+ try { db.exec('DELETE FROM doc_sessions'); } catch { /* migration not applied */ }
957
1393
  } catch { /* not initialized */ }
958
1394
  _scheduleRunsHash = null;
959
1395
  _pipelineRunsHash = null;
@@ -962,6 +1398,10 @@ function _resetAllForTest() {
962
1398
  _qaRunsHash = null;
963
1399
  _qaSessionsHash = null;
964
1400
  _prLinksHash = null;
1401
+ _cooldownsHash = null;
1402
+ _pendingRebasesHash = null;
1403
+ _ccSessionsHash = null;
1404
+ _docSessionsHash = null;
965
1405
  }
966
1406
 
967
1407
  module.exports = {
@@ -993,6 +1433,22 @@ module.exports = {
993
1433
  readPrLinks,
994
1434
  applyPrLinksMutation,
995
1435
  _mirrorPrLinksJson,
1436
+ // cooldowns
1437
+ readCooldowns,
1438
+ applyCooldownsMutation,
1439
+ _mirrorCooldownsJson,
1440
+ // pending_rebases
1441
+ readPendingRebases,
1442
+ applyPendingRebasesMutation,
1443
+ _mirrorPendingRebasesJson,
1444
+ // cc_sessions
1445
+ readCcSessions,
1446
+ applyCcSessionsMutation,
1447
+ _mirrorCcSessionsJson,
1448
+ // doc_sessions
1449
+ readDocSessions,
1450
+ applyDocSessionsMutation,
1451
+ _mirrorDocSessionsJson,
996
1452
  // test seam
997
1453
  _resetAllForTest,
998
1454
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2115",
3
+ "version": "0.1.2116",
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"