@yemi33/minions 0.1.2072 → 0.1.2074

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/docs/README.md CHANGED
@@ -23,6 +23,7 @@ Architecture, design proposals, and lifecycle references for people working on t
23
23
  - [plan-lifecycle.md](plan-lifecycle.md) — Full plan pipeline from `/plan` through PRD materialization, dispatch with dependency gating, verify task, and human archive.
24
24
  - [pr-comment-followup.md](pr-comment-followup.md) — PR-comment follow-up dispatch contract: fix/review agents may spin off a new WI via `POST /api/work-items` with `meta.pr_followup` instead of broadening the current PR or rebutting the comment.
25
25
  - [pr-review-fix-loop.md](pr-review-fix-loop.md) — How the engine moves a PR from creation through review, fix dispatch, and re-review, including stale-status guards.
26
+ - [qa-runbook-lifecycle.md](qa-runbook-lifecycle.md) — End-to-end QA runbook lifecycle (W-mpeiwz6k0005bf34): runbook + run-record storage, `POST /api/qa/runbooks/run` dispatch into the `qa-validate` playbook, artifact contract, and how the `/qa` page mirrors managed-spawn observability.
26
27
  - [qa-runbooks.md](qa-runbooks.md) — Per-project QA runbook schema, storage layout (`projects/<name>/runbooks/<id>.json`), CRUD endpoints, run-record lifecycle, and the `qa-validate` agent sidecar contract.
27
28
  - [rfc-completion-json.md](rfc-completion-json.md) — RFC for replacing stdout regex-scraping with a structured `completion.json` control-plane protocol.
28
29
  - [runtime-adapters.md](runtime-adapters.md) — Runtime adapter contract (`engine/runtimes/*`): how the engine talks to Claude Code, Copilot CLI, and future CLIs through a single capability-flagged interface.
@@ -1,6 +1,6 @@
1
1
  # Auto-Discovery & Execution Pipeline
2
2
 
3
- > Last verified: 2026-05-25 against `engine.js` `tickInner()` (lines 6293-6947) and `routing.md`.
3
+ > Last verified: 2026-05-28 against `engine.js` `tickInner()` and `routing.md`.
4
4
 
5
5
  How the minions engine finds work and dispatches agents automatically.
6
6
 
@@ -15,6 +15,7 @@ tick()
15
15
  1b. checkIdleThreshold() Notify on excessive agent idleness
16
16
  1c. meetingTimeouts() Advance round-based meetings whose timer fired
17
17
  2. consolidateInbox() Merge learnings into notes.md (Haiku-powered)
18
+ 2.1 autoSweepKb() Periodic KB sweep (opt-in via engine.autoConsolidateMemory, 4h cadence)
18
19
  2.5 runCleanup() Periodic cleanup (every 10 ticks ≈ 10min)
19
20
  2.52 sweepKeepProcesses() keep_processes TTL/dead-PID sweep (every 30 ticks)
20
21
  2.53 sweepManagedSpawn() managed_spawn TTL/dead-PID/log-rotate sweep (every 30 ticks)
package/docs/kb-sweep.md CHANGED
@@ -114,6 +114,14 @@ Sweep state is mirrored to `engine/kb-sweep-state.json` so the dashboard can rec
114
114
 
