@yemi33/minions 0.1.2072 → 0.1.2073

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
+ };
Binary file
@@ -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
  }
package/engine/shared.js CHANGED
@@ -2744,6 +2744,43 @@ const WATCH_ACTION_TYPE = {
2744
2744
  RESUME_PLAN: 'resume-plan',
2745
2745
  };
2746
2746
 
2747
+ /**
2748
+ * Route a watches mutation through the SQL store. Same shape as
2749
+ * mutateWorkItems / mutatePullRequests: mutator receives the watches
2750
+ * array, mutates in place or returns a replacement, and the store
2751
+ * diffs by id. Falls back to the legacy mutateJsonFileLocked path on
2752
+ * SQLite failure.
2753
+ */
2754
+ function mutateWatches(mutator) {
2755
+ const watchesPath = path.join(MINIONS_DIR, 'engine', 'watches.json');
2756
+ try {
2757
+ const store = require('./watches-store');
2758
+ const { wrote, result } = store.applyWatchesMutation((arr) => {
2759
+ if (!Array.isArray(arr)) arr = [];
2760
+ return mutator(arr) || arr;
2761
+ });
2762
+ if (wrote) {
2763
+ try { store._mirrorJsonFromSql(watchesPath); } catch { /* mirror best-effort */ }
2764
+ try { require('./db-events').emitStateEvent('watches'); } catch { /* optional */ }
2765
+ }
2766
+ return result;
2767
+ } catch (e) {
2768
+ if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
2769
+ throw e;
2770
+ }
2771
+ // SQLite unavailable — fall through to legacy JSON path.
2772
+ }
2773
+ return mutateJsonFileLocked(watchesPath, (data) => {
2774
+ if (!Array.isArray(data)) data = [];
2775
+ return mutator(data) || data;
2776
+ }, {
2777
+ defaultValue: [],
2778
+ onWrote: () => {
2779
+ try { require('./db-events').emitStateEvent('watches'); } catch { /* optional */ }
2780
+ },
2781
+ });
2782
+ }
2783
+
2747
2784
  /**
2748
2785
  * Route a metrics mutation through the SQL store with a JSON dual-write
2749
2786
  * mirror. Same shape as mutateWorkItems / mutatePullRequests: mutator
@@ -5225,7 +5262,7 @@ module.exports = {
5225
5262
  runtimeConfigWarnings,
5226
5263
  projectWorkSourceWarnings,
5227
5264
  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,
5265
+ 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, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject,
5229
5266
  WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS, WATCH_ACTION_TYPE,
5230
5267
  WATCH_STALLED_DEFAULT_TICKS, WATCH_STUCK_STAGE_DEFAULT_TICKS,
5231
5268
  PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
@@ -0,0 +1,259 @@
1
+ // engine/watches-store.js — SQL-backed implementation of engine/watches.json.
2
+ //
3
+ // Mirrors the Phase 2/3 work_items / pull_requests stores: a single
4
+ // table (no per-scope split — watches.json is a flat array, not
5
+ // per-project), diff-then-apply mutator, JSON dual-write mirror with
6
+ // (mtime, size) fingerprint for external-edit detection.
7
+ //
8
+ // readWatches() -> [watch, watch, ...]
9
+ // applyWatchesMutation(fn) -> diff-then-apply, returns { wrote, result }
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ function _toMs(v) {
15
+ if (v == null) return null;
16
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
17
+ const parsed = Date.parse(v);
18
+ return Number.isFinite(parsed) ? parsed : null;
19
+ }
20
+
21
+ function _watchesFilePath() {
22
+ const shared = require('./shared');
23
+ return path.join(shared.MINIONS_DIR, 'engine', 'watches.json');
24
+ }
25
+
26
+ function _parseRow(row) {
27
+ if (!row || !row.data) return null;
28
+ try { return JSON.parse(row.data); }
29
+ catch { return null; }
30
+ }
31
+
32
+ function _readJsonArrayFallback() {
33
+ const fp = _watchesFilePath();
34
+ let raw;
35
+ try { raw = fs.readFileSync(fp, 'utf8'); }
36
+ catch { return []; }
37
+ try {
38
+ const parsed = JSON.parse(raw);
39
+ return Array.isArray(parsed) ? parsed : [];
40
+ } catch (e) {
41
+ try {
42
+ // eslint-disable-next-line no-console
43
+ console.warn(`[watches-store] corrupt JSON in ${fp}: ${e.message}`);
44
+ } catch { /* console may be wrapped */ }
45
+ return [];
46
+ }
47
+ }
48
+
49
+ // Content-hash fingerprint — (mtime, size) is too coarse: same-size
50
+ // content swaps (e.g. last_checked timestamps that always have the same
51
+ // byte length) inside the same ms tick collide on both axes. Hashing the
52
+ // bytes is the unambiguous signal. SHA-1 on a few-KB file is sub-ms.
53
+ let _lastMirrorHash = null;
54
+
55
+ const crypto = require('crypto');
56
+
57
+ function _fileContentHash(filePath) {
58
+ try {
59
+ const buf = fs.readFileSync(filePath);
60
+ return crypto.createHash('sha1').update(buf).digest('hex');
61
+ }
62
+ catch { return null; }
63
+ }
64
+
65
+ function _hydrateFromJson(db) {
66
+ const items = _readJsonArrayFallback();
67
+ db.prepare('DELETE FROM watches').run();
68
+ if (items.length === 0) return;
69
+ const now = Date.now();
70
+ const ins = db.prepare(`
71
+ INSERT INTO watches (id, target, target_type, condition, status, owner, created_at, last_checked, last_triggered, data, updated_at)
72
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
73
+ ON CONFLICT(id) DO NOTHING
74
+ `);
75
+ for (const w of items) {
76
+ if (!w || !w.id) continue;
77
+ ins.run(
78
+ String(w.id),
79
+ typeof w.target === 'string' ? w.target : (w.target == null ? null : JSON.stringify(w.target)),
80
+ w.targetType || null,
81
+ w.condition || null,
82
+ String(w.status || 'active'),
83
+ w.owner || null,
84
+ _toMs(w.created_at),
85
+ _toMs(w.last_checked),
86
+ _toMs(w.last_triggered),
87
+ JSON.stringify(w),
88
+ now,
89
+ );
90
+ }
91
+ }
92
+
93
+ function _resyncIfJsonDiverged(db) {
94
+ const jsonPath = _watchesFilePath();
95
+ const currentHash = _fileContentHash(jsonPath);
96
+ if (currentHash == null) return;
97
+ if (_lastMirrorHash != null && currentHash === _lastMirrorHash) return;
98
+ if (_lastMirrorHash == null) {
99
+ const sqlHas = db.prepare('SELECT 1 FROM watches LIMIT 1').get();
100
+ if (sqlHas) {
101
+ _lastMirrorHash = currentHash;
102
+ return;
103
+ }
104
+ }
105
+ _hydrateFromJson(db);
106
+ _lastMirrorHash = currentHash;
107
+ }
108
+
109
+ // Read all watch rows from SQL only. No JSON fallback — used by the
110
+ // mirror writer to avoid resurrecting deleted rows: after the mutation
111
+ // deletes a watch from SQL but BEFORE the mirror lands, the JSON still
112
+ // holds the pre-mutation content, so a fallback read would round-trip
113
+ // the deleted row right back into JSON.
114
+ function _readWatchesFromSqlOnly(db) {
115
+ const rows = db.prepare('SELECT data FROM watches ORDER BY rowid').all();
116
+ const out = [];
117
+ for (const row of rows) {
118
+ const w = _parseRow(row);
119
+ if (w) out.push(w);
120
+ }
121
+ return out;
122
+ }
123
+
124
+ function readWatches() {
125
+ const { getDb } = require('./db');
126
+ let db;
127
+ try { db = getDb(); }
128
+ catch { return _readJsonArrayFallback(); }
129
+
130
+ _resyncIfJsonDiverged(db);
131
+
132
+ const out = _readWatchesFromSqlOnly(db);
133
+ if (out.length === 0) {
134
+ // SQL is empty for first-time hydrate: trust the JSON if it has
135
+ // content. After hydrate ran, SQL is empty iff the user really has
136
+ // no watches.
137
+ const fallback = _readJsonArrayFallback();
138
+ if (fallback.length > 0) return fallback;
139
+ return [];
140
+ }
141
+ return out;
142
+ }
143
+
144
+ function _indexById(arr) {
145
+ const out = new Map();
146
+ for (const w of arr) {
147
+ if (!w || !w.id) continue;
148
+ out.set(String(w.id), w);
149
+ }
150
+ return out;
151
+ }
152
+
153
+ function _computeWatchesDiff(before, after) {
154
+ const beforeMap = _indexById(before);
155
+ const afterMap = _indexById(after);
156
+ const toUpsert = [];
157
+ const toDelete = [];
158
+ for (const [id, w] of afterMap) {
159
+ const prev = beforeMap.get(id);
160
+ if (!prev) { toUpsert.push(w); continue; }
161
+ if (JSON.stringify(prev) !== JSON.stringify(w)) toUpsert.push(w);
162
+ }
163
+ for (const [id] of beforeMap) {
164
+ if (!afterMap.has(id)) toDelete.push(id);
165
+ }
166
+ return { toUpsert, toDelete };
167
+ }
168
+
169
+ function _applyWatchesDiff(db, diff) {
170
+ if (diff.toUpsert.length === 0 && diff.toDelete.length === 0) return false;
171
+ const now = Date.now();
172
+ const upsertStmt = db.prepare(`
173
+ INSERT INTO watches (id, target, target_type, condition, status, owner, created_at, last_checked, last_triggered, data, updated_at)
174
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
175
+ ON CONFLICT(id) DO UPDATE SET
176
+ target = excluded.target,
177
+ target_type = excluded.target_type,
178
+ condition = excluded.condition,
179
+ status = excluded.status,
180
+ owner = excluded.owner,
181
+ created_at = excluded.created_at,
182
+ last_checked = excluded.last_checked,
183
+ last_triggered = excluded.last_triggered,
184
+ data = excluded.data,
185
+ updated_at = excluded.updated_at
186
+ `);
187
+ const deleteStmt = db.prepare('DELETE FROM watches WHERE id = ?');
188
+
189
+ for (const w of diff.toUpsert) {
190
+ upsertStmt.run(
191
+ String(w.id),
192
+ typeof w.target === 'string' ? w.target : (w.target == null ? null : JSON.stringify(w.target)),
193
+ w.targetType || null,
194
+ w.condition || null,
195
+ String(w.status || 'active'),
196
+ w.owner || null,
197
+ _toMs(w.created_at),
198
+ _toMs(w.last_checked),
199
+ _toMs(w.last_triggered),
200
+ JSON.stringify(w),
201
+ now,
202
+ );
203
+ }
204
+ for (const id of diff.toDelete) {
205
+ deleteStmt.run(id);
206
+ }
207
+ return true;
208
+ }
209
+
210
+ function applyWatchesMutation(mutator) {
211
+ const { getDb, withTransaction } = require('./db');
212
+ let db;
213
+ try { db = getDb(); }
214
+ catch (e) {
215
+ throw new Error(`engine/watches-store: SQLite unavailable (${e.message}); cannot mutate watches`);
216
+ }
217
+
218
+ return withTransaction(db, () => {
219
+ _resyncIfJsonDiverged(db);
220
+ const before = readWatches();
221
+ const beforeSnapshot = JSON.parse(JSON.stringify(before));
222
+ const next = mutator(before);
223
+ const after = (next === undefined || next === null)
224
+ ? before
225
+ : (Array.isArray(next) ? next : before);
226
+ const diff = _computeWatchesDiff(beforeSnapshot, after);
227
+ const wrote = _applyWatchesDiff(db, diff);
228
+ return { wrote, result: after };
229
+ });
230
+ }
231
+
232
+ function _mirrorJsonFromSql(filePath) {
233
+ try {
234
+ const shared = require('./shared');
235
+ const { getDb } = require('./db');
236
+ // Read SQL directly — bypass the JSON fallback so a deletion that
237
+ // leaves SQL empty doesn't resurrect from the stale JSON content.
238
+ const items = _readWatchesFromSqlOnly(getDb());
239
+ const target = filePath || _watchesFilePath();
240
+ shared.safeWrite(target, items);
241
+ const h = _fileContentHash(target);
242
+ if (h != null) _lastMirrorHash = h;
243
+ } catch {
244
+ // Mirror failures are non-fatal — SQL has already committed.
245
+ }
246
+ }
247
+
248
+ function _resetWatchesTableForTest() {
249
+ const { getDb } = require('./db');
250
+ try { getDb().exec('DELETE FROM watches'); } catch { /* not initialized */ }
251
+ _lastMirrorHash = null;
252
+ }
253
+
254
+ module.exports = {
255
+ readWatches,
256
+ applyWatchesMutation,
257
+ _mirrorJsonFromSql,
258
+ _resetWatchesTableForTest,
259
+ };
package/engine/watches.js CHANGED
@@ -67,7 +67,7 @@
67
67
  const fs = require('fs');
