@yemi33/minions 0.1.2381 → 0.1.2383
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 +42 -2
- package/dashboard/js/refresh.js +8 -2
- package/dashboard/js/render-other.js +38 -0
- package/dashboard/js/render-work-items.js +61 -20
- package/dashboard/js/settings.js +7 -8
- package/dashboard/pages/engine.html +6 -0
- package/dashboard.js +110 -27
- package/docs/README.md +1 -0
- package/docs/claude-md-propagation.md +118 -0
- package/docs/completion-reports.md +20 -1
- package/docs/engine-restart.md +10 -0
- package/docs/runtime-adapters.md +54 -22
- package/docs/skills.md +2 -0
- package/docs/workspace-manifests.md +1 -1
- package/docs/worktree-lifecycle.md +23 -0
- package/engine/acp-transport.js +63 -35
- package/engine/ado-comment.js +3 -1
- package/engine/agent-worker-pool.js +11 -4
- package/engine/cc-worker-pool.js +4 -2
- package/engine/claude-md-context.js +195 -0
- package/engine/cli.js +9 -1
- package/engine/comment-format.js +51 -14
- package/engine/consolidation.js +47 -3
- package/engine/db/migrations/026-pre-dispatch-rejections.js +55 -0
- package/engine/dispatch.js +80 -23
- package/engine/execution-model.js +68 -0
- package/engine/gh-comment.js +6 -3
- package/engine/github.js +6 -7
- package/engine/lifecycle.js +195 -56
- package/engine/llm.js +59 -83
- package/engine/memory-store.js +3 -1
- package/engine/pipeline.js +147 -20
- package/engine/playbook.js +39 -6
- package/engine/pooled-agent-process.js +28 -26
- 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/runtimes/claude.js +100 -25
- package/engine/runtimes/codex.js +19 -0
- package/engine/runtimes/contract.js +239 -0
- package/engine/runtimes/copilot.js +85 -8
- package/engine/runtimes/index.js +37 -9
- package/engine/shared.js +157 -64
- package/engine/spawn-agent.js +79 -113
- package/engine/spawn-phase-watchdog.js +8 -37
- package/engine/supervisor.js +72 -16
- package/engine/timeout.js +87 -16
- package/engine/untrusted-fence.js +2 -1
- package/engine.js +434 -125
- package/package.json +1 -1
- package/playbooks/fix.md +20 -0
- package/playbooks/review.md +2 -0
- package/skills/check-self-authored-review-comment/SKILL.md +34 -0
package/engine/spawn-agent.js
CHANGED
|
@@ -32,12 +32,18 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
const fs = require('fs');
|
|
35
|
-
const os = require('os');
|
|
36
35
|
const path = require('path');
|
|
37
36
|
const { runFile, cleanChildEnv, killGracefully, killImmediate, killByPidsImmediate, listProcessDescendants, ts, readFileNoFollow } = require('./shared');
|
|
38
|
-
const {
|
|
37
|
+
const {
|
|
38
|
+
resolveRuntime,
|
|
39
|
+
RUNTIME_OPTIONS_FLAG,
|
|
40
|
+
RUNTIME_IMAGES_FILE_OPTION,
|
|
41
|
+
decodeRuntimeOptions,
|
|
42
|
+
prepareWorkspace: prepareRuntimeWorkspace,
|
|
43
|
+
} = require('./runtimes');
|
|
39
44
|
const { acquireAdoTokenSync, isLikelyAdoToken } = require('./ado-token');
|
|
40
45
|
const keepProcessSweep = require('./keep-process-sweep');
|
|
46
|
+
const MAX_RUNTIME_IMAGES_FILE_BYTES = 32 * 1024 * 1024;
|
|
41
47
|
|
|
42
48
|
// ─── Pure helpers (exported for tests) ──────────────────────────────────────
|
|
43
49
|
|
|
@@ -76,17 +82,7 @@ function parseSpawnArgs(argv) {
|
|
|
76
82
|
case '--disable-builtin-mcps': opts.disableBuiltinMcps = true; break;
|
|
77
83
|
case '--no-custom-instructions': opts.suppressAgentsMd = true; break;
|
|
78
84
|
case '--enable-reasoning-summaries': opts.reasoningSummaries = true; break;
|
|
79
|
-
case
|
|
80
|
-
const v = peek();
|
|
81
|
-
if (typeof v === 'string' && v) projectHarnessDirs.push(v);
|
|
82
|
-
break;
|
|
83
|
-
}
|
|
84
|
-
// P-49e1c8b7 — hermetic harness opt-out. When set, computeAddDirs in
|
|
85
|
-
// main() returns [minionsDir] only (user-scope asset dirs from
|
|
86
|
-
// runtime.getUserAssetDirs AND any forwarded --project-harness-dir
|
|
87
|
-
// entries are dropped). Engine.js gates this flag via
|
|
88
|
-
// shared.resolveAgentHermeticHarness(agent, engine).
|
|
89
|
-
case '--hermetic-harness': hermetic = true; break;
|
|
85
|
+
case RUNTIME_OPTIONS_FLAG: Object.assign(opts, decodeRuntimeOptions(peek())); break;
|
|
90
86
|
// LEGACY: dropped — the runtime adapter emits its own permission flag.
|
|
91
87
|
// Pre-P-2a6d9c4f engine.js still passes `--permission-mode bypassPermissions`;
|
|
92
88
|
// letting it through would duplicate the permission flag for Claude.
|
|
@@ -168,6 +164,41 @@ function injectAdoTokenEnvForRepoHost(env, opts) {
|
|
|
168
164
|
return injectAdoTokenEnv(env, opts);
|
|
169
165
|
}
|
|
170
166
|
|
|
167
|
+
function hydrateRuntimeOptionFiles(opts, {
|
|
168
|
+
promptFile,
|
|
169
|
+
readFile = readFileNoFollow,
|
|
170
|
+
lstatSync = fs.lstatSync,
|
|
171
|
+
unlinkSync = fs.unlinkSync,
|
|
172
|
+
} = {}) {
|
|
173
|
+
const imagesFile = opts?.[RUNTIME_IMAGES_FILE_OPTION];
|
|
174
|
+
if (!imagesFile) return opts;
|
|
175
|
+
if (typeof imagesFile !== 'string' || !promptFile) {
|
|
176
|
+
throw new Error('Invalid runtime images sidecar path');
|
|
177
|
+
}
|
|
178
|
+
const resolvedFile = path.resolve(imagesFile);
|
|
179
|
+
const optionsDir = path.resolve(path.dirname(promptFile));
|
|
180
|
+
if (path.dirname(resolvedFile) !== optionsDir) {
|
|
181
|
+
throw new Error('Runtime images sidecar must be beside the prompt file');
|
|
182
|
+
}
|
|
183
|
+
const stat = lstatSync(resolvedFile);
|
|
184
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_RUNTIME_IMAGES_FILE_BYTES) {
|
|
185
|
+
throw new Error('Invalid runtime images sidecar file');
|
|
186
|
+
}
|
|
187
|
+
let images;
|
|
188
|
+
try {
|
|
189
|
+
images = JSON.parse(readFile(resolvedFile, 'utf8'));
|
|
190
|
+
} catch (err) {
|
|
191
|
+
throw new Error(`Invalid runtime images sidecar payload: ${err.message}`);
|
|
192
|
+
}
|
|
193
|
+
if (!Array.isArray(images)) {
|
|
194
|
+
throw new Error('Invalid runtime images sidecar payload: expected an array');
|
|
195
|
+
}
|
|
196
|
+
opts.images = images;
|
|
197
|
+
delete opts[RUNTIME_IMAGES_FILE_OPTION];
|
|
198
|
+
try { unlinkSync(resolvedFile); } catch {}
|
|
199
|
+
return opts;
|
|
200
|
+
}
|
|
201
|
+
|
|
171
202
|
function formatProcessExitSentinel(exitCode, signal) {
|
|
172
203
|
return `\n[process-exit] code=${exitCode}${signal ? ` signal=${signal}` : ''}\n`;
|
|
173
204
|
}
|
|
@@ -307,105 +338,16 @@ function createParentPipeForwarder(stream, outputPath, prefix = '') {
|
|
|
307
338
|
}
|
|
308
339
|
|
|
309
340
|
/**
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
* silently suppresses → the agent runs without the workspace MCPs).
|
|
314
|
-
*
|
|
315
|
-
* Writes to `projects[worktreePath].enabledMcpjsonServers` (the field Claude
|
|
316
|
-
* Code actually reads — confirmed against the live `~/.claude.json` schema:
|
|
317
|
-
* `enabledMcpjsonServers` / `disabledMcpjsonServers` are siblings of
|
|
318
|
-
* `mcpServers`, indexed by absolute project path). Notes on the PRD wording
|
|
319
|
-
* "mcpServers.approved": that's the intent label; `enabledMcpjsonServers` is
|
|
320
|
-
* the on-disk reality.
|
|
321
|
-
*
|
|
322
|
-
* Pure-ish: all I/O (fs reads, mutator) is injected for tests. Best-effort —
|
|
323
|
-
* callers MUST swallow errors and never block dispatch on this.
|
|
324
|
-
*
|
|
325
|
-
* Returns `{ wrote: boolean, reason: string, servers: string[] }`:
|
|
326
|
-
* - reason ∈ { 'ok', 'flag-off', 'not-claude', 'no-worktree',
|
|
327
|
-
* 'no-workspace-mcp', 'invalid-workspace-mcp', 'no-servers' }
|
|
328
|
-
* - servers: keys discovered in the workspace `.mcp.json` (regardless of write)
|
|
329
|
-
*
|
|
330
|
-
* Args:
|
|
331
|
-
* runtimeName — `runtime.name` from `resolveRuntime(...)`. Helper no-ops if !== 'claude'.
|
|
332
|
-
* worktreePath — absolute path to the agent's cwd (the worktree). Used as both the file location
|
|
333
|
-
* (`<worktreePath>/.mcp.json`) and the `projects[...]` key in `~/.claude.json`.
|
|
334
|
-
* homeDir — `os.homedir()`. Injected for test isolation; helper does NOT call os.homedir directly.
|
|
335
|
-
* engineConfig — config.engine object; reads `claudePreApproveWorkspaceMcps` (default true via ENGINE_DEFAULTS).
|
|
336
|
-
* mutateJsonFileLocked — injected file-locked mutator (engine/shared.js#mutateJsonFileLocked).
|
|
337
|
-
* existsSync, readFileSync — injectable fs primitives (default to node:fs).
|
|
341
|
+
* Backward-compatible wrapper for the old helper name. Runtime-owned workspace
|
|
342
|
+
* preparation now lives behind the adapter contract; the spawn wrapper does
|
|
343
|
+
* not know which harness needs which config file mutation.
|
|
338
344
|
*/
|
|
339
|
-
function preApproveWorkspaceMcps({
|
|
340
|
-
runtimeName,
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
engineConfig,
|
|
344
|
-
mutateJsonFileLocked,
|
|
345
|
-
existsSync = fs.existsSync,
|
|
346
|
-
readFileSync = fs.readFileSync,
|
|
347
|
-
} = {}) {
|
|
348
|
-
if (engineConfig && engineConfig.claudePreApproveWorkspaceMcps === false) {
|
|
349
|
-
return { wrote: false, reason: 'flag-off', servers: [] };
|
|
350
|
-
}
|
|
351
|
-
if (runtimeName !== 'claude') {
|
|
352
|
-
return { wrote: false, reason: 'not-claude', servers: [] };
|
|
353
|
-
}
|
|
354
|
-
if (!worktreePath || typeof worktreePath !== 'string') {
|
|
355
|
-
return { wrote: false, reason: 'no-worktree', servers: [] };
|
|
345
|
+
function preApproveWorkspaceMcps({ runtimeName, ...context } = {}) {
|
|
346
|
+
const result = prepareRuntimeWorkspace(resolveRuntime(runtimeName), context);
|
|
347
|
+
if (result?.reason === 'unsupported-runtime') {
|
|
348
|
+
return { ...result, reason: 'not-claude' };
|
|
356
349
|
}
|
|
357
|
-
|
|
358
|
-
if (!existsSync(workspaceMcpPath)) {
|
|
359
|
-
return { wrote: false, reason: 'no-workspace-mcp', servers: [] };
|
|
360
|
-
}
|
|
361
|
-
let workspaceMcp;
|
|
362
|
-
try {
|
|
363
|
-
workspaceMcp = JSON.parse(readFileSync(workspaceMcpPath, 'utf8'));
|
|
364
|
-
} catch {
|
|
365
|
-
return { wrote: false, reason: 'invalid-workspace-mcp', servers: [] };
|
|
366
|
-
}
|
|
367
|
-
const serversObj = workspaceMcp && typeof workspaceMcp === 'object' ? workspaceMcp.mcpServers : null;
|
|
368
|
-
const servers = serversObj && typeof serversObj === 'object' && !Array.isArray(serversObj)
|
|
369
|
-
? Object.keys(serversObj).filter((k) => typeof k === 'string' && k.length > 0)
|
|
370
|
-
: [];
|
|
371
|
-
if (servers.length === 0) {
|
|
372
|
-
return { wrote: false, reason: 'no-servers', servers: [] };
|
|
373
|
-
}
|
|
374
|
-
if (!homeDir || typeof mutateJsonFileLocked !== 'function') {
|
|
375
|
-
return { wrote: false, reason: 'no-home-or-mutator', servers };
|
|
376
|
-
}
|
|
377
|
-
const claudeJsonPath = path.join(homeDir, '.claude.json');
|
|
378
|
-
let didWrite = false;
|
|
379
|
-
mutateJsonFileLocked(claudeJsonPath, (data) => {
|
|
380
|
-
const root = data && typeof data === 'object' ? data : {};
|
|
381
|
-
if (!root.projects || typeof root.projects !== 'object') root.projects = {};
|
|
382
|
-
const projectKey = worktreePath;
|
|
383
|
-
const proj = root.projects[projectKey] && typeof root.projects[projectKey] === 'object'
|
|
384
|
-
? root.projects[projectKey]
|
|
385
|
-
: {};
|
|
386
|
-
// enabledMcpjsonServers is Claude's actual on-disk approval field
|
|
387
|
-
const enabled = Array.isArray(proj.enabledMcpjsonServers) ? proj.enabledMcpjsonServers.slice() : [];
|
|
388
|
-
const disabled = Array.isArray(proj.disabledMcpjsonServers) ? proj.disabledMcpjsonServers.slice() : [];
|
|
389
|
-
const enabledSet = new Set(enabled.filter((s) => typeof s === 'string'));
|
|
390
|
-
let mutated = false;
|
|
391
|
-
for (const name of servers) {
|
|
392
|
-
if (!enabledSet.has(name)) {
|
|
393
|
-
enabledSet.add(name);
|
|
394
|
-
mutated = true;
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
// If a server was previously explicitly disabled, lift the disabled mark when we
|
|
398
|
-
// re-approve it — otherwise Claude treats the disabled entry as authoritative.
|
|
399
|
-
const nextDisabled = disabled.filter((s) => typeof s === 'string' && !servers.includes(s));
|
|
400
|
-
if (nextDisabled.length !== disabled.length) mutated = true;
|
|
401
|
-
if (!mutated) return data;
|
|
402
|
-
proj.enabledMcpjsonServers = [...enabledSet];
|
|
403
|
-
proj.disabledMcpjsonServers = nextDisabled;
|
|
404
|
-
root.projects[projectKey] = proj;
|
|
405
|
-
didWrite = true;
|
|
406
|
-
return root;
|
|
407
|
-
}, { defaultValue: {}, skipWriteIfUnchanged: true });
|
|
408
|
-
return { wrote: didWrite, reason: 'ok', servers };
|
|
350
|
+
return result;
|
|
409
351
|
}
|
|
410
352
|
|
|
411
353
|
// ─── Main script execution ──────────────────────────────────────────────────
|
|
@@ -421,7 +363,13 @@ function _installHint(name, runtime) {
|
|
|
421
363
|
}
|
|
422
364
|
|
|
423
365
|
function main() {
|
|
424
|
-
|
|
366
|
+
let parsed;
|
|
367
|
+
try {
|
|
368
|
+
parsed = parseSpawnArgs(process.argv);
|
|
369
|
+
} catch (err) {
|
|
370
|
+
console.error(`FATAL: ${err.message}`);
|
|
371
|
+
process.exit(78);
|
|
372
|
+
}
|
|
425
373
|
if (!parsed) {
|
|
426
374
|
console.error('Usage: node spawn-agent.js <prompt-file> <sysprompt-file> [--runtime <name>] [args...]');
|
|
427
375
|
process.exit(1);
|
|
@@ -445,12 +393,18 @@ function main() {
|
|
|
445
393
|
// (Windows non-junction reads don't follow symlinks the same way POSIX does).
|
|
446
394
|
const promptText = readFileNoFollow(promptFile, 'utf8');
|
|
447
395
|
const sysPromptText = readFileNoFollow(sysPromptFile, 'utf8');
|
|
396
|
+
try {
|
|
397
|
+
hydrateRuntimeOptionFiles(opts, { promptFile });
|
|
398
|
+
} catch (err) {
|
|
399
|
+
console.error(`FATAL: ${err.message}`);
|
|
400
|
+
process.exit(78);
|
|
401
|
+
}
|
|
448
402
|
|
|
449
403
|
// Sys prompt tmp file — only when (a) NOT resuming and (b) the adapter
|
|
450
404
|
// accepts a system prompt as a separate file. For runtimes that bake the
|
|
451
405
|
// system prompt into the user prompt (e.g. Copilot), sysPromptText is
|
|
452
406
|
// already merged inside `runtime.buildPrompt(prompt, sys)`.
|
|
453
|
-
const isResume = opts.sessionId
|
|
407
|
+
const isResume = typeof opts.sessionId === 'string' && opts.sessionId.trim() !== '';
|
|
454
408
|
const sysTmpPath = sysPromptFile + '.tmp';
|
|
455
409
|
const wantsSystemPromptFile = typeof runtime.usesSystemPromptFile === 'function'
|
|
456
410
|
? runtime.usesSystemPromptFile({ isResume, opts })
|
|
@@ -714,6 +668,18 @@ function main() {
|
|
|
714
668
|
});
|
|
715
669
|
}
|
|
716
670
|
|
|
717
|
-
module.exports = {
|
|
671
|
+
module.exports = {
|
|
672
|
+
parseSpawnArgs,
|
|
673
|
+
buildSpawnInvocation,
|
|
674
|
+
normalizeRuntimeExit,
|
|
675
|
+
shouldInjectAdoTokenEnv,
|
|
676
|
+
injectAdoTokenEnv,
|
|
677
|
+
injectAdoTokenEnvForRepoHost,
|
|
678
|
+
writeProcessExitSentinel,
|
|
679
|
+
preApproveWorkspaceMcps,
|
|
680
|
+
createParentPipeForwarder,
|
|
681
|
+
assertStaleHeadOk,
|
|
682
|
+
hydrateRuntimeOptionFiles, // exported for testing
|
|
683
|
+
};
|
|
718
684
|
|
|
719
685
|
if (require.main === module) main();
|
|
@@ -36,6 +36,7 @@ const fs = require('fs');
|
|
|
36
36
|
const path = require('path');
|
|
37
37
|
const shared = require('./shared');
|
|
38
38
|
const queries = require('./queries');
|
|
39
|
+
const { resolveRuntime, isSpawnStartupEvent } = require('./runtimes');
|
|
39
40
|
const { ENGINE_DEFAULTS, FAILURE_CLASS } = shared;
|
|
40
41
|
const AGENTS_DIR = queries.AGENTS_DIR;
|
|
41
42
|
const log = (level, msg) => shared.log ? shared.log(level, msg) : console.log(`[${level}] ${msg}`);
|
|
@@ -43,24 +44,6 @@ const log = (level, msg) => shared.log ? shared.log(level, msg) : console.log(`[
|
|
|
43
44
|
// Lazy require to break circular dep with dispatch.js (which lazy-requires engine.js).
|
|
44
45
|
function dispatch() { return require('./dispatch'); }
|
|
45
46
|
|
|
46
|
-
// ── Per-runtime startup-only event filters ──────────────────────────────────
|
|
47
|
-
//
|
|
48
|
-
// Anything NOT in these sets is treated as "real task activity" and the
|
|
49
|
-
// watchdog stands down. These match the runtimes/*.js KNOWN_EVENT_TYPES
|
|
50
|
-
// startup section — keep in sync.
|
|
51
|
-
|
|
52
|
-
const COPILOT_STARTUP_TYPES = new Set([
|
|
53
|
-
'session.mcp_server_status_changed',
|
|
54
|
-
'session.mcp_servers_loaded',
|
|
55
|
-
'session.skills_loaded',
|
|
56
|
-
'session.tools_updated',
|
|
57
|
-
'session.info',
|
|
58
|
-
]);
|
|
59
|
-
|
|
60
|
-
// Claude stream-json prints {"type":"system","subtype":"init|hook_started|hook_response"}
|
|
61
|
-
// during startup before any assistant/user/tool_use/result events fire.
|
|
62
|
-
const CLAUDE_STARTUP_SUBTYPES = new Set(['init', 'hook_started', 'hook_response']);
|
|
63
|
-
|
|
64
47
|
// Header line that engine.js writes at spawn: "[<iso>] pid: <n>"
|
|
65
48
|
const HEADER_LINE_RE = /^\[[^\]]+\]\s+(?:pid:|spawn-failed)/;
|
|
66
49
|
|
|
@@ -79,6 +62,12 @@ const HEADER_LINE_RE = /^\[[^\]]+\]\s+(?:pid:|spawn-failed)/;
|
|
|
79
62
|
*/
|
|
80
63
|
function hasRealTaskActivity(tailText, runtimeName) {
|
|
81
64
|
if (!tailText) return false;
|
|
65
|
+
let runtime;
|
|
66
|
+
try {
|
|
67
|
+
runtime = resolveRuntime(runtimeName);
|
|
68
|
+
} catch {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
82
71
|
const lines = String(tailText).split(/\r?\n/);
|
|
83
72
|
for (const rawLine of lines) {
|
|
84
73
|
const line = rawLine.trim();
|
|
@@ -100,23 +89,7 @@ function hasRealTaskActivity(tailText, runtimeName) {
|
|
|
100
89
|
// Object with no `type` — unusual; treat as activity to stay safe.
|
|
101
90
|
return true;
|
|
102
91
|
}
|
|
103
|
-
if (
|
|
104
|
-
if (COPILOT_STARTUP_TYPES.has(type)) continue;
|
|
105
|
-
// Anything else from Copilot is real (assistant.*, tool.*, result,
|
|
106
|
-
// session.task_complete, user.message, function).
|
|
107
|
-
return true;
|
|
108
|
-
}
|
|
109
|
-
if (runtimeName === 'claude' || !runtimeName) {
|
|
110
|
-
// Claude stream-json: only {type:'system', subtype:'init|hook_*'} is startup.
|
|
111
|
-
if (type === 'system') {
|
|
112
|
-
const sub = typeof evt.subtype === 'string' ? evt.subtype : '';
|
|
113
|
-
if (CLAUDE_STARTUP_SUBTYPES.has(sub)) continue;
|
|
114
|
-
// Other `system` subtypes (e.g. errors, banners) — treat as activity.
|
|
115
|
-
return true;
|
|
116
|
-
}
|
|
117
|
-
return true;
|
|
118
|
-
}
|
|
119
|
-
// Unknown runtime — be conservative.
|
|
92
|
+
if (isSpawnStartupEvent(runtime, evt)) continue;
|
|
120
93
|
return true;
|
|
121
94
|
}
|
|
122
95
|
return false;
|
|
@@ -266,6 +239,4 @@ function _defaultTailReader(filePath) {
|
|
|
266
239
|
module.exports = {
|
|
267
240
|
checkSpawnPhaseStalls,
|
|
268
241
|
hasRealTaskActivity, // exported for testing
|
|
269
|
-
COPILOT_STARTUP_TYPES, // exported for testing
|
|
270
|
-
CLAUDE_STARTUP_SUBTYPES, // exported for testing
|
|
271
242
|
};
|
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') {
|