@yemi33/minions 0.1.2419 → 0.1.2420
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/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
package/dashboard.js
CHANGED
|
@@ -12280,7 +12280,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12280
12280
|
// by a potentially-60s timeout_s. The loop self-flips state.healthy.
|
|
12281
12281
|
const newSpec = managedSpawn.listManagedSpecs().find(s => s && s.name === name);
|
|
12282
12282
|
if (newSpec && newSpec.healthcheck) {
|
|
12283
|
-
managedSpawn.waitForFirstHealth(newSpec).
|
|
12283
|
+
managedSpawn.waitForFirstHealth(newSpec, { exit: runtime.exit }).then((result) => {
|
|
12284
|
+
if (!result.healthy) {
|
|
12285
|
+
shared.log('warn', `managed-processes restart: first health failed for ${name}: ${result.error}`);
|
|
12286
|
+
}
|
|
12287
|
+
}).catch((err) => {
|
|
12284
12288
|
shared.log('warn', `managed-processes restart: waitForFirstHealth failed for ${name}: ${err.message}`);
|
|
12285
12289
|
});
|
|
12286
12290
|
}
|
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,
|
package/engine.js
CHANGED
|
@@ -5854,7 +5854,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5854
5854
|
// healthy:false, alive:true and the engine sweep / item-3 healthcheck
|
|
5855
5855
|
// loop will drive its state from there.
|
|
5856
5856
|
let managedSpawnAcceptanceFailure = null;
|
|
5857
|
-
let managedSpawnSpawned = []; //
|
|
5857
|
+
let managedSpawnSpawned = []; // serializable runtime facts + in-memory exit promise
|
|
5858
5858
|
{
|
|
5859
5859
|
const _wiMeta = dispatchItem.meta?.item?.meta || {};
|
|
5860
5860
|
const _msEnabled = !!_wiMeta.managed_spawn
|
|
@@ -5935,7 +5935,13 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5935
5935
|
try {
|
|
5936
5936
|
const runtime = managedSpawn.spawnManagedSpec(spec, ctx);
|
|
5937
5937
|
spawnedItems.push({ spec, runtime });
|
|
5938
|
-
managedSpawnSpawned.push({
|
|
5938
|
+
managedSpawnSpawned.push({
|
|
5939
|
+
name: spec.name,
|
|
5940
|
+
pid: runtime.pid,
|
|
5941
|
+
started_at: runtime.started_at,
|
|
5942
|
+
log_path: runtime.log_path,
|
|
5943
|
+
exit: runtime.exit,
|
|
5944
|
+
});
|
|
5939
5945
|
} catch (specErr) {
|
|
5940
5946
|
spawnFailureReason = `spawn failed for ${spec.name}: ${specErr.message}`;
|
|
5941
5947
|
log('warn', `managed-spawn: ${spawnFailureReason}`);
|
|
@@ -5947,7 +5953,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5947
5953
|
// state. This is consistent with the "all healthy or fail"
|
|
5948
5954
|
// contract item 3 will enforce on healthcheck timeout.
|
|
5949
5955
|
for (const item of spawnedItems) {
|
|
5950
|
-
try { shared.
|
|
5956
|
+
try { shared.killImmediate({ pid: item.runtime.pid }); } catch (_e) {}
|
|
5951
5957
|
}
|
|
5952
5958
|
managedSpawnSpawned = [];
|
|
5953
5959
|
managedSpawnAcceptanceFailure = {
|
|
@@ -5999,7 +6005,8 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5999
6005
|
if (!spec || !spec.healthcheck) {
|
|
6000
6006
|
return Promise.resolve({ healthy: false, error: 'no healthcheck recorded for ' + spawned.name, _name: spawned.name });
|
|
6001
6007
|
}
|
|
6002
|
-
return managedSpawn.waitForFirstHealth(spec
|
|
6008
|
+
return managedSpawn.waitForFirstHealth(spec, { exit: spawned.exit })
|
|
6009
|
+
.then(r => Object.assign({ _name: spec.name, _pid: spawned.pid }, r));
|
|
6003
6010
|
}));
|
|
6004
6011
|
const failed = [];
|
|
6005
6012
|
for (let i = 0; i < results.length; i++) {
|
|
@@ -6009,7 +6016,12 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
6009
6016
|
continue;
|
|
6010
6017
|
}
|
|
6011
6018
|
if (!r.value.healthy) {
|
|
6012
|
-
failed.push({
|
|
6019
|
+
failed.push({
|
|
6020
|
+
name: r.value._name || items[i].name,
|
|
6021
|
+
pid: r.value._pid || items[i].pid,
|
|
6022
|
+
error: r.value.error || 'unhealthy',
|
|
6023
|
+
log_tail: r.value.log_tail || '',
|
|
6024
|
+
});
|
|
6013
6025
|
}
|
|
6014
6026
|
}
|
|
6015
6027
|
if (failed.length > 0) {
|
|
@@ -6024,8 +6036,8 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
6024
6036
|
// re-reading the log files later. Use the same tailManagedLog helper
|
|
6025
6037
|
// the inbox alert uses; cap each tail to 2KB to bound the WI write.
|
|
6026
6038
|
const _failedDetails = failed.map(f => {
|
|
6027
|
-
let tail = '';
|
|
6028
|
-
try { tail = (managedSpawn.tailManagedLog(f.name, 20) || '').slice(-2048); }
|
|
6039
|
+
let tail = f.log_tail || '';
|
|
6040
|
+
try { if (!tail) tail = (managedSpawn.tailManagedLog(f.name, 20) || '').slice(-2048); }
|
|
6029
6041
|
catch (_e) { tail = ''; }
|
|
6030
6042
|
return { name: f.name, reason: f.error, log_tail: tail };
|
|
6031
6043
|
});
|
|
@@ -6051,7 +6063,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
6051
6063
|
try {
|
|
6052
6064
|
const wiId = dispatchItem.meta?.item?.id || '';
|
|
6053
6065
|
const logTails = failed.map(f => {
|
|
6054
|
-
const tail = managedSpawn.tailManagedLog(f.name, 50) || '(log empty or unreadable)';
|
|
6066
|
+
const tail = f.log_tail || managedSpawn.tailManagedLog(f.name, 50) || '(log empty or unreadable)';
|
|
6055
6067
|
return '### ' + f.name + ' (pid ' + (f.pid || '?') + ')\n\nReason: `' + f.error + '`\n\n```\n' + tail.slice(-2000) + '\n```';
|
|
6056
6068
|
}).join('\n\n');
|
|
6057
6069
|
const alertBody = [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2420",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|