@yemi33/minions 0.1.2380 → 0.1.2382
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/bin/minions.js +41 -2
- package/dashboard/js/refresh.js +9 -3
- package/dashboard/js/render-other.js +81 -23
- package/dashboard/js/render-work-items.js +61 -20
- package/dashboard/js/settings.js +47 -25
- package/dashboard/pages/engine.html +6 -0
- package/dashboard.js +126 -62
- package/docs/README.md +1 -0
- package/docs/claude-md-propagation.md +118 -0
- package/docs/engine-restart.md +10 -0
- package/docs/live-checkout-mode.md +45 -26
- package/docs/worktree-lifecycle.md +9 -0
- package/engine/claude-md-context.js +195 -0
- package/engine/consolidation.js +47 -3
- package/engine/db/migrations/026-pre-dispatch-rejections.js +55 -0
- package/engine/dispatch.js +78 -24
- package/engine/lifecycle.js +129 -84
- package/engine/live-checkout.js +193 -149
- package/engine/pipeline.js +147 -20
- package/engine/playbook.js +47 -15
- package/engine/prd-store.js +14 -6
- package/engine/quarantine-refs.js +33 -0
- package/engine/queries.js +8 -6
- package/engine/restart-health.js +69 -4
- package/engine/routing.js +1 -1
- package/engine/runtimes/claude.js +6 -0
- package/engine/runtimes/codex.js +18 -0
- package/engine/runtimes/copilot.js +7 -0
- package/engine/shared.js +199 -106
- package/engine/supervisor.js +72 -16
- package/engine/timeout.js +87 -16
- package/engine/untrusted-fence.js +2 -1
- package/engine.js +301 -31
- package/package.json +1 -1
- package/prompts/cc-system.md +7 -7
package/engine/supervisor.js
CHANGED
|
@@ -104,6 +104,8 @@ const SUPERVISOR_STALE_ENGINE_HEARTBEAT_MS = Number(process.env.MINIONS_SUPERVIS
|
|
|
104
104
|
// 3 strikes ≈ 90s of continuous unresponsiveness before we act.
|
|
105
105
|
const DASH_HEALTH_TIMEOUT_MS = Number(process.env.MINIONS_SUPERVISOR_DASH_HEALTH_TIMEOUT_MS) || 5000;
|
|
106
106
|
const DASH_HEALTH_MAX_FAILS = Number(process.env.MINIONS_SUPERVISOR_DASH_HEALTH_FAILS) || 3;
|
|
107
|
+
const REAP_WAIT_TIMEOUT_MS = Number(process.env.MINIONS_SUPERVISOR_REAP_TIMEOUT_MS) || 10000;
|
|
108
|
+
const REAP_WAIT_POLL_MS = Number(process.env.MINIONS_SUPERVISOR_REAP_POLL_MS) || 100;
|
|
107
109
|
const isWin = process.platform === 'win32';
|
|
108
110
|
|
|
109
111
|
function safeReadJson(p) {
|
|
@@ -126,11 +128,27 @@ function isStopIntentSet() {
|
|
|
126
128
|
return fs.existsSync(STOP_INTENT_PATH_FN());
|
|
127
129
|
}
|
|
128
130
|
|
|
129
|
-
function
|
|
130
|
-
|
|
131
|
+
function _readPosixProcessState(pid) {
|
|
132
|
+
const result = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], {
|
|
133
|
+
encoding: 'utf8',
|
|
134
|
+
timeout: 3000,
|
|
135
|
+
windowsHide: true,
|
|
136
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
137
|
+
});
|
|
138
|
+
if (result.error || result.status !== 0) return null;
|
|
139
|
+
return String(result.stdout || '').trim();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isPidAlive(pid, {
|
|
143
|
+
platform = process.platform,
|
|
144
|
+
kill = process.kill,
|
|
145
|
+
readPosixProcessState = _readPosixProcessState,
|
|
146
|
+
} = {}) {
|
|
147
|
+
const n = Number(pid);
|
|
148
|
+
if (!Number.isInteger(n) || n <= 0) return false;
|
|
131
149
|
try {
|
|
132
|
-
if (
|
|
133
|
-
const out = execSync(`tasklist /FI "PID eq ${
|
|
150
|
+
if (platform === 'win32') {
|
|
151
|
+
const out = execSync(`tasklist /FI "PID eq ${n}" /NH`, {
|
|
134
152
|
encoding: 'utf8', timeout: 3000, windowsHide: true,
|
|
135
153
|
});
|
|
136
154
|
// Word-boundary + image-name match (mirrors engine/restart-health.js) so a
|
|
@@ -138,12 +156,16 @@ function isPidAlive(pid) {
|
|
|
138
156
|
// alive. Fall back to an inline regex if shared.js isn't loadable.
|
|
139
157
|
const shared = _sharedOrNull();
|
|
140
158
|
if (shared && typeof shared.tasklistOutputShowsPid === 'function') {
|
|
141
|
-
return shared.tasklistOutputShowsPid(out,
|
|
159
|
+
return shared.tasklistOutputShowsPid(out, n, { imageName: 'node' });
|
|
142
160
|
}
|
|
143
|
-
return new RegExp(`\\b${
|
|
161
|
+
return new RegExp(`\\b${n}\\b`).test(out) && out.toLowerCase().includes('node');
|
|
144
162
|
}
|
|
145
|
-
|
|
146
|
-
|
|
163
|
+
kill(n, 0);
|
|
164
|
+
// kill(0) also succeeds for Unix zombies. They cannot execute or retain
|
|
165
|
+
// runtime resources, and a synchronous reap wait prevents Node from
|
|
166
|
+
// processing SIGCHLD, so treating them as live deadlocks every respawn.
|
|
167
|
+
const state = readPosixProcessState(n);
|
|
168
|
+
return state === null || (state !== '' && !state.startsWith('Z'));
|
|
147
169
|
} catch { return false; }
|
|
148
170
|
}
|
|
149
171
|
|
|
@@ -305,14 +327,43 @@ function _killHard(pid) {
|
|
|
305
327
|
} catch { /* already gone */ }
|
|
306
328
|
}
|
|
307
329
|
|
|
330
|
+
function _sleepSync(ms) {
|
|
331
|
+
if (!(ms > 0)) return;
|
|
332
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function waitForPidsToExit(pids, {
|
|
336
|
+
timeoutMs = REAP_WAIT_TIMEOUT_MS,
|
|
337
|
+
pollMs = REAP_WAIT_POLL_MS,
|
|
338
|
+
isAlive = isPidAlive,
|
|
339
|
+
sleep = _sleepSync,
|
|
340
|
+
} = {}) {
|
|
341
|
+
const unique = [...new Set((pids || []).map(Number).filter(pid => Number.isInteger(pid) && pid > 0))];
|
|
342
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
343
|
+
let remaining = unique.filter(isAlive);
|
|
344
|
+
while (remaining.length > 0 && Date.now() < deadline) {
|
|
345
|
+
sleep(Math.min(pollMs, Math.max(1, deadline - Date.now())));
|
|
346
|
+
remaining = remaining.filter(isAlive);
|
|
347
|
+
}
|
|
348
|
+
return { ok: remaining.length === 0, remaining };
|
|
349
|
+
}
|
|
350
|
+
|
|
308
351
|
// Kill every stray process running `scriptPath`, except this supervisor.
|
|
309
|
-
// Returns the number reaped.
|
|
310
|
-
|
|
352
|
+
// Returns the number reaped. Throws instead of spawning a replacement when any
|
|
353
|
+
// old process survives the bounded wait — binding a fallback port or starting a
|
|
354
|
+
// second engine is worse than retrying on the next supervisor tick.
|
|
355
|
+
function reapStrayProcesses(scriptPath, label, options = {}) {
|
|
311
356
|
if (!REAP_ENABLED) return 0;
|
|
312
|
-
const
|
|
357
|
+
const listPids = options.listPids || listNodePidsMatching;
|
|
358
|
+
const kill = options.kill || _killHard;
|
|
359
|
+
const pids = listPids(scriptPath).filter(p => p !== process.pid);
|
|
313
360
|
if (!pids.length) return 0;
|
|
314
361
|
console.log(`[supervisor] Reaping ${pids.length} stray ${label} process(es) before respawn: ${pids.join(', ')}`);
|
|
315
|
-
for (const pid of pids)
|
|
362
|
+
for (const pid of pids) kill(pid);
|
|
363
|
+
const waited = waitForPidsToExit(pids, options);
|
|
364
|
+
if (!waited.ok) {
|
|
365
|
+
throw new Error(`Refusing to respawn ${label}: stale PID(s) still alive after ${options.timeoutMs || REAP_WAIT_TIMEOUT_MS}ms: ${waited.remaining.join(', ')}`);
|
|
366
|
+
}
|
|
316
367
|
return pids.length;
|
|
317
368
|
}
|
|
318
369
|
|
|
@@ -372,11 +423,15 @@ function spawnEngine() {
|
|
|
372
423
|
return proc.pid;
|
|
373
424
|
}
|
|
374
425
|
|
|
375
|
-
function spawnDashboard() {
|
|
426
|
+
function spawnDashboard(requestedPort = _resolveDashPort()) {
|
|
376
427
|
const out = openAppendFd('dashboard-stdio.log');
|
|
377
428
|
const err = openAppendFd('dashboard-stdio.log');
|
|
378
|
-
const env = {
|
|
379
|
-
|
|
429
|
+
const env = {
|
|
430
|
+
...process.env,
|
|
431
|
+
MINIONS_NO_AUTO_OPEN: '1',
|
|
432
|
+
MINIONS_REQUIRE_DASHBOARD_PORT: '1',
|
|
433
|
+
};
|
|
434
|
+
const proc = spawn(process.execPath, [..._sqliteSpawnFlags(), path.join(MINIONS_DIR, 'dashboard.js'), String(requestedPort)], {
|
|
380
435
|
cwd: MINIONS_DIR,
|
|
381
436
|
stdio: ['ignore', out, err],
|
|
382
437
|
detached: true,
|
|
@@ -533,7 +588,7 @@ async function checkDashboard(now) {
|
|
|
533
588
|
// Critically, for the frozen-but-listening case the reap also KILLS the wedged
|
|
534
589
|
// process still holding the port so spawnDashboard() can bind it.
|
|
535
590
|
reapStrayProcesses(path.join(MINIONS_DIR, 'dashboard.js'), 'dashboard');
|
|
536
|
-
const newPid = spawnDashboard();
|
|
591
|
+
const newPid = spawnDashboard(dashPort);
|
|
537
592
|
_lastDashboardRespawnAt = now;
|
|
538
593
|
_dashHealthFailStreak = 0;
|
|
539
594
|
console.log(`[supervisor] Dashboard respawned (new PID: ${newPid})`);
|
|
@@ -647,6 +702,7 @@ module.exports = {
|
|
|
647
702
|
listeningPidsForPort,
|
|
648
703
|
listNodePidsMatching,
|
|
649
704
|
reapStrayProcesses,
|
|
705
|
+
waitForPidsToExit,
|
|
650
706
|
_cmdRunsScript,
|
|
651
707
|
openAppendFd,
|
|
652
708
|
checkEngine,
|
package/engine/timeout.js
CHANGED
|
@@ -23,6 +23,60 @@ const MINIONS_DIR = shared.MINIONS_DIR;
|
|
|
23
23
|
// Only needed for engine().activeProcesses and engine().engineRestartGraceUntil — log/ts come from shared.js
|
|
24
24
|
let _engine = null;
|
|
25
25
|
function engine() { if (!_engine) _engine = require('../engine'); return _engine; }
|
|
26
|
+
const _pendingBranchPreservations = new Set();
|
|
27
|
+
|
|
28
|
+
function resolveDispatchBranchPath(item) {
|
|
29
|
+
if (item?.worktreePath) return item.worktreePath;
|
|
30
|
+
if (item?.originalRef && item.meta?.project?.localPath) return item.meta.project.localPath;
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function preserveDispatchBranch(item, config) {
|
|
35
|
+
const branchPath = resolveDispatchBranchPath(item);
|
|
36
|
+
if (!branchPath || !item.meta?.branch) {
|
|
37
|
+
return Promise.resolve({ pushed: false, reason: 'invalid-worktree' });
|
|
38
|
+
}
|
|
39
|
+
const persistedProject = item.meta?.project || {};
|
|
40
|
+
const project = getProjects(config).find(p => p.name === persistedProject.name) || persistedProject;
|
|
41
|
+
if (!project.localPath) return Promise.resolve({ pushed: false, reason: 'invalid-project' });
|
|
42
|
+
return engine().pushCleanAheadBranch({
|
|
43
|
+
rootDir: project.localPath,
|
|
44
|
+
worktreePath: branchPath,
|
|
45
|
+
branchName: item.meta.branch,
|
|
46
|
+
mainBranch: shared.resolveMainBranch(project.localPath, project.mainBranch),
|
|
47
|
+
project,
|
|
48
|
+
gitOpts: { env: shared.gitEnv() },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function _finalizeDeadDispatch(item, reason, failureClass, config, deps = {}) {
|
|
53
|
+
const preserveBranch = deps.preserveBranch || preserveDispatchBranch;
|
|
54
|
+
const restoreLiveCheckout = deps.restoreLiveCheckout ||
|
|
55
|
+
require('./live-checkout').maybeRestoreLiveCheckoutFromRecord;
|
|
56
|
+
const complete = deps.complete || dispatch().completeDispatch;
|
|
57
|
+
|
|
58
|
+
if (resolveDispatchBranchPath(item) && item.meta?.branch) {
|
|
59
|
+
try {
|
|
60
|
+
await preserveBranch(item, config);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
log('warn', `Orphan branch preservation failed for ${item.id}: ${err.message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (item.originalRef && item.meta?.branch && item.meta?.project?.localPath) {
|
|
66
|
+
try {
|
|
67
|
+
await restoreLiveCheckout({
|
|
68
|
+
item,
|
|
69
|
+
isTerminalFailure: true,
|
|
70
|
+
resultLabel: 'orphaned',
|
|
71
|
+
log: (lvl, msg) => log(lvl, msg),
|
|
72
|
+
writeInboxAlert: dispatch().writeInboxAlert,
|
|
73
|
+
});
|
|
74
|
+
} catch (err) {
|
|
75
|
+
log('warn', 'live-checkout restore (orphan): ' + err.message);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
complete(item.id, DISPATCH_RESULT.ERROR, reason, '', failureClass ? { failureClass } : {});
|
|
79
|
+
}
|
|
26
80
|
|
|
27
81
|
// Lazy require for dispatch module (also circular via engine)
|
|
28
82
|
let _dispatch = null;
|
|
@@ -664,7 +718,27 @@ function checkTimeouts(config) {
|
|
|
664
718
|
const deadItems = [];
|
|
665
719
|
const legacyAnnotationClears = new Set();
|
|
666
720
|
|
|
667
|
-
function completeFromOutput(item, liveLogPath, processExitCode, detectedLogText, hasProcess) {
|
|
721
|
+
function completeFromOutput(item, liveLogPath, processExitCode, detectedLogText, hasProcess, preservationDone = false, expectedProcInfo = activeProcesses.get(item.id)) {
|
|
722
|
+
if (!preservationDone && resolveDispatchBranchPath(item) && item.meta?.branch) {
|
|
723
|
+
if (_pendingBranchPreservations.has(item.id)) return;
|
|
724
|
+
_pendingBranchPreservations.add(item.id);
|
|
725
|
+
preserveDispatchBranch(item, config)
|
|
726
|
+
.catch(err => log('warn', `Output-completion branch preservation failed for ${item.id}: ${err.message}`))
|
|
727
|
+
.then(() => completeFromOutput(item, liveLogPath, processExitCode, detectedLogText, hasProcess, true, expectedProcInfo))
|
|
728
|
+
.finally(() => _pendingBranchPreservations.delete(item.id));
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
if (!(getDispatch().active || []).some(active => active.id === item.id)) return;
|
|
732
|
+
if (hasProcess) {
|
|
733
|
+
const currentProcInfo = activeProcesses.get(item.id);
|
|
734
|
+
if (currentProcInfo !== expectedProcInfo ||
|
|
735
|
+
currentProcInfo?._steeringAt ||
|
|
736
|
+
currentProcInfo?._steeringMessage ||
|
|
737
|
+
currentProcInfo?._steeringNoSession) {
|
|
738
|
+
log('info', `${item.id}: output-detected completion yielded to the close/steering owner after branch preservation`);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
668
742
|
const isSuccess = processExitCode === 0;
|
|
669
743
|
log('info', `Agent ${item.agent} (${item.id}) completed via output detection (exit code ${processExitCode}, ${isSuccess ? 'success' : 'error'})`);
|
|
670
744
|
|
|
@@ -956,22 +1030,18 @@ function checkTimeouts(config) {
|
|
|
956
1030
|
|
|
957
1031
|
// Clean up dead items
|
|
958
1032
|
for (const { item, reason, failureClass } of deadItems) {
|
|
959
|
-
|
|
960
|
-
//
|
|
961
|
-
//
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
resultLabel: 'orphaned',
|
|
970
|
-
log: (lvl, msg) => log(lvl, msg),
|
|
971
|
-
writeInboxAlert: dispatch().writeInboxAlert,
|
|
972
|
-
}).catch(() => {});
|
|
973
|
-
} catch (e) { log('warn', 'live-checkout restore (orphan): ' + e.message); }
|
|
1033
|
+
// #853 — stale orphans have no process close event, so preserve their
|
|
1034
|
+
// clean committed branch explicitly before completeDispatch makes the
|
|
1035
|
+
// worktree eligible for teardown/quarantine.
|
|
1036
|
+
if (resolveDispatchBranchPath(item) && item.meta?.branch) {
|
|
1037
|
+
if (_pendingBranchPreservations.has(item.id)) continue;
|
|
1038
|
+
_pendingBranchPreservations.add(item.id);
|
|
1039
|
+
_finalizeDeadDispatch(item, reason, failureClass, config, { complete: completeDispatch })
|
|
1040
|
+
.catch(err => log('warn', `Orphan cleanup failed for ${item.id}: ${err.message}`))
|
|
1041
|
+
.finally(() => _pendingBranchPreservations.delete(item.id));
|
|
1042
|
+
continue;
|
|
974
1043
|
}
|
|
1044
|
+
completeDispatch(item.id, DISPATCH_RESULT.ERROR, reason, '', failureClass ? { failureClass } : {});
|
|
975
1045
|
}
|
|
976
1046
|
|
|
977
1047
|
// Clear legacy blocking-tool annotations; process liveness no longer depends on tool parsing.
|
|
@@ -1055,4 +1125,5 @@ module.exports = {
|
|
|
1055
1125
|
// exported for testing — steering clamp helpers and deferred checkpoint
|
|
1056
1126
|
_clampSteeringDeferredMaxMs, _clampSteeringMaxKillRetries, _appendLiveOutputLine, deferSteeringUntilCheckpoint,
|
|
1057
1127
|
killTrackedProcess, _escalatePlatformKill, // exported for testing — P-6e2a4c15 cancel-vs-kill
|
|
1128
|
+
_finalizeDeadDispatch, // exported for testing
|
|
1058
1129
|
};
|
|
@@ -113,7 +113,8 @@ function buildSource(kind, parts) {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
if (k === 'pinned-note' || k === 'team-notes' || k === 'agent-memory'
|
|
116
|
-
|| k === 'wi-reference' || k === 'doc-content' || k === 'doc-selection'
|
|
116
|
+
|| k === 'wi-reference' || k === 'doc-content' || k === 'doc-selection'
|
|
117
|
+
|| k === 'project-instructions') {
|
|
117
118
|
return parts.path ? `${k}:${get('path')}` : k;
|
|
118
119
|
}
|
|
119
120
|
if (k === 'inbox') {
|