@yemi33/minions 0.1.1950 → 0.1.1952

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.
Files changed (40) hide show
  1. package/dashboard/js/command-center.js +13 -2
  2. package/dashboard/js/modal-qa.js +10 -0
  3. package/dashboard/js/refresh.js +4 -0
  4. package/dashboard/js/render-dispatch.js +25 -0
  5. package/dashboard/js/render-other.js +109 -2
  6. package/dashboard/js/settings.js +1 -1
  7. package/dashboard/layout.html +2 -2
  8. package/dashboard/pages/engine.html +6 -0
  9. package/dashboard/slim.html +1987 -0
  10. package/dashboard/styles.css +8 -0
  11. package/dashboard.js +450 -40
  12. package/docs/completion-reports.md +25 -0
  13. package/docs/design-state-storage.md +1 -1
  14. package/docs/slim-ux/architecture-suggestions.md +467 -0
  15. package/docs/slim-ux/concepts.md +824 -0
  16. package/engine/ado-mcp-wrapper.js +33 -7
  17. package/engine/ado.js +123 -15
  18. package/engine/cc-worker-pool.js +41 -0
  19. package/engine/cleanup.js +71 -34
  20. package/engine/cli.js +37 -0
  21. package/engine/dispatch.js +32 -9
  22. package/engine/features.js +6 -0
  23. package/engine/gh-token.js +137 -0
  24. package/engine/github.js +166 -29
  25. package/engine/issues.js +29 -0
  26. package/engine/keep-process-sweep.js +397 -0
  27. package/engine/lifecycle.js +150 -33
  28. package/engine/playbook.js +17 -0
  29. package/engine/queries.js +71 -0
  30. package/engine/recovery.js +6 -0
  31. package/engine/shared.js +446 -14
  32. package/engine/spawn-agent.js +44 -2
  33. package/engine/timeout.js +34 -11
  34. package/engine/worktree-pool.js +410 -0
  35. package/engine.js +643 -119
  36. package/package.json +6 -3
  37. package/playbooks/review.md +2 -0
  38. package/playbooks/shared-rules.md +3 -1
  39. package/prompts/cc-system.md +24 -0
  40. package/engine/copilot-models.json +0 -5