68
68
  const path = require('path');
69
69
  const shared = require('./shared');
70
- const { safeJsonArr, mutateJsonFileLocked, ts, uid, log, writeToInbox,
70
+ const { safeJsonArr, mutateJsonFileLocked, mutateWatches, ts, uid, log, writeToInbox,
71
71
  WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS } = shared;
72
72
  const watchActions = require('./watch-actions');
73
73
 
@@ -230,11 +230,10 @@ function createWatch({ target, targetType, condition, interval, owner, descripti
230
230
  _history: [],
231
231
  };
232
232
 
233
- mutateJsonFileLocked(_watchesPath(), (watches) => {
234
- if (!Array.isArray(watches)) watches = [];
233
+ mutateWatches((watches) => {
235
234
  watches.push(watch);
236
235
  return watches;
237
- }, { defaultValue: [] });
236
+ });
238
237
 
239
238
  log('info', `Watch created: ${watch.id} → ${watch.target} (${watch.condition})`);
240
239
  return watch;
@@ -264,8 +263,7 @@ function updateWatch(id, updates) {
264
263
  if (reqErr) throw new Error(reqErr);
265
264
  }
266
265
  let found = null;
267
- mutateJsonFileLocked(_watchesPath(), (watches) => {
268
- if (!Array.isArray(watches)) return watches;
266
+ mutateWatches((watches) => {
269
267
  const watch = watches.find(w => w.id === id);
270
268
  if (!watch) return watches;
271
269
  // Only allow safe field updates
@@ -275,7 +273,7 @@ function updateWatch(id, updates) {
275
273
  }
276
274
  found = { ...watch };
277
275
  return watches;
278
- }, { defaultValue: [] });
276
+ });
279
277
  return found;
280
278
  }
281
279
 
@@ -287,15 +285,14 @@ function updateWatch(id, updates) {
287
285
  function deleteWatch(id) {
288
286
  if (!id) return false;
289
287
  let deleted = false;
290
- mutateJsonFileLocked(_watchesPath(), (watches) => {
291
- if (!Array.isArray(watches)) return watches;
288
+ mutateWatches((watches) => {
292
289
  const idx = watches.findIndex(w => w.id === id);
293
290
  if (idx >= 0) {
294
291
  watches.splice(idx, 1);
295
292
  deleted = true;
296
293
  }
297
294
  return watches;
298
- }, { defaultValue: [] });
295
+ });
299
296
  if (deleted) log('info', `Watch deleted: ${id}`);
300
297
  return deleted;
301
298
  }
@@ -413,8 +410,8 @@ function checkWatches(config, state) {
413
410
  const notifications = [];
414
411
  const actionsToRun = [];
415
412
 
416
- mutateJsonFileLocked(_watchesPath(), (watches) => {
417
- if (!Array.isArray(watches) || watches.length === 0) return watches;
413
+ mutateWatches((watches) => {
414
+ if (watches.length === 0) return watches;
418
415
 
419
416
  for (const watch of watches) {
420
417
  try {
@@ -539,7 +536,7 @@ function checkWatches(config, state) {
539
536
  }
540
537
 
541
538
  return watches;
542
- }, { defaultValue: [] });
539
+ });
543
540
 
544
541
  // Fire notifications outside the lock — writeToInbox does disk I/O
545
542
  for (const n of notifications) {
@@ -591,8 +588,7 @@ async function _runActionTask(task) {
591
588
  // can inspect each step's result. Single-action watches keep the legacy
592
589
  // `dispatchedItemId` shortcut for back-compat.
593
590
  try {
594
- mutateJsonFileLocked(_watchesPath(), (watches) => {
595
- if (!Array.isArray(watches)) return watches;
591
+ mutateWatches((watches) => {
596
592
  const w = watches.find(x => x.id === task.watchId);
597
593
  if (w) {
598
594
  if (isChain) {
@@ -639,7 +635,7 @@ async function _runActionTask(task) {
639
635
  }
640
636
  }
641
637
  return watches;
642
- }, { defaultValue: [] });
638
+ });
643
639
  } catch (err) {
644
640
  log('warn', `Watch ${task.watchId} action result persist failed: ${err.message}`);
645
641
  }
@@ -116,28 +116,19 @@ function readWorkItemsForScope(scope) {
116
116
  // scope is bounded by the JSON's row count.
117
117
  function _resyncScopeIfJsonDiverged(db, scope) {
118
118
  const jsonPath = _filePathForScope(scope);
119
- const current = _statFingerprint(jsonPath);
120
- const lastMirror = _lastMirrorByScope.get(scope);
121
- if (current == null) return; // JSON absent → nothing to resync
122
- // In-sync iff BOTH mtime AND size match. Either differing signals an
123
- // external write including the same-ms-tick safeWrite pattern that
124
- // mtime-only checks miss on Windows (ms-rounded mtimeMs collides).
125
- // Resync is idempotent (DELETE + bulk re-INSERT from JSON).
126
- if (lastMirror != null
127
- && current.mtime === lastMirror.mtime
128
- && current.size === lastMirror.size) return;
129
- // No mirror record but JSON exists: first-time hydrate iff SQL is empty
130
- // for this scope (avoid trampling a freshly-backfilled migration state
131
- // on the very first read).
132
- if (lastMirror == null) {
119
+ const currentHash = _fileContentHash(jsonPath);
120
+ const lastHash = _lastMirrorHashByScope.get(scope);
121
+ if (currentHash == null) return;
122
+ if (lastHash != null && currentHash === lastHash) return;
123
+ if (lastHash == null) {
133
124
  const sqlHas = db.prepare('SELECT 1 FROM work_items WHERE scope = ? LIMIT 1').get(scope);
134
125
  if (sqlHas) {
135
- _lastMirrorByScope.set(scope, current);
126
+ _lastMirrorHashByScope.set(scope, currentHash);
136
127
  return;
137
128
  }
138
129
  }
139
130
  _hydrateScopeFromJson(db, scope);
140
- _lastMirrorByScope.set(scope, current);
131
+ _lastMirrorHashByScope.set(scope, currentHash);
141
132
  }
142
133
 
143
134
  // Read all rows across all scopes — used by queries.getWorkItems which
@@ -243,18 +234,18 @@ function _applyWorkItemsDiff(db, diff) {
243
234
  // wrote — true iff at least one INSERT/UPDATE/DELETE landed
244
235
  // result — the post-mutation array (legacy return shape of
245
236
  // mutateJsonFileLocked)
246
- // In-process record of (mtime, size) of the JSON mirror we last wrote,
247
- // keyed by scope. Two-field fingerprint catches the same-ms-tick write
248
- // pattern that mtime-only checks miss on Windows: NTFS reports
249
- // ms-rounded mtimeMs, so back-to-back writes inside the same tick look
250
- // identical to "no change" by mtime alone. File size catches the rest
251
- // — content swaps almost always change byte length.
252
- const _lastMirrorByScope = new Map();
237
+ // Content-hash fingerprint of the JSON mirror per scope, so external
238
+ // edits are detected unambiguously. (mtime, size) is too coarse: same-
239
+ // length timestamp swaps (last_checked / last_triggered / completed_at)
240
+ // preserve both axes. SHA-1 is sub-ms on a few-hundred-KB file.
241
+ const _lastMirrorHashByScope = new Map();
253
242
 
254
- function _statFingerprint(filePath) {
243
+ const crypto = require('crypto');
244
+
245
+ function _fileContentHash(filePath) {
255
246
  try {
256
- const st = fs.statSync(filePath);
257
- return { mtime: st.mtimeMs, size: st.size };
247
+ const buf = fs.readFileSync(filePath);
248
+ return crypto.createHash('sha1').update(buf).digest('hex');
258
249
  }
259
250
  catch { return null; }
260
251
  }
@@ -325,17 +316,24 @@ function applyWorkItemsMutation(scope, mutator) {
325
316
  function _mirrorJsonFromSql(scope, filePath) {
326
317
  try {
327
318
  const shared = require('./shared');
328
- const items = readWorkItemsForScope(scope);
329
- // Strip _source if presentonly added by readAllWorkItems, but
330
- // belt-and-suspenders since the mirror is observable.
331
- for (const wi of items) { if (wi && wi._source) delete wi._source; }
319
+ const { getDb } = require('./db');
320
+ // Read SQL directly for this scope bypass the JSON fallback so a
321
+ // mutation that empties SQL for the scope doesn't resurrect stale
322
+ // JSON content. (See pull-requests-store and watches-store for the
323
+ // same defense.)
324
+ const db = getDb();
325
+ const rows = db.prepare('SELECT data FROM work_items WHERE scope = ? ORDER BY rowid').all(scope);
326
+ const items = [];
327
+ for (const row of rows) {
328
+ const wi = _parseRow(row);
329
+ if (!wi) continue;
330
+ if (wi._source) delete wi._source;
331
+ items.push(wi);
332
+ }
332
333
  const target = filePath || _filePathForScope(scope);
333
334
  shared.safeWrite(target, items);
334
- // Record (mtime, size) we just wrote so the next mutation can detect
335
- // an external edit. mtime alone misses same-ms-tick writes; size
336
- // catches them.
337
- const fp = _statFingerprint(target);
338
- if (fp != null) _lastMirrorByScope.set(scope, fp);
335
+ const h = _fileContentHash(target);
336
+ if (h != null) _lastMirrorHashByScope.set(scope, h);
339
337
  } catch {
340
338
  // Mirror failures are non-fatal: SQL has already committed.
341
339
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2072",
3
+ "version": "0.1.2073",
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"