@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.
Files changed (54) hide show
  1. package/bin/minions.js +42 -2
  2. package/dashboard/js/refresh.js +8 -2
  3. package/dashboard/js/render-other.js +38 -0
  4. package/dashboard/js/render-work-items.js +61 -20
  5. package/dashboard/js/settings.js +7 -8
  6. package/dashboard/pages/engine.html +6 -0
  7. package/dashboard.js +110 -27
  8. package/docs/README.md +1 -0
  9. package/docs/claude-md-propagation.md +118 -0
  10. package/docs/completion-reports.md +20 -1
  11. package/docs/engine-restart.md +10 -0
  12. package/docs/runtime-adapters.md +54 -22
  13. package/docs/skills.md +2 -0
  14. package/docs/workspace-manifests.md +1 -1
  15. package/docs/worktree-lifecycle.md +23 -0
  16. package/engine/acp-transport.js +63 -35
  17. package/engine/ado-comment.js +3 -1
  18. package/engine/agent-worker-pool.js +11 -4
  19. package/engine/cc-worker-pool.js +4 -2
  20. package/engine/claude-md-context.js +195 -0
  21. package/engine/cli.js +9 -1
  22. package/engine/comment-format.js +51 -14
  23. package/engine/consolidation.js +47 -3
  24. package/engine/db/migrations/026-pre-dispatch-rejections.js +55 -0
  25. package/engine/dispatch.js +80 -23
  26. package/engine/execution-model.js +68 -0
  27. package/engine/gh-comment.js +6 -3
  28. package/engine/github.js +6 -7
  29. package/engine/lifecycle.js +195 -56
  30. package/engine/llm.js +59 -83
  31. package/engine/memory-store.js +3 -1
  32. package/engine/pipeline.js +147 -20
  33. package/engine/playbook.js +39 -6
  34. package/engine/pooled-agent-process.js +28 -26
  35. package/engine/prd-store.js +14 -6
  36. package/engine/quarantine-refs.js +33 -0
  37. package/engine/queries.js +8 -6
  38. package/engine/restart-health.js +69 -4
  39. package/engine/runtimes/claude.js +100 -25
  40. package/engine/runtimes/codex.js +19 -0
  41. package/engine/runtimes/contract.js +239 -0
  42. package/engine/runtimes/copilot.js +85 -8
  43. package/engine/runtimes/index.js +37 -9
  44. package/engine/shared.js +157 -64
  45. package/engine/spawn-agent.js +79 -113
  46. package/engine/spawn-phase-watchdog.js +8 -37
  47. package/engine/supervisor.js +72 -16
  48. package/engine/timeout.js +87 -16
  49. package/engine/untrusted-fence.js +2 -1
  50. package/engine.js +434 -125
  51. package/package.json +1 -1
  52. package/playbooks/fix.md +20 -0
  53. package/playbooks/review.md +2 -0
  54. package/skills/check-self-authored-review-comment/SKILL.md +34 -0
@@ -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 { resolveRuntime } = require('./runtimes');
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 '--project-harness-dir': {
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
- * P-7d31a06b Pre-approve a worktree's `.mcp.json` servers in `~/.claude.json`
311
- * so Claude Code's first invocation in a fresh worktree cwd doesn't show the
312
- * project-scoped "trust this MCP server?" prompt (which `--dangerously-skip-permissions`
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
- worktreePath,
342
- homeDir,
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
- const workspaceMcpPath = path.join(worktreePath, '.mcp.json');
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
- const parsed = parseSpawnArgs(process.argv);
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 != null;
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 = { parseSpawnArgs, buildSpawnInvocation, normalizeRuntimeExit, shouldInjectAdoTokenEnv, injectAdoTokenEnv, injectAdoTokenEnvForRepoHost, writeProcessExitSentinel, preApproveWorkspaceMcps, createParentPipeForwarder, assertStaleHeadOk };
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 (runtimeName === 'copilot') {
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
  };
@@ -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 isPidAlive(pid) {
130
- if (!pid) return false;
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 (isWin) {
133
- const out = execSync(`tasklist /FI "PID eq ${pid}" /NH`, {
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, pid, { imageName: 'node' });
159
+ return shared.tasklistOutputShowsPid(out, n, { imageName: 'node' });
142
160
  }
143
- return new RegExp(`\\b${pid}\\b`).test(out) && out.toLowerCase().includes('node');
161
+ return new RegExp(`\\b${n}\\b`).test(out) && out.toLowerCase().includes('node');
144
162
  }
145
- process.kill(pid, 0);
146
- return true;
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. No-op when MINIONS_SUPERVISOR_REAP=0.
310
- function reapStrayProcesses(scriptPath, label) {
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 pids = listNodePidsMatching(scriptPath).filter(p => p !== process.pid);
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) _killHard(pid);
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 = { ...process.env, MINIONS_NO_AUTO_OPEN: '1' };
379
- const proc = spawn(process.execPath, [..._sqliteSpawnFlags(), path.join(MINIONS_DIR, 'dashboard.js')], {
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
- completeDispatch(item.id, DISPATCH_RESULT.ERROR, reason, '', failureClass ? { failureClass } : {});
960
- // PL-live-checkout-reliability-hardening a live-mode dispatch declared an
961
- // orphan (lost process handle, e.g. across an engine restart) leaves the
962
- // operator checkout on the agent branch. Restore it from the persisted
963
- // record (originalRef present ⇒ live mode). Always a terminal failure here.
964
- if (item.originalRef && item.meta?.branch && item.meta?.project?.localPath) {
965
- try {
966
- require('./live-checkout').maybeRestoreLiveCheckoutFromRecord({
967
- item,
968
- isTerminalFailure: true,
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') {