@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 +21 -0
- package/engine/cleanup.js +10 -3
- package/engine/consolidation.js +1 -1
- package/engine/db/index.js +39 -10
- package/engine/db/migrate.js +11 -4
- package/engine/db/migrations/011-remaining-state.js +116 -0
- package/engine/dispatch-store.js +1 -3
- package/engine/dispatch.js +24 -59
- package/engine/kb-sweep.js +20 -14
- package/engine/pull-requests-store.js +3 -25
- package/engine/shared.js +201 -171
- package/engine/small-state-store.js +455 -21
- package/engine/work-items-store.js +6 -33
- package/package.json +1 -1
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
|
-
|
|
1418
|
-
|
|
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
|
|
package/engine/consolidation.js
CHANGED
|
@@ -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
|
-
|
|
852
|
+
shared.mutateKbCheckpoint(() => ({ count, updatedAt: ts() }));
|
|
853
853
|
} catch (err) { log('warn', `KB checkpoint: ${err.message}`); }
|
|
854
854
|
}
|
|
855
855
|
|
package/engine/db/index.js
CHANGED
|
@@ -88,16 +88,45 @@ function getDb() {
|
|
|
88
88
|
try {
|
|
89
89
|
_installExperimentalWarningFilter();
|
|
90
90
|
const { DatabaseSync } = require('node:sqlite');
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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;
|
package/engine/db/migrate.js
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
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
|
+
};
|
package/engine/dispatch-store.js
CHANGED
|
@@ -36,9 +36,7 @@ function _parseRow(row) {
|
|
|
36
36
|
|
|
37
37
|
function readDispatchSectioned() {
|
|
38
38
|
const { getDb } = require('./db');
|
|
39
|
-
|
|
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
|
package/engine/dispatch.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
|
package/engine/kb-sweep.js
CHANGED
|
@@ -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 {
|
|
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
|
|
420
|
-
// for forensics — _writeSweepState would overwrite it with this
|
|
421
|
-
// pid, destroying the diagnostic value of the record.
|
|
422
|
-
try {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|