@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/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
 
@@ -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;
@@ -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
+ };
@@ -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
 
@@ -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();
@@ -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);