@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.
- package/dashboard.js +5 -1
- package/docs/demo/memory-system.html +1431 -0
- package/docs/demo/memory-system.js +208 -0
- package/docs/index.html +5 -2
- package/docs/managed-spawn.md +10 -9
- package/engine/managed-spawn-launcher.js +51 -0
- package/engine/managed-spawn.js +112 -19
- package/engine/process-utils.js +33 -0
- package/engine.js +20 -8
- package/package.json +1 -1
|
@@ -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
|
-
<
|
|
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 & 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 & Doc Chat</h3><p>Natural language orchestration, persistent CC tabs, document Q&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
|
-
|
package/docs/managed-spawn.md
CHANGED
|
@@ -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
|
|
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
|
-
│ -
|
|
188
|
-
│ - Records
|
|
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
|
-
│ -
|
|
198
|
-
│ failure_class: managed-spawn-healthcheck
|
|
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
|
|
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
|
|
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` |
|
|
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
|
+
}
|
package/engine/managed-spawn.js
CHANGED
|
@@ -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) →
|
|
781
|
-
//
|
|
782
|
-
// Returns
|
|
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
|
-
//
|
|
858
|
-
//
|
|
859
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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
|
|
1235
|
+
return finish({ healthy: true, error: null, lastCheckAt: result.lastCheckAt });
|
|
1142
1236
|
}
|
|
1143
1237
|
lastError = result.error;
|
|
1144
1238
|
if (Date.now() >= deadline) {
|
|
1145
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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;
|
package/engine/process-utils.js
CHANGED
|
@@ -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,
|