@yemi33/minions 0.1.1975 → 0.1.1977
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/render-work-items.js +29 -0
- package/docs/deprecated.json +14 -0
- package/docs/managed-spawn.md +12 -3
- package/engine/managed-spawn.js +163 -14
- package/engine/shared.js +29 -17
- package/engine.js +63 -4
- package/package.json +1 -1
|
@@ -51,6 +51,15 @@ function wiRow(item) {
|
|
|
51
51
|
(item._reopened ? ' <span style="font-size:9px;color:var(--purple);margin-left:4px" title="This item was reopened from a previously completed state">reopened</span>' : '') +
|
|
52
52
|
(item._pendingReason && item.status === 'pending' && item._pendingReason !== 'already_dispatched' ? ' <span style="font-size:9px;color:var(--muted);margin-left:4px" title="Pending reason: ' + escapeHtml(item._pendingReason) + '">' + escapeHtml(item._pendingReason.replace(/_/g, ' ')) + '</span>' : '') +
|
|
53
53
|
(item._pendingReason === 'already_dispatched' && item.status === 'pending' ? ' <span style="font-size:9px;color:var(--blue);margin-left:4px" title="In dispatch queue, waiting to be assigned">queued</span>' : '') +
|
|
54
|
+
(item._managedSpawnPartial && Array.isArray(item._managedSpawnPartial.failed) && item._managedSpawnPartial.failed.length
|
|
55
|
+
? ' <span style="font-size:9px;color:var(--yellow);margin-left:4px;border:1px solid var(--yellow);padding:0 4px;border-radius:6px" title="managed-spawn: '
|
|
56
|
+
+ escapeHtml(((item._managedSpawnPartial.healthy || []).length) + '/' + (((item._managedSpawnPartial.healthy || []).length) + item._managedSpawnPartial.failed.length))
|
|
57
|
+
+ ' healthy — failed: '
|
|
58
|
+
+ escapeHtml(item._managedSpawnPartial.failed.map(function(f){ return (f && f.name) || '?'; }).join(', '))
|
|
59
|
+
+ '. Click row for details.">⚠ managed-spawn: '
|
|
60
|
+
+ escapeHtml(((item._managedSpawnPartial.healthy || []).length) + '/' + (((item._managedSpawnPartial.healthy || []).length) + item._managedSpawnPartial.failed.length))
|
|
61
|
+
+ ' healthy</span>'
|
|
62
|
+
: '') +
|
|
54
63
|
(item._skipReason && item.status === 'pending' ? ' <span style="font-size:9px;color:var(--yellow);margin-left:4px" title="Dispatch blocked: ' + escapeHtml(item._skipReason) + (item._blockedBy ? ' (by ' + escapeHtml(item._blockedBy) + ')' : '') + '">' + escapeHtml(item._skipReason.replace(/_/g, ' ')) + (item._blockedBy ? ' <span style="color:var(--muted)">(' + escapeHtml(item._blockedBy) + ')</span>' : '') + '</span>' : '') +
|
|
55
64
|
(item.status === 'failed' ? ' ' + wiRetryBtn(item) : '') +
|
|
56
65
|
'</td>' +
|
|
@@ -472,6 +481,26 @@ function openWorkItemDetail(id) {
|
|
|
472
481
|
if (item.completedAt) html += field('Completed', escapeHtml(formatLocalDateTime(item.completedAt)));
|
|
473
482
|
if (item.failReason) html += field('Failure Reason', '<span style="color:var(--red)">' + escapeHtml(item.failReason) + '</span>');
|
|
474
483
|
if (item._pendingReason && item.status === 'pending') html += field('Pending Reason', item._pendingReason === 'already_dispatched' ? 'Queued — waiting for available agent slot' : escapeHtml(item._pendingReason.replace(/_/g, ' ')));
|
|
484
|
+
if (item._managedSpawnPartial && Array.isArray(item._managedSpawnPartial.failed) && item._managedSpawnPartial.failed.length) {
|
|
485
|
+
var _msp = item._managedSpawnPartial;
|
|
486
|
+
var _mspHealthy = Array.isArray(_msp.healthy) ? _msp.healthy : [];
|
|
487
|
+
var _mspTotal = _mspHealthy.length + _msp.failed.length;
|
|
488
|
+
var _mspBody = '<div style="color:var(--yellow);font-size:11px;margin-bottom:6px">'
|
|
489
|
+
+ escapeHtml(_mspHealthy.length + '/' + _mspTotal) + ' specs healthy. Failed: '
|
|
490
|
+
+ escapeHtml(_msp.failed.map(function(f){ return (f && f.name) || '?'; }).join(', '))
|
|
491
|
+
+ (_msp.evaluated_at ? ' <span style="color:var(--muted)">(evaluated ' + escapeHtml(_msp.evaluated_at) + ')</span>' : '')
|
|
492
|
+
+ '</div>';
|
|
493
|
+
_mspBody += _msp.failed.map(function(f) {
|
|
494
|
+
var name = escapeHtml((f && f.name) || '?');
|
|
495
|
+
var reason = escapeHtml((f && f.reason) || 'unknown');
|
|
496
|
+
var tail = (f && f.log_tail) || '';
|
|
497
|
+
return '<details style="margin-bottom:6px"><summary style="cursor:pointer;font-size:11px"><strong>' + name + '</strong> — ' + reason + '</summary>'
|
|
498
|
+
+ '<pre style="font-size:10px;max-height:240px;overflow:auto;padding:6px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);margin:4px 0 0 0">'
|
|
499
|
+
+ escapeHtml(tail || '(no log tail captured)')
|
|
500
|
+
+ '</pre></details>';
|
|
501
|
+
}).join('');
|
|
502
|
+
html += field('Managed-spawn partial failure', _mspBody);
|
|
503
|
+
}
|
|
475
504
|
if (item._skipReason && item.status === 'pending') html += field('Dispatch Blocked', '<span style="color:var(--yellow)">' + escapeHtml(item._skipReason.replace(/_/g, ' ')) + '</span>' + (item._blockedBy ? ' — blocked by <strong>' + escapeHtml(item._blockedBy) + '</strong>' : ''));
|
|
476
505
|
// Defensive: CC dispatches can land here with these fields as strings
|
|
477
506
|
// (e.g. acceptanceCriteria: "fix the login bug"). Coerce to arrays so
|
package/docs/deprecated.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"id": "managed-spawn-env-allowlist",
|
|
4
|
+
"removedAt": "2026-05-18",
|
|
5
|
+
"reason": "ENGINE_DEFAULTS.managedSpawn.envKeyAllowlist + envKeyAllowlistPrefixes removed; replaced by envKeyDenyPatterns + envKeyDenyOverrides. The allowlist shape required an engine PR for every new framework/project env prefix (W-mpbpa09c000rd513 tried per-project allowlist extension; user steered away — 'make sure that we are not hardcoding any env variables or being so rigid about it'). The denylist shape matches the actual credential-leakage threat model and lets plain project vars like CONSTELLATION_SERVER, DATABASE_URL, REDIS_HOST work with zero engine config while still blocking credential-shaped keys (AWS_*, *_TOKEN, *_SECRET, etc.). Per-project tightening is supported via project.managedSpawnExtraDenyPatterns (additive only, no per-project override list).",
|
|
6
|
+
"removedLocations": [
|
|
7
|
+
"engine/shared.js ENGINE_DEFAULTS.managedSpawn.envKeyAllowlist (15 keys)",
|
|
8
|
+
"engine/shared.js ENGINE_DEFAULTS.managedSpawn.envKeyAllowlistPrefixes (8 prefixes)",
|
|
9
|
+
"engine/managed-spawn.js _envKeyAllowed (rewritten to deny+override+shape model)",
|
|
10
|
+
"engine/managed-spawn.js buildManagedSpawnHint (env-key guidance rewritten)",
|
|
11
|
+
"PR #2624 (closed, superseded — added per-project allowlist union; replaced here by per-project deny tightening)",
|
|
12
|
+
"test/unit/managed-spawn-validator.test.js 4e/4f/11a (rewritten for denylist semantics)"
|
|
13
|
+
],
|
|
14
|
+
"notes": "Already removed in this PR; entry exists to track the breaking shape change in the deprecation log. Delete entry after 3 days per /cleanup-deprecated."
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"id": "config-poll-key-migration",
|
|
4
18
|
"location": "engine/queries.js:123-162",
|
package/docs/managed-spawn.md
CHANGED
|
@@ -36,7 +36,7 @@ The sidecar lives at `<MINIONS_DIR>/agents/<agentId>/managed-spawn.json` and is
|
|
|
36
36
|
"cmd": "bun", // must be on engine.managedSpawn.executableAllowlist
|
|
37
37
|
"args": ["run", "dev"], // ≤64 entries
|
|
38
38
|
"cwd": "D:/repos/constellation", // must be a real git worktree (requireGitWorkdir: true)
|
|
39
|
-
"env": { "
|
|
39
|
+
"env": { "CONSTELLATION_SERVER": "http://localhost:3000" }, // ≤32 keys; POSIX-shape + denylist enforced
|
|
40
40
|
"ports": [3001], // 1024-65535; ≤20 per spec; advisory only (engine doesn't bind)
|
|
41
41
|
"ttl_minutes": 240, // ≤1440 (24h hard cap); defaults to 240 (4h)
|
|
42
42
|
"attrs": { // opaque per-spec metadata, ≤2048 bytes serialized
|
|
@@ -59,6 +59,14 @@ The sidecar lives at `<MINIONS_DIR>/agents/<agentId>/managed-spawn.json` and is
|
|
|
59
59
|
|
|
60
60
|
The renderer in [`buildManagedSpawnHint`](../engine/managed-spawn.js) at `engine/managed-spawn.js:419` emits this exact shape (with allowlist + cap reminders) into the agent's prompt whenever the work item has `meta.managed_spawn: true`. Treat the rendered hint as the source of truth — if this doc and the hint drift, the hint wins.
|
|
61
61
|
|
|
62
|
+
### Smoke-test before writing the sidecar (mandatory, W-mpbpexrg00110661)
|
|
63
|
+
|
|
64
|
+
The hint mandates that agents run each `cmd args` in the declared `cwd` for at least 5 seconds AND confirm the healthcheck endpoint returns the expected status BEFORE writing `managed-spawn.json`. Sidecars written from guessed-at commands are how dispatches silently lose specs: the engine spawns what the agent declared; if the command crashes on first launch, the engine kills the spec, removes it from state, and (for partial failures) marks the WI with `_managedSpawnPartial` rather than demoting it — the agent's primary work succeeded by validator standards. The cost of guessing wrong is the operator finding out hours later that half the stack is down.
|
|
65
|
+
|
|
66
|
+
Real failure class to internalize: workspace-filter resolution (`bun -F <name>`, `pnpm --filter <name>`) failing because workspace deps were never installed in the worktree. Exits 1 in <1s with `No packages matched the filter`. Smoke-test catches it in seconds; an operator finding it catches it in hours (incident W-mpbolwvt000gb9b1: 1 of 3 Constellation specs survived, WI showed `done`).
|
|
67
|
+
|
|
68
|
+
Both a PowerShell (Windows) and bash (macOS/Linux) example are inlined in the hint — see `buildManagedSpawnHint` for the canonical patterns.
|
|
69
|
+
|
|
62
70
|
## Healthcheck examples
|
|
63
71
|
|
|
64
72
|
### HTTP — most common
|
|
@@ -211,8 +219,8 @@ All knobs live under `engine.managedSpawn` in `engine/shared.js:1500` (`ENGINE_D
|
|
|
211
219
|
| `promptContextMaxBytes` | `2048` | Auto-injected `## Live managed processes` block cap. |
|
|
212
220
|
| `requireGitWorkdir` | `true` | Reject specs whose `cwd` isn't a git worktree. |
|
|
213
221
|
| `executableAllowlist` | `[node, bun, npm, …]` | Single global. Applies to `spec.cmd` AND `command` healthcheck `cmd`. |
|
|
214
|
-
| `
|
|
215
|
-
| `
|
|
222
|
+
| `envKeyDenyPatterns` | `[^AWS_, ^AZURE_, _SECRET, _TOKEN, _API_KEY, …]` | Regex source strings, matched case-insensitively. Keys matching ANY pattern are rejected unless exact-listed in `envKeyDenyOverrides`. Threat model: credential leakage, not env-key enumeration — plain project vars (`CONSTELLATION_SERVER`, `DATABASE_URL`, …) pass with no config. |
|
|
223
|
+
| `envKeyDenyOverrides` | `[AWS_REGION, AWS_DEFAULT_REGION, AZURE_REGION, GCP_REGION, AWS_PROFILE]` | Exact-match exemptions for known-safe keys that would otherwise be caught by a broad prefix pattern. Case-sensitive. |
|
|
216
224
|
|
|
217
225
|
## Failure modes
|
|
218
226
|
|
|
@@ -220,6 +228,7 @@ All knobs live under `engine.managedSpawn` in `engine/shared.js:1500` (`ENGINE_D
|
|
|
220
228
|
|---|---|---|
|
|
221
229
|
| 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. |
|
|
222
230
|
| 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. |
|
|
231
|
+
| 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. |
|
|
223
232
|
| 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. |
|
|
224
233
|
| 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. |
|
|
225
234
|
| 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. |
|
package/engine/managed-spawn.js
CHANGED
|
@@ -64,15 +64,78 @@ function _isOnAllowlist(name, allowlist) {
|
|
|
64
64
|
return false;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
67
|
+
// Env-key shape guard. Bash/POSIX-style identifier: leading letter or
|
|
68
|
+
// underscore, followed by letters / digits / underscores. Rejects keys with
|
|
69
|
+
// shell metachars ('FOO;BAR', 'FOO BAR', '$FOO') regardless of deny rules —
|
|
70
|
+
// argv smuggling protection, separate from the credential-shape threat
|
|
71
|
+
// model below.
|
|
72
|
+
const _ENV_KEY_SHAPE_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
73
|
+
|
|
74
|
+
// Compile envKeyDenyPatterns once per validator invocation and cache. The
|
|
75
|
+
// returned object also carries the override Set for fast exact-match lookup.
|
|
76
|
+
// All patterns are compiled case-insensitively so '_secret' / '_SECRET' are
|
|
77
|
+
// equivalent; overrides remain case-sensitive (exact match).
|
|
78
|
+
function _compileDenyRules(limits, projectExtraPatterns) {
|
|
79
|
+
const globalPatterns = Array.isArray(limits.envKeyDenyPatterns) ? limits.envKeyDenyPatterns : [];
|
|
80
|
+
const extra = Array.isArray(projectExtraPatterns) ? projectExtraPatterns : [];
|
|
81
|
+
const overridesArr = Array.isArray(limits.envKeyDenyOverrides) ? limits.envKeyDenyOverrides : [];
|
|
82
|
+
const sources = [];
|
|
83
|
+
const compiled = [];
|
|
84
|
+
for (const src of globalPatterns.concat(extra)) {
|
|
85
|
+
if (typeof src !== 'string' || src.length === 0) continue;
|
|
86
|
+
let re;
|
|
87
|
+
try { re = new RegExp(src, 'i'); }
|
|
88
|
+
catch (_e) { continue; }
|
|
89
|
+
sources.push(src);
|
|
90
|
+
compiled.push(re);
|
|
74
91
|
}
|
|
75
|
-
|
|
92
|
+
const overrides = new Set();
|
|
93
|
+
for (const k of overridesArr) {
|
|
94
|
+
if (typeof k === 'string' && k.length > 0) overrides.add(k);
|
|
95
|
+
}
|
|
96
|
+
return { sources, compiled, overrides };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Check an env key against compiled deny rules. Returns
|
|
100
|
+
// { allowed: true } when the key passes, or
|
|
101
|
+
// { allowed: false, reason: 'env-key-denied (matched pattern X)' }
|
|
102
|
+
// when blocked. Does NOT enforce the shape guard — callers do that
|
|
103
|
+
// separately so shape-failures get a distinct reason.
|
|
104
|
+
function _envKeyAllowed(key, denyRules) {
|
|
105
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
106
|
+
return { allowed: false, reason: 'env-key-empty' };
|
|
107
|
+
}
|
|
108
|
+
if (denyRules.overrides.has(key)) return { allowed: true };
|
|
109
|
+
for (let i = 0; i < denyRules.compiled.length; i++) {
|
|
110
|
+
if (denyRules.compiled[i].test(key)) {
|
|
111
|
+
return { allowed: false, reason: 'env-key-denied (matched pattern ' + denyRules.sources[i] + ')' };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { allowed: true };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Find the configured project whose `localPath` contains the spec's cwd.
|
|
118
|
+
// Returns the most-specific match (longest matching localPath) so nested
|
|
119
|
+
// project layouts (e.g. /repos/parent and /repos/parent/sub) prefer the
|
|
120
|
+
// inner one. Returns null when no project matches or when inputs are
|
|
121
|
+
// missing. Pure — does not touch disk.
|
|
122
|
+
function _resolveProjectForCwd(cwd, projects) {
|
|
123
|
+
if (typeof cwd !== 'string' || cwd.length === 0) return null;
|
|
124
|
+
if (!Array.isArray(projects) || projects.length === 0) return null;
|
|
125
|
+
let best = null;
|
|
126
|
+
let bestLen = -1;
|
|
127
|
+
for (const p of projects) {
|
|
128
|
+
if (!p || typeof p !== 'object') continue;
|
|
129
|
+
const lp = typeof p.localPath === 'string' ? p.localPath : '';
|
|
130
|
+
if (!lp) continue;
|
|
131
|
+
if (!shared.isPathInsideOrEqual(cwd, lp)) continue;
|
|
132
|
+
const len = path.resolve(lp).length;
|
|
133
|
+
if (len > bestLen) {
|
|
134
|
+
best = p;
|
|
135
|
+
bestLen = len;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return best;
|
|
76
139
|
}
|
|
77
140
|
|
|
78
141
|
// Extract the first executable-like token from a shell-style healthcheck cmd
|
|
@@ -229,7 +292,16 @@ function _validateSpec(spec, index, limits, opts) {
|
|
|
229
292
|
}
|
|
230
293
|
}
|
|
231
294
|
|
|
232
|
-
// env (optional)
|
|
295
|
+
// env (optional). Validation has three independent layers:
|
|
296
|
+
// 1. Shape guard: key matches /^[A-Za-z_][A-Za-z0-9_]*$/ (argv-smuggling
|
|
297
|
+
// protection). Reason: env-key-invalid-shape.
|
|
298
|
+
// 2. Denylist: key does NOT match any credential-shaped pattern unless
|
|
299
|
+
// explicitly listed in envKeyDenyOverrides. Per-project tightening
|
|
300
|
+
// (project.managedSpawnExtraDenyPatterns) can add MORE patterns
|
|
301
|
+
// when spec.cwd resolves under that project's localPath — projects
|
|
302
|
+
// can only tighten, never loosen (asymmetric on purpose; no
|
|
303
|
+
// per-project override list).
|
|
304
|
+
// 3. Value sanity: string, ≤1000 chars.
|
|
233
305
|
const envRaw = spec.env == null ? {} : spec.env;
|
|
234
306
|
if (typeof envRaw !== 'object' || Array.isArray(envRaw)) {
|
|
235
307
|
return { ok: false, reason: 'env-not-object' };
|
|
@@ -239,10 +311,22 @@ function _validateSpec(spec, index, limits, opts) {
|
|
|
239
311
|
if (envKeys.length > maxEnv) {
|
|
240
312
|
return { ok: false, reason: 'env-too-many (>' + maxEnv + ')' };
|
|
241
313
|
}
|
|
314
|
+
const projectForSpec = _resolveProjectForCwd(
|
|
315
|
+
typeof spec.cwd === 'string' ? spec.cwd : '',
|
|
316
|
+
opts && Array.isArray(opts.projects) ? opts.projects : null,
|
|
317
|
+
);
|
|
318
|
+
const projectExtraPatterns = projectForSpec && Array.isArray(projectForSpec.managedSpawnExtraDenyPatterns)
|
|
319
|
+
? projectForSpec.managedSpawnExtraDenyPatterns
|
|
320
|
+
: null;
|
|
321
|
+
const denyRules = _compileDenyRules(limits, projectExtraPatterns);
|
|
242
322
|
const env = {};
|
|
243
323
|
for (const k of envKeys) {
|
|
244
|
-
if (!
|
|
245
|
-
return { ok: false, reason: 'env-key-
|
|
324
|
+
if (!_ENV_KEY_SHAPE_RE.test(k)) {
|
|
325
|
+
return { ok: false, reason: 'env-key-invalid-shape (' + k + ')' };
|
|
326
|
+
}
|
|
327
|
+
const decision = _envKeyAllowed(k, denyRules);
|
|
328
|
+
if (!decision.allowed) {
|
|
329
|
+
return { ok: false, reason: decision.reason };
|
|
246
330
|
}
|
|
247
331
|
const v = envRaw[k];
|
|
248
332
|
if (typeof v !== 'string') return { ok: false, reason: 'env-value-not-string (' + k + ')' };
|
|
@@ -323,10 +407,25 @@ function validateManagedSpawnRecord(parsed, opts) {
|
|
|
323
407
|
return { ok: false, reason: 'specs-too-many (>' + maxSpecs + ')' };
|
|
324
408
|
}
|
|
325
409
|
|
|
410
|
+
// Resolve `projects` for per-project env-deny tightening. Callers (tests,
|
|
411
|
+
// future engine wiring) may pre-supply `opts.projects` for determinism;
|
|
412
|
+
// when undefined we lazy-load from MINIONS_DIR/config.json so the engine
|
|
413
|
+
// close-handler doesn't have to change signature. A read failure or a
|
|
414
|
+
// config without `projects` falls back to `[]` — unchanged behavior, the
|
|
415
|
+
// global deny patterns remain the only source.
|
|
416
|
+
let projects;
|
|
417
|
+
if (Array.isArray(opts.projects)) {
|
|
418
|
+
projects = opts.projects;
|
|
419
|
+
} else {
|
|
420
|
+
try { projects = shared.getProjects(); }
|
|
421
|
+
catch (_e) { projects = []; }
|
|
422
|
+
}
|
|
423
|
+
const specOpts = Object.assign({}, opts, { projects: projects });
|
|
424
|
+
|
|
326
425
|
const seen = new Set();
|
|
327
426
|
const out = [];
|
|
328
427
|
for (let i = 0; i < parsed.specs.length; i++) {
|
|
329
|
-
const v = _validateSpec(parsed.specs[i], i, limits,
|
|
428
|
+
const v = _validateSpec(parsed.specs[i], i, limits, specOpts);
|
|
330
429
|
if (!v.ok) {
|
|
331
430
|
// Preserve workdir-rejection prefix at the top level so the engine
|
|
332
431
|
// close-handler gate can key off it the same way it does for
|
|
@@ -480,7 +579,7 @@ function buildManagedSpawnHint(opts) {
|
|
|
480
579
|
'1. Reads your sidecar after you exit.',
|
|
481
580
|
'2. Spawns each spec detached (the working Windows pattern is centralised in the engine — you do **not** need to write `Start-Process` or `spawn({ detached: true })` yourself).',
|
|
482
581
|
'3. Drives the healthcheck loop until each spec passes its first check (within `timeout_s`).',
|
|
483
|
-
'4. **Fails this dispatch (ERROR) if any spec fails its healthcheck.** Surviving siblings stay alive; failing PIDs are killed.',
|
|
582
|
+
'4. **Fails this dispatch (ERROR) if any spec fails its healthcheck.** Surviving siblings stay alive; failing PIDs are killed. When at least one spec stays healthy (partial failure) the engine also writes a `_managedSpawnPartial` annotation onto the WI (status stays `done`) so the dashboard surfaces a warning chip instead of silently swallowing the failure. When ALL specs fail the WI is demoted to FAILED via the normal force-demote path.',
|
|
484
583
|
'5. Auto-injects a `## Live managed processes` block into downstream agents\' prompts (scoped to your project) so the next dispatch can find the service without you telling it.',
|
|
485
584
|
'6. Sweeps dead PIDs / TTL-expired specs every ' + (limits.sweepEvery || 30) + ' ticks; kills + unlinks at TTL.',
|
|
486
585
|
'',
|
|
@@ -489,13 +588,63 @@ function buildManagedSpawnHint(opts) {
|
|
|
489
588
|
'- Specs per file: ≤ ' + maxSpecs,
|
|
490
589
|
'- Name: kebab-case, ≤ 64 chars, unique within file',
|
|
491
590
|
'- Executable (`cmd` and any `command` healthcheck cmd): on the engine\'s allowlist (node, bun, npm, npx, python, docker, adb, gradle, mvn, pwsh, …)',
|
|
492
|
-
'- Env keys:
|
|
591
|
+
'- Env keys: any well-formed POSIX identifier (`/^[A-Za-z_][A-Za-z0-9_]*$/`) is accepted EXCEPT keys matching credential-shaped deny patterns (`*_SECRET`, `*_TOKEN`, `*_API_KEY`, `*_PASSWORD`, `*_PRIVATE_KEY`, `*_CREDENTIALS`, `*_AUTH`, `*_PAT`, `AWS_*`, `AZURE_*`, `GCP_*`, `GH_TOKEN`, `GITHUB_TOKEN`, `OPENAI_*`, `ANTHROPIC_*`, `COPILOT_*`, `DOCKER_AUTH*`, `NPM_TOKEN`). Region/profile names (`AWS_REGION`, `AWS_PROFILE`, …) are exempt. If your service genuinely needs a credential-looking var, rename it (e.g. `DATABASE_URL` not `DB_API_KEY`) or stash the value in `attrs` and have the child read it from there. **The engine forwards env values verbatim — the deny shape is a tripwire, not a scrubber. Do not put real credentials in env even if the key passes.** Projects may tighten by adding patterns to `config.projects[N].managedSpawnExtraDenyPatterns`; projects cannot loosen.',
|
|
493
592
|
'- Ports: 1024–65535, ≤ 20 per spec',
|
|
494
593
|
'- TTL: ≤ ' + maxTtl + ' minutes (hard cap), defaults to ' + defaultTtl + ' if omitted',
|
|
495
594
|
'- `attrs` serialized: ≤ 2048 bytes (opaque blob the engine surfaces to downstream agents)',
|
|
496
595
|
'',
|
|
497
596
|
'If your file is invalid the engine marks this dispatch ERROR with `failure_class: invalid-managed-spawn` (non-retryable) — fix the file shape, don\'t retry blindly.',
|
|
498
597
|
'',
|
|
598
|
+
'### Mandatory: smoke-test before writing the sidecar',
|
|
599
|
+
'',
|
|
600
|
+
'Before writing `managed-spawn.json`, run each `cmd args` in the declared `cwd` for at least 5 seconds. Confirm the process stays alive AND its healthcheck endpoint returns the expected status. Kill the test process. THEN write the sidecar. Sidecars written from guessed-at commands are how dispatches silently lose specs — the engine spawns what you declared; if you declared a command that crashes, the engine kills it and removes it from state without alerting the WI as failed. The cost of guessing wrong is the user finding out hours later that half the stack is down.',
|
|
601
|
+
'',
|
|
602
|
+
'Concrete failure class to avoid: workspace-filter resolution (e.g. `bun -F <name>` or `pnpm --filter <name>`) failing because the workspace deps were never installed in the worktree. The command exits 1 in <1s with `No packages matched the filter` and the engine kills the spec without surfacing anything beyond a partial-healthcheck annotation. Smoke-test catches this in seconds; the user finding out catches it in hours.',
|
|
603
|
+
'',
|
|
604
|
+
'Smoke-test pattern (PowerShell — Windows):',
|
|
605
|
+
'',
|
|
606
|
+
'```powershell',
|
|
607
|
+
'# Replace <cwd>, <cmd>, <args...>, <healthcheck-url>, <expected-status> with your spec values.',
|
|
608
|
+
'Push-Location <cwd>',
|
|
609
|
+
'$proc = Start-Process -FilePath <cmd> -ArgumentList \'<arg1>\',\'<arg2>\' -PassThru -WindowStyle Hidden -RedirectStandardOutput smoke.out -RedirectStandardError smoke.err',
|
|
610
|
+
'try {',
|
|
611
|
+
' $deadline = (Get-Date).AddSeconds(15)',
|
|
612
|
+
' $healthy = $false',
|
|
613
|
+
' while ((Get-Date) -lt $deadline) {',
|
|
614
|
+
' if ($proc.HasExited) { throw "spec exited early with code $($proc.ExitCode); tail smoke.err for the reason" }',
|
|
615
|
+
' try {',
|
|
616
|
+
' $r = Invoke-WebRequest -Uri \'<healthcheck-url>\' -UseBasicParsing -TimeoutSec 2',
|
|
617
|
+
' if ($r.StatusCode -eq <expected-status>) { $healthy = $true; break }',
|
|
618
|
+
' } catch { Start-Sleep -Milliseconds 500 }',
|
|
619
|
+
' }',
|
|
620
|
+
' if (-not $healthy) { throw "spec stayed alive but healthcheck never returned <expected-status> within 15s" }',
|
|
621
|
+
' Write-Host "smoke-test OK — spec stayed alive AND healthcheck passed"',
|
|
622
|
+
'} finally {',
|
|
623
|
+
' if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force }',
|
|
624
|
+
' Pop-Location',
|
|
625
|
+
'}',
|
|
626
|
+
'```',
|
|
627
|
+
'',
|
|
628
|
+
'Smoke-test pattern (bash — macOS/Linux):',
|
|
629
|
+
'',
|
|
630
|
+
'```bash',
|
|
631
|
+
'# Replace <cwd>, <cmd>, <args...>, <healthcheck-url>, <expected-status> with your spec values.',
|
|
632
|
+
'cd <cwd>',
|
|
633
|
+
'<cmd> <arg1> <arg2> >smoke.out 2>smoke.err &',
|
|
634
|
+
'pid=$!',
|
|
635
|
+
'healthy=0',
|
|
636
|
+
'for _ in $(seq 1 15); do',
|
|
637
|
+
' if ! kill -0 "$pid" 2>/dev/null; then echo "spec exited early; tail smoke.err for the reason" >&2; break; fi',
|
|
638
|
+
' if [ "$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 2 \'<healthcheck-url>\')" = "<expected-status>" ]; then healthy=1; break; fi',
|
|
639
|
+
' sleep 1',
|
|
640
|
+
'done',
|
|
641
|
+
'kill "$pid" 2>/dev/null || true',
|
|
642
|
+
'wait "$pid" 2>/dev/null || true',
|
|
643
|
+
'[ "$healthy" = 1 ] && echo "smoke-test OK — spec stayed alive AND healthcheck passed" || { echo "smoke-test FAILED" >&2; exit 1; }',
|
|
644
|
+
'```',
|
|
645
|
+
'',
|
|
646
|
+
'A passing smoke-test is the entry gate to writing the sidecar — not a nice-to-have. If you skip it, you are betting the WI completion against a command you never ran.',
|
|
647
|
+
'',
|
|
499
648
|
'### Verify before exit',
|
|
500
649
|
'',
|
|
501
650
|
'After you write the file, query the engine to confirm acceptance:',
|
package/engine/shared.js
CHANGED
|
@@ -1526,24 +1526,36 @@ const ENGINE_DEFAULTS = {
|
|
|
1526
1526
|
'curl', 'wget',
|
|
1527
1527
|
'git',
|
|
1528
1528
|
],
|
|
1529
|
-
// Env-key
|
|
1530
|
-
//
|
|
1531
|
-
//
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1529
|
+
// Env-key denylist (regex source strings, matched case-insensitively).
|
|
1530
|
+
// The threat model is credential leakage, not env-key enumeration —
|
|
1531
|
+
// any key NOT matching one of these patterns is accepted, so project
|
|
1532
|
+
// vars like CONSTELLATION_SERVER / DATABASE_URL / REDIS_HOST work with
|
|
1533
|
+
// zero engine config. Patterns are compiled lazily by the validator
|
|
1534
|
+
// and tested in order; the first match wins for the reject reason, so
|
|
1535
|
+
// the more specific prefix patterns are listed before the broad
|
|
1536
|
+
// suffix patterns (a key like AWS_SECRET_ACCESS_KEY should report the
|
|
1537
|
+
// narrower `^AWS_` cause, not the generic `_SECRET` cause). Extend
|
|
1538
|
+
// with caution; broad patterns belong here, ecosystem allow
|
|
1539
|
+
// exemptions belong in `envKeyDenyOverrides`.
|
|
1540
|
+
envKeyDenyPatterns: [
|
|
1541
|
+
// Prefix patterns first (more specific cause for vendor keys).
|
|
1542
|
+
'^AWS_', '^AZURE_', '^GCP_', '^GOOGLE_APPLICATION_CREDENTIALS',
|
|
1543
|
+
'^GH_TOKEN', '^GITHUB_TOKEN',
|
|
1544
|
+
'^OPENAI_', '^ANTHROPIC_', '^COPILOT_',
|
|
1545
|
+
'^DOCKER_AUTH', '^NPM_TOKEN',
|
|
1546
|
+
// Suffix / substring patterns (generic credential shapes).
|
|
1547
|
+
'_SECRET', '_TOKEN', '_API_KEY', '_APIKEY',
|
|
1548
|
+
'_PASSWORD', '_PASSWD',
|
|
1549
|
+
'_PRIVATE_KEY', '_PRIVKEY',
|
|
1550
|
+
'_CREDENTIALS', '_AUTH', '_PAT',
|
|
1538
1551
|
],
|
|
1539
|
-
//
|
|
1540
|
-
//
|
|
1541
|
-
//
|
|
1542
|
-
//
|
|
1543
|
-
|
|
1544
|
-
'
|
|
1545
|
-
'
|
|
1546
|
-
'MINIONS_',
|
|
1552
|
+
// Exact-match exemption list for keys that would otherwise be caught
|
|
1553
|
+
// by a broad deny pattern (region/profile names aren't secrets).
|
|
1554
|
+
// Match is case-sensitive — the override key must equal the spec key
|
|
1555
|
+
// exactly.
|
|
1556
|
+
envKeyDenyOverrides: [
|
|
1557
|
+
'AWS_REGION', 'AWS_DEFAULT_REGION', 'AZURE_REGION', 'GCP_REGION',
|
|
1558
|
+
'AWS_PROFILE',
|
|
1547
1559
|
],
|
|
1548
1560
|
},
|
|
1549
1561
|
// Backward-compat: keep `engine.claude.*` field family deprecation tracker. Listed here so preflight
|
package/engine.js
CHANGED
|
@@ -2393,12 +2393,35 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2393
2393
|
try { managedSpawn.removeManagedSpec(f.name); }
|
|
2394
2394
|
catch (e) { log('warn', `managed-spawn healthcheck: cleanup failed for ${f.name}: ${e.message}`); }
|
|
2395
2395
|
}
|
|
2396
|
+
// W-mpbpexrg00110661 — capture per-spec log tails (last 20 lines) so
|
|
2397
|
+
// the WI annotation can surface them in the dashboard chip without
|
|
2398
|
+
// re-reading the log files later. Use the same tailManagedLog helper
|
|
2399
|
+
// the inbox alert uses; cap each tail to 2KB to bound the WI write.
|
|
2400
|
+
const _failedDetails = failed.map(f => {
|
|
2401
|
+
let tail = '';
|
|
2402
|
+
try { tail = (managedSpawn.tailManagedLog(f.name, 20) || '').slice(-2048); }
|
|
2403
|
+
catch (_e) { tail = ''; }
|
|
2404
|
+
return { name: f.name, reason: f.error, log_tail: tail };
|
|
2405
|
+
});
|
|
2406
|
+
const _survivedNames = items.filter(it => !failed.some(f => f.name === it.name)).map(it => it.name);
|
|
2396
2407
|
managedSpawnHealthcheckFailure = {
|
|
2397
2408
|
failed: failed,
|
|
2398
|
-
survivedNames:
|
|
2409
|
+
survivedNames: _survivedNames,
|
|
2410
|
+
// partial = at least one spec survived. Drives the
|
|
2411
|
+
// processWorkItemFailure: false override below so the WI stays
|
|
2412
|
+
// `done` (the agent's primary work — declaring an accepted sidecar
|
|
2413
|
+
// — succeeded; the dashboard surfaces the warning via
|
|
2414
|
+
// _managedSpawnPartial instead of demoting to FAILED).
|
|
2415
|
+
partial: _survivedNames.length > 0,
|
|
2416
|
+
annotation: {
|
|
2417
|
+
healthy: _survivedNames,
|
|
2418
|
+
failed: _failedDetails,
|
|
2419
|
+
evaluated_at: new Date().toISOString(),
|
|
2420
|
+
},
|
|
2399
2421
|
};
|
|
2400
|
-
log('warn', `managed-spawn healthcheck: ${failed.length}/${items.length} spec(s) failed for ${agentId} (${id})
|
|
2401
|
-
|
|
2422
|
+
log('warn', `managed-spawn healthcheck: ${failed.length}/${items.length} spec(s) failed for ${agentId} (${id})` +
|
|
2423
|
+
(managedSpawnHealthcheckFailure.partial ? ' (partial — WI stays done, annotated)' : ' (total — WI will be demoted)') +
|
|
2424
|
+
`; ` + failed.map(f => `${f.name}=${f.error}`).join('; '));
|
|
2402
2425
|
try {
|
|
2403
2426
|
const wiId = dispatchItem.meta?.item?.id || '';
|
|
2404
2427
|
const logTails = failed.map(f => {
|
|
@@ -2468,7 +2491,18 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2468
2491
|
: FAILURE_CLASS.INVALID_KEEP_PROCESSES_SCHEMA)
|
|
2469
2492
|
: null;
|
|
2470
2493
|
const completeOpts = managedSpawnHealthcheckFail
|
|
2471
|
-
? {
|
|
2494
|
+
? {
|
|
2495
|
+
...completionOpts,
|
|
2496
|
+
failureClass: FAILURE_CLASS.MANAGED_SPAWN_HEALTHCHECK_FAILED,
|
|
2497
|
+
agentRetryable: false,
|
|
2498
|
+
// W-mpbpexrg00110661 — partial healthcheck failure: at least one spec
|
|
2499
|
+
// stayed healthy. The agent's primary work (declaring + getting an
|
|
2500
|
+
// accepted sidecar) succeeded, so don't force-demote the WI. The
|
|
2501
|
+
// _managedSpawnPartial annotation written after completeDispatch is
|
|
2502
|
+
// the visibility lever. Total failure (no survivors) still
|
|
2503
|
+
// force-demotes through the normal FORCE_DEMOTE_FAILURE_CLASSES path.
|
|
2504
|
+
...(managedSpawnHealthcheckFailure.partial ? { processWorkItemFailure: false } : {}),
|
|
2505
|
+
}
|
|
2472
2506
|
: (managedSpawnAcceptanceFail
|
|
2473
2507
|
? { ...completionOpts, failureClass: FAILURE_CLASS.INVALID_MANAGED_SPAWN, agentRetryable: false }
|
|
2474
2508
|
: (keepProcessesAcceptanceFail
|
|
@@ -2581,6 +2615,31 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2581
2615
|
|
|
2582
2616
|
completeDispatch(id, effectiveResult, errorReason, resultSummary, completeOpts);
|
|
2583
2617
|
|
|
2618
|
+
// W-mpbpexrg00110661 — surface managed-spawn partial-healthcheck failures
|
|
2619
|
+
// on the WI so the dashboard renders a warning chip instead of silently
|
|
2620
|
+
// swallowing the loss. This runs regardless of partial vs total failure:
|
|
2621
|
+
// - Partial (survivors > 0): WI status stayed `done` because we passed
|
|
2622
|
+
// processWorkItemFailure:false above; the annotation tells operators
|
|
2623
|
+
// half the stack is down.
|
|
2624
|
+
// - Total (no survivors): WI was force-demoted to FAILED by the
|
|
2625
|
+
// FORCE_DEMOTE_FAILURE_CLASSES path; the annotation still attaches
|
|
2626
|
+
// for forensics so the dashboard can show which specs failed and
|
|
2627
|
+
// their log tails without operators having to dig through
|
|
2628
|
+
// engine/managed-logs/.
|
|
2629
|
+
if (managedSpawnHealthcheckFailure && managedSpawnHealthcheckFailure.annotation && dispatchItem.meta?.item?.id) {
|
|
2630
|
+
try {
|
|
2631
|
+
const wiPath = resolveWorkItemPath(dispatchItem.meta);
|
|
2632
|
+
if (wiPath) {
|
|
2633
|
+
mutateWorkItems(wiPath, items => {
|
|
2634
|
+
const wi = items.find(i => i.id === dispatchItem.meta.item.id);
|
|
2635
|
+
if (!wi) return items;
|
|
2636
|
+
wi._managedSpawnPartial = managedSpawnHealthcheckFailure.annotation;
|
|
2637
|
+
return items;
|
|
2638
|
+
});
|
|
2639
|
+
}
|
|
2640
|
+
} catch (e) { log('warn', `managed-spawn partial-healthcheck: failed to annotate WI: ${e.message}`); }
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2584
2643
|
// W-mp6k7ywi000fa33c / W-mp7i902u000l991f — surface the keep_processes
|
|
2585
2644
|
// rejection on the WI so the dashboard pending-reason area shows the
|
|
2586
2645
|
// missing structure instead of a bare failure_class label. _pendingReason
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1977",
|
|
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"
|