@@ -0,0 +1,397 @@
1
+ /**
2
+ * engine/keep-process-sweep.js -- opt-in `meta.keep_processes` reaper.
3
+ *
4
+ * Walks agents/<id>/keep-pids.json files and, for each:
5
+ * - Validates the file (bounded JSON shape, see validateKeepPidsRecord).
6
+ * Malformed files are left alone (the user/agent can clean them up by
7
+ * hand) but they are never honored as anchors.
8
+ * - When expires_at < now: kills each declared PID via shared.killByPidImmediate
9
+ * and unlinks the file. Audit-logs {agentId, pid, reason: 'ttl_expired'}.
10
+ * - When all declared PIDs are dead but expires_at has not fired: silently
11
+ * unlinks the file (nothing to anchor).
12
+ *
13
+ * Run once at engine boot AND from the tick cycle every
14
+ * ENGINE_DEFAULTS.keepProcesses.sweepEvery ticks. Idempotent -- a no-op when
15
+ * no agents declared keep_processes.
16
+ *
17
+ * See W-mp68q6ke0010de68 for the file shape and acceptance tests.
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const shared = require('./shared');
23
+ const queries = require('./queries');
24
+
25
+ const { log, ENGINE_DEFAULTS } = shared;
26
+
27
+ const KEEP_PIDS_FILENAME = 'keep-pids.json';
28
+ const INVALID_WORKDIR_REASON_PREFIX = 'invalid-workdir: ';
29
+
30
+ function _agentsDir() {
31
+ return queries.AGENTS_DIR || path.join(shared.MINIONS_DIR, 'agents');
32
+ }
33
+
34
+ function _resolveRequireGitWorkdir(opts) {
35
+ const limits = ENGINE_DEFAULTS.keepProcesses || {};
36
+ if (opts && Object.prototype.hasOwnProperty.call(opts, 'requireGitWorkdir')) {
37
+ return !!opts.requireGitWorkdir;
38
+ }
39
+ // Treat undefined default as ON to fail closed.
40
+ if (limits.requireGitWorkdir === false) return false;
41
+ return true;
42
+ }
43
+
44
+ function validateKeepPidsRecord(parsed, opts) {
45
+ opts = opts || {};
46
+ const limits = ENGINE_DEFAULTS.keepProcesses || {};
47
+ const maxPids = Math.max(1, Number(limits.maxPerAgent) || 5);
48
+ const maxTtlMs = Math.max(60000, (Number(limits.maxTtlMinutes) || 1440) * 60 * 1000);
49
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
50
+ const requireGitWorkdir = _resolveRequireGitWorkdir(opts);
51
+
52
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
53
+ return { ok: false, reason: 'not-an-object' };
54
+ }
55
+ if (!Array.isArray(parsed.pids)) return { ok: false, reason: 'pids-missing' };
56
+ if (parsed.pids.length === 0) return { ok: false, reason: 'pids-empty' };
57
+ if (parsed.pids.length > maxPids) return { ok: false, reason: 'pids-too-many (>' + maxPids + ')' };
58
+ const pids = [];
59
+ for (const raw of parsed.pids) {
60
+ const n = Number(raw);
61
+ if (!Number.isInteger(n) || n <= 0) return { ok: false, reason: 'pid-invalid (' + raw + ')' };
62
+ pids.push(n);
63
+ }
64
+
65
+ if (typeof parsed.expires_at !== 'string') return { ok: false, reason: 'expires_at-missing' };
66
+ const expiresAtMs = Date.parse(parsed.expires_at);
67
+ if (!Number.isFinite(expiresAtMs)) return { ok: false, reason: 'expires_at-unparsable' };
68
+ if (expiresAtMs - now > maxTtlMs) {
69
+ return { ok: false, reason: 'ttl-too-long (>' + Math.round(maxTtlMs / 60000) + 'min)' };
70
+ }
71
+
72
+ if (parsed.purpose != null && typeof parsed.purpose !== 'string') {
73
+ return { ok: false, reason: 'purpose-not-string' };
74
+ }
75
+ if (typeof parsed.purpose === 'string' && parsed.purpose.length > 500) {
76
+ return { ok: false, reason: 'purpose-too-long' };
77
+ }
78
+ if (parsed.cwd != null && typeof parsed.cwd !== 'string') {
79
+ return { ok: false, reason: 'cwd-not-string' };
80
+ }
81
+ if (typeof parsed.cwd === 'string' && parsed.cwd.length > 500) {
82
+ return { ok: false, reason: 'cwd-too-long' };
83
+ }
84
+ // W-mp6k7ywi000fa33c — when the engine requires a real git workdir
85
+ // (default true; per-WI override via `meta.keep_processes_skip_workdir_check`),
86
+ // verify the recorded `cwd` looks like a real worktree. Empty/missing
87
+ // `cwd` is not validated for back-compat with files that pre-date this
88
+ // check; agents authored after this change SHOULD include `cwd`. The
89
+ // engine acceptance hook in engine.js keys off the `invalid-workdir:`
90
+ // prefix to mark the WI failed and emit an inbox alert.
91
+ if (requireGitWorkdir && typeof parsed.cwd === 'string' && parsed.cwd.length > 0) {
92
+ const wt = shared.isValidGitWorktree(parsed.cwd);
93
+ if (!wt.ok) {
94
+ return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + wt.reason };
95
+ }
96
+ }
97
+ if (parsed.ports != null) {
98
+ if (!Array.isArray(parsed.ports)) return { ok: false, reason: 'ports-not-array' };
99
+ if (parsed.ports.length > 20) return { ok: false, reason: 'ports-too-many (>20)' };
100
+ for (const p of parsed.ports) {
101
+ const n = Number(p);
102
+ if (!Number.isInteger(n) || n <= 0 || n > 65535) {
103
+ return { ok: false, reason: 'port-invalid (' + p + ')' };
104
+ }
105
+ }
106
+ }
107
+ if (parsed.written_by != null && typeof parsed.written_by !== 'string') {
108
+ return { ok: false, reason: 'written_by-not-string' };
109
+ }
110
+ if (parsed.wi_id != null && typeof parsed.wi_id !== 'string') {
111
+ return { ok: false, reason: 'wi_id-not-string' };
112
+ }
113
+
114
+ return {
115
+ ok: true,
116
+ value: {
117
+ pids: pids,
118
+ purpose: typeof parsed.purpose === 'string' ? parsed.purpose : '',
119
+ cwd: typeof parsed.cwd === 'string' ? parsed.cwd : '',
120
+ ports: Array.isArray(parsed.ports) ? parsed.ports.map(Number) : [],
121
+ expires_at: parsed.expires_at,
122
+ expiresAtMs: expiresAtMs,
123
+ written_by: typeof parsed.written_by === 'string' ? parsed.written_by : '',
124
+ wi_id: typeof parsed.wi_id === 'string' ? parsed.wi_id : '',
125
+ },
126
+ };
127
+ }
128
+
129
+ function readKeepPidsFile(agentId, opts) {
130
+ opts = opts || {};
131
+ const filePath = path.join(_agentsDir(), agentId, KEEP_PIDS_FILENAME);
132
+ let raw;
133
+ try { raw = fs.readFileSync(filePath, 'utf8'); }
134
+ catch (_e) { return null; }
135
+ let parsed = null;
136
+ try { parsed = JSON.parse(raw); }
137
+ catch (e) {
138
+ return { agentId: agentId, filePath: filePath, valid: false, reason: 'json-parse: ' + e.message, parsed: null };
139
+ }
140
+ const v = validateKeepPidsRecord(parsed, opts);
141
+ if (!v.ok) return { agentId: agentId, filePath: filePath, valid: false, reason: v.reason, parsed: parsed };
142
+ return { agentId: agentId, filePath: filePath, valid: true, value: v.value, parsed: parsed };
143
+ }
144
+
145
+ function listAllKeepPidsFiles(opts) {
146
+ opts = opts || {};
147
+ const dir = _agentsDir();
148
+ let entries = [];
149
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
150
+ catch (_e) { return []; }
151
+ const out = [];
152
+ for (const ent of entries) {
153
+ if (!ent.isDirectory()) continue;
154
+ if (ent.name.startsWith('_') || ent.name.startsWith('.')) continue;
155
+ // W-mp6k7ywi000fa33c — forward opts (notably `requireGitWorkdir`) so
156
+ // sweep callers can disable workdir validation for cleanup of files
157
+ // pointing at directories that no longer exist.
158
+ const rec = readKeepPidsFile(ent.name, opts);
159
+ if (rec) out.push(rec);
160
+ }
161
+ return out;
162
+ }
163
+
164
+ function alivePids(pids, opts) {
165
+ opts = opts || {};
166
+ const isAlive = typeof opts.isAlive === 'function'
167
+ ? opts.isAlive
168
+ : function (pid) { try { process.kill(pid, 0); return true; } catch (_e) { return false; } };
169
+ return pids.filter(isAlive);
170
+ }
171
+
172
+ function _audit(eventType, payload) {
173
+ try {
174
+ log('info', 'keep-processes ' + eventType + ': ' + JSON.stringify(payload));
175
+ } catch (_e) { /* logging best-effort */ }
176
+ }
177
+
178
+ function sweepKeepProcesses(opts) {
179
+ opts = opts || {};
180
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
181
+ const killOne = typeof opts.killOne === 'function'
182
+ ? opts.killOne
183
+ : function (pid) { return shared.killByPidImmediate(pid); };
184
+ const stats = { scanned: 0, expiredFiles: 0, killedPids: 0, deadFiles: 0, malformed: 0 };
185
+ // W-mp6k7ywi000fa33c — sweep ignores workdir validation so TTL/dead-PID
186
+ // cleanup still drains files whose cwd no longer exists (e.g., the agent
187
+ // worktree was already torn down). Anchor reads (computeReapPlan,
188
+ // getActiveAnchorPids*) keep the default-on validation so invalid records
189
+ // are not honored as anchors.
190
+ const records = listAllKeepPidsFiles({ now: now, requireGitWorkdir: false });
191
+ for (const rec of records) {
192
+ stats.scanned++;
193
+ if (!rec.valid) {
194
+ stats.malformed++;
195
+ log('warn', 'keep-processes: malformed ' + rec.filePath + ' -- ' + rec.reason + '; leaving in place');
196
+ continue;
197
+ }
198
+ const value = rec.value;
199
+ const filePath = rec.filePath;
200
+ const agentId = rec.agentId;
201
+ if (value.expiresAtMs < now) {
202
+ let killed = 0;
203
+ for (const pid of value.pids) {
204
+ const ok = killOne(pid);
205
+ if (ok) killed++;
206
+ _audit('ttl-expired-kill', { agentId: agentId, pid: pid, reason: 'ttl_expired', wi_id: value.wi_id });
207
+ }
208
+ stats.killedPids += killed;
209
+ stats.expiredFiles++;
210
+ try { fs.unlinkSync(filePath); } catch (_e) { /* gone already */ }
211
+ continue;
212
+ }
213
+ const stillAlive = alivePids(value.pids, opts);
214
+ if (stillAlive.length === 0) {
215
+ stats.deadFiles++;
216
+ try { fs.unlinkSync(filePath); } catch (_e) { /* gone already */ }
217
+ _audit('dead-pids-cleanup', { agentId: agentId, pids: value.pids, wi_id: value.wi_id });
218
+ }
219
+ }
220
+ return stats;
221
+ }
222
+
223
+ function getActiveAnchorPids(opts) {
224
+ opts = opts || {};
225
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
226
+ const out = new Set();
227
+ for (const rec of listAllKeepPidsFiles({ now: now })) {
228
+ if (!rec.valid) continue;
229
+ if (rec.value.expiresAtMs < now) continue;
230
+ for (const pid of rec.value.pids) out.add(pid);
231
+ }
232
+ return out;
233
+ }
234
+
235
+ function getActiveAnchorPidsForAgent(agentId, opts) {
236
+ opts = opts || {};
237
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
238
+ const out = new Set();
239
+ if (!agentId) return { pids: out, record: null };
240
+ // W-mp6k7ywi000fa33c — forward `requireGitWorkdir` (and any future opts)
241
+ // so the per-WI override path can disable workdir validation. Earlier
242
+ // implementation only forwarded `now`, which silently ignored opts.
243
+ const readOpts = { now: now };
244
+ if (Object.prototype.hasOwnProperty.call(opts, 'requireGitWorkdir')) {
245
+ readOpts.requireGitWorkdir = opts.requireGitWorkdir;
246
+ }
247
+ const rec = readKeepPidsFile(String(agentId), readOpts);
248
+ if (!rec) return { pids: out, record: null };
249
+ if (!rec.valid) {
250
+ log('warn', 'keep-processes: ignoring ' + rec.filePath + ' for ' + agentId + ' -- ' + rec.reason);
251
+ return { pids: out, record: null, reason: rec.reason };
252
+ }
253
+ if (rec.value.expiresAtMs < now) return { pids: out, record: rec.value, reason: 'expired' };
254
+ for (const pid of rec.value.pids) out.add(pid);
255
+ return { pids: out, record: rec.value };
256
+ }
257
+
258
+ function computeReapPlan(descendants, agentId, opts) {
259
+ opts = opts || {};
260
+ const list = (Array.isArray(descendants) ? descendants : []).map(Number)
261
+ .filter(function (n) { return Number.isInteger(n) && n > 0; });
262
+ const loader = typeof opts.loadAnchors === 'function'
263
+ ? opts.loadAnchors
264
+ : function (id, o) { return getActiveAnchorPidsForAgent(id, o); };
265
+ const result = loader(agentId, opts);
266
+ const keptSet = result && result.pids;
267
+ const record = result && result.record;
268
+ const reason = result && result.reason;
269
+ if (!keptSet || keptSet.size === 0) {
270
+ return { toKill: list, kept: [], record: record || null, reason: reason || null };
271
+ }
272
+ const kept = [];
273
+ const toKill = [];
274
+ for (const pid of list) {
275
+ if (keptSet.has(pid)) kept.push(pid);
276
+ else toKill.push(pid);
277
+ }
278
+ return { toKill: toKill, kept: kept, record: record || null, reason: null };
279
+ }
280
+
281
+ // W-mp6k7ywi000fa33c — engine-side acceptance helper. Returns a structured
282
+ // summary the engine.js close handler uses to decide whether to:
283
+ // (a) accept the file silently (no keep-pids.json present, or valid),
284
+ // (b) reject it as workdir-invalid → mark WI failed + emit inbox alert
285
+ // + delete the file so it does not anchor anything stale,
286
+ // (c) note a non-workdir validation failure (logged but not WI-failing,
287
+ // since spawn-agent already reaped the PIDs).
288
+ //
289
+ // Pure: no side effects. Caller is responsible for the WI/inbox writes.
290
+ //
291
+ // opts.requireGitWorkdir defaults to ENGINE_DEFAULTS.keepProcesses.requireGitWorkdir.
292
+ // Pass `false` to bypass workdir validation (per-WI override path).
293
+ function evaluateKeepPidsAcceptance(agentId, opts) {
294
+ opts = opts || {};
295
+ const filePath = path.join(_agentsDir(), String(agentId || ''), KEEP_PIDS_FILENAME);
296
+ if (!fs.existsSync(filePath)) {
297
+ return { exists: false, accepted: false, isWorkdirRejection: false, reason: null, record: null, recordedCwd: null, filePath: filePath };
298
+ }
299
+ const rec = readKeepPidsFile(agentId, opts);
300
+ if (!rec) {
301
+ // Race: file existed in `existsSync` check above but was unlinked
302
+ // before readFileSync ran. Treat as no-file.
303
+ return { exists: false, accepted: false, isWorkdirRejection: false, reason: null, record: null, recordedCwd: null, filePath: filePath };
304
+ }
305
+ if (rec.valid) {
306
+ return {
307
+ exists: true,
308
+ accepted: true,
309
+ isWorkdirRejection: false,
310
+ reason: null,
311
+ record: rec.value,
312
+ recordedCwd: rec.value.cwd || null,
313
+ filePath: rec.filePath,
314
+ };
315
+ }
316
+ const reason = rec.reason || 'unknown';
317
+ const isWorkdirRejection = typeof reason === 'string' && reason.indexOf(INVALID_WORKDIR_REASON_PREFIX) === 0;
318
+ let recordedCwd = null;
319
+ if (rec.parsed && typeof rec.parsed === 'object' && typeof rec.parsed.cwd === 'string') {
320
+ recordedCwd = rec.parsed.cwd;
321
+ }
322
+ return {
323
+ exists: true,
324
+ accepted: false,
325
+ isWorkdirRejection: isWorkdirRejection,
326
+ reason: reason,
327
+ record: null,
328
+ recordedCwd: recordedCwd,
329
+ filePath: rec.filePath,
330
+ parsedRaw: rec.parsed || null,
331
+ };
332
+ }
333
+
334
+ function buildKeepProcessesHint(opts) {
335
+ opts = opts || {};
336
+ const limits = ENGINE_DEFAULTS.keepProcesses || {};
337
+ const maxTtl = Math.max(1, Number(limits.maxTtlMinutes) || 1440);
338
+ const defaultTtl = Math.max(1, Number(limits.defaultTtlMinutes) || 60);
339
+ const maxPids = Math.max(1, Number(limits.maxPerAgent) || 5);
340
+ const ttlIn = Number(opts.ttlMinutes);
341
+ const ttl = Number.isFinite(ttlIn) && ttlIn > 0
342
+ ? Math.min(maxTtl, Math.floor(ttlIn))
343
+ : defaultTtl;
344
+ const agentId = opts.agentId || '<your-agent-id>';
345
+ const wiId = opts.workItemId || '<this-work-item-id>';
346
+ const minionsDir = opts.minionsDir || '<minions-dir>';
347
+ const lines = [
348
+ '',
349
+ '',
350
+ '---',
351
+ '',
352
+ '## Long-running processes (keep_processes flag)',
353
+ '',
354
+ 'This work item permits you to leave specific descendant processes running after you exit (TTL: ' + ttl + ' minutes, hard-cap ' + maxTtl + ' minutes).',
355
+ '',
356
+ 'If -- and ONLY if -- you start a long-running process (dev server, watcher, daemon, emulator, etc.) that the user explicitly asked to remain alive, then BEFORE you write your completion report you MUST write its PID(s) to:',
357
+ '',
358
+ ' `' + minionsDir + '/agents/' + agentId + '/keep-pids.json`',
359
+ '',
360
+ 'with this exact JSON shape:',
361
+ '',
362
+ '```json',
363
+ '{',
364
+ ' "pids": [12345, 12346],',
365
+ ' "purpose": "bun run dev (Constellation server + dashboard)",',
366
+ ' "cwd": "D:/repos/constellation",',
367
+ ' "ports": [3001, 5173],',
368
+ ' "expires_at": "<ISO-8601 timestamp <= ' + ttl + ' minutes from now>",',
369
+ ' "written_by": "' + agentId + '",',
370
+ ' "wi_id": "' + wiId + '"',
371
+ '}',
372
+ '```',
373
+ '',
374
+ 'Caps the engine enforces: max ' + maxPids + ' PIDs, TTL <= ' + maxTtl + ' minutes, `purpose`/`cwd` <= 500 chars, <= 20 ports. Files outside the cap are dropped (engine reaps as normal) and a warning is logged.',
375
+ '',
376
+ 'If you do NOT write the file, the engine will kill ALL of your descendant processes when you exit (today\'s default). Do not write the file unless you are intentionally leaving a process behind for the human or follow-up work.',
377
+ '',
378
+ 'After you write the file, the engine\'s sweep removes it automatically when its TTL fires (it kills the kept PIDs at that point) or when all declared PIDs are already dead. Humans can also kill any kept PID early from the dashboard.',
379
+ '',
380
+ ];
381
+ return lines.join('\n');
382
+ }
383
+
384
+ module.exports = {
385
+ KEEP_PIDS_FILENAME: KEEP_PIDS_FILENAME,
386
+ INVALID_WORKDIR_REASON_PREFIX: INVALID_WORKDIR_REASON_PREFIX,
387
+ validateKeepPidsRecord: validateKeepPidsRecord,
388
+ readKeepPidsFile: readKeepPidsFile,
389
+ listAllKeepPidsFiles: listAllKeepPidsFiles,
390
+ alivePids: alivePids,
391
+ sweepKeepProcesses: sweepKeepProcesses,
392
+ getActiveAnchorPids: getActiveAnchorPids,
393
+ getActiveAnchorPidsForAgent: getActiveAnchorPidsForAgent,
394
+ computeReapPlan: computeReapPlan,
395
+ buildKeepProcessesHint: buildKeepProcessesHint,
396
+ evaluateKeepPidsAcceptance: evaluateKeepPidsAcceptance,
397
+ };