@yemi33/minions 0.1.1763 → 0.1.1765
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 +18 -0
- package/bin/minions.js +59 -24
- package/dashboard/js/render-meetings.js +22 -3
- package/dashboard.js +99 -16
- package/engine/cleanup.js +8 -3
- package/engine/cli.js +10 -2
- package/engine/copilot-models.json +1 -1
- package/engine/lifecycle.js +9 -3
- package/engine/pipeline.js +6 -3
- package/engine/preflight.js +28 -6
- package/engine/runtimes/claude.js +51 -1
- package/engine/runtimes/copilot.js +77 -1
- package/engine/shared.js +48 -7
- package/engine.js +62 -5
- package/minions.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.1765 (2026-05-07)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
- suppress duplicate browser tabs on restart/update
|
|
7
|
+
- pre-flight model compat check for ccCall/ccCallStreaming + Copilot resolveModel
|
|
8
|
+
- probe every registered runtime adapter, not just claude
|
|
9
|
+
|
|
10
|
+
### Fixes
|
|
11
|
+
- close remaining test isolation leaks + add end-of-run regression guard (#2152)
|
|
12
|
+
- surface legacy .minions dir removal in logs
|
|
13
|
+
- clear session.json when conversation jsonl missing (W-mouugzow00068741) (#2153)
|
|
14
|
+
- stop archived PRDs from resurrecting via safeJson .backup restore (#2144)
|
|
15
|
+
|
|
16
|
+
## 0.1.1764 (2026-05-07)
|
|
17
|
+
|
|
18
|
+
### Other
|
|
19
|
+
- Speed up meetings pagination
|
|
20
|
+
|
|
3
21
|
## 0.1.1762 (2026-05-07)
|
|
4
22
|
|
|
5
23
|
### Fixes
|
package/bin/minions.js
CHANGED
|
@@ -28,9 +28,11 @@ const os = require('os');
|
|
|
28
28
|
const { spawn, spawnSync, execSync } = require('child_process');
|
|
29
29
|
|
|
30
30
|
const PKG_ROOT = path.resolve(__dirname, '..');
|
|
31
|
+
const DASH_PORT = 7331;
|
|
31
32
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
33
|
+
/** Returns PIDs (as strings) of processes LISTENING on `port`. Empty on no match
|
|
34
|
+
* or when the platform tool (netstat/findstr/lsof) is unavailable. */
|
|
35
|
+
function getListeningPids(port) {
|
|
34
36
|
try {
|
|
35
37
|
if (process.platform === 'win32') {
|
|
36
38
|
const out = execSync(`netstat -ano | findstr ":${port} " | findstr LISTENING`, { encoding: 'utf8', timeout: 5000, windowsHide: true });
|
|
@@ -39,13 +41,25 @@ function killByPort(port) {
|
|
|
39
41
|
const pid = line.trim().split(/\s+/).pop();
|
|
40
42
|
if (pid && /^\d+$/.test(pid) && pid !== '0' && pid !== String(process.pid)) pids.add(pid);
|
|
41
43
|
}
|
|
42
|
-
|
|
43
|
-
} else {
|
|
44
|
-
execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null`, { timeout: 5000 });
|
|
44
|
+
return [...pids];
|
|
45
45
|
}
|
|
46
|
-
|
|
46
|
+
const out = execSync(`lsof -ti:${port}`, { encoding: 'utf8', timeout: 5000 });
|
|
47
|
+
return out.split('\n').map(s => s.trim()).filter(Boolean);
|
|
48
|
+
} catch { return []; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Kill process(es) listening on a given port. Works cross-platform. */
|
|
52
|
+
function killByPort(port) {
|
|
53
|
+
const pids = getListeningPids(port);
|
|
54
|
+
if (process.platform === 'win32') {
|
|
55
|
+
for (const pid of pids) try { execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore', timeout: 5000, windowsHide: true }); } catch {}
|
|
56
|
+
} else {
|
|
57
|
+
for (const pid of pids) try { process.kill(Number(pid), 'SIGKILL'); } catch {}
|
|
58
|
+
}
|
|
47
59
|
}
|
|
48
60
|
|
|
61
|
+
const isPortListening = (port) => getListeningPids(port).length > 0;
|
|
62
|
+
|
|
49
63
|
/**
|
|
50
64
|
* Read the engine's recorded PID from engine/control.json. Returns null if
|
|
51
65
|
* the file is missing/corrupt or the PID isn't a positive integer.
|
|
@@ -102,6 +116,18 @@ function killMinionsProcesses(patterns) {
|
|
|
102
116
|
}
|
|
103
117
|
} catch {}
|
|
104
118
|
}
|
|
119
|
+
|
|
120
|
+
/** Spawn a detached dashboard. When `suppressOpen` is true, the new dashboard
|
|
121
|
+
* skips its auto-open of the browser — the existing tab will SSE-reconnect. */
|
|
122
|
+
function spawnDashboard(suppressOpen) {
|
|
123
|
+
const env = suppressOpen ? { ...process.env, MINIONS_NO_AUTO_OPEN: '1' } : process.env;
|
|
124
|
+
const proc = spawn(process.execPath, [path.join(MINIONS_HOME, 'dashboard.js')], {
|
|
125
|
+
cwd: MINIONS_HOME, stdio: 'ignore', detached: true, windowsHide: true, env
|
|
126
|
+
});
|
|
127
|
+
proc.unref();
|
|
128
|
+
return proc;
|
|
129
|
+
}
|
|
130
|
+
|
|
105
131
|
const DEFAULT_MINIONS_HOME = path.join(os.homedir(), '.minions');
|
|
106
132
|
const ROOT_POINTER_PATH = path.join(os.homedir(), '.minions-root');
|
|
107
133
|
const LEGACY_DEFAULT_SQUAD_HOME = path.join(os.homedir(), '.squad');
|
|
@@ -353,10 +379,17 @@ function init() {
|
|
|
353
379
|
showChangelog(installedVersion);
|
|
354
380
|
}
|
|
355
381
|
|
|
356
|
-
// Run preflight checks (warn only — don't block init)
|
|
382
|
+
// Run preflight checks (warn only — don't block init).
|
|
383
|
+
// includeAllRegistered: probe every registered runtime adapter (claude AND
|
|
384
|
+
// copilot today) so the user sees availability of both, even if their
|
|
385
|
+
// current config only references one. Missing optional runtimes surface as
|
|
386
|
+
// warns, not failures.
|
|
357
387
|
try {
|
|
358
388
|
const { runPreflight, printPreflight } = require(path.join(MINIONS_HOME, 'engine', 'preflight'));
|
|
359
|
-
|
|
389
|
+
let preflightConfig = null;
|
|
390
|
+
try { preflightConfig = JSON.parse(fs.readFileSync(path.join(MINIONS_HOME, 'config.json'), 'utf8')); }
|
|
391
|
+
catch { /* config may not exist on first init — fine, preflight handles null */ }
|
|
392
|
+
const { results } = runPreflight({ config: preflightConfig, includeAllRegistered: true });
|
|
360
393
|
printPreflight(results, { label: 'Preflight checks' });
|
|
361
394
|
} catch {}
|
|
362
395
|
|
|
@@ -364,8 +397,14 @@ function init() {
|
|
|
364
397
|
if (isUpgrade && skipStart) return;
|
|
365
398
|
|
|
366
399
|
// Auto-start on fresh install; direct force-upgrade restarts automatically.
|
|
400
|
+
// Probe before kill so we can suppress the new dashboard's auto-open when an
|
|
401
|
+
// existing tab is already live (it'll SSE-reconnect to the new dashboard).
|
|
402
|
+
const dashWasUp = isPortListening(DASH_PORT);
|
|
367
403
|
if (isUpgrade) {
|
|
368
404
|
try { execSync(`node "${path.join(MINIONS_HOME, 'engine.js')}" stop`, { stdio: 'ignore', cwd: MINIONS_HOME, timeout: 10000, windowsHide: true }); } catch {}
|
|
405
|
+
// Free the dashboard port too — without this the new dashboard EADDRINUSE-dies
|
|
406
|
+
// silently and the user keeps running stale code from the old dashboard process.
|
|
407
|
+
killByPort(DASH_PORT);
|
|
369
408
|
}
|
|
370
409
|
console.log(isUpgrade
|
|
371
410
|
? `\n Upgrade complete (${pkgVersion}). Restarting engine and dashboard...\n`
|
|
@@ -376,12 +415,9 @@ function init() {
|
|
|
376
415
|
engineProc.unref();
|
|
377
416
|
console.log(` Engine started (PID: ${engineProc.pid})`);
|
|
378
417
|
|
|
379
|
-
const dashProc =
|
|
380
|
-
cwd: MINIONS_HOME, stdio: 'ignore', detached: true, windowsHide: true
|
|
381
|
-
});
|
|
382
|
-
dashProc.unref();
|
|
418
|
+
const dashProc = spawnDashboard(dashWasUp);
|
|
383
419
|
console.log(` Dashboard started (PID: ${dashProc.pid})`);
|
|
384
|
-
console.log(
|
|
420
|
+
console.log(` Dashboard: http://localhost:${DASH_PORT}`);
|
|
385
421
|
|
|
386
422
|
// Next steps guidance
|
|
387
423
|
console.log(`
|
|
@@ -634,6 +670,9 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
634
670
|
// `--cli` / `--model` flags forward to `engine.js start` so the runtime
|
|
635
671
|
// fleet flips before the daemon spawns (P-6b3f9c2e AC: works on restart).
|
|
636
672
|
ensureInstalled();
|
|
673
|
+
// Probe before kill so we can suppress the new dashboard's auto-open when an
|
|
674
|
+
// existing tab is already live (it'll SSE-reconnect to the new dashboard).
|
|
675
|
+
const dashWasUp = isPortListening(DASH_PORT);
|
|
637
676
|
// Layered kill — each step is best-effort, layered so the next still runs if
|
|
638
677
|
// one fails. Goal: the old engine is gone before we spawn a new one, even if
|
|
639
678
|
// PowerShell is unavailable, the engine is hung, or its cmdline doesn't match.
|
|
@@ -644,7 +683,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
644
683
|
// survive so the new engine can re-attach them via PID files).
|
|
645
684
|
killPidOnly(oldEnginePid);
|
|
646
685
|
// 3. Free dashboard port (catches orphan dashboards with no recorded PID).
|
|
647
|
-
killByPort(
|
|
686
|
+
killByPort(DASH_PORT);
|
|
648
687
|
// 4. Belt-and-suspenders cmdline match for anything still alive.
|
|
649
688
|
killMinionsProcesses(['engine.js', 'dashboard.js']);
|
|
650
689
|
const engineProc = spawn(process.execPath, [path.join(MINIONS_HOME, 'engine.js'), 'start', ...rest], {
|
|
@@ -652,12 +691,9 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
652
691
|
});
|
|
653
692
|
engineProc.unref();
|
|
654
693
|
console.log(`\n Engine started (PID: ${engineProc.pid})`);
|
|
655
|
-
const dashProc =
|
|
656
|
-
cwd: MINIONS_HOME, stdio: 'ignore', detached: true, windowsHide: true
|
|
657
|
-
});
|
|
658
|
-
dashProc.unref();
|
|
694
|
+
const dashProc = spawnDashboard(dashWasUp);
|
|
659
695
|
console.log(` Dashboard started (PID: ${dashProc.pid})`);
|
|
660
|
-
console.log(
|
|
696
|
+
console.log(` Dashboard: http://localhost:${DASH_PORT}\n`);
|
|
661
697
|
} else if (cmd === 'nuke') {
|
|
662
698
|
ensureInstalled();
|
|
663
699
|
if (!rest.includes('--confirm')) {
|
|
@@ -688,7 +724,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
688
724
|
|
|
689
725
|
// 1. Kill all processes
|
|
690
726
|
try { execSync(`node "${path.join(MINIONS_HOME, 'engine.js')}" stop`, { stdio: 'ignore', cwd: MINIONS_HOME }); } catch {}
|
|
691
|
-
killByPort(
|
|
727
|
+
killByPort(DASH_PORT);
|
|
692
728
|
killMinionsProcesses(['engine.js', 'dashboard.js', 'spawn-agent.js']);
|
|
693
729
|
console.log(' Killed all processes');
|
|
694
730
|
|
|
@@ -774,7 +810,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
774
810
|
|
|
775
811
|
// 1. Kill all processes
|
|
776
812
|
try { execSync(`node "${path.join(MINIONS_HOME, 'engine.js')}" stop`, { stdio: 'ignore', cwd: MINIONS_HOME, timeout: 10000 }); } catch {}
|
|
777
|
-
killByPort(
|
|
813
|
+
killByPort(DASH_PORT);
|
|
778
814
|
killMinionsProcesses(['engine.js', 'dashboard.js', 'spawn-agent.js']);
|
|
779
815
|
console.log(' Killed all processes');
|
|
780
816
|
|
|
@@ -816,7 +852,6 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
816
852
|
ensureInstalled();
|
|
817
853
|
// If dashboard is already running, just open the browser
|
|
818
854
|
const net = require('net');
|
|
819
|
-
const dashPort = 7331;
|
|
820
855
|
const sock = new net.Socket();
|
|
821
856
|
let handled = false;
|
|
822
857
|
sock.setTimeout(1000);
|
|
@@ -824,7 +859,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
824
859
|
sock.destroy();
|
|
825
860
|
if (handled) return;
|
|
826
861
|
handled = true;
|
|
827
|
-
const url = `http://localhost:${
|
|
862
|
+
const url = `http://localhost:${DASH_PORT}`;
|
|
828
863
|
console.log(`\n Dashboard already running: ${url}\n`);
|
|
829
864
|
try {
|
|
830
865
|
const openCmd = process.platform === 'win32' ? `start ${url}` : process.platform === 'darwin' ? `open ${url}` : `xdg-open ${url}`;
|
|
@@ -843,7 +878,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
843
878
|
handled = true;
|
|
844
879
|
delegate('dashboard.js', rest);
|
|
845
880
|
});
|
|
846
|
-
sock.connect(
|
|
881
|
+
sock.connect(DASH_PORT, '127.0.0.1');
|
|
847
882
|
} else if (engineCmds.has(cmd)) {
|
|
848
883
|
delegate('engine.js', [cmd, ...rest]);
|
|
849
884
|
} else {
|
|
@@ -4,15 +4,19 @@ let _showArchived = false;
|
|
|
4
4
|
const MTG_PER_PAGE = 10;
|
|
5
5
|
let _mtgPage = 0;
|
|
6
6
|
let _lastMeetingHash = '';
|
|
7
|
+
let _lastMeetingsForPaging = [];
|
|
8
|
+
let _mtgTotalPages = 1;
|
|
7
9
|
|
|
8
10
|
function renderMeetings(meetings) {
|
|
9
11
|
meetings = (meetings || []).filter(function(m) { return !isDeleted('mtg:' + m.id); });
|
|
10
12
|
meetings.sort((a, b) => (b.createdAt || b.completedAt || '').localeCompare(a.createdAt || a.completedAt || ''));
|
|
13
|
+
_lastMeetingsForPaging = meetings;
|
|
11
14
|
const el = document.getElementById('meetings-content');
|
|
12
15
|
const countEl = document.getElementById('meetings-count');
|
|
13
16
|
if (!meetings || meetings.length === 0) {
|
|
14
17
|
countEl.textContent = '0';
|
|
15
18
|
el.innerHTML = '<p class="empty">No meetings yet. Start one to have agents investigate, debate, and conclude on a topic.</p>';
|
|
19
|
+
_mtgTotalPages = 1;
|
|
16
20
|
return;
|
|
17
21
|
}
|
|
18
22
|
|
|
@@ -27,10 +31,12 @@ function renderMeetings(meetings) {
|
|
|
27
31
|
if (visible.length === 0) {
|
|
28
32
|
el.innerHTML = '<p class="empty">No active meetings.</p>';
|
|
29
33
|
if (archived.length) el.insertAdjacentHTML('beforeend', '<div style="text-align:center;margin-top:8px"><button class="pr-pager-btn" style="font-size:10px" onclick="_toggleArchivedMeetings()">Show ' + archived.length + ' archived</button></div>');
|
|
34
|
+
_mtgTotalPages = 1;
|
|
30
35
|
return;
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
const totalPages = Math.ceil(visible.length / MTG_PER_PAGE);
|
|
39
|
+
_mtgTotalPages = totalPages;
|
|
34
40
|
if (_mtgPage >= totalPages) _mtgPage = totalPages - 1;
|
|
35
41
|
const start = _mtgPage * MTG_PER_PAGE;
|
|
36
42
|
const pageItems = visible.slice(start, start + MTG_PER_PAGE);
|
|
@@ -81,13 +87,26 @@ function renderMeetings(meetings) {
|
|
|
81
87
|
restoreNotifBadges();
|
|
82
88
|
}
|
|
83
89
|
|
|
84
|
-
function
|
|
85
|
-
|
|
90
|
+
function _rerenderMeetingPageFromCache() {
|
|
91
|
+
renderMeetings(_lastMeetingsForPaging || []);
|
|
92
|
+
}
|
|
93
|
+
function _mtgPrev() {
|
|
94
|
+
if (_mtgPage > 0) {
|
|
95
|
+
_mtgPage--;
|
|
96
|
+
_rerenderMeetingPageFromCache();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function _mtgNext() {
|
|
100
|
+
if (_mtgPage < _mtgTotalPages - 1) {
|
|
101
|
+
_mtgPage++;
|
|
102
|
+
_rerenderMeetingPageFromCache();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
86
105
|
|
|
87
106
|
function _toggleArchivedMeetings() {
|
|
88
107
|
_showArchived = !_showArchived;
|
|
89
108
|
_mtgPage = 0;
|
|
90
|
-
|
|
109
|
+
_rerenderMeetingPageFromCache();
|
|
91
110
|
}
|
|
92
111
|
|
|
93
112
|
let _meetingPollInterval = null;
|
package/dashboard.js
CHANGED
|
@@ -33,7 +33,7 @@ const projectDiscovery = require('./engine/project-discovery');
|
|
|
33
33
|
const features = require('./engine/features');
|
|
34
34
|
const os = require('os');
|
|
35
35
|
|
|
36
|
-
const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeUnlink, mutateJsonFileLocked, mutateControl, mutateCooldowns, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, WORK_TYPE, reopenWorkItem } = shared;
|
|
36
|
+
const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeUnlink, mutateJsonFileLocked, mutateControl, mutateCooldowns, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, WORK_TYPE, reopenWorkItem } = shared;
|
|
37
37
|
const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
|
|
38
38
|
getSkills, getInbox, getNotesWithMeta, getPullRequests,
|
|
39
39
|
getEngineLog, getMetrics, getKnowledgeBaseEntries, timeSince,
|
|
@@ -66,7 +66,17 @@ function ensureConfiguredProjectStateFiles() {
|
|
|
66
66
|
const root = p.localPath ? path.resolve(p.localPath) : null;
|
|
67
67
|
if (!root || !fs.existsSync(root)) continue;
|
|
68
68
|
try {
|
|
69
|
-
shared.ensureProjectStateFiles(p, { migrateLegacy: true, removeLegacy: true });
|
|
69
|
+
const state = shared.ensureProjectStateFiles(p, { migrateLegacy: true, removeLegacy: true });
|
|
70
|
+
if (state.migrated.length > 0 || state.removedLegacy.length > 0 || state.legacyDirRemoved) {
|
|
71
|
+
const parts = [];
|
|
72
|
+
if (state.migrated.length > 0) parts.push(`merged ${state.migrated.join(', ')}`);
|
|
73
|
+
if (state.removedLegacy.length > 0) parts.push(`removed ${state.removedLegacy.join(', ')}`);
|
|
74
|
+
if (state.legacyDirRemoved) parts.push(`removed legacy .minions dir`);
|
|
75
|
+
console.log(`[dashboard] migrated project state for "${p.name}" → projects/${p.name} (${parts.join('; ')})`);
|
|
76
|
+
}
|
|
77
|
+
if (state.legacyDirRemoveError) {
|
|
78
|
+
console.warn(`[dashboard] failed to remove legacy .minions dir for "${p.name}": ${state.legacyDirRemoveError}`);
|
|
79
|
+
}
|
|
70
80
|
} catch (e) {
|
|
71
81
|
console.warn(`[dashboard] project state migration failed for "${p.name}": ${e.message}`);
|
|
72
82
|
}
|
|
@@ -2300,6 +2310,50 @@ function updateSession(store, key, sessionId, existing) {
|
|
|
2300
2310
|
}
|
|
2301
2311
|
}
|
|
2302
2312
|
|
|
2313
|
+
// Pre-flight check: when the resolved runtime publishes a model catalog, reject
|
|
2314
|
+
// configs whose model isn't in it BEFORE spawning. Without this guard, Copilot
|
|
2315
|
+
// crashes mid-call ("Copilot rejected the requested model"), which empties the
|
|
2316
|
+
// stream, drops the doc-chat session, and surfaces as a confusing
|
|
2317
|
+
// "agent exited unexpectedly" banner with no actionable cause. We pull the
|
|
2318
|
+
// adapter via llm._resolveRuntimeFor + llm._resolveModelForRuntime so the same
|
|
2319
|
+
// resolution path llm.callLLM uses produces the same final model string.
|
|
2320
|
+
// Returns a structured error result ({code, errorClass, errorMessage}) on
|
|
2321
|
+
// rejection, or null when the call should proceed. Catalog-less runtimes
|
|
2322
|
+
// (Claude) always proceed — no opinion.
|
|
2323
|
+
async function _preflightModelCheck({ runtime: cliOverride, model: modelOverride, engineConfig }) {
|
|
2324
|
+
let adapter;
|
|
2325
|
+
try {
|
|
2326
|
+
adapter = llm._resolveRuntimeFor({ cli: cliOverride, engineConfig });
|
|
2327
|
+
} catch { return null; }
|
|
2328
|
+
if (!adapter) return null;
|
|
2329
|
+
const resolvedModel = llm._resolveModelForRuntime(adapter, { model: modelOverride, engineConfig });
|
|
2330
|
+
if (!resolvedModel) return null;
|
|
2331
|
+
if (!adapter.capabilities || adapter.capabilities.modelDiscovery !== true) return null;
|
|
2332
|
+
|
|
2333
|
+
let list;
|
|
2334
|
+
try {
|
|
2335
|
+
const md = require('./engine/model-discovery');
|
|
2336
|
+
list = await md.getRuntimeModels(adapter.name, { config: { engine: engineConfig || {} } });
|
|
2337
|
+
} catch { return null; }
|
|
2338
|
+
if (!list || !Array.isArray(list.models) || list.models.length === 0) return null;
|
|
2339
|
+
|
|
2340
|
+
const known = new Set(list.models.map(m => m && (m.id || m.name)).filter(Boolean));
|
|
2341
|
+
if (known.has(resolvedModel)) return null;
|
|
2342
|
+
|
|
2343
|
+
const sample = [...known].slice(0, 4).join(', ') + (known.size > 4 ? '…' : '');
|
|
2344
|
+
return {
|
|
2345
|
+
text: null,
|
|
2346
|
+
code: 1,
|
|
2347
|
+
errorClass: 'unknown-model',
|
|
2348
|
+
errorMessage: `Configured model "${resolvedModel}" is not valid for runtime "${adapter.name}" (known: ${sample}). Update engine.ccModel or engine.defaultModel.`,
|
|
2349
|
+
runtime: adapter.name,
|
|
2350
|
+
sessionId: null,
|
|
2351
|
+
usage: null,
|
|
2352
|
+
stderr: '',
|
|
2353
|
+
toolUses: [],
|
|
2354
|
+
};
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2303
2357
|
/**
|
|
2304
2358
|
* Core LLM call — shared by CC panel and doc modals.
|
|
2305
2359
|
* @param {string} message - User message
|
|
@@ -2316,6 +2370,13 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
|
|
|
2316
2370
|
if (!maxTurns) maxTurns = CONFIG.engine?.ccMaxTurns || shared.ENGINE_DEFAULTS.ccMaxTurns;
|
|
2317
2371
|
if (!model) model = CONFIG.engine?.ccModel || shared.ENGINE_DEFAULTS.ccModel;
|
|
2318
2372
|
const ccEffort = CONFIG.engine?.ccEffort || shared.ENGINE_DEFAULTS.ccEffort;
|
|
2373
|
+
|
|
2374
|
+
const preflight = await _preflightModelCheck({ model, engineConfig: CONFIG.engine });
|
|
2375
|
+
if (preflight) {
|
|
2376
|
+
console.warn(`[${label}] Pre-flight rejected: ${preflight.errorMessage}`);
|
|
2377
|
+
return preflight;
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2319
2380
|
const existing = resolveSession(store, sessionKey);
|
|
2320
2381
|
let sessionId = existing ? existing.sessionId : null;
|
|
2321
2382
|
|
|
@@ -2405,6 +2466,13 @@ async function ccCallStreaming(message, { store = 'cc', sessionKey, extraContext
|
|
|
2405
2466
|
if (!maxTurns) maxTurns = CONFIG.engine?.ccMaxTurns || shared.ENGINE_DEFAULTS.ccMaxTurns;
|
|
2406
2467
|
if (!model) model = CONFIG.engine?.ccModel || shared.ENGINE_DEFAULTS.ccModel;
|
|
2407
2468
|
const ccEffort = CONFIG.engine?.ccEffort || shared.ENGINE_DEFAULTS.ccEffort;
|
|
2469
|
+
|
|
2470
|
+
const preflight = await _preflightModelCheck({ model, engineConfig: CONFIG.engine });
|
|
2471
|
+
if (preflight) {
|
|
2472
|
+
console.warn(`[${label}] Pre-flight rejected: ${preflight.errorMessage}`);
|
|
2473
|
+
return preflight;
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2408
2476
|
const existing = resolveSession(store, sessionKey);
|
|
2409
2477
|
let sessionId = existing ? existing.sessionId : null;
|
|
2410
2478
|
|
|
@@ -2574,6 +2642,7 @@ function _docChatErrorMessage(errorClass, sessionPreserved = false, toolUses = [
|
|
|
2574
2642
|
if (errorClass === 'context-limit') return 'Session context is too long. Click "Clear" to start a fresh conversation.' + toolHint;
|
|
2575
2643
|
if (errorClass === 'budget-exceeded') return (errorMessage || 'Runtime budget exceeded — check your account or quota.') + toolHint;
|
|
2576
2644
|
if (errorClass === 'crash') return (errorMessage || 'Runtime crashed unexpectedly. Try again.') + toolHint;
|
|
2645
|
+
if (errorClass === 'unknown-model') return (errorMessage || 'Configured model is not valid for the active runtime. Update engine.ccModel or engine.defaultModel.') + toolHint;
|
|
2577
2646
|
if (sessionPreserved) return 'Temporary connection issue — your conversation is intact, send your message again.' + toolHint;
|
|
2578
2647
|
if (tools.length > 0) return 'The agent stopped responding before producing a final answer.' + toolHint;
|
|
2579
2648
|
return 'Failed to process request. Try again.';
|
|
@@ -3161,7 +3230,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
3161
3230
|
|
|
3162
3231
|
const config = queries.getConfig();
|
|
3163
3232
|
const project = PROJECTS.find(p => {
|
|
3164
|
-
|
|
3233
|
+
// safeJsonNoRestore — never resurrect an archived PRD's .backup
|
|
3234
|
+
// sidecar during project resolution (W-mouptdh1000h9f39). The
|
|
3235
|
+
// active-from-archive write above (mutateJsonFileLocked) already
|
|
3236
|
+
// created activePath when needed.
|
|
3237
|
+
const plan = safeJsonNoRestore(activePath) || safeJsonNoRestore(prdPath);
|
|
3165
3238
|
return plan && p.name?.toLowerCase() === (plan.project || '').toLowerCase();
|
|
3166
3239
|
}) || PROJECTS[0] || null;
|
|
3167
3240
|
|
|
@@ -3241,9 +3314,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
3241
3314
|
const prdFile = body.prdFile;
|
|
3242
3315
|
if (!prdFile) return jsonReply(res, 404, { error: 'work item not found in any source' });
|
|
3243
3316
|
|
|
3244
|
-
// Look up PRD item to create a new work item on-demand
|
|
3317
|
+
// Look up PRD item to create a new work item on-demand.
|
|
3318
|
+
// safeJsonNoRestore — PRDs are terminal artifacts; a stale .backup
|
|
3319
|
+
// sidecar from an archived PRD must not resurrect the active PRD
|
|
3320
|
+
// just because a user clicked retry on a stray work item
|
|
3321
|
+
// (W-mouptdh1000h9f39).
|
|
3245
3322
|
const prdPath = path.join(PRD_DIR, prdFile);
|
|
3246
|
-
const plan = shared.
|
|
3323
|
+
const plan = shared.safeJsonNoRestore(prdPath);
|
|
3247
3324
|
if (!plan?.missing_features) return jsonReply(res, 404, { error: 'PRD file not found or invalid' });
|
|
3248
3325
|
const prdItem = plan.missing_features.find(f => f.id === id);
|
|
3249
3326
|
if (!prdItem) return jsonReply(res, 404, { error: 'PRD item not found in ' + prdFile });
|
|
@@ -7571,18 +7648,24 @@ if (require.main === module) {
|
|
|
7571
7648
|
console.log(` Projects: ${PROJECTS.map(p => `${p.name} (${p.localPath})`).join(', ')}`);
|
|
7572
7649
|
console.log(`\n Auto-refreshes every 4s. Ctrl+C to stop.\n`);
|
|
7573
7650
|
|
|
7574
|
-
|
|
7575
|
-
|
|
7576
|
-
|
|
7577
|
-
|
|
7578
|
-
|
|
7579
|
-
|
|
7580
|
-
|
|
7581
|
-
|
|
7651
|
+
// Auto-open the browser unless suppressed. `minions restart` and the
|
|
7652
|
+
// upgrade path set MINIONS_NO_AUTO_OPEN=1 when the previous dashboard was
|
|
7653
|
+
// already listening — the existing tab will SSE-reconnect, so a new tab
|
|
7654
|
+
// would just be a duplicate.
|
|
7655
|
+
if (!process.env.MINIONS_NO_AUTO_OPEN) {
|
|
7656
|
+
const { exec } = require('child_process');
|
|
7657
|
+
try {
|
|
7658
|
+
if (process.platform === 'win32') {
|
|
7659
|
+
exec(`start "" "http://localhost:${PORT}"`);
|
|
7660
|
+
} else if (process.platform === 'darwin') {
|
|
7661
|
+
exec(`open http://localhost:${PORT}`);
|
|
7662
|
+
} else {
|
|
7663
|
+
exec(`xdg-open http://localhost:${PORT}`);
|
|
7664
|
+
}
|
|
7665
|
+
} catch (e) {
|
|
7666
|
+
console.log(` Could not auto-open browser: ${e.message}`);
|
|
7667
|
+
console.log(` Please open http://localhost:${PORT} manually.`);
|
|
7582
7668
|
}
|
|
7583
|
-
} catch (e) {
|
|
7584
|
-
console.log(` Could not auto-open browser: ${e.message}`);
|
|
7585
|
-
console.log(` Please open http://localhost:${PORT} manually.`);
|
|
7586
7669
|
}
|
|
7587
7670
|
|
|
7588
7671
|
// ─── Engine Watchdog ─────────────────────────────────────────────────────
|
package/engine/cleanup.js
CHANGED
|
@@ -9,7 +9,7 @@ const shared = require('./shared');
|
|
|
9
9
|
const queries = require('./queries');
|
|
10
10
|
|
|
11
11
|
const { exec, execAsync, execSilent, log, ts, ENGINE_DEFAULTS } = shared;
|
|
12
|
-
const { safeJson, safeWrite, safeReadDir, mutateCooldowns, mutateWorkItems, mutateJsonFileLocked, getProjects, projectWorkItemsPath, projectPrPath,
|
|
12
|
+
const { safeJson, safeJsonNoRestore, safeWrite, safeReadDir, mutateCooldowns, mutateWorkItems, mutateJsonFileLocked, getProjects, projectWorkItemsPath, projectPrPath,
|
|
13
13
|
sanitizeBranch, KB_CATEGORIES } = shared;
|
|
14
14
|
const { getDispatch, getAgentStatus } = queries;
|
|
15
15
|
|
|
@@ -688,7 +688,10 @@ async function runCleanup(config, verbose = false) {
|
|
|
688
688
|
const prdPath = path.join(PRD_DIR, pf);
|
|
689
689
|
let migrated = 0;
|
|
690
690
|
shared.withFileLock(`${prdPath}.lock`, () => {
|
|
691
|
-
|
|
691
|
+
// safeJsonNoRestore: PRDs are terminal artifacts. If the active PRD
|
|
692
|
+
// was archived but a stale .backup sidecar remains, never resurrect
|
|
693
|
+
// it during a routine migration sweep (W-mouptdh1000h9f39).
|
|
694
|
+
const prd = safeJsonNoRestore(prdPath);
|
|
692
695
|
if (!prd?.missing_features) return;
|
|
693
696
|
for (const feat of prd.missing_features) {
|
|
694
697
|
if (LEGACY_DONE_ALIASES.has(feat.status)) {
|
|
@@ -725,7 +728,9 @@ async function runCleanup(config, verbose = false) {
|
|
|
725
728
|
catch { orphanPrdEntries = []; }
|
|
726
729
|
for (const pf of orphanPrdEntries) {
|
|
727
730
|
const prdPath = path.join(PRD_DIR, pf);
|
|
728
|
-
|
|
731
|
+
// safeJsonNoRestore — peek must not auto-restore archived PRDs
|
|
732
|
+
// from a stale .backup sidecar (W-mouptdh1000h9f39).
|
|
733
|
+
const peek = safeJsonNoRestore(prdPath);
|
|
729
734
|
if (!peek?.missing_features) continue;
|
|
730
735
|
let reset = 0;
|
|
731
736
|
mutateJsonFileLocked(prdPath, (prd) => {
|
package/engine/cli.js
CHANGED
|
@@ -431,8 +431,16 @@ const commands = {
|
|
|
431
431
|
} else {
|
|
432
432
|
try {
|
|
433
433
|
const state = shared.ensureProjectStateFiles(p, { migrateLegacy: true, removeLegacy: true });
|
|
434
|
-
if (state.migrated.length > 0 || state.removedLegacy.length > 0) {
|
|
435
|
-
|
|
434
|
+
if (state.migrated.length > 0 || state.removedLegacy.length > 0 || state.legacyDirRemoved) {
|
|
435
|
+
const parts = [];
|
|
436
|
+
if (state.migrated.length > 0) parts.push(`merged ${state.migrated.join(', ')}`);
|
|
437
|
+
if (state.removedLegacy.length > 0) parts.push(`removed ${state.removedLegacy.join(', ')}`);
|
|
438
|
+
if (state.legacyDirRemoved) parts.push(`removed legacy .minions dir`);
|
|
439
|
+
e.log('info', `Migrated project state for "${p.name}" → projects/${p.name} (${parts.join('; ')})`);
|
|
440
|
+
}
|
|
441
|
+
if (state.legacyDirRemoveError) {
|
|
442
|
+
e.log('warn', `Failed to remove legacy .minions dir for "${p.name}": ${state.legacyDirRemoveError}`);
|
|
443
|
+
console.log(` WARNING: legacy .minions dir for "${p.name}" could not be removed: ${state.legacyDirRemoveError}`);
|
|
436
444
|
}
|
|
437
445
|
} catch (err) {
|
|
438
446
|
e.log('warn', `Project state migration failed for "${p.name}": ${err.message}`);
|
package/engine/lifecycle.js
CHANGED
|
@@ -7,7 +7,7 @@ const fs = require('fs');
|
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const os = require('os');
|
|
9
9
|
const shared = require('./shared');
|
|
10
|
-
const { safeRead, safeJson, safeWrite, mutateJsonFileLocked, mutateWorkItems, execSilent, execAsync, projectPrPath, getPrLinks,
|
|
10
|
+
const { safeRead, safeJson, safeJsonNoRestore, safeWrite, mutateJsonFileLocked, mutateWorkItems, execSilent, execAsync, projectPrPath, getPrLinks,
|
|
11
11
|
log, ts, dateStamp, WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PR_STATUS, DISPATCH_RESULT,
|
|
12
12
|
ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS, FAILURE_CLASS } = shared;
|
|
13
13
|
const { trackEngineUsage } = require('./llm');
|
|
@@ -24,7 +24,11 @@ function checkPlanCompletion(meta, config) {
|
|
|
24
24
|
const planFile = meta.item?.sourcePlan;
|
|
25
25
|
if (!planFile) return;
|
|
26
26
|
const planPath = path.join(PRD_DIR, planFile);
|
|
27
|
-
|
|
27
|
+
// Use safeJsonNoRestore — if the active PRD has been archived, the .backup
|
|
28
|
+
// sidecar may still be sitting in prd/. safeJson would auto-restore it and
|
|
29
|
+
// resurrect the archived PRD as active (W-mouptdh1000h9f39). PRDs are
|
|
30
|
+
// terminal artifacts; missing primary means "gone", not "needs recovery".
|
|
31
|
+
const plan = safeJsonNoRestore(planPath);
|
|
28
32
|
if (!plan?.missing_features) return;
|
|
29
33
|
if (plan.status === PLAN_STATUS.COMPLETED) {
|
|
30
34
|
if (plan._completionNotified) return;
|
|
@@ -580,7 +584,9 @@ function syncPrdItemStatus(itemId, status, sourcePlan) {
|
|
|
580
584
|
for (const pf of files) {
|
|
581
585
|
const fpath = path.join(prdDir, pf);
|
|
582
586
|
// Lock-free peek: most PRDs won't contain the ID, so skip the lock cost.
|
|
583
|
-
|
|
587
|
+
// safeJsonNoRestore so an archived PRD's .backup sidecar can't resurrect
|
|
588
|
+
// the active PRD just because a done WI still references it.
|
|
589
|
+
const plan = safeJsonNoRestore(fpath);
|
|
584
590
|
const feature = plan?.missing_features?.find(f => f.id === itemId);
|
|
585
591
|
if (!feature || feature.status === status) continue;
|
|
586
592
|
let updated = false;
|
package/engine/pipeline.js
CHANGED
|
@@ -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, 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, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
|
|
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, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
|
|
12
12
|
const routing = require('./routing');
|
|
13
13
|
const http = require('http');
|
|
14
14
|
const { parseCronExpr, shouldRunNow } = require('./scheduler');
|
|
@@ -388,7 +388,9 @@ function _findExistingPrdForPlan(planFile, prdDir) {
|
|
|
388
388
|
if (!fs.existsSync(prdDir)) return null;
|
|
389
389
|
const prdFiles = safeReadDir(prdDir).filter(f => f.endsWith('.json'));
|
|
390
390
|
for (const pf of prdFiles) {
|
|
391
|
-
|
|
391
|
+
// safeJsonNoRestore: PRDs are terminal artifacts — never restore archived
|
|
392
|
+
// PRDs from a stale .backup sidecar (W-mouptdh1000h9f39).
|
|
393
|
+
const prd = safeJsonNoRestore(path.join(prdDir, pf));
|
|
392
394
|
if (prd?.source_plan === planFile) return pf;
|
|
393
395
|
}
|
|
394
396
|
return null;
|
|
@@ -709,7 +711,8 @@ function isStageComplete(stage, stageState, run, config) {
|
|
|
709
711
|
const prdFiles = fs.existsSync(prdDir) ? safeReadDir(prdDir).filter(f => f.endsWith('.json')) : [];
|
|
710
712
|
for (const planFile of plans) {
|
|
711
713
|
for (const pf of prdFiles) {
|
|
712
|
-
|
|
714
|
+
// safeJsonNoRestore — see _findExistingPrdForPlan above (W-mouptdh1000h9f39).
|
|
715
|
+
const prd = safeJsonNoRestore(path.join(prdDir, pf));
|
|
713
716
|
if (prd?.source_plan === planFile && !(artifacts.prds || []).includes(pf) && !discoveredPrds.includes(pf)) {
|
|
714
717
|
discoveredPrds.push(pf);
|
|
715
718
|
}
|
package/engine/preflight.js
CHANGED
|
@@ -109,8 +109,13 @@ function _distinctRuntimes(config) {
|
|
|
109
109
|
* Try to resolve a runtime's binary via the registry. Returns a preflight
|
|
110
110
|
* result entry — never throws. Unknown-runtime errors collapse to a single
|
|
111
111
|
* warn entry so the rest of the loop keeps running.
|
|
112
|
+
*
|
|
113
|
+
* When `optional` is true, a missing binary surfaces as `ok: 'warn'` instead
|
|
114
|
+
* of `ok: false`. Used for runtimes the user hasn't configured but that are
|
|
115
|
+
* registered in the adapter registry — surfacing availability without making
|
|
116
|
+
* the check itself fail when the user only uses one runtime.
|
|
112
117
|
*/
|
|
113
|
-
function _checkRuntimeBinary(runtimeName) {
|
|
118
|
+
function _checkRuntimeBinary(runtimeName, { optional = false } = {}) {
|
|
114
119
|
let adapter;
|
|
115
120
|
try {
|
|
116
121
|
adapter = require('./runtimes').resolveRuntime(runtimeName);
|
|
@@ -139,8 +144,10 @@ function _checkRuntimeBinary(runtimeName) {
|
|
|
139
144
|
: `${runtimeName} CLI binary not found on PATH`;
|
|
140
145
|
return {
|
|
141
146
|
name: `Runtime: ${runtimeName}`,
|
|
142
|
-
ok: false,
|
|
143
|
-
message:
|
|
147
|
+
ok: optional ? 'warn' : false,
|
|
148
|
+
message: optional
|
|
149
|
+
? `not installed (optional) — ${hint}`
|
|
150
|
+
: `not found — ${hint}`,
|
|
144
151
|
};
|
|
145
152
|
}
|
|
146
153
|
|
|
@@ -172,6 +179,11 @@ function _warmModelCache(runtimeName, config) {
|
|
|
172
179
|
* - warnOnly: if true, missing items don't cause passed=false (for init)
|
|
173
180
|
* - verbose: include extra detail in messages
|
|
174
181
|
* - config: fleet config for runtime checks + runtime-config warnings
|
|
182
|
+
* - includeAllRegistered: when true, every adapter registered in the runtime
|
|
183
|
+
* registry is probed even if the user hasn't
|
|
184
|
+
* configured it. Configured runtimes still fail
|
|
185
|
+
* critically when missing; non-configured ones
|
|
186
|
+
* surface as warn-level "not installed (optional)".
|
|
175
187
|
*/
|
|
176
188
|
function runPreflight(opts = {}) {
|
|
177
189
|
const results = [];
|
|
@@ -198,10 +210,20 @@ function runPreflight(opts = {}) {
|
|
|
198
210
|
|
|
199
211
|
// 3. Per-runtime binary check (P-9e8a3f1d). Legacy single-runtime callers
|
|
200
212
|
// (no config passed) still get exactly one entry — for `claude` — so the
|
|
201
|
-
// historical "3 results" shape is preserved.
|
|
202
|
-
|
|
213
|
+
// historical "3 results" shape is preserved. With includeAllRegistered,
|
|
214
|
+
// add every registered adapter (e.g. copilot) as an optional probe so the
|
|
215
|
+
// CLI surfaces availability without forcing the user to configure it.
|
|
216
|
+
const configuredRuntimes = _distinctRuntimes(opts.config);
|
|
217
|
+
let runtimes = configuredRuntimes;
|
|
218
|
+
if (opts.includeAllRegistered) {
|
|
219
|
+
let registered = [];
|
|
220
|
+
try { registered = require('./runtimes').listRuntimes(); }
|
|
221
|
+
catch { /* registry may be missing during partial installs */ }
|
|
222
|
+
runtimes = Array.from(new Set([...configuredRuntimes, ...registered])).sort();
|
|
223
|
+
}
|
|
224
|
+
const configuredSet = new Set(configuredRuntimes);
|
|
203
225
|
for (const runtimeName of runtimes) {
|
|
204
|
-
const r = _checkRuntimeBinary(runtimeName);
|
|
226
|
+
const r = _checkRuntimeBinary(runtimeName, { optional: !configuredSet.has(runtimeName) });
|
|
205
227
|
if (r.ok === false) allOk = false;
|
|
206
228
|
results.push(r);
|
|
207
229
|
// Warm the model cache in the background — silent no-op when the
|
|
@@ -294,7 +294,40 @@ function getSkillWriteTargets({ homeDir = os.homedir(), project = null } = {}) {
|
|
|
294
294
|
// engine.js:1195 fires. See W-mot9fwya000d09cb.
|
|
295
295
|
const RUNTIME_NAME = 'claude';
|
|
296
296
|
|
|
297
|
-
|
|
297
|
+
/**
|
|
298
|
+
* Compute the directory Claude CLI uses to persist a project's conversation
|
|
299
|
+
* jsonl files: `~/.claude/projects/<dashed-cwd>/`. The hash algorithm replaces
|
|
300
|
+
* any character outside `[a-zA-Z0-9-]` with `-` (verified empirically against
|
|
301
|
+
* existing project dirs on multiple platforms — preserves dashes, dashes out
|
|
302
|
+
* separators, drive letters, dots, and spaces).
|
|
303
|
+
*/
|
|
304
|
+
function _claudeProjectHashDir(cwd, homeDir) {
|
|
305
|
+
if (!cwd || !homeDir) return null;
|
|
306
|
+
const dashed = String(cwd).replace(/[^a-zA-Z0-9-]/g, '-');
|
|
307
|
+
return path.join(homeDir, '.claude', 'projects', dashed);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Pre-spawn resume lookup. Returns the session ID to pass to `--resume`, or
|
|
312
|
+
* null when the cached session is unusable (stale by age, runtime mismatch,
|
|
313
|
+
* or — see W-mouugzow00068741 — references a UUID Claude never persisted to
|
|
314
|
+
* its on-disk conversation log).
|
|
315
|
+
*
|
|
316
|
+
* Stale-resume-target case (the W-mouugzow00068741 bug): the engine writes
|
|
317
|
+
* `session.json` as soon as Claude emits its first session_id, but Claude only
|
|
318
|
+
* persists the conversation to `~/.claude/projects/<hash>/<uuid>.jsonl` on a
|
|
319
|
+
* stable checkpoint. If the agent dies before checkpoint, session.json holds
|
|
320
|
+
* a UUID that no jsonl file backs. On retry, `--resume <uuid>` fails with
|
|
321
|
+
* "No conversation found", the retry burns instantly, and (because the failed
|
|
322
|
+
* retry never reaches saveSession) session.json never gets overwritten — so
|
|
323
|
+
* every subsequent retry hits the same dead UUID until the 2h TTL kicks in.
|
|
324
|
+
*
|
|
325
|
+
* Fix: when `cwd` is provided, probe for the jsonl. If it's absent, treat the
|
|
326
|
+
* session as never-persisted, clear session.json, and return null so the
|
|
327
|
+
* caller spawns fresh. The probe is opt-in (cwd-gated) — older callers that
|
|
328
|
+
* don't pass cwd keep the pre-fix behavior unchanged.
|
|
329
|
+
*/
|
|
330
|
+
function getResumeSessionId({ agentId, branchName, agentsDir, cwd = null, homeDir = os.homedir(), maxAgeMs = 2 * 60 * 60 * 1000, logger = console } = {}) {
|
|
298
331
|
if (!agentId || agentId.startsWith('temp-') || !agentsDir) return null;
|
|
299
332
|
const sessionPath = path.join(agentsDir, agentId, 'session.json');
|
|
300
333
|
try {
|
|
@@ -314,6 +347,23 @@ function getResumeSessionId({ agentId, branchName, agentsDir, maxAgeMs = 2 * 60
|
|
|
314
347
|
return null;
|
|
315
348
|
}
|
|
316
349
|
|
|
350
|
+
// Stale-resume-target invalidation (W-mouugzow00068741). Claude only writes
|
|
351
|
+
// the jsonl on stable checkpoint; a session ID stamped before checkpoint
|
|
352
|
+
// dies with the agent. Probe for the jsonl and clear session.json when
|
|
353
|
+
// it's missing so the next dispatch spawns fresh instead of looping
|
|
354
|
+
// through `--resume <dead-uuid>` failures.
|
|
355
|
+
if (cwd) {
|
|
356
|
+
const projectDir = _claudeProjectHashDir(cwd, homeDir);
|
|
357
|
+
const jsonlPath = projectDir ? path.join(projectDir, `${sessionFile.sessionId}.jsonl`) : null;
|
|
358
|
+
if (jsonlPath && !fs.existsSync(jsonlPath)) {
|
|
359
|
+
if (logger && typeof logger.info === 'function') {
|
|
360
|
+
logger.info(`Skipping resume for ${agentId}: no conversation jsonl at ${jsonlPath} (stale resume target — agent died before checkpoint) — clearing session.json`);
|
|
361
|
+
}
|
|
362
|
+
try { fs.unlinkSync(sessionPath); } catch {}
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
317
367
|
const sessionAge = Date.now() - new Date(sessionFile.savedAt).getTime();
|
|
318
368
|
const sameBranch = branchName && sessionFile.branch && sessionFile.branch === branchName;
|
|
319
369
|
if (sessionAge < maxAgeMs && sameBranch) {
|
|
@@ -158,6 +158,15 @@ function resolveBinary({ env = process.env } = {}) {
|
|
|
158
158
|
// adapter maps those aliases to Copilot model IDs while passing all other input
|
|
159
159
|
// through verbatim. The capability remains false: Copilot CLI does not accept
|
|
160
160
|
// these aliases directly.
|
|
161
|
+
//
|
|
162
|
+
// `resolveModel` ALSO normalizes Anthropic-style hyphenated IDs that callers may
|
|
163
|
+
// have inherited from a Claude-runtime config. `claude-opus-4-7` and
|
|
164
|
+
// `claude-opus-4-7-1m` are passed through verbatim by Claude (which has no
|
|
165
|
+
// catalog), so when a user runs `minions config set-cli copilot` without
|
|
166
|
+
// updating `defaultModel` the hyphenated ID lands in Copilot's --model arg and
|
|
167
|
+
// the CLI rejects it. We translate the inter-version hyphen to a dot and fall
|
|
168
|
+
// through to catalog matching when one is cached, which is the common case for
|
|
169
|
+
// installs that have run model discovery at least once.
|
|
161
170
|
|
|
162
171
|
const _MINIONS_MODEL_ALIASES = {
|
|
163
172
|
haiku: 'claude-haiku-4.5',
|
|
@@ -165,12 +174,76 @@ const _MINIONS_MODEL_ALIASES = {
|
|
|
165
174
|
opus: 'claude-opus-4.5',
|
|
166
175
|
};
|
|
167
176
|
|
|
177
|
+
// Read the cached catalog (engine/copilot-models.json) and return a Set of IDs.
|
|
178
|
+
// Returns null when the cache is missing/empty so callers can fall through to
|
|
179
|
+
// passthrough behavior — never throws, never reaches the network.
|
|
180
|
+
function _readCatalogIds() {
|
|
181
|
+
const cached = _safeJson(MODELS_CACHE);
|
|
182
|
+
const list = cached && Array.isArray(cached.models) ? cached.models : null;
|
|
183
|
+
if (!list || list.length === 0) return null;
|
|
184
|
+
const ids = new Set();
|
|
185
|
+
for (const m of list) {
|
|
186
|
+
const id = m && (m.id || m.name);
|
|
187
|
+
if (id) ids.add(String(id));
|
|
188
|
+
}
|
|
189
|
+
return ids.size > 0 ? ids : null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Translate Anthropic-style hyphenated `claude-(family)-X-Y[-suffix]` to
|
|
193
|
+
// Copilot's dot form `claude-(family)-X.Y[-suffix]`. Returns the transformed
|
|
194
|
+
// string (or the original when the pattern doesn't match) — a separate catalog
|
|
195
|
+
// match check then decides whether the result is usable.
|
|
196
|
+
function _hyphenToDotVersion(s) {
|
|
197
|
+
return s.replace(/^(claude-(?:opus|sonnet|haiku))-(\d+)-(\d+)(.*)$/i, '$1-$2.$3$4');
|
|
198
|
+
}
|
|
199
|
+
|
|
168
200
|
function resolveModel(input) {
|
|
169
201
|
if (input == null || input === '') return undefined;
|
|
170
202
|
const s = String(input);
|
|
171
203
|
const mapped = _MINIONS_MODEL_ALIASES[s.toLowerCase()];
|
|
172
204
|
if (mapped) return mapped;
|
|
173
|
-
|
|
205
|
+
|
|
206
|
+
// Call through module.exports so tests can monkey-patch _readCatalogIds
|
|
207
|
+
// without writing to the live engine/copilot-models.json cache. Same
|
|
208
|
+
// runtime behavior as a direct call when the export hasn't been replaced.
|
|
209
|
+
const catalog = module.exports._readCatalogIds();
|
|
210
|
+
if (catalog && catalog.has(s)) return s;
|
|
211
|
+
|
|
212
|
+
const dotted = _hyphenToDotVersion(s);
|
|
213
|
+
if (dotted !== s && catalog) {
|
|
214
|
+
if (catalog.has(dotted)) return dotted;
|
|
215
|
+
// `-1m` Anthropic suffix maps to Copilot's `-1m-internal` variant when
|
|
216
|
+
// present in the catalog. Other suffixes pass through unchanged.
|
|
217
|
+
if (/-1m$/i.test(dotted) && catalog.has(`${dotted}-internal`)) {
|
|
218
|
+
return `${dotted}-internal`;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// No catalog (test env, no token) OR no match — passthrough so the original
|
|
222
|
+
// input still reaches the CLI for an authoritative reject. Hyphen→dot is
|
|
223
|
+
// applied even without a catalog, since the dot form is universally what
|
|
224
|
+
// Copilot expects and the alternative is a guaranteed reject.
|
|
225
|
+
return dotted !== s && !catalog ? dotted : s;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Heuristic: does `model` look like a Copilot-namespace identifier? Mirrors
|
|
229
|
+
// engine/runtimes/claude.js#modelLooksFamiliar so engine/cli.js#_modelLooksIncompatible
|
|
230
|
+
// can warn when a fleet/CC switch strands an incompatible model. Accepts the
|
|
231
|
+
// three Minions short aliases (`sonnet`/`opus`/`haiku`) since the adapter
|
|
232
|
+
// translates them to valid Copilot IDs at argv-build time. Returning false
|
|
233
|
+
// means "this looks wrong for Copilot" — anything outside Anthropic / OpenAI /
|
|
234
|
+
// catalog membership is flagged.
|
|
235
|
+
function modelLooksFamiliar(model) {
|
|
236
|
+
if (!model) return true;
|
|
237
|
+
const m = String(model).toLowerCase();
|
|
238
|
+
if (m === 'sonnet' || m === 'opus' || m === 'haiku') return true;
|
|
239
|
+
if (m.startsWith('claude-')) return true;
|
|
240
|
+
if (m.startsWith('gpt-')) return true;
|
|
241
|
+
if (m.startsWith('o3') || m.startsWith('o4')) return true;
|
|
242
|
+
// Mirror resolveModel: route through module.exports so test monkey-patching
|
|
243
|
+
// of _readCatalogIds reaches this call site too.
|
|
244
|
+
const catalog = module.exports._readCatalogIds();
|
|
245
|
+
if (catalog && catalog.has(m)) return true;
|
|
246
|
+
return false;
|
|
174
247
|
}
|
|
175
248
|
|
|
176
249
|
/**
|
|
@@ -872,6 +945,7 @@ module.exports = {
|
|
|
872
945
|
usesSystemPromptFile,
|
|
873
946
|
classifyFailure,
|
|
874
947
|
resolveModel,
|
|
948
|
+
modelLooksFamiliar,
|
|
875
949
|
parseOutput,
|
|
876
950
|
parseStreamChunk,
|
|
877
951
|
parseError,
|
|
@@ -881,5 +955,7 @@ module.exports = {
|
|
|
881
955
|
_MINIONS_MODEL_ALIASES,
|
|
882
956
|
_mapEffort,
|
|
883
957
|
_copilotAssistantMessageHasTools,
|
|
958
|
+
_readCatalogIds,
|
|
959
|
+
_hyphenToDotVersion,
|
|
884
960
|
KNOWN_EVENT_TYPES,
|
|
885
961
|
};
|
package/engine/shared.js
CHANGED
|
@@ -283,6 +283,40 @@ function safeJsonObj(p) { return safeJson(p) || {}; }
|
|
|
283
283
|
/** Null-safe safeJson wrapper — returns [] when file is missing/corrupt. */
|
|
284
284
|
function safeJsonArr(p) { return safeJson(p) || []; }
|
|
285
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Sibling of safeJson for terminal-artifact reads (PRDs in `prd/`, archived
|
|
288
|
+
* plans, anything where a missing primary should NOT auto-restore from a
|
|
289
|
+
* stale `.backup` sidecar). Returns the parsed JSON on success, or null when
|
|
290
|
+
* the primary is missing or unparseable.
|
|
291
|
+
*
|
|
292
|
+
* Why a separate primitive: safeJson's restore-on-miss is correct for live
|
|
293
|
+
* state files (work-items.json, dispatch.json, pull-requests.json, etc.) but
|
|
294
|
+
* actively harmful for terminal artifacts. Archived PRDs leave a `.backup`
|
|
295
|
+
* sidecar in `prd/`; if any caller reads the active path with safeJson, the
|
|
296
|
+
* .backup is silently restored and the dashboard sees a phantom "active" PRD
|
|
297
|
+
* (W-mouptdh1000h9f39). PRDs are end-state — no automatic resurrection.
|
|
298
|
+
*
|
|
299
|
+
* Parse errors are logged so silent corruption still surfaces (mirrors
|
|
300
|
+
* safeJson's contract). Read errors other than ENOENT are also logged.
|
|
301
|
+
*/
|
|
302
|
+
function safeJsonNoRestore(p) {
|
|
303
|
+
let raw;
|
|
304
|
+
try {
|
|
305
|
+
raw = fs.readFileSync(p, 'utf8');
|
|
306
|
+
} catch (e) {
|
|
307
|
+
if (e && e.code !== 'ENOENT') {
|
|
308
|
+
console.warn(`[safeJsonNoRestore] read failed for ${path.basename(p)}: ${e.message}`);
|
|
309
|
+
}
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
return JSON.parse(raw);
|
|
314
|
+
} catch (parseErr) {
|
|
315
|
+
console.error(`[safeJsonNoRestore] parse failure for ${path.basename(p)}: ${parseErr.message}`);
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
286
320
|
/**
|
|
287
321
|
* Monotonic counter for generating unique temp file names within this process.
|
|
288
322
|
* Assumes single-thread execution (no worker_threads). If worker_threads are
|
|
@@ -1614,12 +1648,15 @@ function sameResolvedPath(a, b) {
|
|
|
1614
1648
|
|
|
1615
1649
|
function removeLegacyProjectStateDir(project) {
|
|
1616
1650
|
const dir = legacyProjectStateDir(project);
|
|
1617
|
-
if (!dir) return false;
|
|
1618
|
-
if (sameResolvedPath(dir, MINIONS_DIR)) return false;
|
|
1651
|
+
if (!dir) return { removed: false, error: null };
|
|
1652
|
+
if (sameResolvedPath(dir, MINIONS_DIR)) return { removed: false, error: null };
|
|
1653
|
+
if (!fs.existsSync(dir)) return { removed: false, error: null };
|
|
1619
1654
|
try {
|
|
1620
1655
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
1621
|
-
return true;
|
|
1622
|
-
} catch {
|
|
1656
|
+
return { removed: true, error: null };
|
|
1657
|
+
} catch (err) {
|
|
1658
|
+
return { removed: false, error: err && err.message ? err.message : String(err) };
|
|
1659
|
+
}
|
|
1623
1660
|
}
|
|
1624
1661
|
|
|
1625
1662
|
function ensureProjectStateFiles(project, options = {}) {
|
|
@@ -1629,7 +1666,7 @@ function ensureProjectStateFiles(project, options = {}) {
|
|
|
1629
1666
|
{ name: 'pull-requests.json', centralPath: projectPrPath(project) },
|
|
1630
1667
|
{ name: 'work-items.json', centralPath: projectWorkItemsPath(project) },
|
|
1631
1668
|
];
|
|
1632
|
-
const result = { created: [], migrated: [], removedLegacy: [], legacyDirRemoved: false };
|
|
1669
|
+
const result = { created: [], migrated: [], removedLegacy: [], legacyDirRemoved: false, legacyDirRemoveError: null };
|
|
1633
1670
|
|
|
1634
1671
|
projectStateDirEnsure(project);
|
|
1635
1672
|
for (const file of files) {
|
|
@@ -1662,7 +1699,11 @@ function ensureProjectStateFiles(project, options = {}) {
|
|
|
1662
1699
|
}
|
|
1663
1700
|
}
|
|
1664
1701
|
|
|
1665
|
-
if (removeLegacy)
|
|
1702
|
+
if (removeLegacy) {
|
|
1703
|
+
const res = removeLegacyProjectStateDir(project);
|
|
1704
|
+
result.legacyDirRemoved = res.removed;
|
|
1705
|
+
result.legacyDirRemoveError = res.error;
|
|
1706
|
+
}
|
|
1666
1707
|
return result;
|
|
1667
1708
|
}
|
|
1668
1709
|
|
|
@@ -2963,7 +3004,7 @@ module.exports = {
|
|
|
2963
3004
|
log,
|
|
2964
3005
|
safeRead,
|
|
2965
3006
|
safeReadDir,
|
|
2966
|
-
safeJson, safeJsonObj, safeJsonArr,
|
|
3007
|
+
safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore,
|
|
2967
3008
|
safeWrite,
|
|
2968
3009
|
safeUnlink,
|
|
2969
3010
|
neutralizeJsonBackupSidecar,
|
package/engine.js
CHANGED
|
@@ -91,6 +91,7 @@ const { getProjects, projectRoot, projectStateDir, projectWorkItemsPath, project
|
|
|
91
91
|
// ─── Utilities ──────────────────────────────────────────────────────────────
|
|
92
92
|
|
|
93
93
|
const safeJson = shared.safeJson;
|
|
94
|
+
const safeJsonNoRestore = shared.safeJsonNoRestore;
|
|
94
95
|
const safeRead = shared.safeRead;
|
|
95
96
|
const safeWrite = shared.safeWrite;
|
|
96
97
|
const safeUnlink = shared.safeUnlink;
|
|
@@ -1015,6 +1016,10 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
1015
1016
|
agentId,
|
|
1016
1017
|
branchName,
|
|
1017
1018
|
agentsDir: AGENTS_DIR,
|
|
1019
|
+
// Pass the working directory so the Claude adapter can probe for the
|
|
1020
|
+
// conversation jsonl and avoid `--resume <dead-uuid>` retry loops when
|
|
1021
|
+
// the agent died before checkpoint (W-mouugzow00068741).
|
|
1022
|
+
cwd,
|
|
1018
1023
|
logger: _runtimeLogger(),
|
|
1019
1024
|
});
|
|
1020
1025
|
}
|
|
@@ -1697,9 +1702,12 @@ function areDependenciesMet(item, config) {
|
|
|
1697
1702
|
for (const depId of deps) {
|
|
1698
1703
|
const depItem = allWorkItems.find(w => w.id === depId);
|
|
1699
1704
|
if (!depItem) {
|
|
1700
|
-
// Fallback: check PRD JSON — plan-to-prd agents may pre-set items to done
|
|
1705
|
+
// Fallback: check PRD JSON — plan-to-prd agents may pre-set items to done.
|
|
1706
|
+
// safeJsonNoRestore — if the PRD has been archived, treat the dep as
|
|
1707
|
+
// unmet rather than resurrecting the active PRD from .backup
|
|
1708
|
+
// (W-mouptdh1000h9f39).
|
|
1701
1709
|
try {
|
|
1702
|
-
const plan =
|
|
1710
|
+
const plan = safeJsonNoRestore(path.join(PRD_DIR, sourcePlan));
|
|
1703
1711
|
const prdItem = (plan?.missing_features || []).find(f => f.id === depId);
|
|
1704
1712
|
if (prdItem && PRD_MET_STATUSES.has(prdItem.status)) continue; // PRD says done — treat as met
|
|
1705
1713
|
} catch (e) { log('warn', 'check PRD dep status: ' + e.message); }
|
|
@@ -1951,7 +1959,10 @@ function materializePlansAsWorkItems(config) {
|
|
|
1951
1959
|
const SEQUENTIAL_ID_RE = /^P-?\d+$/;
|
|
1952
1960
|
|
|
1953
1961
|
for (const file of planFiles) {
|
|
1954
|
-
|
|
1962
|
+
// safeJsonNoRestore — if a PRD was archived between readdir and this
|
|
1963
|
+
// read, do not auto-resurrect it from a stale .backup sidecar
|
|
1964
|
+
// (W-mouptdh1000h9f39).
|
|
1965
|
+
let plan = safeJsonNoRestore(path.join(PRD_DIR, file));
|
|
1955
1966
|
if (!plan?.missing_features) continue;
|
|
1956
1967
|
|
|
1957
1968
|
// ID collision prevention: remap sequential IDs (P-001, P-002) to globally unique P-<uid> IDs.
|
|
@@ -3771,6 +3782,39 @@ function discoverCentralWorkItems(config) {
|
|
|
3771
3782
|
}
|
|
3772
3783
|
|
|
3773
3784
|
|
|
3785
|
+
/**
|
|
3786
|
+
* Sweep stale `.backup` sidecars in `prd/` whose archived counterpart already
|
|
3787
|
+
* lives in `prd/archive/<name>.json` and the active `prd/<name>.json` is
|
|
3788
|
+
* absent. Pre-neutralize-era archives left these sidecars behind; without this
|
|
3789
|
+
* sweep, any caller that touched `prd/<name>.json` with the restore-enabled
|
|
3790
|
+
* `safeJson` would resurrect the archived PRD as active (W-mouptdh1000h9f39).
|
|
3791
|
+
*
|
|
3792
|
+
* Idempotent and best-effort: readdir / existsSync / unlink failures are
|
|
3793
|
+
* swallowed so a single weird filesystem state never blocks the discovery
|
|
3794
|
+
* tick. Returns the number of sidecars purged (for tests / logging).
|
|
3795
|
+
*/
|
|
3796
|
+
function sweepStaleArchivedPrdBackups(prdDir, prdArchiveDir) {
|
|
3797
|
+
let purged = 0;
|
|
3798
|
+
if (!fs.existsSync(prdArchiveDir)) return purged;
|
|
3799
|
+
let archivedNames;
|
|
3800
|
+
try { archivedNames = fs.readdirSync(prdArchiveDir).filter(f => f.endsWith('.json')); }
|
|
3801
|
+
catch { return purged; }
|
|
3802
|
+
for (const f of archivedNames) {
|
|
3803
|
+
const activePath = path.join(prdDir, f);
|
|
3804
|
+
const backupPath = activePath + '.backup';
|
|
3805
|
+
// Active PRD wins — the dedicated ghost-PRD purge in discoverWork handles
|
|
3806
|
+
// the case where both active.json and active.json.backup are present.
|
|
3807
|
+
if (fs.existsSync(activePath)) continue;
|
|
3808
|
+
if (!fs.existsSync(backupPath)) continue;
|
|
3809
|
+
try {
|
|
3810
|
+
fs.unlinkSync(backupPath);
|
|
3811
|
+
purged++;
|
|
3812
|
+
log('info', `Purged stale .backup sidecar for archived PRD: ${f}`);
|
|
3813
|
+
} catch { /* best-effort */ }
|
|
3814
|
+
}
|
|
3815
|
+
return purged;
|
|
3816
|
+
}
|
|
3817
|
+
|
|
3774
3818
|
/**
|
|
3775
3819
|
* Run all work discovery sources and queue new items
|
|
3776
3820
|
* Priority: fix (0) > ask (1) > review (1) > implement (2) > work-items (3) > central (4)
|
|
@@ -3865,17 +3909,29 @@ async function discoverWork(config) {
|
|
|
3865
3909
|
try {
|
|
3866
3910
|
const lifecycle = require('./engine/lifecycle');
|
|
3867
3911
|
const prdDir = path.join(MINIONS_DIR, 'prd');
|
|
3912
|
+
const prdArchiveDir = path.join(prdDir, 'archive');
|
|
3868
3913
|
if (fs.existsSync(prdDir)) {
|
|
3914
|
+
// Pass 1 — Burn the landmine: stale .backup sidecars whose archived
|
|
3915
|
+
// counterpart already lives in prd/archive/ but no active prd/<f>.json.
|
|
3916
|
+
// Without this, any future safeJson(prd/<f>.json) (or stray legacy
|
|
3917
|
+
// call) would auto-restore the .backup and resurrect the archived PRD
|
|
3918
|
+
// (W-mouptdh1000h9f39). Idempotent and fast — readdir + existsSync.
|
|
3919
|
+
sweepStaleArchivedPrdBackups(prdDir, prdArchiveDir);
|
|
3920
|
+
|
|
3921
|
+
// Pass 2 — Existing orphan ghost-PRD purge + completion sweep.
|
|
3869
3922
|
for (const f of fs.readdirSync(prdDir).filter(f => f.endsWith('.json'))) {
|
|
3870
3923
|
if (completedPlanCache.has(f)) continue;
|
|
3871
|
-
if (fs.existsSync(path.join(
|
|
3924
|
+
if (fs.existsSync(path.join(prdArchiveDir, f))) {
|
|
3872
3925
|
// Orphaned backup restore — plan is already archived. Purge the ghost copy.
|
|
3873
3926
|
try { fs.unlinkSync(path.join(prdDir, f)); } catch { }
|
|
3874
3927
|
try { fs.unlinkSync(path.join(prdDir, f + '.backup')); } catch { }
|
|
3875
3928
|
completedPlanCache.add(f);
|
|
3876
3929
|
continue;
|
|
3877
3930
|
}
|
|
3878
|
-
|
|
3931
|
+
// safeJsonNoRestore — defense in depth: if the file vanished between
|
|
3932
|
+
// readdir and read (e.g. concurrent archive), do not resurrect it
|
|
3933
|
+
// from a stale .backup sidecar (W-mouptdh1000h9f39).
|
|
3934
|
+
const plan = safeJsonNoRestore(path.join(prdDir, f));
|
|
3879
3935
|
if (!plan?.missing_features || plan.status === 'completed') {
|
|
3880
3936
|
if (plan?.status === 'completed') completedPlanCache.add(f);
|
|
3881
3937
|
continue;
|
|
@@ -4605,6 +4661,7 @@ module.exports = {
|
|
|
4605
4661
|
// Discovery
|
|
4606
4662
|
discoverWork, discoverFromPrs, discoverFromWorkItems, discoverCentralWorkItems,
|
|
4607
4663
|
materializePlansAsWorkItems,
|
|
4664
|
+
sweepStaleArchivedPrdBackups, // exported for testing
|
|
4608
4665
|
|
|
4609
4666
|
// Shared helpers (used by lifecycle.js and tests)
|
|
4610
4667
|
reconcileItemsWithPrs, detectDependencyCycles,
|
package/minions.js
CHANGED
|
@@ -435,7 +435,7 @@ async function initMinions({ skipScan = false, scanRoot, scanDepth } = {}) {
|
|
|
435
435
|
}
|
|
436
436
|
|
|
437
437
|
if (skipScan) {
|
|
438
|
-
console.log(' Run "minions scan" or "minions add <dir>" to link projects
|
|
438
|
+
console.log(' Run "minions scan" or "minions add <dir>" to link projects.');
|
|
439
439
|
rl.close();
|
|
440
440
|
} else {
|
|
441
441
|
// Offer to scan for repos (optional)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1765",
|
|
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"
|