@yemi33/minions 0.1.2419 → 0.1.2421

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.
@@ -0,0 +1,208 @@
1
+ (function attachMemoryExplainer(globalObject, factory) {
2
+ const api = factory();
3
+
4
+ if (typeof module !== 'undefined' && module.exports) {
5
+ module.exports = api;
6
+ }
7
+ if (globalObject) {
8
+ globalObject.MemoryExplainer = api;
9
+ }
10
+
11
+ if (typeof document !== 'undefined') {
12
+ const start = () => api.init(document);
13
+ if (document.readyState === 'loading') {
14
+ document.addEventListener('DOMContentLoaded', start, { once: true });
15
+ } else {
16
+ start();
17
+ }
18
+ }
19
+ })(typeof globalThis !== 'undefined' ? globalThis : this, function memoryExplainerFactory() {
20
+ 'use strict';
21
+
22
+ const FORWARD_KEYS = new Set(['ArrowRight', 'ArrowDown', 'next']);
23
+ const BACKWARD_KEYS = new Set(['ArrowLeft', 'ArrowUp', 'previous']);
24
+
25
+ function createSelectionModel(inputIds, initialId) {
26
+ if (!Array.isArray(inputIds) || inputIds.length === 0) {
27
+ throw new TypeError('Selection model requires at least one item');
28
+ }
29
+ const ids = inputIds.map(id => String(id || '').trim());
30
+ if (ids.some(id => !id)) {
31
+ throw new TypeError('Selection model ids must be non-empty');
32
+ }
33
+ if (new Set(ids).size !== ids.length) {
34
+ throw new TypeError('Selection model ids must be unique');
35
+ }
36
+
37
+ let index = ids.indexOf(String(initialId || ''));
38
+ if (index < 0) index = 0;
39
+
40
+ function current() {
41
+ return ids[index];
42
+ }
43
+
44
+ function select(id) {
45
+ const nextIndex = ids.indexOf(String(id || ''));
46
+ if (nextIndex >= 0) index = nextIndex;
47
+ return current();
48
+ }
49
+
50
+ function move(action) {
51
+ if (FORWARD_KEYS.has(action)) {
52
+ index = (index + 1) % ids.length;
53
+ } else if (BACKWARD_KEYS.has(action)) {
54
+ index = (index - 1 + ids.length) % ids.length;
55
+ } else if (action === 'Home') {
56
+ index = 0;
57
+ } else if (action === 'End') {
58
+ index = ids.length - 1;
59
+ }
60
+ return current();
61
+ }
62
+
63
+ return { current, select, move };
64
+ }
65
+
66
+ function setupSelectionGroup(root, options) {
67
+ if (!root) return null;
68
+ const items = Array.from(root.querySelectorAll(options.itemSelector));
69
+ if (!items.length) return null;
70
+ const ids = items.map(item => item.dataset[options.dataKey]);
71
+ const initial = ids.find((id, index) => options.isInitiallySelected(items[index])) || ids[0];
72
+ const model = createSelectionModel(ids, initial);
73
+ const panels = Array.from(root.querySelectorAll(options.panelSelector));
74
+
75
+ function render(id, focusItem) {
76
+ items.forEach(item => {
77
+ const selected = item.dataset[options.dataKey] === id;
78
+ item.setAttribute(options.selectedAttribute, String(selected));
79
+ item.tabIndex = selected ? 0 : -1;
80
+ item.classList.toggle('is-active', selected);
81
+ if (selected && focusItem) item.focus();
82
+ });
83
+ panels.forEach(panel => {
84
+ const selected = panel.id === options.panelId(id);
85
+ panel.hidden = !selected;
86
+ panel.classList.toggle('is-active', selected);
87
+ });
88
+ if (typeof options.onRender === 'function') options.onRender(id, ids.indexOf(id), ids.length);
89
+ return id;
90
+ }
91
+
92
+ items.forEach(item => {
93
+ item.addEventListener('click', () => render(model.select(item.dataset[options.dataKey]), false));
94
+ item.addEventListener('keydown', event => {
95
+ if (!FORWARD_KEYS.has(event.key)
96
+ && !BACKWARD_KEYS.has(event.key)
97
+ && event.key !== 'Home'
98
+ && event.key !== 'End') return;
99
+ event.preventDefault();
100
+ render(model.move(event.key), true);
101
+ });
102
+ });
103
+
104
+ render(model.current(), false);
105
+ return {
106
+ current: model.current,
107
+ select(id, focusItem) {
108
+ return render(model.select(id), !!focusItem);
109
+ },
110
+ move(action, focusItem) {
111
+ return render(model.move(action), !!focusItem);
112
+ },
113
+ };
114
+ }
115
+
116
+ function initJourney(doc) {
117
+ const root = doc.getElementById('memory-journey');
118
+ const shell = root && root.querySelector('.journey-shell');
119
+ if (!root || !shell) return null;
120
+ const liveRegion = root.querySelector('[data-journey-status]');
121
+ const controller = setupSelectionGroup(root, {
122
+ itemSelector: '[data-step-id]',
123
+ panelSelector: '.journey-panel',
124
+ dataKey: 'stepId',
125
+ selectedAttribute: 'aria-selected',
126
+ panelId: id => `journey-${id}`,
127
+ isInitiallySelected: item => item.getAttribute('aria-selected') === 'true',
128
+ onRender(id, index, count) {
129
+ const progress = count > 1 ? (index * 100) / (count - 1) : 0;
130
+ shell.style.setProperty('--journey-progress', `${progress}%`);
131
+ if (liveRegion) {
132
+ const label = root.querySelector(`[data-step-id="${id}"] .step-label`);
133
+ liveRegion.textContent = `Showing step ${index + 1} of 7: ${label ? label.textContent : id}`;
134
+ }
135
+ },
136
+ });
137
+ if (!controller) return null;
138
+
139
+ const playButton = root.querySelector('[data-play-journey]');
140
+ const reducedMotion = typeof matchMedia === 'function'
141
+ && matchMedia('(prefers-reduced-motion: reduce)').matches;
142
+ let timer = null;
143
+
144
+ function stop() {
145
+ if (timer) clearInterval(timer);
146
+ timer = null;
147
+ shell.classList.remove('is-playing');
148
+ if (playButton) {
149
+ playButton.setAttribute('aria-pressed', 'false');
150
+ playButton.querySelector('span').textContent = 'Play the flow';
151
+ }
152
+ }
153
+
154
+ function advance() {
155
+ controller.move('next', false);
156
+ }
157
+
158
+ if (playButton) {
159
+ playButton.addEventListener('click', () => {
160
+ if (reducedMotion) {
161
+ advance();
162
+ return;
163
+ }
164
+ if (timer) {
165
+ stop();
166
+ return;
167
+ }
168
+ advance();
169
+ shell.classList.add('is-playing');
170
+ playButton.setAttribute('aria-pressed', 'true');
171
+ playButton.querySelector('span').textContent = 'Pause the flow';
172
+ timer = setInterval(advance, 3200);
173
+ });
174
+ }
175
+
176
+ doc.addEventListener('visibilitychange', () => {
177
+ if (doc.hidden) stop();
178
+ });
179
+ return controller;
180
+ }
181
+
182
+ function initArchitectureMap(doc) {
183
+ const root = doc.getElementById('architecture-map');
184
+ return setupSelectionGroup(root, {
185
+ itemSelector: '[data-map-node]',
186
+ panelSelector: '.map-detail',
187
+ dataKey: 'mapNode',
188
+ selectedAttribute: 'aria-pressed',
189
+ panelId: id => `map-detail-${id}`,
190
+ isInitiallySelected: item => item.getAttribute('aria-pressed') === 'true',
191
+ });
192
+ }
193
+
194
+ function init(doc) {
195
+ doc.documentElement.classList.remove('no-js');
196
+ doc.documentElement.classList.add('js');
197
+ const journey = initJourney(doc);
198
+ const architectureMap = initArchitectureMap(doc);
199
+ return { journey, architectureMap };
200
+ }
201
+
202
+ return {
203
+ createSelectionModel,
204
+ init,
205
+ initJourney,
206
+ initArchitectureMap,
207
+ };
208
+ });
package/docs/index.html CHANGED
@@ -27,6 +27,7 @@
27
27
  .cta-primary:hover { background: #79c0ff; }
28
28
  .cta-secondary { background: var(--surface); color: var(--text); border: 1px solid var(--border); }
29
29
  .cta-secondary:hover { border-color: var(--blue); color: var(--blue); }
30
+ .cta a:focus-visible, .feature-link:focus-visible { outline: 3px solid var(--yellow); outline-offset: 4px; }
30
31
 
31
32
  /* Container */
32
33
  .container { max-width: 1100px; margin: 0 auto; padding: 0 20px; }
@@ -43,6 +44,8 @@
43
44
  .feature { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 24px; }
44
45
  .feature h3 { color: var(--blue); font-size: 1em; margin-bottom: 6px; }
45
46
  .feature p { color: var(--muted); font-size: 13px; line-height: 1.5; }
47
+ .feature-link { color: inherit; text-decoration: none; transition: border-color 0.2s, transform 0.2s; }
48
+ .feature-link:hover { border-color: var(--blue); transform: translateY(-2px); }
46
49
 
47
50
  /* Scenario sections */
48
51
  .scenario { margin: 60px 0; }
@@ -93,6 +96,7 @@
93
96
  <div class="cta">
94
97
  <a href="https://www.npmjs.com/package/@yemi33/minions" class="cta-primary">Install from npm</a>
95
98
  <a href="#quickstart" class="cta-secondary">Quick Start</a>
99
+ <a href="demo/memory-system.html" class="cta-secondary">Explore the Memory System</a>
96
100
  </div>
97
101
  </div>
98
102
 
@@ -114,7 +118,7 @@
114
118
  <div class="feature"><h3>Multi-Stage Pipelines</h3><p>Chain tasks, meetings, plans, and more into automated workflows. Cron triggers or manual. Artifacts flow between stages.</p></div>
115
119
  <div class="feature"><h3>Review / Fix Loop</h3><p>After implementation, Minions can dispatch reviews, react to human PR comments, and send fixes back to the original author agent.</p></div>
116
120
  <div class="feature"><h3>Git Worktree Isolation</h3><p>Each agent works in its own worktree. No conflicts, no cross-contamination, safe parallel execution.</p></div>
117
- <div class="feature"><h3>Knowledge Consolidation</h3><p>Agent findings, quick notes, pinned notes, and feedback consolidate into team notes and a categorized knowledge base.</p></div>
121
+ <a class="feature feature-link" href="demo/memory-system.html"><h3>Interactive Memory System</h3><p>Follow findings from inbox consolidation through SQL/FTS5 retrieval, prompt bounds, provenance, fencing, and episodic capture.</p></a>
118
122
  <div class="feature"><h3>PR Integration</h3><p>Tracks Azure DevOps and GitHub PRs, including review votes, build status, merge status, human comments, and context-only links.</p></div>
119
123
  <div class="feature"><h3>Scheduled Tasks &amp; Watches</h3><p>Cron-style recurring work plus watches for PRs, work items, dispatches, meetings, pipelines, agents, and status changes.</p></div>
120
124
  <div class="feature"><h3>Command Center &amp; Doc Chat</h3><p>Natural language orchestration, persistent CC tabs, document Q&amp;A/editing, issue filing, routing updates, and local API actions.</p></div>
@@ -277,4 +281,3 @@ minions update</pre>
277
281
 
278
282
  </body>
279
283
  </html>
280
-
@@ -12,7 +12,7 @@ Before managed-spawn, Constellation (and similar multi-process projects) lost de
12
12
  3. `minions restart` killed the engine but left the child running — *on Node*. **On `bun` + Windows the child died with the parent** because the Windows detached-spawn semantics differ across runtimes.
13
13
  4. Next dispatch landed against a dead URL with no signal it had died.
14
14
 
15
- Managed-spawn moves the spawn ownership from the agent into the engine. Agents *describe* services in a sidecar; the engine spawns them using the proven [`bin/minions.js spawnDashboard`](../bin/minions.js) pattern (works for Node, bun, python, docker, …), runs healthchecks, gates dispatch SUCCESS on first-healthy, sweeps dead PIDs / expired TTL, auto-injects live processes into downstream agent prompts, and exposes everything through `/api/managed-processes` + the dashboard.
15
+ Managed-spawn moves the spawn ownership from the agent into the engine. Agents *describe* services in a sidecar; the engine detached-spawns an engine-owned Node launcher using the proven [`bin/minions.js spawnDashboard`](../bin/minions.js) pattern, and that launcher starts the declared executable non-detached. The launcher PID remains the managed process-tree root across engine restarts. The engine races its observed exit against first health, sweeps dead PIDs / expired TTL, auto-injects live processes into downstream agent prompts, and exposes everything through `/api/managed-processes` + the dashboard.
16
16
 
17
17
  ## When to use managed-spawn vs `keep_processes`
18
18
 
@@ -184,24 +184,25 @@ Single quotes (`'…'`) work the same way. No `powershell -Command "& '<path>' <
184
184
 
185
185
  ┌───────────────────────────────────────────────────────────────────┐
186
186
  │ 3. spawnManagedSpec(spec) per spec │
187
- │ - Uses the proven Windows detached-spawn pattern
188
- │ - Records each in the SQL managed-process store
187
+ │ - Detached Node launcher starts the target non-detached
188
+ │ - Records the launcher PID in the SQL managed-process store
189
189
  │ - Stdio → engine/managed-logs/<name>.log (append fd, NOT pipe) │
190
190
  └───────────────────────────────────┬───────────────────────────────┘
191
191
 
192
192
 
193
193
  ┌───────────────────────────────────────────────────────────────────┐
194
- │ 4. waitForFirstHealth(spec) per spec, in parallel
194
+ │ 4. waitForFirstHealth(spec,{exit}) per spec, in parallel
195
+ │ - Races probes against launcher/target exit │
195
196
  │ - Probes every interval_s, gives up at timeout_s │
196
197
  │ - First success → state.healthy = true, last_health_at = now │
197
- │ - Any failure → dispatch ERROR + sibling spawns left alive
198
- │ failure_class: managed-spawn-healthcheck (retryable)
198
+ │ - Exit/timeout → dispatch ERROR + sibling spawns left alive
199
+ │ failure_class: managed-spawn-healthcheck-failed
199
200
  └───────────────────────────────────┬───────────────────────────────┘
200
201
  │ all healthy
201
202
 
202
203
  ┌───────────────────────────────────────────────────────────────────┐
203
204
  │ 5. Dispatch SUCCESS │
204
- │ - Spec survives engine restart (PID detached + boot reconcile)
205
+ │ - Spec survives restart (launcher PID + boot reconcile)
205
206
  │ - Visible at /api/managed-processes + dashboard "Managed │
206
207
  │ Processes" panel │
207
208
  │ - Downstream agent prompts auto-inject a "## Live managed │
@@ -287,9 +288,9 @@ All knobs live under `engine.managedSpawn` in `engine/shared.js` (`ENGINE_DEFAUL
287
288
  | Symptom | Likely cause | Fix |
288
289
  |---|---|---|
289
290
  | Dispatch ERROR `failure_class: invalid-managed-spawn` | Sidecar schema/allowlist violation | Read inbox alert; the validator includes a precise reason. Non-retryable — fix and re-dispatch. |
290
- | Dispatch ERROR `failure_class: managed-spawn-healthcheck` | `timeout_s` elapsed before any spec became healthy | Check `engine/managed-logs/<name>.log` for the child's crash output. Sibling spawns are left alive. Retryable. |
291
+ | Dispatch ERROR `failure_class: managed-spawn-healthcheck-failed` | The target exited before first health, or `timeout_s` elapsed before it became healthy | Check the reported code/signal and bounded `engine/managed-logs/<name>.log` tail. Sibling spawns are left alive. |
291
292
  | WI shows yellow `⚠ managed-spawn: N/M healthy` chip on dashboard | Partial healthcheck failure: ≥1 spec passed, ≥1 failed. WI keeps `status: done` (the agent's primary work — getting an accepted sidecar — succeeded); annotation `_managedSpawnPartial = { healthy, failed[{name, reason, log_tail}], evaluated_at }` lives on the WI. Click the row to see per-spec failure reasons + last 20 log lines. Dispatch is still recorded ERROR with `failure_class: managed-spawn-healthcheck-failed`. Restart the failing spec via `POST /api/managed-processes/restart` once you've fixed the root cause (often: workspace deps not installed; see smoke-test rule in the hint). W-mpbpexrg00110661. |
292
- | Spec gone after `minions restart` | Bun child died with parent (the original failure mode) | Should be fixed by item 2's detached-spawn pattern. If it recurs, verify `bin/minions.js spawnDashboard` semantics still work for the runtime that's the canonical reference. |
293
+ | Spec gone after `minions restart` | The tracked Node launcher died or boot reconciliation dropped its PID | Inspect the managed log and verify `engine/managed-spawn-launcher.js` remained the process-tree root until the target exited. |
293
294
  | Spec listed `alive: true, healthy: false` for >30s | Healthcheck loop self-detected service degradation | The spec did not pass a subsequent healthcheck. Inspect the service; restart via API once recovered. |
294
295
  | Stale row sticks around with dead PID | Spec killed outside Minions | Wait one sweep cycle (~30 min) or call `POST /api/managed-processes/kill` manually. |
295
296
 
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { spawn } = require('child_process');
5
+
6
+ const [cmd, ...args] = process.argv.slice(2);
7
+ if (!cmd) {
8
+ console.error('[managed-spawn-launcher] target executable missing');
9
+ process.exitCode = 64;
10
+ } else {
11
+ let settled = false;
12
+ let target;
13
+ try {
14
+ target = spawn(cmd, args, {
15
+ cwd: process.cwd(),
16
+ env: process.env,
17
+ detached: false,
18
+ stdio: ['ignore', 'inherit', 'inherit'],
19
+ windowsHide: true,
20
+ });
21
+ } catch (error) {
22
+ settled = true;
23
+ console.error('[managed-spawn-launcher] target spawn failed: ' + error.message);
24
+ process.exitCode = 127;
25
+ }
26
+
27
+ if (target) {
28
+ target.once('error', (error) => {
29
+ if (settled) return;
30
+ settled = true;
31
+ console.error('[managed-spawn-launcher] target spawn failed: ' + error.message);
32
+ process.exitCode = 127;
33
+ });
34
+ target.once('exit', (code, signal) => {
35
+ if (settled) return;
36
+ settled = true;
37
+ console.error('[managed-spawn-launcher] target exited (code='
38
+ + (code == null ? 'null' : code) + ', signal=' + (signal || 'null') + ')');
39
+ if (signal) {
40
+ try {
41
+ process.kill(process.pid, signal);
42
+ return;
43
+ } catch {
44
+ process.exitCode = 1;
45
+ return;
46
+ }
47
+ }
48
+ process.exitCode = Number.isInteger(code) ? code : 1;
49
+ });
50
+ }
51
+ }
@@ -29,6 +29,7 @@ const { log, ENGINE_DEFAULTS } = shared;
29
29
 
30
30
  const MANAGED_SPAWN_FILENAME = 'managed-spawn.json';
31
31
  const MANAGED_LOGS_DIR = 'managed-logs';
32
+ const MANAGED_SPAWN_LAUNCHER_PATH = path.join(__dirname, 'managed-spawn-launcher.js');
32
33
  const INVALID_WORKDIR_REASON_PREFIX = 'invalid-workdir: ';
33
34
 
34
35
  const HEALTHCHECK_TYPES = ['http', 'command'];
@@ -777,9 +778,10 @@ function buildManagedSpawnHint(opts) {
777
778
  // stdio is wired to this fd (NOT a pipe)
778
779
  // so the detached process survives our
779
780
  // exit on Windows.
780
- // 2. spawnManagedSpec(spec, ctx) → uses the proven bin/minions.js
781
- // spawnDashboard detached-spawn pattern.
782
- // Returns {pid, started_at, log_path}.
781
+ // 2. spawnManagedSpec(spec, ctx) → detached-spawns an engine-owned Node
782
+ // launcher which owns the attached target.
783
+ // Returns serializable runtime facts plus
784
+ // in-memory startup/exit observations.
783
785
  // 3. recordManagedSpec(spec, → writes one entry to
784
786
  // runtime, ctx) SQL managed-process state via
785
787
  // mutateJsonFileLocked. Replaces any
@@ -836,6 +838,54 @@ function _buildChildEnv(specEnv) {
836
838
  return env;
837
839
  }
838
840
 
841
+ function _observeManagedLauncher(child) {
842
+ let startupSettled = false;
843
+ let exitSettled = false;
844
+ let resolveStartup;
845
+ let resolveExit;
846
+ const startup = new Promise((resolve) => { resolveStartup = resolve; });
847
+ const exit = new Promise((resolve) => { resolveExit = resolve; });
848
+
849
+ const settleStartup = (result) => {
850
+ if (startupSettled) return;
851
+ startupSettled = true;
852
+ resolveStartup(result);
853
+ };
854
+ const settleExit = (result) => {
855
+ if (exitSettled) return;
856
+ exitSettled = true;
857
+ resolveExit(Object.assign({ observed_at: Date.now() }, result));
858
+ };
859
+
860
+ child.once('spawn', () => {
861
+ settleStartup({ started: true, pid: child.pid, error: null });
862
+ });
863
+ child.once('error', (error) => {
864
+ const message = String((error && error.message) || error);
865
+ settleStartup({ started: false, pid: child.pid || null, error: message });
866
+ settleExit({ kind: 'error', code: null, signal: null, error: message });
867
+ });
868
+ child.once('exit', (code, signal) => {
869
+ settleStartup({ started: true, pid: child.pid, error: null });
870
+ settleExit({
871
+ kind: 'exit',
872
+ code: Number.isInteger(code) ? code : null,
873
+ signal: typeof signal === 'string' && signal ? signal : null,
874
+ error: null,
875
+ });
876
+ });
877
+ child.once('close', (code, signal) => {
878
+ settleExit({
879
+ kind: 'close',
880
+ code: Number.isInteger(code) ? code : null,
881
+ signal: typeof signal === 'string' && signal ? signal : null,
882
+ error: null,
883
+ });
884
+ });
885
+
886
+ return { startup, exit };
887
+ }
888
+
839
889
  function spawnManagedSpec(spec, ctx) {
840
890
  if (!spec || typeof spec !== 'object') throw new Error('spawnManagedSpec: spec required');
841
891
  if (typeof spec.cmd !== 'string' || spec.cmd.length === 0) {
@@ -854,10 +904,10 @@ function spawnManagedSpec(spec, ctx) {
854
904
  const argv = Array.isArray(spec.args) ? spec.args : [];
855
905
  let child;
856
906
  try {
857
- // Working Windows-correct detached spawn same shape as
858
- // bin/minions.js spawnDashboard. DO NOT switch stdio to 'pipe' or
859
- // 'inherit': pipes die on EPIPE once the parent exits.
860
- child = spawn(spec.cmd, argv, {
907
+ // Detach only process.execPath, whose Windows behavior is proven by the
908
+ // dashboard/engine daemon launchers. The shim starts the validated target
909
+ // attached and remains its process-tree root until that target exits.
910
+ child = spawn(process.execPath, [MANAGED_SPAWN_LAUNCHER_PATH, spec.cmd, ...argv], {
861
911
  cwd: cwd,
862
912
  env: env,
863
913
  detached: true,
@@ -873,12 +923,19 @@ function spawnManagedSpec(spec, ctx) {
873
923
  if (!child || !child.pid) {
874
924
  throw new Error('spawnManagedSpec: spawn failed for ' + spec.cmd);
875
925
  }
926
+ const observation = _observeManagedLauncher(child);
876
927
  child.unref();
877
928
  const startedAt = Date.now();
878
929
  log('info', 'managed-spawn born: name=' + spec.name + ' pid=' + child.pid
879
930
  + ' owner_project=' + (ctx.owner_project || '')
880
931
  + ' owner_wi=' + (ctx.owner_wi || ''));
881
- return { pid: child.pid, started_at: startedAt, log_path: logPath };
932
+ return {
933
+ pid: child.pid,
934
+ started_at: startedAt,
935
+ log_path: logPath,
936
+ startup: observation.startup,
937
+ exit: observation.exit,
938
+ };
882
939
  }
883
940
 
884
941
  function _initialStateShape() {
@@ -1117,7 +1174,46 @@ function waitForFirstHealth(spec, opts) {
1117
1174
  const deadline = Date.now() + timeoutMs;
1118
1175
  return new Promise((resolve) => {
1119
1176
  let stopped = false;
1177
+ let timer = null;
1120
1178
  let lastError = null;
1179
+ const finish = (result) => {
1180
+ if (stopped) return;
1181
+ stopped = true;
1182
+ if (timer) clearTimeout(timer);
1183
+ resolve(result);
1184
+ };
1185
+ const exitObservation = opts.exit;
1186
+ if (exitObservation && typeof exitObservation.then === 'function') {
1187
+ Promise.resolve(exitObservation).then((outcome) => {
1188
+ if (stopped) return;
1189
+ const code = outcome && Number.isInteger(outcome.code) ? outcome.code : null;
1190
+ const signal = outcome && typeof outcome.signal === 'string' && outcome.signal
1191
+ ? outcome.signal : null;
1192
+ const observerError = outcome && outcome.error ? String(outcome.error) : '';
1193
+ let logTail = '';
1194
+ try { logTail = (tailManagedLog(spec.name, 20) || '').slice(-2048); } catch {}
1195
+ finish({
1196
+ healthy: false,
1197
+ error: 'process exited before first health (code=' + (code == null ? 'null' : code)
1198
+ + ', signal=' + (signal || 'null') + ')'
1199
+ + (observerError ? ': ' + observerError : ''),
1200
+ log_tail: logTail,
1201
+ exit: outcome || null,
1202
+ lastCheckAt: Date.now(),
1203
+ });
1204
+ }, (error) => {
1205
+ if (stopped) return;
1206
+ let logTail = '';
1207
+ try { logTail = (tailManagedLog(spec.name, 20) || '').slice(-2048); } catch {}
1208
+ finish({
1209
+ healthy: false,
1210
+ error: 'process exit observation failed before first health (code=null, signal=null): '
1211
+ + String((error && error.message) || error),
1212
+ log_tail: logTail,
1213
+ lastCheckAt: Date.now(),
1214
+ });
1215
+ });
1216
+ }
1121
1217
  const tick = async () => {
1122
1218
  if (stopped) return;
1123
1219
  let result;
@@ -1130,26 +1226,23 @@ function waitForFirstHealth(spec, opts) {
1130
1226
  // hanging forever — resolve unhealthy so the caller's
1131
1227
  // Promise.allSettled/timeout path still progresses.
1132
1228
  if (stopped) return;
1133
- stopped = true;
1134
- return resolve({ healthy: false, error: String((e && e.message) || e), lastCheckAt: Date.now() });
1229
+ return finish({ healthy: false, error: String((e && e.message) || e), lastCheckAt: Date.now() });
1135
1230
  }
1136
1231
  if (stopped) return;
1137
1232
  if (result.healthy) {
1138
- stopped = true;
1139
1233
  try { _markHealthy(spec.name, result.lastCheckAt); }
1140
1234
  catch (e) { log('warn', 'managed-spawn waitForFirstHealth: state write failed for ' + spec.name + ': ' + e.message); }
1141
- return resolve({ healthy: true, error: null, lastCheckAt: result.lastCheckAt });
1235
+ return finish({ healthy: true, error: null, lastCheckAt: result.lastCheckAt });
1142
1236
  }
1143
1237
  lastError = result.error;
1144
1238
  if (Date.now() >= deadline) {
1145
- stopped = true;
1146
- return resolve({
1239
+ return finish({
1147
1240
  healthy: false,
1148
1241
  error: 'timeout: spec ' + spec.name + ' did not become healthy within ' + Math.round(timeoutMs / 1000) + 's (last: ' + (lastError || 'no probes ran') + ')',
1149
1242
  lastCheckAt: result.lastCheckAt,
1150
1243
  });
1151
1244
  }
1152
- setTimeout(tick, intervalMs);
1245
+ timer = setTimeout(tick, intervalMs);
1153
1246
  };
1154
1247
  // First probe fires immediately so a fast service doesn't pay an
1155
1248
  // interval_s delay.
@@ -1413,8 +1506,8 @@ function restartManagedSpec(name, opts) {
1413
1506
  // dropped from state (no kill —
1414
1507
  // the OS already reaped them).
1415
1508
  // 2. ttl_expires_at past now → batch
1416
- // kill via killByPidsImmediate +
1417
- // drop from state.
1509
+ // tree-kill via killByPidTreesImmediate
1510
+ // + drop from state.
1418
1511
  // 3. rotate log_path when size >
1419
1512
  // logRotateBytes (rename to .1,
1420
1513
  // overwrite any prior .1).
@@ -1474,7 +1567,7 @@ function _runManagedReconcile(opts) {
1474
1567
  : shared.isPidAlive;
1475
1568
  const killBatch = typeof opts.killBatch === 'function'
1476
1569
  ? opts.killBatch
1477
- : shared.killByPidsImmediate;
1570
+ : shared.killByPidTreesImmediate;
1478
1571
  let projects;
1479
1572
  try {
1480
1573
  projects = Array.isArray(opts.projects) ? opts.projects : shared.getProjects(opts.config);
@@ -1618,7 +1711,7 @@ function removeManagedSpecsForProject(projectName) {
1618
1711
  }, { defaultValue: _initialStateShape() });
1619
1712
  let killed = 0;
1620
1713
  if (toKill.length > 0) {
1621
- try { killed = shared.killByPidsImmediate(toKill); }
1714
+ try { killed = shared.killByPidTreesImmediate(toKill); }
1622
1715
  catch (e) { log('warn', 'managed-spawn removeForProject: kill batch failed for ' + projectName + ': ' + e.message); }
1623
1716
  }
1624
1717
  let unlinked = 0;
@@ -197,6 +197,38 @@ function killByPidsImmediate(pids) {
197
197
  return killed;
198
198
  }
199
199
 
200
+ // Batched FULL-PROCESS-TREE kill — like killByPidsImmediate but walks each
201
+ // pid's descendant tree (Windows `taskkill /F /T`; Unix killUnixProcessTree).
202
+ // Use this when the recorded pid is a launcher/shim whose real workload runs in
203
+ // an attached child (e.g. managed-spawn's Node launcher shim, P-8a4d6f29): a
204
+ // non-tree kill would reap the shim and orphan/leak the child. Returns the
205
+ // count of pids for which the kill call was issued without throwing.
206
+ function killByPidTreesImmediate(pids) {
207
+ const valid = (Array.isArray(pids) ? pids : [])
208
+ .map(Number)
209
+ .filter(n => Number.isInteger(n) && n > 0 && n !== process.pid);
210
+ if (!valid.length) return 0;
211
+ if (process.platform === 'win32') {
212
+ const flags = valid.map(p => `/PID ${p}`).join(' ');
213
+ try { _execSync(`taskkill /F /T ${flags}`, { stdio: 'pipe', timeout: 5000, windowsHide: true }); return valid.length; }
214
+ catch {
215
+ // taskkill fails wholesale if ANY pid is already dead — fall back to a
216
+ // per-pid tree kill so live siblings still get reaped.
217
+ let killed = 0;
218
+ for (const p of valid) {
219
+ try { _execSync(`taskkill /PID ${p} /F /T`, { stdio: 'pipe', timeout: 3000, windowsHide: true }); killed++; }
220
+ catch { /* process may be dead */ }
221
+ }
222
+ return killed;
223
+ }
224
+ }
225
+ let killed = 0;
226
+ for (const p of valid) {
227
+ try { killUnixProcessTree(p, 'SIGKILL'); killed++; } catch { /* dead */ }
228
+ }
229
+ return killed;
230
+ }
231
+
200
232
  // ─── Process Table Enumeration ───────────────────────────────────────────────
201
233
  // Cross-platform listing of every live process as { pid, ppid, name, cmd? }.
202
234
  // `cmd` is best-effort — included on Windows (via wmic) and Linux (/proc); may
@@ -822,6 +854,7 @@ module.exports = {
822
854
  killImmediate,
823
855
  killByPidImmediate,
824
856
  killByPidsImmediate,
857
+ killByPidTreesImmediate,
825
858
  tasklistOutputShowsPid,
826
859
  getProcessCpuSeconds,
827
860
  _parseWmicCsv,