@yemi33/minions 0.1.291 → 0.1.293
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 +6 -1
- package/engine/cleanup.js +112 -26
- package/engine/shared.js +37 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.293 (2026-04-03)
|
|
4
4
|
|
|
5
5
|
### Features
|
|
6
|
+
- fix cleanup.js — worktree TOCTOU, readdirSync isolation, KB restore verify
|
|
7
|
+
- harden shared.js — backup verification, lock TOCTOU, docs
|
|
6
8
|
- all doc-chats use Sonnet with full tools (agent change)
|
|
7
9
|
|
|
8
10
|
### Fixes
|
|
11
|
+
- address review feedback on PR-122
|
|
12
|
+
- add behavioral tests for CRITICAL propagation and stale lock ENOENT
|
|
13
|
+
- CRITICAL errors in safeJson now propagate to callers
|
|
9
14
|
- defer plan archiving until verify completes, add 20 verify tests
|
|
10
15
|
- enforce worktree isolation — 4 code paths fixed
|
|
11
16
|
- ' not 'Evaluate:'
|
package/engine/cleanup.js
CHANGED
|
@@ -28,6 +28,17 @@ function engine() { if (!_engine) _engine = require('../engine'); return _engine
|
|
|
28
28
|
let _dispatch = null;
|
|
29
29
|
function dispatchModule() { if (!_dispatch) _dispatch = require('./dispatch'); return _dispatch; }
|
|
30
30
|
|
|
31
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Check if a worktree directory name matches a branch via sanitized slug comparison.
|
|
35
|
+
* Eliminates 3x duplication of the branch matching logic (review feedback: Rebecca).
|
|
36
|
+
*/
|
|
37
|
+
function worktreeDirMatchesBranch(dirLower, branch) {
|
|
38
|
+
const branchSlug = sanitizeBranch(branch).toLowerCase();
|
|
39
|
+
return dirLower === branchSlug || dirLower.includes(branchSlug + '-') || dirLower.endsWith('-' + branchSlug);
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
// ─── Cleanup Orchestrator ────────────────────────────────────────────────────
|
|
32
43
|
|
|
33
44
|
function runCleanup(config, verbose = false) {
|
|
@@ -37,27 +48,33 @@ function runCleanup(config, verbose = false) {
|
|
|
37
48
|
|
|
38
49
|
// 1. Clean stale temp prompt/sysprompt files and orphaned safeWrite .tmp.* files (older than 1 hour)
|
|
39
50
|
const oneHourAgo = Date.now() - 3600000;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
51
|
+
const tmpDir = path.join(ENGINE_DIR, 'tmp');
|
|
52
|
+
const scanDirs = [ENGINE_DIR, ...(fs.existsSync(tmpDir) ? [tmpDir] : [])];
|
|
53
|
+
for (const dir of scanDirs) {
|
|
54
|
+
// Each directory gets its own try-catch so one failure doesn't abort other directories (Bug #27)
|
|
55
|
+
let dirEntries;
|
|
56
|
+
try {
|
|
57
|
+
dirEntries = fs.readdirSync(dir);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
log('warn', `cleanup temp files: failed to read ${dir} — ${e.message}`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
for (const f of dirEntries) {
|
|
63
|
+
const isPromptTemp = f.startsWith('prompt-') || f.startsWith('sysprompt-') || f.startsWith('tmp-sysprompt-');
|
|
64
|
+
const isSafeWriteTemp = /\.tmp\.\d+\.\d+$/.test(f);
|
|
65
|
+
if (isPromptTemp || isSafeWriteTemp) {
|
|
66
|
+
const fp = path.join(dir, f);
|
|
67
|
+
try {
|
|
68
|
+
const stat = fs.statSync(fp);
|
|
69
|
+
if (stat.mtimeMs < oneHourAgo) {
|
|
70
|
+
fs.unlinkSync(fp);
|
|
71
|
+
cleaned.tempFiles++;
|
|
72
|
+
if (isSafeWriteTemp) log('info', `Cleaned orphaned temp file: ${f}`);
|
|
73
|
+
}
|
|
74
|
+
} catch { /* cleanup */ }
|
|
58
75
|
}
|
|
59
76
|
}
|
|
60
|
-
}
|
|
77
|
+
}
|
|
61
78
|
|
|
62
79
|
// 2. Clean live-output.log for idle agents (not currently working)
|
|
63
80
|
for (const [agentId] of Object.entries(config.agents || {})) {
|
|
@@ -134,8 +151,7 @@ function runCleanup(config, verbose = false) {
|
|
|
134
151
|
// Use sanitized exact match on the branch portion of the dir name (format: {slug}-{branch}-{suffix})
|
|
135
152
|
const dirLower = dir.toLowerCase();
|
|
136
153
|
for (const branch of mergedBranches) {
|
|
137
|
-
|
|
138
|
-
if (dirLower === branchSlug || dirLower.includes(branchSlug + '-') || dirLower.endsWith('-' + branchSlug)) {
|
|
154
|
+
if (worktreeDirMatchesBranch(dirLower, branch)) {
|
|
139
155
|
shouldClean = true;
|
|
140
156
|
break;
|
|
141
157
|
}
|
|
@@ -202,8 +218,38 @@ function runCleanup(config, verbose = false) {
|
|
|
202
218
|
}
|
|
203
219
|
|
|
204
220
|
// Remove all marked worktrees
|
|
221
|
+
// Re-read PR status immediately before deletion — a PR can be reopened between
|
|
222
|
+
// the initial status check and the actual deletion (Bug #15: TOCTOU race)
|
|
223
|
+
const freshPrs = safeJson(projectPrPath(project)) || [];
|
|
224
|
+
const freshMergedBranches = new Set();
|
|
225
|
+
for (const pr of freshPrs) {
|
|
226
|
+
if (pr.status === 'merged' || pr.status === 'abandoned' || pr.status === 'completed') {
|
|
227
|
+
if (pr.branch) freshMergedBranches.add(pr.branch);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
205
231
|
for (const entry of wtEntries) {
|
|
206
232
|
if (entry.shouldClean) {
|
|
233
|
+
// Verify the branch is still merged/closed — skip if PR was reopened since initial check
|
|
234
|
+
const entryDirLower = entry.dir.toLowerCase();
|
|
235
|
+
let stillMerged = false;
|
|
236
|
+
for (const branch of freshMergedBranches) {
|
|
237
|
+
if (worktreeDirMatchesBranch(entryDirLower, branch)) {
|
|
238
|
+
stillMerged = true;
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// If originally marked due to merged branch but PR was reopened, skip deletion
|
|
243
|
+
if (!stillMerged) {
|
|
244
|
+
// Check if it was marked for age/cap cleanup (not branch-based) — those are still valid
|
|
245
|
+
const wasMarkedByBranch = [...mergedBranches].some(branch => worktreeDirMatchesBranch(entryDirLower, branch));
|
|
246
|
+
if (wasMarkedByBranch) {
|
|
247
|
+
if (verbose) console.log(` Skipping worktree ${entry.dir}: PR was reopened since initial check`);
|
|
248
|
+
log('info', `Worktree deletion skipped — PR reopened: ${entry.dir}`);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
207
253
|
try {
|
|
208
254
|
exec(`git worktree remove "${entry.wtPath}" --force`, { cwd: root, stdio: 'pipe', timeout: 30000 });
|
|
209
255
|
cleaned.worktrees++;
|
|
@@ -312,7 +358,14 @@ function runCleanup(config, verbose = false) {
|
|
|
312
358
|
const sweptDir = path.join(MINIONS_DIR, 'knowledge', '_swept');
|
|
313
359
|
if (fs.existsSync(sweptDir)) {
|
|
314
360
|
const sevenDaysAgo = Date.now() - 7 * 86400000;
|
|
315
|
-
|
|
361
|
+
let sweptEntries;
|
|
362
|
+
try {
|
|
363
|
+
sweptEntries = fs.readdirSync(sweptDir);
|
|
364
|
+
} catch (e) {
|
|
365
|
+
log('warn', `cleanup swept KB: failed to read ${sweptDir} — ${e.message}`);
|
|
366
|
+
sweptEntries = [];
|
|
367
|
+
}
|
|
368
|
+
for (const f of sweptEntries) {
|
|
316
369
|
try {
|
|
317
370
|
const fp = path.join(sweptDir, f);
|
|
318
371
|
if (fs.statSync(fp).mtimeMs < sevenDaysAgo) {
|
|
@@ -334,7 +387,11 @@ function runCleanup(config, verbose = false) {
|
|
|
334
387
|
let current = 0;
|
|
335
388
|
for (const cat of cats) {
|
|
336
389
|
const d = path.join(knowledgeDir, cat);
|
|
337
|
-
|
|
390
|
+
try {
|
|
391
|
+
if (fs.existsSync(d)) current += fs.readdirSync(d).length;
|
|
392
|
+
} catch (e) {
|
|
393
|
+
log('warn', `KB watchdog: failed to read ${cat} directory — ${e.message}`);
|
|
394
|
+
}
|
|
338
395
|
}
|
|
339
396
|
if (current < checkpoint.count) {
|
|
340
397
|
log('warn', `KB watchdog: file count dropped ${checkpoint.count} → ${current}, restoring from git`);
|
|
@@ -343,8 +400,29 @@ function runCleanup(config, verbose = false) {
|
|
|
343
400
|
if (!trackedCheck) {
|
|
344
401
|
log('warn', 'KB watchdog: knowledge/ is not tracked in git HEAD — skipping restore');
|
|
345
402
|
} else {
|
|
346
|
-
|
|
347
|
-
|
|
403
|
+
// Bug #29: Check exit code and verify restore succeeded
|
|
404
|
+
let restoreOutput;
|
|
405
|
+
try {
|
|
406
|
+
restoreOutput = execSilent('git checkout HEAD -- knowledge', { cwd: MINIONS_DIR });
|
|
407
|
+
} catch (restoreErr) {
|
|
408
|
+
log('warn', `KB watchdog: git checkout exited with error — ${restoreErr.message}`);
|
|
409
|
+
restoreOutput = null;
|
|
410
|
+
}
|
|
411
|
+
if (restoreOutput !== null) {
|
|
412
|
+
// Verify the restore actually recovered files
|
|
413
|
+
let postRestoreCount = 0;
|
|
414
|
+
for (const cat of cats) {
|
|
415
|
+
const d = path.join(knowledgeDir, cat);
|
|
416
|
+
try {
|
|
417
|
+
if (fs.existsSync(d)) postRestoreCount += fs.readdirSync(d).length;
|
|
418
|
+
} catch { /* count what we can */ }
|
|
419
|
+
}
|
|
420
|
+
if (postRestoreCount < checkpoint.count) {
|
|
421
|
+
log('warn', `KB watchdog: restore incomplete — expected ${checkpoint.count} files, got ${postRestoreCount}`);
|
|
422
|
+
} else {
|
|
423
|
+
log('info', `KB watchdog: restored knowledge/ from git HEAD (${postRestoreCount} files)`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
348
426
|
}
|
|
349
427
|
} catch (err) {
|
|
350
428
|
log('error', `KB watchdog: git restore failed — ${err.message}`);
|
|
@@ -393,7 +471,14 @@ function runCleanup(config, verbose = false) {
|
|
|
393
471
|
} catch (e) { log('warn', 'migrate central legacy statuses: ' + e.message); }
|
|
394
472
|
// PRD items (missing_features[].status)
|
|
395
473
|
try {
|
|
396
|
-
|
|
474
|
+
let prdDirEntries;
|
|
475
|
+
try {
|
|
476
|
+
prdDirEntries = fs.readdirSync(PRD_DIR);
|
|
477
|
+
} catch (e) {
|
|
478
|
+
log('warn', `migrate PRD statuses: failed to read ${PRD_DIR} — ${e.message}`);
|
|
479
|
+
prdDirEntries = [];
|
|
480
|
+
}
|
|
481
|
+
const prdFiles = prdDirEntries.filter(f => f.endsWith('.json'));
|
|
397
482
|
for (const pf of prdFiles) {
|
|
398
483
|
const prdPath = path.join(PRD_DIR, pf);
|
|
399
484
|
const prd = safeJson(prdPath);
|
|
@@ -419,4 +504,5 @@ function runCleanup(config, verbose = false) {
|
|
|
419
504
|
|
|
420
505
|
module.exports = {
|
|
421
506
|
runCleanup,
|
|
507
|
+
worktreeDirMatchesBranch, // exported for testing
|
|
422
508
|
};
|
package/engine/shared.js
CHANGED
|
@@ -52,14 +52,37 @@ function safeJson(p) {
|
|
|
52
52
|
const backupData = JSON.parse(fs.readFileSync(backupPath, 'utf8'));
|
|
53
53
|
// Backup is valid — restore it to the primary file (atomic via safeWrite)
|
|
54
54
|
console.log(`[safeJson] restored ${path.basename(p)} from .backup sidecar`);
|
|
55
|
-
try {
|
|
55
|
+
try {
|
|
56
|
+
safeWrite(p, backupData);
|
|
57
|
+
// Verify the restored file matches expected content
|
|
58
|
+
const verifyData = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
59
|
+
if (JSON.stringify(verifyData) !== JSON.stringify(backupData)) {
|
|
60
|
+
const errMsg = `[safeJson] CRITICAL: backup restore verification failed for ${p} — written data does not match backup`;
|
|
61
|
+
console.error(errMsg);
|
|
62
|
+
throw new Error(errMsg);
|
|
63
|
+
}
|
|
64
|
+
} catch (restoreErr) {
|
|
65
|
+
// Re-throw CRITICAL errors so they propagate to callers
|
|
66
|
+
if (restoreErr.message && restoreErr.message.includes('CRITICAL')) throw restoreErr;
|
|
67
|
+
const errMsg = `[safeJson] CRITICAL: backup restore failed for ${p}: ${restoreErr.message}`;
|
|
68
|
+
console.error(errMsg);
|
|
69
|
+
throw new Error(errMsg);
|
|
70
|
+
}
|
|
56
71
|
return backupData;
|
|
57
|
-
} catch {
|
|
72
|
+
} catch (outerErr) {
|
|
73
|
+
// Let CRITICAL errors propagate — callers must know about data integrity failures
|
|
74
|
+
if (outerErr.message && outerErr.message.includes('CRITICAL')) throw outerErr;
|
|
58
75
|
return null;
|
|
59
76
|
}
|
|
60
77
|
}
|
|
61
78
|
}
|
|
62
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Monotonic counter for generating unique temp file names within this process.
|
|
82
|
+
* Assumes single-thread execution (no worker_threads). If worker_threads are
|
|
83
|
+
* introduced, this must be replaced with an atomic or thread-safe counter to
|
|
84
|
+
* avoid temp file name collisions.
|
|
85
|
+
*/
|
|
63
86
|
let _tmpCounter = 0;
|
|
64
87
|
|
|
65
88
|
function safeWrite(p, data) {
|
|
@@ -129,10 +152,19 @@ function withFileLock(lockPath, fn, {
|
|
|
129
152
|
try {
|
|
130
153
|
const stat = fs.statSync(lockPath);
|
|
131
154
|
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
|
|
132
|
-
try {
|
|
133
|
-
|
|
155
|
+
try {
|
|
156
|
+
fs.unlinkSync(lockPath);
|
|
157
|
+
} catch (unlinkErr) {
|
|
158
|
+
// ENOENT: another process deleted the lock between stat and unlink — safe to retry
|
|
159
|
+
if (unlinkErr.code !== 'ENOENT') throw unlinkErr;
|
|
160
|
+
}
|
|
161
|
+
sleepMs(retryDelayMs); // avoid busy-loop on contention
|
|
162
|
+
continue;
|
|
134
163
|
}
|
|
135
|
-
} catch {
|
|
164
|
+
} catch (staleErr) {
|
|
165
|
+
// ENOENT from statSync: lock file disappeared between EEXIST and stat — retry will succeed
|
|
166
|
+
if (staleErr.code !== 'ENOENT') throw staleErr;
|
|
167
|
+
}
|
|
136
168
|
sleepMs(retryDelayMs);
|
|
137
169
|
}
|
|
138
170
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.293",
|
|
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"
|