@yemi33/minions 0.1.2069 → 0.1.2071
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/engine/db/migrations/006-metrics.js +106 -0
- package/engine/gh-token.js +7 -9
- package/engine/lifecycle.js +3 -4
- package/engine/llm.js +2 -2
- package/engine/metrics-store.js +0 -0
- package/engine/pull-requests-store.js +22 -12
- package/engine/queries.js +10 -1
- package/engine/shared.js +41 -2
- package/engine/work-items-store.js +30 -23
- package/package.json +1 -1
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// engine/db/migrations/006-metrics.js
|
|
2
|
+
//
|
|
3
|
+
// Phase 5: move engine/metrics.json into a `metrics` table.
|
|
4
|
+
//
|
|
5
|
+
// metrics.json's shape is heterogeneous: a flat object whose top-level
|
|
6
|
+
// keys mix per-agent counters (regular agent ids) with reserved keys
|
|
7
|
+
// (`_engine`, `_daily`, `_contextPressure`). The SQL projection uses a
|
|
8
|
+
// single table with a (kind, key) primary key so each top-level entry
|
|
9
|
+
// becomes one row:
|
|
10
|
+
//
|
|
11
|
+
// kind | key | data (JSON blob)
|
|
12
|
+
// --------------------+--------------------+-----------------------------
|
|
13
|
+
// agent | dallas | { tasksCompleted, ... }
|
|
14
|
+
// agent | ripley | { ... }
|
|
15
|
+
// engine_category | command-center | { calls, costUsd, ... }
|
|
16
|
+
// engine_category | doc-chat | { ... }
|
|
17
|
+
// daily | 2026-05-28 | { costUsd, tasks, ... }
|
|
18
|
+
// context_pressure | current | { ... }
|
|
19
|
+
//
|
|
20
|
+
// Engineless extensions land transparently in `data` — no schema bump
|
|
21
|
+
// required to add a new counter field per category. New top-level kinds
|
|
22
|
+
// (e.g. `pipeline`) only need a new sentinel in the reserved-key list
|
|
23
|
+
// inside the store; the table schema is forward-compatible.
|
|
24
|
+
//
|
|
25
|
+
// Backfill: read metrics.json once, split by reserved-key sentinel,
|
|
26
|
+
// INSERT one row per entry. The JSON file stays on disk as a dual-write
|
|
27
|
+
// mirror so legacy direct-readers (none in engine, a few in tests) keep
|
|
28
|
+
// working.
|
|
29
|
+
|
|
30
|
+
const path = require('path');
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
|
|
33
|
+
function _resolveMinionsDir() {
|
|
34
|
+
const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
|
|
35
|
+
if (envHome) return envHome;
|
|
36
|
+
try { return require('../../shared').MINIONS_DIR; } catch { return null; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const RESERVED_KEYS = new Map([
|
|
40
|
+
['_engine', 'engine_category'],
|
|
41
|
+
['_daily', 'daily'],
|
|
42
|
+
['_contextPressure', 'context_pressure'],
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
module.exports = {
|
|
46
|
+
version: 6,
|
|
47
|
+
description: 'metrics: schema + metrics.json backfill',
|
|
48
|
+
up(db) {
|
|
49
|
+
db.exec(`
|
|
50
|
+
CREATE TABLE metrics (
|
|
51
|
+
kind TEXT NOT NULL,
|
|
52
|
+
key TEXT NOT NULL,
|
|
53
|
+
data TEXT NOT NULL,
|
|
54
|
+
updated_at INTEGER NOT NULL,
|
|
55
|
+
PRIMARY KEY (kind, key)
|
|
56
|
+
);
|
|
57
|
+
CREATE INDEX idx_metrics_kind ON metrics(kind);
|
|
58
|
+
`);
|
|
59
|
+
|
|
60
|
+
const minionsDir = _resolveMinionsDir();
|
|
61
|
+
if (!minionsDir) return;
|
|
62
|
+
const metricsPath = path.join(minionsDir, 'engine', 'metrics.json');
|
|
63
|
+
if (!fs.existsSync(metricsPath)) return;
|
|
64
|
+
|
|
65
|
+
let raw;
|
|
66
|
+
try { raw = JSON.parse(fs.readFileSync(metricsPath, 'utf8')); }
|
|
67
|
+
catch (e) {
|
|
68
|
+
throw new Error(`engine/db/006-metrics: cannot parse metrics.json: ${e.message}`);
|
|
69
|
+
}
|
|
70
|
+
if (!raw || typeof raw !== 'object') return;
|
|
71
|
+
|
|
72
|
+
const insert = db.prepare(`
|
|
73
|
+
INSERT INTO metrics (kind, key, data, updated_at)
|
|
74
|
+
VALUES (?, ?, ?, ?)
|
|
75
|
+
`);
|
|
76
|
+
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
let inserted = 0;
|
|
79
|
+
|
|
80
|
+
for (const [topKey, topValue] of Object.entries(raw)) {
|
|
81
|
+
const reservedKind = RESERVED_KEYS.get(topKey);
|
|
82
|
+
if (reservedKind) {
|
|
83
|
+
if (reservedKind === 'context_pressure') {
|
|
84
|
+
// Single-blob reserved key — store as one row keyed 'current'.
|
|
85
|
+
insert.run('context_pressure', 'current', JSON.stringify(topValue), now);
|
|
86
|
+
inserted += 1;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// Nested reserved key (_engine / _daily) — one row per inner key.
|
|
90
|
+
if (topValue && typeof topValue === 'object') {
|
|
91
|
+
for (const [innerKey, innerValue] of Object.entries(topValue)) {
|
|
92
|
+
insert.run(reservedKind, innerKey, JSON.stringify(innerValue), now);
|
|
93
|
+
inserted += 1;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
// Regular agent counter row.
|
|
99
|
+
insert.run('agent', topKey, JSON.stringify(topValue), now);
|
|
100
|
+
inserted += 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// eslint-disable-next-line no-console
|
|
104
|
+
console.log(`[db-migrate] v6: backfilled ${inserted} metrics rows; metrics.json kept as dual-write mirror`);
|
|
105
|
+
},
|
|
106
|
+
};
|
package/engine/gh-token.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* via `_setTokenForTest(slug, token)` and clear it via `_clearTokenCache()`.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
const {
|
|
21
|
+
const { execFileSync } = require('child_process');
|
|
22
22
|
const path = require('path');
|
|
23
23
|
const shared = require('./shared');
|
|
24
24
|
const { safeJson, MINIONS_DIR, log } = shared;
|
|
@@ -70,14 +70,12 @@ function _fetchTokenForAccount(account, opts = {}) {
|
|
|
70
70
|
const cached = _accountTokens.get(account);
|
|
71
71
|
if (cached && cached.expiresAt > Date.now()) return cached.token;
|
|
72
72
|
|
|
73
|
-
const run = opts.
|
|
73
|
+
const run = opts.execFileSync || execFileSync;
|
|
74
74
|
try {
|
|
75
|
-
// Argv form
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
const cmd = `gh auth token --user ${account} --hostname github.com`;
|
|
80
|
-
const out = run(cmd, {
|
|
75
|
+
// Argv-array form: `account` is passed as a literal argument and never
|
|
76
|
+
// interpreted by a shell, so shell metacharacters in the configured
|
|
77
|
+
// account name (e.g. `;`, backticks, `$()`) cannot be executed.
|
|
78
|
+
const out = run('gh', ['auth', 'token', '--user', account, '--hostname', 'github.com'], {
|
|
81
79
|
timeout: FETCH_TIMEOUT_MS,
|
|
82
80
|
encoding: 'utf8',
|
|
83
81
|
windowsHide: true,
|
|
@@ -102,7 +100,7 @@ function _fetchTokenForAccount(account, opts = {}) {
|
|
|
102
100
|
* caller should fall back to the ambient `gh` identity.
|
|
103
101
|
*
|
|
104
102
|
* Test seam: `_setTokenForTest(slug, token)` short-circuits the entire chain
|
|
105
|
-
* so unit tests do not have to mock
|
|
103
|
+
* so unit tests do not have to mock execFileSync nor stand up a config file.
|
|
106
104
|
*/
|
|
107
105
|
function resolveTokenForSlug(slug, opts = {}) {
|
|
108
106
|
if (slug && _slugTokenOverrides.has(slug)) return _slugTokenOverrides.get(slug);
|
package/engine/lifecycle.js
CHANGED
|
@@ -1788,12 +1788,12 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
|
|
|
1788
1788
|
// Track reviewer for metrics purposes (separate file, separate lock)
|
|
1789
1789
|
const authorAgentId = (reviewPr.agent || '').toLowerCase();
|
|
1790
1790
|
if (authorAgentId && config.agents?.[authorAgentId]) {
|
|
1791
|
-
shared.
|
|
1791
|
+
shared.mutateMetrics((metrics) => {
|
|
1792
1792
|
if (!metrics[authorAgentId]) metrics[authorAgentId] = { ...DEFAULT_AGENT_METRICS };
|
|
1793
1793
|
if (!metrics[agentId]) metrics[agentId] = { ...DEFAULT_AGENT_METRICS };
|
|
1794
1794
|
metrics[agentId].reviewsDone = (metrics[agentId].reviewsDone || 0) + 1;
|
|
1795
1795
|
return metrics;
|
|
1796
|
-
}
|
|
1796
|
+
});
|
|
1797
1797
|
}
|
|
1798
1798
|
|
|
1799
1799
|
log('info', `Updated ${reviewPr.id} → minions review: ${postReviewStatus || 'waiting'} by ${reviewerName}`);
|
|
@@ -2559,8 +2559,7 @@ async function handlePostMerge(pr, project, config, newStatus) {
|
|
|
2559
2559
|
|
|
2560
2560
|
const agentId = (pr.agent || '').toLowerCase();
|
|
2561
2561
|
if (agentId && config.agents?.[agentId]) {
|
|
2562
|
-
|
|
2563
|
-
mutateJsonFileLocked(metricsPath, (metrics) => {
|
|
2562
|
+
shared.mutateMetrics((metrics) => {
|
|
2564
2563
|
if (!metrics[agentId]) metrics[agentId] = { ...DEFAULT_AGENT_METRICS };
|
|
2565
2564
|
metrics[agentId].prsMerged = (metrics[agentId].prsMerged || 0) + 1;
|
|
2566
2565
|
return metrics;
|
package/engine/llm.js
CHANGED
|
@@ -103,8 +103,8 @@ function flushMetricsBuffer() {
|
|
|
103
103
|
_pendingMetrics = { engine: Object.create(null), daily: Object.create(null) };
|
|
104
104
|
|
|
105
105
|
try {
|
|
106
|
-
const
|
|
107
|
-
|
|
106
|
+
const { mutateMetrics } = require('./shared');
|
|
107
|
+
mutateMetrics((metrics) => {
|
|
108
108
|
if (!metrics._engine) metrics._engine = {};
|
|
109
109
|
for (const [category, delta] of Object.entries(pending.engine)) {
|
|
110
110
|
if (!metrics._engine[category]) {
|
|
Binary file
|
|
@@ -62,10 +62,16 @@ function _readJsonArrayFallback(scope) {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
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();
|
|
66
69
|
|
|
67
|
-
function
|
|
68
|
-
try {
|
|
70
|
+
function _statFingerprint(filePath) {
|
|
71
|
+
try {
|
|
72
|
+
const st = fs.statSync(filePath);
|
|
73
|
+
return { mtime: st.mtimeMs, size: st.size };
|
|
74
|
+
}
|
|
69
75
|
catch { return null; }
|
|
70
76
|
}
|
|
71
77
|
|
|
@@ -99,19 +105,23 @@ function _hydrateScopeFromJson(db, scope) {
|
|
|
99
105
|
|
|
100
106
|
function _resyncScopeIfJsonDiverged(db, scope) {
|
|
101
107
|
const jsonPath = _filePathForScope(scope);
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
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) {
|
|
107
117
|
const sqlHas = db.prepare('SELECT 1 FROM pull_requests WHERE scope = ? LIMIT 1').get(scope);
|
|
108
118
|
if (sqlHas) {
|
|
109
|
-
|
|
119
|
+
_lastMirrorByScope.set(scope, current);
|
|
110
120
|
return;
|
|
111
121
|
}
|
|
112
122
|
}
|
|
113
123
|
_hydrateScopeFromJson(db, scope);
|
|
114
|
-
|
|
124
|
+
_lastMirrorByScope.set(scope, current);
|
|
115
125
|
}
|
|
116
126
|
|
|
117
127
|
function readPullRequestsForScope(scope) {
|
|
@@ -263,8 +273,8 @@ function _mirrorJsonFromSql(scope, filePath) {
|
|
|
263
273
|
for (const pr of items) { if (pr && pr._scope) delete pr._scope; }
|
|
264
274
|
const target = filePath || _filePathForScope(scope);
|
|
265
275
|
shared.safeWrite(target, items);
|
|
266
|
-
const
|
|
267
|
-
if (
|
|
276
|
+
const fp = _statFingerprint(target);
|
|
277
|
+
if (fp != null) _lastMirrorByScope.set(scope, fp);
|
|
268
278
|
} catch {
|
|
269
279
|
// Mirror failures are non-fatal — SQL has already committed.
|
|
270
280
|
}
|
package/engine/queries.js
CHANGED
|
@@ -348,7 +348,16 @@ function getEngineLog() {
|
|
|
348
348
|
}
|
|
349
349
|
|
|
350
350
|
function getMetrics() {
|
|
351
|
-
|
|
351
|
+
// Phase 5: prefer the SQL store. Falls back to the JSON mirror on
|
|
352
|
+
// SQLite failure or when the table is empty (fresh install /
|
|
353
|
+
// pre-migration). Returns the legacy flat object shape.
|
|
354
|
+
let metrics;
|
|
355
|
+
try {
|
|
356
|
+
const store = require('./metrics-store');
|
|
357
|
+
metrics = store.readMetrics() || {};
|
|
358
|
+
} catch {
|
|
359
|
+
metrics = readJsonNoRestore(path.join(ENGINE_DIR, 'metrics.json')) || {};
|
|
360
|
+
}
|
|
352
361
|
|
|
353
362
|
for (const [agentId, m] of Object.entries(metrics)) {
|
|
354
363
|
if (agentId.startsWith('_')) continue;
|
package/engine/shared.js
CHANGED
|
@@ -2690,13 +2690,52 @@ const WATCH_ACTION_TYPE = {
|
|
|
2690
2690
|
RESUME_PLAN: 'resume-plan',
|
|
2691
2691
|
};
|
|
2692
2692
|
|
|
2693
|
+
/**
|
|
2694
|
+
* Route a metrics mutation through the SQL store with a JSON dual-write
|
|
2695
|
+
* mirror. Same shape as mutateWorkItems / mutatePullRequests: mutator
|
|
2696
|
+
* receives the full legacy-shape metrics object, mutates in place or
|
|
2697
|
+
* returns a replacement, and the store diffs by (kind, key) row.
|
|
2698
|
+
*
|
|
2699
|
+
* Falls back to the legacy mutateJsonFileLocked path on SQLite failure
|
|
2700
|
+
* so a node:sqlite-broken install keeps recording metrics.
|
|
2701
|
+
*/
|
|
2702
|
+
function mutateMetrics(mutator) {
|
|
2703
|
+
const metricsPath = path.join(MINIONS_DIR, 'engine', 'metrics.json');
|
|
2704
|
+
try {
|
|
2705
|
+
const store = require('./metrics-store');
|
|
2706
|
+
const { wrote, result } = store.applyMetricsMutation((m) => {
|
|
2707
|
+
if (!m || typeof m !== 'object') m = {};
|
|
2708
|
+
return mutator(m) || m;
|
|
2709
|
+
});
|
|
2710
|
+
if (wrote) {
|
|
2711
|
+
try { store._mirrorJsonFromSql(metricsPath); } catch { /* mirror best-effort */ }
|
|
2712
|
+
try { require('./db-events').emitStateEvent('metrics'); } catch { /* optional */ }
|
|
2713
|
+
}
|
|
2714
|
+
return result;
|
|
2715
|
+
} catch (e) {
|
|
2716
|
+
if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
|
|
2717
|
+
throw e;
|
|
2718
|
+
}
|
|
2719
|
+
// SQLite unavailable — fall through to legacy JSON path.
|
|
2720
|
+
}
|
|
2721
|
+
return mutateJsonFileLocked(metricsPath, (metrics) => {
|
|
2722
|
+
if (!metrics || typeof metrics !== 'object') metrics = {};
|
|
2723
|
+
return mutator(metrics) || metrics;
|
|
2724
|
+
}, {
|
|
2725
|
+
defaultValue: {},
|
|
2726
|
+
onWrote: () => {
|
|
2727
|
+
try { require('./db-events').emitStateEvent('metrics'); } catch { /* optional */ }
|
|
2728
|
+
},
|
|
2729
|
+
});
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2693
2732
|
/** Update per-agent review metrics (prsApproved/prsRejected). Only writes for configured agents. */
|
|
2694
2733
|
function trackReviewMetric(pr, newReviewStatus, config) {
|
|
2695
2734
|
if (newReviewStatus !== 'approved' && newReviewStatus !== 'changes-requested') return;
|
|
2696
2735
|
const authorId = (pr.agent || '').toLowerCase();
|
|
2697
2736
|
if (!authorId || !config?.agents?.[authorId]) return;
|
|
2698
2737
|
try {
|
|
2699
|
-
|
|
2738
|
+
mutateMetrics((metrics) => {
|
|
2700
2739
|
if (!metrics[authorId]) metrics[authorId] = { ...DEFAULT_AGENT_METRICS };
|
|
2701
2740
|
if (newReviewStatus === 'approved') metrics[authorId].prsApproved = (metrics[authorId].prsApproved || 0) + 1;
|
|
2702
2741
|
else metrics[authorId].prsRejected = (metrics[authorId].prsRejected || 0) + 1;
|
|
@@ -5127,7 +5166,7 @@ module.exports = {
|
|
|
5127
5166
|
runtimeConfigWarnings,
|
|
5128
5167
|
projectWorkSourceWarnings,
|
|
5129
5168
|
backfillProjectWorkSourceDefaults,
|
|
5130
|
-
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, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject,
|
|
5169
|
+
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,
|
|
5131
5170
|
WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS, WATCH_ACTION_TYPE,
|
|
5132
5171
|
WATCH_STALLED_DEFAULT_TICKS, WATCH_STUCK_STAGE_DEFAULT_TICKS,
|
|
5133
5172
|
PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
|
|
@@ -116,24 +116,28 @@ 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
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
122
|
-
|
|
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;
|
|
123
129
|
// No mirror record but JSON exists: first-time hydrate iff SQL is empty
|
|
124
130
|
// for this scope (avoid trampling a freshly-backfilled migration state
|
|
125
131
|
// on the very first read).
|
|
126
|
-
if (
|
|
132
|
+
if (lastMirror == null) {
|
|
127
133
|
const sqlHas = db.prepare('SELECT 1 FROM work_items WHERE scope = ? LIMIT 1').get(scope);
|
|
128
134
|
if (sqlHas) {
|
|
129
|
-
|
|
130
|
-
// detected, but don't rebuild — migration already populated SQL.
|
|
131
|
-
_lastMirrorMtimeByScope.set(scope, currentMtime);
|
|
135
|
+
_lastMirrorByScope.set(scope, current);
|
|
132
136
|
return;
|
|
133
137
|
}
|
|
134
138
|
}
|
|
135
139
|
_hydrateScopeFromJson(db, scope);
|
|
136
|
-
|
|
140
|
+
_lastMirrorByScope.set(scope, current);
|
|
137
141
|
}
|
|
138
142
|
|
|
139
143
|
// Read all rows across all scopes — used by queries.getWorkItems which
|
|
@@ -239,17 +243,19 @@ function _applyWorkItemsDiff(db, diff) {
|
|
|
239
243
|
// wrote — true iff at least one INSERT/UPDATE/DELETE landed
|
|
240
244
|
// result — the post-mutation array (legacy return shape of
|
|
241
245
|
// mutateJsonFileLocked)
|
|
242
|
-
// In-process record of the JSON mirror
|
|
243
|
-
// scope.
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
const _lastMirrorMtimeByScope = new Map();
|
|
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();
|
|
250
253
|
|
|
251
|
-
function
|
|
252
|
-
try {
|
|
254
|
+
function _statFingerprint(filePath) {
|
|
255
|
+
try {
|
|
256
|
+
const st = fs.statSync(filePath);
|
|
257
|
+
return { mtime: st.mtimeMs, size: st.size };
|
|
258
|
+
}
|
|
253
259
|
catch { return null; }
|
|
254
260
|
}
|
|
255
261
|
|
|
@@ -325,10 +331,11 @@ function _mirrorJsonFromSql(scope, filePath) {
|
|
|
325
331
|
for (const wi of items) { if (wi && wi._source) delete wi._source; }
|
|
326
332
|
const target = filePath || _filePathForScope(scope);
|
|
327
333
|
shared.safeWrite(target, items);
|
|
328
|
-
// Record
|
|
329
|
-
// an external edit
|
|
330
|
-
|
|
331
|
-
|
|
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);
|
|
332
339
|
} catch {
|
|
333
340
|
// Mirror failures are non-fatal: SQL has already committed.
|
|
334
341
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2071",
|
|
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"
|