115
115
  Memory still wins when present; the disk file is a fallback (source: [`engine/kb-sweep.js:298-320`](../engine/kb-sweep.js#L298), [`dashboard.js:4296-4354`](../dashboard.js#L4296)). A separate `engine/kb-swept.json` is written after each successful sweep with the human-readable summary line shown by the dashboard's "swept N days ago" badge (source: [`engine/kb-sweep.js:407`](../engine/kb-sweep.js#L407)).
116
116
 
117
+ ## Automatic Periodic Sweep (opt-in)
118
+
119
+ The engine tick loop can also auto-spawn the KB sweep without dashboard interaction. Gated by `engine.autoConsolidateMemory` (default `false` — source: [`engine/shared.js:1835`](../engine/shared.js#L1835)):
120
+
121
+ - When `engine.autoConsolidateMemory: true`, every tick the engine consults `shouldAutoSweep()` from [`engine/kb-sweep.js`](../engine/kb-sweep.js) and, when the 4-hour cadence has elapsed since the last completion, calls `spawnSweepRunnerDetached()` to fire-and-forget a fresh `engine/kb-sweep-runner.js` process (source: [`engine.js`](../engine.js) tick step 2.1).
122
+ - The inbox→`notes.md` consolidation runs every tick *regardless* of this flag via `consolidateInbox()`; `autoConsolidateMemory` controls **only** the heavier `knowledge/` sweep.
123
+ - The detached runner survives `minions restart` (same pattern used by the manual trigger), and the in-flight guard above prevents overlap with manual sweeps.
124
+
117
125
  ## Dashboard UI
118
126
 
119
127
  The KB page surfaces sweep state in two places:
@@ -0,0 +1,95 @@
1
+ // engine/db/migrations/007-watches.js
2
+ //
3
+ // Phase 6: move engine/watches.json into a `watches` table.
4
+ //
5
+ // watches.json is an array of watch jobs polled every 3 ticks. Most
6
+ // installs carry only a handful (operator-defined notifications, plan-
7
+ // completion gates, etc), but the table is still indexed by status +
8
+ // target_type so dashboards / future filters can do "show active PR
9
+ // watches" without pulling the whole array.
10
+ //
11
+ // Schema is the same hybrid pattern Phases 2/3/5 use: typed projection
12
+ // columns for the hot filters, a `data` TEXT blob for everything else.
13
+
14
+ const path = require('path');
15
+ const fs = require('fs');
16
+
17
+ function _resolveMinionsDir() {
18
+ const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
19
+ if (envHome) return envHome;
20
+ try { return require('../../shared').MINIONS_DIR; } catch { return null; }
21
+ }
22
+
23
+ function _toMs(v) {
24
+ if (v == null) return null;
25
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
26
+ const parsed = Date.parse(v);
27
+ return Number.isFinite(parsed) ? parsed : null;
28
+ }
29
+
30
+ module.exports = {
31
+ version: 7,
32
+ description: 'watches: schema + watches.json backfill',
33
+ up(db) {
34
+ db.exec(`
35
+ CREATE TABLE watches (
36
+ id TEXT PRIMARY KEY,
37
+ target TEXT,
38
+ target_type TEXT,
39
+ condition TEXT,
40
+ status TEXT NOT NULL,
41
+ owner TEXT,
42
+ created_at INTEGER,
43
+ last_checked INTEGER,
44
+ last_triggered INTEGER,
45
+ data TEXT NOT NULL,
46
+ updated_at INTEGER NOT NULL
47
+ );
48
+ CREATE INDEX idx_watches_status ON watches(status);
49
+ CREATE INDEX idx_watches_target_type ON watches(target_type);
50
+ CREATE INDEX idx_watches_target ON watches(target);
51
+ `);
52
+
53
+ const minionsDir = _resolveMinionsDir();
54
+ if (!minionsDir) return;
55
+ const watchesPath = path.join(minionsDir, 'engine', 'watches.json');
56
+ if (!fs.existsSync(watchesPath)) return;
57
+
58
+ let raw;
59
+ try { raw = JSON.parse(fs.readFileSync(watchesPath, 'utf8')); }
60
+ catch (e) {
61
+ throw new Error(`engine/db/007-watches: cannot parse watches.json: ${e.message}`);
62
+ }
63
+ if (!Array.isArray(raw)) return;
64
+
65
+ const now = Date.now();
66
+ const insert = db.prepare(`
67
+ INSERT INTO watches (id, target, target_type, condition, status, owner, created_at, last_checked, last_triggered, data, updated_at)
68
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
69
+ `);
70
+
71
+ let inserted = 0;
72
+ for (const w of raw) {
73
+ if (!w || typeof w !== 'object' || !w.id) continue;
74
+ try {
75
+ insert.run(
76
+ String(w.id),
77
+ typeof w.target === 'string' ? w.target : (w.target == null ? null : JSON.stringify(w.target)),
78
+ w.targetType || null,
79
+ w.condition || null,
80
+ String(w.status || 'active'),
81
+ w.owner || null,
82
+ _toMs(w.created_at),
83
+ _toMs(w.last_checked),
84
+ _toMs(w.last_triggered),
85
+ JSON.stringify(w),
86
+ now,
87
+ );
88
+ inserted += 1;
89
+ } catch { /* duplicate id — skip defensively */ }
90
+ }
91
+
92
+ // eslint-disable-next-line no-console
93
+ console.log(`[db-migrate] v7: backfilled ${inserted} watches; watches.json kept as dual-write mirror`);
94
+ },
95
+ };
@@ -0,0 +1,155 @@
1
+ // engine/db/migrations/008-small-state.js
2
+ //
3
+ // Phase 7: move the four remaining small state files into SQL in one
4
+ // migration. Each gets its own table — the files are too dissimilar to
5
+ // share schemas — but they ship together because they're all simple,
6
+ // low-traffic singletons.
7
+ //
8
+ // engine/schedule-runs.json -> schedule_runs
9
+ // engine/pipeline-runs.json -> pipeline_runs
10
+ // engine/managed-processes.json -> managed_processes
11
+ // engine/worktree-pool.json -> worktree_pool
12
+
13
+ const path = require('path');
14
+ const fs = require('fs');
15
+
16
+ function _resolveMinionsDir() {
17
+ const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
18
+ if (envHome) return envHome;
19
+ try { return require('../../shared').MINIONS_DIR; } catch { return null; }
20
+ }
21
+
22
+ function _toMs(v) {
23
+ if (v == null) return null;
24
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
25
+ const parsed = Date.parse(v);
26
+ return Number.isFinite(parsed) ? parsed : null;
27
+ }
28
+
29
+ function _readJsonOr(filePath, fallback) {
30
+ try {
31
+ const raw = fs.readFileSync(filePath, 'utf8');
32
+ return JSON.parse(raw);
33
+ } catch { return fallback; }
34
+ }
35
+
36
+ module.exports = {
37
+ version: 8,
38
+ description: 'schedule_runs + pipeline_runs + managed_processes + worktree_pool',
39
+ up(db) {
40
+ db.exec(`
41
+ CREATE TABLE schedule_runs (
42
+ schedule_id TEXT PRIMARY KEY,
43
+ data TEXT NOT NULL,
44
+ updated_at INTEGER NOT NULL
45
+ );
46
+
47
+ CREATE TABLE pipeline_runs (
48
+ pipeline_id TEXT NOT NULL,
49
+ run_id TEXT NOT NULL,
50
+ status TEXT,
51
+ started_at INTEGER,
52
+ data TEXT NOT NULL,
53
+ updated_at INTEGER NOT NULL,
54
+ PRIMARY KEY (pipeline_id, run_id)
55
+ );
56
+ CREATE INDEX idx_pipeline_runs_status ON pipeline_runs(status);
57
+ CREATE INDEX idx_pipeline_runs_pipeline ON pipeline_runs(pipeline_id);
58
+
59
+ CREATE TABLE managed_processes (
60
+ name TEXT PRIMARY KEY,
61
+ data TEXT NOT NULL,
62
+ updated_at INTEGER NOT NULL
63
+ );
64
+
65
+ CREATE TABLE worktree_pool (
66
+ entry_id TEXT PRIMARY KEY,
67
+ data TEXT NOT NULL,
68
+ updated_at INTEGER NOT NULL
69
+ );
70
+ `);
71
+
72
+ const minionsDir = _resolveMinionsDir();
73
+ if (!minionsDir) return;
74
+ const now = Date.now();
75
+ let inserted = 0;
76
+
77
+ // ── schedule_runs ──────────────────────────────────────────────────────
78
+ {
79
+ const raw = _readJsonOr(path.join(minionsDir, 'engine', 'schedule-runs.json'), null);
80
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
81
+ const ins = db.prepare(`INSERT INTO schedule_runs (schedule_id, data, updated_at) VALUES (?, ?, ?)`);
82
+ for (const [scheduleId, value] of Object.entries(raw)) {
83
+ try {
84
+ ins.run(scheduleId, JSON.stringify(value), now);
85
+ inserted += 1;
86
+ } catch { /* duplicate / corrupt — skip */ }
87
+ }
88
+ }
89
+ }
90
+
91
+ // ── pipeline_runs ──────────────────────────────────────────────────────
92
+ {
93
+ const raw = _readJsonOr(path.join(minionsDir, 'engine', 'pipeline-runs.json'), null);
94
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
95
+ const ins = db.prepare(`
96
+ INSERT INTO pipeline_runs (pipeline_id, run_id, status, started_at, data, updated_at)
97
+ VALUES (?, ?, ?, ?, ?, ?)
98
+ `);
99
+ for (const [pipelineId, runs] of Object.entries(raw)) {
100
+ if (!Array.isArray(runs)) continue;
101
+ for (const run of runs) {
102
+ if (!run || !run.runId) continue;
103
+ try {
104
+ ins.run(
105
+ pipelineId,
106
+ String(run.runId),
107
+ run.status || null,
108
+ _toMs(run.startedAt),
109
+ JSON.stringify(run),
110
+ now,
111
+ );
112
+ inserted += 1;
113
+ } catch { /* duplicate / corrupt — skip */ }
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ // ── managed_processes ──────────────────────────────────────────────────
120
+ {
121
+ const raw = _readJsonOr(path.join(minionsDir, 'engine', 'managed-processes.json'), null);
122
+ if (raw && Array.isArray(raw.specs)) {
123
+ const ins = db.prepare(`INSERT INTO managed_processes (name, data, updated_at) VALUES (?, ?, ?)`);
124
+ for (const spec of raw.specs) {
125
+ if (!spec || typeof spec !== 'object' || !spec.name) continue;
126
+ try {
127
+ ins.run(String(spec.name), JSON.stringify(spec), now);
128
+ inserted += 1;
129
+ } catch { /* duplicate name — skip */ }
130
+ }
131
+ }
132
+ }
133
+
134
+ // ── worktree_pool ──────────────────────────────────────────────────────
135
+ {
136
+ const raw = _readJsonOr(path.join(minionsDir, 'engine', 'worktree-pool.json'), null);
137
+ if (raw && Array.isArray(raw.entries)) {
138
+ const ins = db.prepare(`INSERT INTO worktree_pool (entry_id, data, updated_at) VALUES (?, ?, ?)`);
139
+ for (const entry of raw.entries) {
140
+ if (!entry || typeof entry !== 'object') continue;
141
+ // Worktree pool entries don't have an obvious unique id field;
142
+ // use the worktree path (always unique within a single pool).
143
+ const id = entry.path || entry.id || JSON.stringify(entry).slice(0, 64);
144
+ try {
145
+ ins.run(String(id), JSON.stringify(entry), now);
146
+ inserted += 1;
147
+ } catch { /* duplicate — skip */ }
148
+ }
149
+ }
150
+ }
151
+
152
+ // eslint-disable-next-line no-console
153
+ console.log(`[db-migrate] v8: backfilled ${inserted} small-state rows; JSON files kept as dual-write mirrors`);
154
+ },
155
+ };
@@ -850,7 +850,7 @@ function _toStateRecord(spec, runtime, ctx) {
850
850
  function recordManagedSpec(spec, runtime, ctx) {
851
851
  if (!spec || !spec.name) throw new Error('recordManagedSpec: spec.name required');
852
852
  const statePath = _getStatePath();
853
- shared.mutateJsonFileLocked(statePath, (data) => {
853
+ shared.mutateManagedProcesses((data) => {
854
854
  if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.specs)) {
855
855
  data = _initialStateShape();
856
856
  }
@@ -865,7 +865,7 @@ function recordManagedSpec(spec, runtime, ctx) {
865
865
  function recordManagedBatch(items, ctx) {
866
866
  if (!Array.isArray(items) || items.length === 0) return;
867
867
  const statePath = _getStatePath();
868
- shared.mutateJsonFileLocked(statePath, (data) => {
868
+ shared.mutateManagedProcesses((data) => {
869
869
  if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.specs)) {
870
870
  data = _initialStateShape();
871
871
  }
@@ -886,7 +886,7 @@ function removeManagedSpec(name) {
886
886
  if (typeof name !== 'string' || name.length === 0) return;
887
887
  let killPid = null;
888
888
  const statePath = _getStatePath();
889
- shared.mutateJsonFileLocked(statePath, (data) => {
889
+ shared.mutateManagedProcesses((data) => {
890
890
  if (!data || !Array.isArray(data.specs)) return data;
891
891
  const idx = data.specs.findIndex(s => s && s.name === name);
892
892
  if (idx < 0) return data;
@@ -1033,7 +1033,7 @@ async function runHealthcheck(spec) {
1033
1033
  // lock — this acquires its own.
1034
1034
  function _markHealthy(name, now) {
1035
1035
  const statePath = _getStatePath();
1036
- shared.mutateJsonFileLocked(statePath, (data) => {
1036
+ shared.mutateManagedProcesses((data) => {
1037
1037
  if (!data || !Array.isArray(data.specs)) return data;
1038
1038
  const rec = data.specs.find(s => s && s.name === name);
1039
1039
  if (!rec) return data;
@@ -1334,7 +1334,7 @@ function _runManagedReconcile(opts) {
1334
1334
  const statePath = _getStatePath();
1335
1335
  const ttlPidsToKill = [];
1336
1336
  const survivors = []; // [{name, log_path}] post-mutation, used for log rotation + bootReconcile probes
1337
- shared.mutateJsonFileLocked(statePath, (data) => {
1337
+ shared.mutateManagedProcesses((data) => {
1338
1338
  if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.specs)) {
1339
1339
  stats.malformed++;
1340
1340
  return _initialStateShape();
@@ -1387,7 +1387,7 @@ function sweepManagedSpawn(opts) {
1387
1387
  // to (re)establish initial truth on engine restart).
1388
1388
  function _markBootProbe(name, result) {
1389
1389
  const statePath = _getStatePath();
1390
- shared.mutateJsonFileLocked(statePath, (data) => {
1390
+ shared.mutateManagedProcesses((data) => {
1391
1391
  if (!data || !Array.isArray(data.specs)) return data;
1392
1392
  const rec = data.specs.find(s => s && s.name === name);
1393
1393
  if (!rec) return data;
@@ -1440,7 +1440,7 @@ function removeManagedSpecsForProject(projectName) {
1440
1440
  const statePath = _getStatePath();
1441
1441
  let toKill = [];
1442
1442
  let logPaths = [];
1443
- shared.mutateJsonFileLocked(statePath, (data) => {
1443
+ shared.mutateManagedProcesses((data) => {
1444
1444
  if (!data || typeof data !== 'object' || !Array.isArray(data.specs)) {
1445
1445
  return _initialStateShape();
1446
1446
  }
Binary file
@@ -8,7 +8,7 @@ const fs = require('fs');
8
8
  const path = require('path');
9
9
  const shared = require('./shared');
10
10
  const queries = require('./queries');
11
- const { safeJson, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
11
+ const { safeJson, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, mutatePipelineRuns, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
12
12
  const routing = require('./routing');
13
13
  const http = require('http');
14
14
  const { parseCronExpr, shouldRunNow } = require('./scheduler');
@@ -90,18 +90,16 @@ function startRun(pipelineId, pipeline) {
90
90
  const run = { runId, pipelineId, startedAt: ts(), status: PIPELINE_STATUS.RUNNING, stages };
91
91
 
92
92
  let alreadyActive = false;
93
- mutateJsonFileLocked(PIPELINE_RUNS_PATH, (data) => {
93
+ mutatePipelineRuns((data) => {
94
94
  if (!data[pipelineId]) data[pipelineId] = [];
95
- // Guard: skip if there's already an active run (prevents race between ticks)
96
95
  if (data[pipelineId].some(r => r.status === PIPELINE_STATUS.RUNNING || r.status === PIPELINE_STATUS.PAUSED)) {
97
96
  alreadyActive = true;
98
97
  return data;
99
98
  }
100
- // Keep last 10 runs per pipeline
101
99
  if (data[pipelineId].length >= 10) data[pipelineId] = data[pipelineId].slice(-9);
102
100
  data[pipelineId].push(run);
103
101
  return data;
104
- }, { defaultValue: {} });
102
+ });
105
103
 
106
104
  if (alreadyActive) {
107
105
  log('info', `Pipeline ${pipelineId}: skipped startRun — active run already exists`);
@@ -118,14 +116,14 @@ function updateRunStage(pipelineId, runId, stageId, updates) {
118
116
  throw new Error(`updateRunStage: invalid status '${updates.status}' (expected one of: ${validStatuses.join('|')})`);
119
117
  }
120
118
  }
121
- mutateJsonFileLocked(PIPELINE_RUNS_PATH, (data) => {
119
+ mutatePipelineRuns((data) => {
122
120
  const runs = data[pipelineId] || [];
123
121
  const run = runs.find(r => r.runId === runId);
124
122
  if (run && run.stages[stageId]) {
125
123
  Object.assign(run.stages[stageId], updates);
126
124
  }
127
125
  return data;
128
- }, { defaultValue: {} });
126
+ });
129
127
  }
130
128
 
131
129
  function completeRun(pipelineId, runId, status) {
@@ -135,7 +133,7 @@ function completeRun(pipelineId, runId, status) {
135
133
  // never shows "in progress" for a stage inside a closed run.
136
134
  const TERMINAL = new Set([PIPELINE_STATUS.COMPLETED, PIPELINE_STATUS.FAILED, PIPELINE_STATUS.STOPPED]);
137
135
  const anomalies = [];
138
- mutateJsonFileLocked(PIPELINE_RUNS_PATH, (data) => {
136
+ mutatePipelineRuns((data) => {
139
137
  const runs = data[pipelineId] || [];
140
138
  const run = runs.find(r => r.runId === runId);
141
139
  if (run) {
@@ -158,7 +156,7 @@ function completeRun(pipelineId, runId, status) {
158
156
  }
159
157
  }
160
158
  return data;
161
- }, { defaultValue: {} });
159
+ });
162
160
  log('info', `Pipeline ${pipelineId}: run ${runId} → ${status}`);
163
161
  if (anomalies.length > 0) {
164
162
  log('warn', `Pipeline ${pipelineId}: run ${runId} closed COMPLETED with non-terminal stages [${anomalies.join(', ')}] — forced to completed`);
@@ -62,15 +62,16 @@ function _readJsonArrayFallback(scope) {
62
62
  }
63
63
  }
64
64
 
65
- // Track (mtime, size) per scope so back-to-back writes inside the same
66
- // ms tick still get detected as external edits (mtime-only checks miss
67
- // them on Windows because NTFS reports ms-rounded mtimeMs).
68
- const _lastMirrorByScope = new Map();
65
+ // Content-hash fingerprint per scope: same-length swaps (timestamps,
66
+ // reviewStatus enums) collide on (mtime, size) but never on SHA-1.
67
+ const _lastMirrorHashByScope = new Map();
69
68
 
70
- function _statFingerprint(filePath) {
69
+ const crypto = require('crypto');
70
+
71
+ function _fileContentHash(filePath) {
71
72
  try {
72
- const st = fs.statSync(filePath);
73
- return { mtime: st.mtimeMs, size: st.size };
73
+ const buf = fs.readFileSync(filePath);
74
+ return crypto.createHash('sha1').update(buf).digest('hex');
74
75
  }
75
76
  catch { return null; }
76
77
  }
@@ -105,23 +106,19 @@ function _hydrateScopeFromJson(db, scope) {
105
106
 
106
107
  function _resyncScopeIfJsonDiverged(db, scope) {
107
108
  const jsonPath = _filePathForScope(scope);
108
- const current = _statFingerprint(jsonPath);
109
- const lastMirror = _lastMirrorByScope.get(scope);
110
- if (current == null) return;
111
- // In-sync iff BOTH mtime AND size match. Size catches same-ms-tick
112
- // external writes that mtime alone misses. Resync is idempotent.
113
- if (lastMirror != null
114
- && current.mtime === lastMirror.mtime
115
- && current.size === lastMirror.size) return;
116
- if (lastMirror == null) {
109
+ const currentHash = _fileContentHash(jsonPath);
110
+ const lastHash = _lastMirrorHashByScope.get(scope);
111
+ if (currentHash == null) return;
112
+ if (lastHash != null && currentHash === lastHash) return;
113
+ if (lastHash == null) {
117
114
  const sqlHas = db.prepare('SELECT 1 FROM pull_requests WHERE scope = ? LIMIT 1').get(scope);
118
115
  if (sqlHas) {
119
- _lastMirrorByScope.set(scope, current);
116
+ _lastMirrorHashByScope.set(scope, currentHash);
120
117
  return;
121
118
  }
122
119
  }
123
120
  _hydrateScopeFromJson(db, scope);
124
- _lastMirrorByScope.set(scope, current);
121
+ _lastMirrorHashByScope.set(scope, currentHash);
125
122
  }
126
123
 
127
124
  function readPullRequestsForScope(scope) {
@@ -269,12 +266,23 @@ function applyPullRequestsMutation(scope, mutator) {
269
266
  function _mirrorJsonFromSql(scope, filePath) {
270
267
  try {
271
268
  const shared = require('./shared');
272
- const items = readPullRequestsForScope(scope);
273
- for (const pr of items) { if (pr && pr._scope) delete pr._scope; }
269
+ const { getDb } = require('./db');
270
+ // Read SQL directly for this scope bypass JSON fallback so a
271
+ // mutation that empties SQL for the scope doesn't resurrect stale
272
+ // JSON content.
273
+ const db = getDb();
274
+ const rows = db.prepare('SELECT data FROM pull_requests WHERE scope = ? ORDER BY rowid').all(scope);
275
+ const items = [];
276
+ for (const row of rows) {
277
+ const pr = _parseRow(row);
278
+ if (!pr) continue;
279
+ if (pr._scope) delete pr._scope;
280
+ items.push(pr);
281
+ }
274
282
  const target = filePath || _filePathForScope(scope);
275
283
  shared.safeWrite(target, items);
276
- const fp = _statFingerprint(target);
277
- if (fp != null) _lastMirrorByScope.set(scope, fp);
284
+ const h = _fileContentHash(target);
285
+ if (h != null) _lastMirrorHashByScope.set(scope, h);
278
286
  } catch {
279
287
  // Mirror failures are non-fatal — SQL has already committed.
280
288
  }
@@ -25,7 +25,7 @@ const fs = require('fs');
25
25
  const path = require('path');
26
26
  const shared = require('./shared');
27
27
  const routing = require('./routing');
28
- const { safeJson, safeWrite, mutateJsonFileLocked, ts, dateStamp, WI_STATUS, WORK_TYPE } = shared;
28
+ const { safeJson, safeWrite, mutateJsonFileLocked, mutateScheduleRuns, ts, dateStamp, WI_STATUS, WORK_TYPE } = shared;
29
29
 
30
30
  const SCHEDULE_RUNS_PATH = path.join(shared.MINIONS_DIR, 'engine', 'schedule-runs.json');
31
31
 
@@ -194,10 +194,10 @@ function writeScheduleRunEntry(runs, scheduleId, workItemId) {
194
194
 
195
195
  function recordScheduleRun(scheduleId, workItemId) {
196
196
  let entry = null;
197
- mutateJsonFileLocked(SCHEDULE_RUNS_PATH, (runs) => {
197
+ mutateScheduleRuns((runs) => {
198
198
  entry = writeScheduleRunEntry(runs, scheduleId, workItemId);
199
199
  return runs;
200
- }, { defaultValue: {} });
200
+ });
201
201
  return entry;
202
202
  }
203
203
 
@@ -212,7 +212,7 @@ function discoverScheduledWork(config) {
212
212
 
213
213
  // Use file-locked mutation to prevent race conditions on rapid calls
214
214
  const work = [];
215
- mutateJsonFileLocked(SCHEDULE_RUNS_PATH, (runs) => {
215
+ mutateScheduleRuns((runs) => {
216
216
  for (const sched of schedules) {
217
217
  if (!sched.id || !sched.cron || !sched.title) continue;
218
218
  if (sched.enabled === false) continue; // strict false check — undefined/null default to enabled per schema
@@ -236,7 +236,8 @@ function discoverScheduledWork(config) {
236
236
  // the double-fire window alongside the same-minute guard (W-mo3zu273f8tm).
237
237
  writeScheduleRunEntry(runs, sched.id, workItem.id);
238
238
  }
239
- }, { defaultValue: {} });
239
+ return runs;
240
+ });
240
241
 
241
242
  return work;
242
243
  }
package/engine/shared.js CHANGED
@@ -2744,6 +2744,106 @@ const WATCH_ACTION_TYPE = {
2744
2744
  RESUME_PLAN: 'resume-plan',
2745
2745
  };
2746
2746
 
2747
+ /**
2748
+ * Phase 7 — small state file mutators. Each routes through the
2749
+ * small-state-store, mirrors back to its JSON file, and emits a topic
2750
+ * event on real writes. SQLite failure falls through to the legacy
2751
+ * mutateJsonFileLocked path.
2752
+ */
2753
+ function _smallStateMutator({ filePath, applyMutation, mirror, topic, defaultValue }) {
2754
+ return (mutator) => {
2755
+ try {
2756
+ const store = require('./small-state-store');
2757
+ const { wrote, result } = store[applyMutation]((obj) => mutator(obj) || obj);
2758
+ if (wrote) {
2759
+ try { store[mirror](filePath); } catch { /* mirror best-effort */ }
2760
+ try { require('./db-events').emitStateEvent(topic); } catch { /* optional */ }
2761
+ }
2762
+ return result;
2763
+ } catch (e) {
2764
+ if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) throw e;
2765
+ }
2766
+ return mutateJsonFileLocked(filePath, (data) => {
2767
+ if (data == null) data = defaultValue();
2768
+ return mutator(data) || data;
2769
+ }, {
2770
+ defaultValue: defaultValue(),
2771
+ onWrote: () => {
2772
+ try { require('./db-events').emitStateEvent(topic); } catch { /* optional */ }
2773
+ },
2774
+ });
2775
+ };
2776
+ }
2777
+
2778
+ const mutateScheduleRuns = _smallStateMutator({
2779
+ filePath: path.join(MINIONS_DIR, 'engine', 'schedule-runs.json'),
2780
+ applyMutation: 'applyScheduleRunsMutation',
2781
+ mirror: '_mirrorScheduleRunsJson',
2782
+ topic: 'schedule_runs',
2783
+ defaultValue: () => ({}),
2784
+ });
2785
+
2786
+ const mutatePipelineRuns = _smallStateMutator({
2787
+ filePath: path.join(MINIONS_DIR, 'engine', 'pipeline-runs.json'),
2788
+ applyMutation: 'applyPipelineRunsMutation',
2789
+ mirror: '_mirrorPipelineRunsJson',
2790
+ topic: 'pipeline_runs',
2791
+ defaultValue: () => ({}),
2792
+ });
2793
+
2794
+ const mutateManagedProcesses = _smallStateMutator({
2795
+ filePath: path.join(MINIONS_DIR, 'engine', 'managed-processes.json'),
2796
+ applyMutation: 'applyManagedProcessesMutation',
2797
+ mirror: '_mirrorManagedProcessesJson',
2798
+ topic: 'managed_processes',
2799
+ defaultValue: () => ({ specs: [] }),
2800
+ });
2801
+
2802
+ const mutateWorktreePool = _smallStateMutator({
2803
+ filePath: path.join(MINIONS_DIR, 'engine', 'worktree-pool.json'),
2804
+ applyMutation: 'applyWorktreePoolMutation',
2805
+ mirror: '_mirrorWorktreePoolJson',
2806
+ topic: 'worktree_pool',
2807
+ defaultValue: () => ({ entries: [] }),
2808
+ });
2809
+
2810
+ /**
2811
+ * Route a watches mutation through the SQL store. Same shape as
2812
+ * mutateWorkItems / mutatePullRequests: mutator receives the watches
2813
+ * array, mutates in place or returns a replacement, and the store
2814
+ * diffs by id. Falls back to the legacy mutateJsonFileLocked path on
2815
+ * SQLite failure.
2816
+ */
2817
+ function mutateWatches(mutator) {
2818
+ const watchesPath = path.join(MINIONS_DIR, 'engine', 'watches.json');
2819
+ try {
2820
+ const store = require('./watches-store');
2821
+ const { wrote, result } = store.applyWatchesMutation((arr) => {
2822
+ if (!Array.isArray(arr)) arr = [];
2823
+ return mutator(arr) || arr;
2824
+ });
2825
+ if (wrote) {
2826
+ try { store._mirrorJsonFromSql(watchesPath); } catch { /* mirror best-effort */ }
2827
+ try { require('./db-events').emitStateEvent('watches'); } catch { /* optional */ }
2828
+ }
2829
+ return result;
2830
+ } catch (e) {
2831
+ if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
2832
+ throw e;
2833
+ }
2834
+ // SQLite unavailable — fall through to legacy JSON path.
2835
+ }
2836
+ return mutateJsonFileLocked(watchesPath, (data) => {
2837
+ if (!Array.isArray(data)) data = [];
2838
+ return mutator(data) || data;
2839
+ }, {
2840
+ defaultValue: [],
2841
+ onWrote: () => {
2842
+ try { require('./db-events').emitStateEvent('watches'); } catch { /* optional */ }
2843
+ },
2844
+ });
2845
+ }
2846
+
2747
2847
  /**
2748
2848
  * Route a metrics mutation through the SQL store with a JSON dual-write
2749
2849
  * mirror. Same shape as mutateWorkItems / mutatePullRequests: mutator
@@ -5225,7 +5325,7 @@ module.exports = {
5225
5325
  runtimeConfigWarnings,
5226
5326
  projectWorkSourceWarnings,
5227
5327
  backfillProjectWorkSourceDefaults,
5228
- WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, WORKTREE_REQUIRING_TYPES, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, DISPATCH_RESULT, mutateMetrics, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject,
5328
+ WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, WORKTREE_REQUIRING_TYPES, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, DISPATCH_RESULT, mutateMetrics, mutateWatches, mutateScheduleRuns, mutatePipelineRuns, mutateManagedProcesses, mutateWorktreePool, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject,
5229
5329
  WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS, WATCH_ACTION_TYPE,
5230
5330
  WATCH_STALLED_DEFAULT_TICKS, WATCH_STUCK_STAGE_DEFAULT_TICKS,
5231
5331
  PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,