@yemi33/minions 0.1.293 → 0.1.295
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/CHANGELOG.md +4 -1
- package/dashboard.js +102 -98
- package/engine/shared.js +5 -10
- package/engine.js +18 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.295 (2026-04-03)
|
|
4
4
|
|
|
5
5
|
### Features
|
|
6
|
+
- fix engine.js race conditions — worktree TOCTOU, self-heal, dispatch dedup
|
|
7
|
+
- fix dashboard.js race conditions, input validation, and watcher leaks
|
|
6
8
|
- fix cleanup.js — worktree TOCTOU, readdirSync isolation, KB restore verify
|
|
7
9
|
- harden shared.js — backup verification, lock TOCTOU, docs
|
|
8
10
|
- all doc-chats use Sonnet with full tools (agent change)
|
|
9
11
|
|
|
10
12
|
### Fixes
|
|
13
|
+
- address PR-124 review feedback — safeJson regression, read-only lock, filter cleanup
|
|
11
14
|
- address review feedback on PR-122
|
|
12
15
|
- add behavioral tests for CRITICAL propagation and stale lock ENOENT
|
|
13
16
|
- CRITICAL errors in safeJson now propagate to callers
|
package/dashboard.js
CHANGED
|
@@ -1172,28 +1172,25 @@ const server = http.createServer(async (req, res) => {
|
|
|
1172
1172
|
if (!body.source || !body.itemId) return jsonReply(res, 400, { error: 'source and itemId required' });
|
|
1173
1173
|
const planPath = resolvePlanPath(body.source);
|
|
1174
1174
|
if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
|
|
1175
|
-
|
|
1176
|
-
const
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
if (body.status !== undefined) freshItem.status = body.status;
|
|
1195
|
-
}
|
|
1196
|
-
safeWrite(planPath, freshPlan);
|
|
1175
|
+
// Pre-check: verify item exists before taking the lock
|
|
1176
|
+
const preCheck = safeJson(planPath);
|
|
1177
|
+
const preItem = (preCheck.missing_features || []).find(f => f.id === body.itemId);
|
|
1178
|
+
if (!preItem) return jsonReply(res, 404, { error: 'item not found in plan' });
|
|
1179
|
+
|
|
1180
|
+
// Atomically read-modify-write under file lock
|
|
1181
|
+
let item;
|
|
1182
|
+
mutateJsonFileLocked(planPath, (plan) => {
|
|
1183
|
+
const target = (plan.missing_features || []).find(f => f.id === body.itemId);
|
|
1184
|
+
if (target) {
|
|
1185
|
+
if (body.name !== undefined) target.name = body.name;
|
|
1186
|
+
if (body.description !== undefined) target.description = body.description;
|
|
1187
|
+
if (body.priority !== undefined) target.priority = body.priority;
|
|
1188
|
+
if (body.estimated_complexity !== undefined) target.estimated_complexity = body.estimated_complexity;
|
|
1189
|
+
if (body.status !== undefined) target.status = body.status;
|
|
1190
|
+
item = target;
|
|
1191
|
+
}
|
|
1192
|
+
return plan;
|
|
1193
|
+
}, { defaultValue: preCheck });
|
|
1197
1194
|
|
|
1198
1195
|
// Feature 3: Sync edits to materialized work item if still pending
|
|
1199
1196
|
let workItemSynced = false;
|
|
@@ -1365,24 +1362,31 @@ const server = http.createServer(async (req, res) => {
|
|
|
1365
1362
|
|
|
1366
1363
|
fs.watchFile(liveLogPath, { interval: 500 }, watcher);
|
|
1367
1364
|
|
|
1365
|
+
// Cleanup helper to prevent handle leaks
|
|
1366
|
+
const cleanup = () => {
|
|
1367
|
+
try { clearInterval(doneCheck); } catch { /* optional */ }
|
|
1368
|
+
try { fs.unwatchFile(liveLogPath, watcher); } catch { /* optional */ }
|
|
1369
|
+
};
|
|
1370
|
+
|
|
1368
1371
|
// Check if agent is still active (poll every 5s)
|
|
1369
1372
|
const doneCheck = setInterval(() => {
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1373
|
+
try {
|
|
1374
|
+
const dispatch = getDispatchQueue();
|
|
1375
|
+
const isActive = (dispatch.active || []).some(d => d.agent === agentId);
|
|
1376
|
+
if (!isActive) {
|
|
1377
|
+
watcher(); // flush final content
|
|
1378
|
+
res.write(`event: done\ndata: complete\n\n`);
|
|
1379
|
+
cleanup();
|
|
1380
|
+
res.end();
|
|
1381
|
+
}
|
|
1382
|
+
} catch (e) {
|
|
1383
|
+
cleanup();
|
|
1384
|
+
try { res.end(); } catch { /* optional */ }
|
|
1378
1385
|
}
|
|
1379
1386
|
}, 5000);
|
|
1380
1387
|
|
|
1381
1388
|
// Cleanup on client disconnect
|
|
1382
|
-
req.on('close',
|
|
1383
|
-
clearInterval(doneCheck);
|
|
1384
|
-
fs.unwatchFile(liveLogPath, watcher);
|
|
1385
|
-
});
|
|
1389
|
+
req.on('close', cleanup);
|
|
1386
1390
|
|
|
1387
1391
|
return;
|
|
1388
1392
|
}
|
|
@@ -1398,7 +1402,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1398
1402
|
} else {
|
|
1399
1403
|
// Return last N bytes via ?tail=N param (default last 8KB)
|
|
1400
1404
|
const params = new URL(req.url, 'http://localhost').searchParams;
|
|
1401
|
-
const
|
|
1405
|
+
const rawTail = parseInt(params.get('tail'));
|
|
1406
|
+
if (params.has('tail') && isNaN(rawTail)) return jsonReply(res, 400, { error: 'tail must be a number' });
|
|
1407
|
+
const tailBytes = isNaN(rawTail) ? 8192 : Math.max(1, Math.min(10000, rawTail));
|
|
1402
1408
|
res.end(content.length > tailBytes ? content.slice(-tailBytes) : content);
|
|
1403
1409
|
}
|
|
1404
1410
|
return;
|
|
@@ -1425,7 +1431,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1425
1431
|
async function handleNotesSave(req, res) {
|
|
1426
1432
|
try {
|
|
1427
1433
|
const body = await readBody(req);
|
|
1428
|
-
if (
|
|
1434
|
+
if (body.content == null) return jsonReply(res, 400, { error: 'content required' });
|
|
1429
1435
|
const file = body.file || 'notes.md';
|
|
1430
1436
|
// Only allow saving notes.md (prevent arbitrary file writes)
|
|
1431
1437
|
if (file !== 'notes.md') return jsonReply(res, 400, { error: 'only notes.md can be edited' });
|
|
@@ -1819,74 +1825,72 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
|
|
|
1819
1825
|
wiPaths.push(shared.projectWorkItemsPath(proj));
|
|
1820
1826
|
}
|
|
1821
1827
|
const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
|
|
1822
|
-
const dispatch = JSON.parse(safeRead(dispatchPath) || '{}');
|
|
1823
1828
|
const killedAgents = new Set();
|
|
1824
1829
|
const resetItemIds = new Set();
|
|
1825
1830
|
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
const
|
|
1841
|
-
|
|
1842
|
-
const
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1831
|
+
// Read dispatch inside the lock so PID list is consistent with state being modified
|
|
1832
|
+
mutateJsonFileLocked(dispatchPath, (dispatch) => {
|
|
1833
|
+
for (const wiPath of wiPaths) {
|
|
1834
|
+
try {
|
|
1835
|
+
const items = safeJson(wiPath);
|
|
1836
|
+
if (!items) continue;
|
|
1837
|
+
let changed = false;
|
|
1838
|
+
for (const w of items) {
|
|
1839
|
+
if (w.sourcePlan !== body.file) continue;
|
|
1840
|
+
// Keep completed items as-is, reset everything else to pending.
|
|
1841
|
+
if (w.status === 'done' || w.status === 'implemented' || w.status === 'complete' || w.status === 'in-pr') continue;
|
|
1842
|
+
|
|
1843
|
+
if (w.status === 'dispatched') {
|
|
1844
|
+
// Kill the agent working on this item, if any.
|
|
1845
|
+
const activeEntry = (dispatch.active || []).find(d => d.meta?.item?.id === w.id || d.meta?.dispatchKey?.includes(w.id));
|
|
1846
|
+
if (activeEntry) {
|
|
1847
|
+
const statusPath = path.join(MINIONS_DIR, 'agents', activeEntry.agent, 'status.json');
|
|
1848
|
+
try {
|
|
1849
|
+
const agentStatus = JSON.parse(safeRead(statusPath) || '{}');
|
|
1850
|
+
if (agentStatus.pid) {
|
|
1851
|
+
try {
|
|
1852
|
+
const safePid = shared.validatePid(agentStatus.pid);
|
|
1853
|
+
if (process.platform === 'win32') {
|
|
1854
|
+
require('child_process').execFileSync('taskkill', ['/PID', String(safePid), '/F', '/T'], { stdio: 'pipe', timeout: 5000, windowsHide: true });
|
|
1855
|
+
} else {
|
|
1856
|
+
process.kill(safePid, 'SIGTERM');
|
|
1857
|
+
}
|
|
1858
|
+
} catch { /* process may be dead or invalid PID */ }
|
|
1859
|
+
}
|
|
1860
|
+
agentStatus.status = 'idle';
|
|
1861
|
+
delete agentStatus.currentTask;
|
|
1862
|
+
delete agentStatus.dispatched;
|
|
1863
|
+
safeWrite(statusPath, agentStatus);
|
|
1864
|
+
} catch (e) { console.error('agent reset:', e.message); }
|
|
1865
|
+
killedAgents.add(activeEntry.agent);
|
|
1866
|
+
}
|
|
1859
1867
|
}
|
|
1860
|
-
}
|
|
1861
1868
|
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1869
|
+
if (w.status !== 'pending') reset++;
|
|
1870
|
+
w.status = 'pending';
|
|
1871
|
+
delete w._pausedBy;
|
|
1872
|
+
delete w._resumedAt;
|
|
1873
|
+
delete w.dispatched_at;
|
|
1874
|
+
delete w.dispatched_to;
|
|
1875
|
+
delete w.failReason;
|
|
1876
|
+
delete w.failedAt;
|
|
1877
|
+
changed = true;
|
|
1878
|
+
if (w.id) resetItemIds.add(w.id);
|
|
1879
|
+
}
|
|
1880
|
+
if (changed) safeWrite(wiPath, items);
|
|
1881
|
+
} catch (e) { console.error('reset work items:', e.message); }
|
|
1882
|
+
}
|
|
1876
1883
|
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
return dp;
|
|
1888
|
-
}, { defaultValue: { pending: [], active: [], completed: [] } });
|
|
1889
|
-
}
|
|
1884
|
+
// Remove dispatch active entries for reset items or killed agents.
|
|
1885
|
+
dispatch.active = Array.isArray(dispatch.active) ? dispatch.active : [];
|
|
1886
|
+
dispatch.active = dispatch.active.filter(d => {
|
|
1887
|
+
const itemId = d.meta?.item?.id;
|
|
1888
|
+
if (itemId && resetItemIds.has(itemId)) return false;
|
|
1889
|
+
if (killedAgents.has(d.agent)) return false;
|
|
1890
|
+
return true;
|
|
1891
|
+
});
|
|
1892
|
+
return dispatch;
|
|
1893
|
+
}, { defaultValue: { pending: [], active: [], completed: [] } });
|
|
1890
1894
|
|
|
1891
1895
|
invalidateStatusCache();
|
|
1892
1896
|
return jsonReply(res, 200, { ok: true, status: 'paused', resetWorkItems: reset });
|
package/engine/shared.js
CHANGED
|
@@ -57,16 +57,12 @@ function safeJson(p) {
|
|
|
57
57
|
// Verify the restored file matches expected content
|
|
58
58
|
const verifyData = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
59
59
|
if (JSON.stringify(verifyData) !== JSON.stringify(backupData)) {
|
|
60
|
-
|
|
61
|
-
console.error(errMsg);
|
|
62
|
-
throw new Error(errMsg);
|
|
60
|
+
console.error(`[safeJson] CRITICAL: backup restore verification failed for ${p} — written data does not match backup`);
|
|
63
61
|
}
|
|
64
62
|
} catch (restoreErr) {
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
console.error(errMsg);
|
|
69
|
-
throw new Error(errMsg);
|
|
63
|
+
// Restore-to-primary is best-effort — backupData is already parsed and valid.
|
|
64
|
+
// Don't throw: disk-full / permission errors should not discard valid data.
|
|
65
|
+
console.error(`[safeJson] restore write failed for ${p}: ${restoreErr.message}`);
|
|
70
66
|
}
|
|
71
67
|
return backupData;
|
|
72
68
|
} catch (outerErr) {
|
|
@@ -158,8 +154,7 @@ function withFileLock(lockPath, fn, {
|
|
|
158
154
|
// ENOENT: another process deleted the lock between stat and unlink — safe to retry
|
|
159
155
|
if (unlinkErr.code !== 'ENOENT') throw unlinkErr;
|
|
160
156
|
}
|
|
161
|
-
|
|
162
|
-
continue;
|
|
157
|
+
continue; // lock just removed — retry immediately
|
|
163
158
|
}
|
|
164
159
|
} catch (staleErr) {
|
|
165
160
|
// ENOENT from statSync: lock file disappeared between EEXIST and stat — retry will succeed
|
package/engine.js
CHANGED
|
@@ -91,6 +91,7 @@ const safeJson = shared.safeJson;
|
|
|
91
91
|
const safeRead = shared.safeRead;
|
|
92
92
|
const safeWrite = shared.safeWrite;
|
|
93
93
|
const mutateJsonFileLocked = shared.mutateJsonFileLocked;
|
|
94
|
+
const withFileLock = shared.withFileLock;
|
|
94
95
|
|
|
95
96
|
// ─── Dispatch Management (extracted to engine/dispatch.js) ───────────────────
|
|
96
97
|
|
|
@@ -352,10 +353,15 @@ function spawnAgent(dispatchItem, config) {
|
|
|
352
353
|
if (alreadyUsed) {
|
|
353
354
|
const existingWtPath = findExistingWorktree(rootDir, branchName);
|
|
354
355
|
if (existingWtPath && fs.existsSync(existingWtPath)) {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
356
|
+
// Bug fix: read dispatch under file lock so check-and-act is atomic
|
|
357
|
+
// Uses withFileLock directly (read-only) — no unnecessary disk write
|
|
358
|
+
let activelyUsed = false;
|
|
359
|
+
withFileLock(DISPATCH_PATH + '.lock', () => {
|
|
360
|
+
const dp = safeJson(DISPATCH_PATH) || {};
|
|
361
|
+
activelyUsed = (dp.active || []).some(d => {
|
|
362
|
+
const dBranch = d.meta?.branch ? sanitizeBranch(d.meta.branch) : '';
|
|
363
|
+
return dBranch === branchName && d.id !== id;
|
|
364
|
+
});
|
|
359
365
|
});
|
|
360
366
|
if (activelyUsed) {
|
|
361
367
|
log('warn', `Branch ${branchName} actively used by another agent at ${existingWtPath} — cannot create worktree`);
|
|
@@ -2354,9 +2360,17 @@ async function tickInner() {
|
|
|
2354
2360
|
// Only dispatch to agents that aren't already busy (one task per agent at a time).
|
|
2355
2361
|
// Build set of agents currently active.
|
|
2356
2362
|
const busyAgents = new Set((dispatch.active || []).map(d => d.agent));
|
|
2363
|
+
// Bug fix #14: deduplicate pending by dispatch ID to prevent double-dispatch.
|
|
2364
|
+
// This guards against the same item appearing twice in the in-memory pending array.
|
|
2365
|
+
const seenPendingIds = new Set();
|
|
2357
2366
|
const toDispatch = [];
|
|
2358
2367
|
for (const item of dispatch.pending) {
|
|
2359
2368
|
if (toDispatch.length >= slotsAvailable) break;
|
|
2369
|
+
if (seenPendingIds.has(item.id)) {
|
|
2370
|
+
log('warn', `Duplicate dispatch ID ${item.id} in pending queue — skipping`);
|
|
2371
|
+
continue;
|
|
2372
|
+
}
|
|
2373
|
+
seenPendingIds.add(item.id);
|
|
2360
2374
|
if (busyAgents.has(item.agent)) continue; // agent already has an active task
|
|
2361
2375
|
toDispatch.push(item);
|
|
2362
2376
|
busyAgents.add(item.agent); // mark busy for this dispatch round too
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.295",
|
|
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"
|