@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.
@@ -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') {