@yemi33/minions 0.1.1954 → 0.1.1955

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 CHANGED
@@ -4258,6 +4258,15 @@ const server = http.createServer(async (req, res) => {
4258
4258
  }
4259
4259
  if (body.skipPr) item.skipPr = true;
4260
4260
  if (body.oneShot) item.oneShot = true;
4261
+ // body.meta passthrough (W-mp7hypmw000jf329) — engine reads
4262
+ // item.meta.keep_processes and meta.keep_processes_skip_workdir_check
4263
+ // (engine.js:3891, engine.js:1584). Without this copy, the
4264
+ // meta.keep_processes documentation in prompts/cc-system.md and
4265
+ // the documented `meta?` /api/routes parameter would silently no-op.
4266
+ // Shallow copy of plain objects only — arrays/null/primitives are dropped.
4267
+ if (body.meta && typeof body.meta === 'object' && !Array.isArray(body.meta)) {
4268
+ item.meta = { ...body.meta };
4269
+ }
4261
4270
  copyWorkItemPrFields(item, body);
4262
4271
  const createResult = createWorkItemWithDedup(wiPath, item);
4263
4272
  if (!createResult.created) {
@@ -7956,7 +7965,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7956
7965
  }},
7957
7966
 
7958
7967
  // Work items
7959
- { method: 'POST', path: '/api/work-items', desc: 'Create a new work item', params: 'title, type?, description?, priority?, project?, agent?, agents?, scope?, references?, acceptanceCriteria?', handler: handleWorkItemsCreate },
7968
+ { method: 'POST', path: '/api/work-items', desc: 'Create a new work item', params: 'title, type?, description?, priority?, project?, agent?, agents?, scope?, references?, acceptanceCriteria?, skipPr?, oneShot?, meta?', handler: handleWorkItemsCreate },
7960
7969
  { method: 'POST', path: '/api/work-items/update', desc: 'Edit a pending/failed work item', params: 'id, source?, title?, description?, type?, priority?, agent?, references?, acceptanceCriteria?', handler: handleWorkItemsUpdate },
7961
7970
  { method: 'POST', path: '/api/work-items/retry', desc: 'Reset a failed/dispatched item to pending', params: 'id, source?', handler: handleWorkItemsRetry },
7962
7971
  { method: 'POST', path: '/api/work-items/delete', desc: 'Remove a work item, kill agent, clear dispatch', params: 'id, source?', handler: handleWorkItemsDelete },
@@ -345,6 +345,7 @@ function isRetryableFailureReason(reason = '', failureClass = '') {
345
345
  FAILURE_CLASS.PERMISSION_BLOCKED,
346
346
  FAILURE_CLASS.WORKTREE_PREFLIGHT, // pre-spawn worktree validation — recompute will produce the same failure
347
347
  FAILURE_CLASS.INVALID_KEEP_PROCESSES_WORKDIR, // W-mp6k7ywi000fa33c — keep-pids cwd is not a real git worktree; re-running won't fix the structural issue
348
+ FAILURE_CLASS.INVALID_KEEP_PROCESSES_SCHEMA, // W-mp7i902u000l991f — keep-pids.json failed shape validation; re-running with the same wrong file won't fix it
348
349
  ]);
349
350
  if (neverRetry.has(failureClass)) return false;
350
351
  }
@@ -600,6 +601,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
600
601
  [FAILURE_CLASS.PERMISSION_BLOCKED]: 'permission or auth failure',
601
602
  [FAILURE_CLASS.WORKTREE_PREFLIGHT]: 'worktree preflight rejected (nested in project root or rootDir collapsed to drive root)',
602
603
  [FAILURE_CLASS.INVALID_KEEP_PROCESSES_WORKDIR]: 'keep_processes cwd is not a real git worktree (rerun in a `git worktree add` directory)',
604
+ [FAILURE_CLASS.INVALID_KEEP_PROCESSES_SCHEMA]: 'keep-pids.json failed shape validation (wrong keys/types/values — see inbox alert for the canonical shape)',
603
605
  [FAILURE_CLASS.UNKNOWN]: 'unknown error',
604
606
  };
605
607
  const classLabel = failureClass ? (CLASS_LABELS[failureClass] || failureClass) : '';
@@ -344,6 +344,11 @@ function buildKeepProcessesHint(opts) {
344
344
  const agentId = opts.agentId || '<your-agent-id>';
345
345
  const wiId = opts.workItemId || '<this-work-item-id>';
346
346
  const minionsDir = opts.minionsDir || '<minions-dir>';
347
+ // W-mp7i902u000l991f — dashboard port for the self-check curl. Caller can
348
+ // pass an explicit `dashboardPort`; falls back to 7331 (the documented
349
+ // default for `dashboard.js`).
350
+ const portIn = Number(opts.dashboardPort);
351
+ const dashboardPort = Number.isFinite(portIn) && portIn > 0 ? portIn : 7331;
347
352
  const lines = [
348
353
  '',
349
354
  '',
@@ -377,6 +382,14 @@ function buildKeepProcessesHint(opts) {
377
382
  '',
378
383
  '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
384
  '',
385
+ '## Verify before exit (REQUIRED)',
386
+ '',
387
+ 'Before you write your completion report, verify the engine accepted your file:',
388
+ '',
389
+ ' curl -s http://localhost:' + dashboardPort + '/api/keep-processes',
390
+ '',
391
+ 'Find your entry (`agentId: "' + agentId + '"`) and confirm `valid: true`. If `valid: false`, the `reason` field tells you what to fix (e.g. `pids-missing` means you used a wrong top-level key like `processes` instead of `pids`; `ttl-too-long` means your `expires_at` is too far in the future; `expires_at-missing` means the field is absent or non-string). Rewrite the file with the exact shape above and re-check. If you exit with `valid: false`, the engine will mark the dispatch as ERROR (non-retryable) and your kept PIDs will be reaped.',
392
+ '',
380
393
  ];
381
394
  return lines.join('\n');
382
395
  }
@@ -34,7 +34,12 @@ const { callLLM } = require('./llm');
34
34
 
35
35
  const SYSTEM_PROMPT = 'Output only JSON.';
36
36
  const DEFAULT_TIMEOUT_MS = 60000;
37
- const DEFAULT_MODEL = 'haiku'; // claude shorthand; the runtime adapter expands it (see engine/runtimes/claude.js resolveModel)
37
+ // NOTE: no hardcoded default model. Hardcoding `'haiku'` (Claude shorthand)
38
+ // broke the validator on Copilot fleets — copilot.js has
39
+ // `modelShorthands: false`, so it shelled out `copilot --model haiku ...`,
40
+ // the CLI rejected the unknown id, and every dispatch hit `LLM exit 1` and
41
+ // fell open. Resolve the model from the caller's engine config instead
42
+ // (W-mp7ig18j000o7996).
38
43
  // Minimum trimmed description length that justifies a description-only LLM
39
44
  // validation. Below this we fail-open without burning an LLM call — short
40
45
  // descriptions don't carry enough signal for the gate to add value.
@@ -89,6 +94,37 @@ function _parseResponse(text) {
89
94
  try { return JSON.parse(body); } catch { return null; }
90
95
  }
91
96
 
97
+ /**
98
+ * Resolve the model for the validator's `callLLM` call.
99
+ *
100
+ * Priority:
101
+ * 1. `opts.model` — explicit caller override (preserves back-compat for
102
+ * unit tests that pin a fixture model).
103
+ * 2. `shared.resolveCcModel(<engine sub-config>)` — defer to whatever the
104
+ * engine has configured for its CC/direct LLM path. Tolerates both the
105
+ * flat shape (`{ ccModel, defaultCli, ... }`, which is what
106
+ * `engine/dispatch.js addToDispatchWithValidation` actually passes today)
107
+ * and a nested shape (`{ engine: { ccModel, ... } }`) for callers that
108
+ * hand in the whole config blob.
109
+ * 3. `undefined` — let the runtime adapter pick its own default. The Claude
110
+ * and Copilot adapters skip `--model` entirely when the value is falsy
111
+ * (engine/runtimes/claude.js:231, engine/runtimes/copilot.js:456), so
112
+ * Copilot ends up reading the user's `~/.copilot/settings.json` model.
113
+ *
114
+ * Returning `undefined` is intentional and must be preserved through to the
115
+ * `callLLM` call — passing `''` or `null` instead would cause the adapter to
116
+ * still skip the flag (truthy check), but it muddies the contract.
117
+ */
118
+ function _resolveModel(opts) {
119
+ if (opts && opts.model) return opts.model;
120
+ const engineCfg = opts && opts.engineConfig
121
+ ? (opts.engineConfig.engine && typeof opts.engineConfig.engine === 'object'
122
+ ? opts.engineConfig.engine
123
+ : opts.engineConfig)
124
+ : undefined;
125
+ return shared.resolveCcModel(engineCfg);
126
+ }
127
+
92
128
  /**
93
129
  * Validate a work item's acceptance criteria with a fast/cheap LLM call.
94
130
  *
@@ -101,9 +137,15 @@ function _parseResponse(text) {
101
137
  * @param {object} workItem - work item with `acceptance_criteria` (or
102
138
  * `acceptanceCriteria`) plus title/description for context.
103
139
  * @param {object} [opts]
104
- * @param {object} [opts.engineConfig] - passed through to callLLM for
105
- * runtime/model resolution (CC path).
106
- * @param {string} [opts.model] - explicit model override; defaults to 'haiku'.
140
+ * @param {object} [opts.engineConfig] - engine sub-config (flat
141
+ * `{ ccModel, defaultCli, ... }` as passed by
142
+ * `engine/dispatch.js addToDispatchWithValidation`, or the nested
143
+ * `{ engine: { ... } }` whole-config shape). Used both for
144
+ * runtime/model resolution in `callLLM` and for resolving the validator's
145
+ * own model via `shared.resolveCcModel`.
146
+ * @param {string} [opts.model] - explicit model override; otherwise the
147
+ * engine's CC model resolution applies, falling back to `undefined`
148
+ * (adapter default) when nothing is configured.
107
149
  * @param {number} [opts.timeout] - LLM timeout in ms.
108
150
  * @returns {Promise<{valid: boolean, reason: string}>}
109
151
  */
@@ -123,7 +165,7 @@ async function validateAcceptanceCriteria(workItem, opts = {}) {
123
165
  result = await callLLM(prompt, SYSTEM_PROMPT, {
124
166
  timeout: Number(opts.timeout) > 0 ? Number(opts.timeout) : DEFAULT_TIMEOUT_MS,
125
167
  label: 'pre-dispatch-eval',
126
- model: opts.model || DEFAULT_MODEL,
168
+ model: _resolveModel(opts),
127
169
  maxTurns: 1,
128
170
  direct: true,
129
171
  engineConfig: opts.engineConfig,
@@ -166,5 +208,6 @@ module.exports = {
166
208
  _extractDescription,
167
209
  _buildPrompt,
168
210
  _parseResponse,
211
+ _resolveModel,
169
212
  DESCRIPTION_MIN_CHARS,
170
213
  };
package/engine/shared.js CHANGED
@@ -2080,6 +2080,7 @@ const FAILURE_CLASS = {
2080
2080
  COMPLETION_NONCE_MISMATCH: 'completion-nonce-mismatch', // P-d2a8f6c1: completion JSON nonce did not match the per-spawn value injected via MINIONS_COMPLETION_NONCE — treat as forged/untrusted; ignore PR/noop/status fields from the report
2081
2081
  WORKTREE_PREFLIGHT: 'worktree-preflight', // Pre-spawn worktree validation rejected (nested-in-project, drive-root collapse) — never retryable
2082
2082
  INVALID_KEEP_PROCESSES_WORKDIR: 'invalid-keep-processes-workdir', // W-mp6k7ywi000fa33c: keep-pids.json declared a cwd that is not a real git worktree (likely a selective copy of the repo) — never retryable; agent must rerun in a real worktree
2083
+ INVALID_KEEP_PROCESSES_SCHEMA: 'invalid-keep-processes-schema', // W-mp7i902u000l991f: keep-pids.json failed validation for a reason other than workdir (pids-missing, ttl-too-long, expires_at-missing, pids-too-many, port-invalid, etc.) — agent wrote the wrong shape; never retryable until they fix the file
2083
2084
  UNKNOWN: 'unknown', // Unclassified failure
2084
2085
  };
2085
2086
  const ESCALATION_POLICY = {
package/engine.js CHANGED
@@ -2092,69 +2092,145 @@ async function spawnAgent(dispatchItem, config) {
2092
2092
 
2093
2093
  // W-mp6k7ywi000fa33c — keep_processes acceptance gate. When the work
2094
2094
  // item carried `meta.keep_processes: true` and produced a keep-pids.json
2095
- // sidecar whose `cwd` does not look like a real git worktree (default
2096
- // `requireGitWorkdir: true` in ENGINE_DEFAULTS.keepProcesses), reject
2097
- // the file and force the dispatch to fail with a dedicated failure
2098
- // class. spawn-agent's close handler has already reaped the kept PIDs
2099
- // (the same validation runs there via computeReapPlan), so the engine's
2100
- // job here is just (a) flip the dispatch outcome to ERROR, (b) emit an
2101
- // inbox alert that the responsible agent will see on its next dispatch,
2102
- // and (c) delete the now-rejected sidecar so it does not accumulate.
2095
+ // sidecar that fails validation, reject the file and force the dispatch
2096
+ // to fail with a dedicated failure class. spawn-agent's close handler
2097
+ // has already reaped the kept PIDs (the same validation runs there via
2098
+ // computeReapPlan), so the engine's job here is just (a) flip the
2099
+ // dispatch outcome to ERROR, (b) emit an inbox alert that the
2100
+ // responsible agent will see on its next dispatch, and (c) delete the
2101
+ // now-rejected sidecar so it does not accumulate.
2102
+ //
2103
+ // W-mp7i902u000l991f — symmetric gate. The original W-mp6k7ywi000fa33c
2104
+ // ship only flipped ERROR for `invalid-workdir:*` rejections. Other
2105
+ // schema failures (pids-missing, ttl-too-long, expires_at-missing,
2106
+ // pids-too-many, port-invalid, etc.) merely logged a warning while the
2107
+ // dispatch finished green — visible failure: a green WI with dead
2108
+ // processes (Ralph's W-mp7g7byh000h4b4a). Now ANY rejection is a hard
2109
+ // non-retryable failure when keep_processes was opted in. Workdir
2110
+ // rejections still get their own failure_class
2111
+ // (INVALID_KEEP_PROCESSES_WORKDIR) and inbox-slug to preserve audit
2112
+ // history; everything else routes to INVALID_KEEP_PROCESSES_SCHEMA with
2113
+ // a slug of `keep-processes-schema-<agentId>`.
2103
2114
  //
2104
2115
  // Per-WI override: `meta.keep_processes_skip_workdir_check: true` skips
2105
- // the gate entirely (legitimate non-git keep_processes use cases).
2106
- let keepProcessesWorkdirFailure = null;
2116
+ // workdir validation only schema validation always runs because pure
2117
+ // shape failures are independent of the workdir feature.
2118
+ let keepProcessesAcceptanceFailure = null;
2107
2119
  {
2108
2120
  const _wiMeta = dispatchItem.meta?.item?.meta || {};
2109
2121
  const _kpEnabled = !!_wiMeta.keep_processes
2110
2122
  || !!dispatchItem.meta?.keep_processes;
2111
2123
  const _kpSkipWorkdir = !!_wiMeta.keep_processes_skip_workdir_check
2112
2124
  || !!dispatchItem.meta?.keep_processes_skip_workdir_check;
2113
- if (_kpEnabled && !_kpSkipWorkdir && ENGINE_DEFAULTS.keepProcesses?.requireGitWorkdir !== false) {
2125
+ if (_kpEnabled) {
2114
2126
  try {
2115
2127
  const keepProcessSweep = require('./engine/keep-process-sweep');
2116
- const evalResult = keepProcessSweep.evaluateKeepPidsAcceptance(agentId, { requireGitWorkdir: true });
2117
- if (evalResult.exists && evalResult.isWorkdirRejection) {
2118
- keepProcessesWorkdirFailure = {
2128
+ // First evaluate with workdir validation OFF so we always catch
2129
+ // schema failures (pids-missing, ttl-too-long, etc.) regardless
2130
+ // of whether requireGitWorkdir is enabled or skipped per-WI.
2131
+ const schemaResult = keepProcessSweep.evaluateKeepPidsAcceptance(agentId, { requireGitWorkdir: false });
2132
+ // Then, only when the schema passes AND workdir validation is
2133
+ // gated on, run the workdir check separately. This keeps schema
2134
+ // and workdir failure classes cleanly distinguished.
2135
+ let evalResult = schemaResult;
2136
+ let isWorkdirRejection = false;
2137
+ const workdirGateOn = !_kpSkipWorkdir && ENGINE_DEFAULTS.keepProcesses?.requireGitWorkdir !== false;
2138
+ if (schemaResult.exists && schemaResult.accepted && workdirGateOn) {
2139
+ const workdirResult = keepProcessSweep.evaluateKeepPidsAcceptance(agentId, { requireGitWorkdir: true });
2140
+ if (workdirResult.exists && !workdirResult.accepted && workdirResult.isWorkdirRejection) {
2141
+ evalResult = workdirResult;
2142
+ isWorkdirRejection = true;
2143
+ }
2144
+ } else if (schemaResult.exists && !schemaResult.accepted) {
2145
+ // Schema failure — never workdir even if reason string happens
2146
+ // to start with `invalid-workdir:` (it can't, given we set
2147
+ // requireGitWorkdir:false above).
2148
+ isWorkdirRejection = false;
2149
+ }
2150
+
2151
+ if (evalResult.exists && !evalResult.accepted) {
2152
+ keepProcessesAcceptanceFailure = {
2119
2153
  reason: evalResult.reason,
2120
2154
  cwd: evalResult.recordedCwd || '',
2121
2155
  filePath: evalResult.filePath,
2156
+ isWorkdirRejection,
2157
+ parsedRaw: evalResult.parsedRaw || null,
2122
2158
  };
2123
2159
  // Delete the sidecar so it does not anchor stale PIDs on later
2124
2160
  // sweeps and does not show up as "malformed" forever.
2125
2161
  try { fs.unlinkSync(evalResult.filePath); } catch (_e) { /* gone or busy */ }
2126
- log('warn', `keep-processes acceptance: REJECTED ${agentId} (${id}) ${evalResult.reason}; PIDs reaped by spawn-agent, sidecar deleted`);
2162
+ const failKind = isWorkdirRejection ? 'workdir' : 'schema';
2163
+ log('warn', `keep-processes acceptance: REJECTED ${agentId} (${id}) — ${failKind}: ${evalResult.reason}; PIDs reaped by spawn-agent, sidecar deleted`);
2127
2164
  // Emit inbox alert so the agent sees this on its next turn.
2128
2165
  try {
2129
2166
  const wiId = dispatchItem.meta?.item?.id || '';
2130
- const slug = `keep-processes-workdir-${agentId}`;
2131
- const alertBody = [
2132
- `# keep_processes setup REJECTED for ${agentId}`,
2133
- '',
2134
- `Your kept-PIDs setup at \`${evalResult.recordedCwd || '<unknown>'}\` failed validation: ${evalResult.reason}.`,
2135
- 'The directory is not a git worktree. PIDs were NOT protected and will be reaped.',
2136
- '',
2137
- wiId ? `Work item: ${wiId}` : '',
2138
- `Agent: ${agentId}`,
2139
- `Dispatch: ${id}`,
2140
- '',
2141
- 'Why this matters: a keep_processes work item that runs in a non-git directory',
2142
- 'is almost always a partial copy of a repo (a selective `cp -r`). The Minions',
2143
- 'cleanup sweep cannot reason about such directories safely; later sweeps may',
2144
- 'rmSync subdirs treating them as separate worktrees. Re-run the work item',
2145
- 'inside a real `git worktree add` directory, or set',
2146
- '`meta.keep_processes_skip_workdir_check: true` on the work item if you',
2147
- 'genuinely intend to keep PIDs alive in a non-git directory.',
2148
- '',
2149
- ].join('\n');
2167
+ const slug = isWorkdirRejection
2168
+ ? `keep-processes-workdir-${agentId}`
2169
+ : `keep-processes-schema-${agentId}`;
2170
+ const ttlIn = Number(_wiMeta.keep_processes_ttl_minutes
2171
+ || dispatchItem.meta?.keep_processes_ttl_minutes);
2172
+ const canonicalHint = (() => {
2173
+ try {
2174
+ return keepProcessSweep.buildKeepProcessesHint({
2175
+ agentId,
2176
+ workItemId: wiId,
2177
+ ttlMinutes: Number.isFinite(ttlIn) && ttlIn > 0 ? ttlIn : undefined,
2178
+ minionsDir: shared.MINIONS_DIR,
2179
+ });
2180
+ } catch (_hintErr) { return ''; }
2181
+ })();
2182
+ let alertBody;
2183
+ if (isWorkdirRejection) {
2184
+ alertBody = [
2185
+ `# keep_processes setup REJECTED for ${agentId}`,
2186
+ '',
2187
+ `Your kept-PIDs setup at \`${evalResult.recordedCwd || '<unknown>'}\` failed validation: ${evalResult.reason}.`,
2188
+ 'The directory is not a git worktree. PIDs were NOT protected and will be reaped.',
2189
+ '',
2190
+ wiId ? `Work item: ${wiId}` : '',
2191
+ `Agent: ${agentId}`,
2192
+ `Dispatch: ${id}`,
2193
+ '',
2194
+ 'Why this matters: a keep_processes work item that runs in a non-git directory',
2195
+ 'is almost always a partial copy of a repo (a selective `cp -r`). The Minions',
2196
+ 'cleanup sweep cannot reason about such directories safely; later sweeps may',
2197
+ 'rmSync subdirs treating them as separate worktrees. Re-run the work item',
2198
+ 'inside a real `git worktree add` directory, or set',
2199
+ '`meta.keep_processes_skip_workdir_check: true` on the work item if you',
2200
+ 'genuinely intend to keep PIDs alive in a non-git directory.',
2201
+ '',
2202
+ ].join('\n');
2203
+ } else {
2204
+ let parsedSnippet = '';
2205
+ if (evalResult.parsedRaw) {
2206
+ try {
2207
+ parsedSnippet = JSON.stringify(evalResult.parsedRaw, null, 2);
2208
+ } catch (_jsonErr) {
2209
+ parsedSnippet = String(evalResult.parsedRaw);
2210
+ }
2211
+ if (parsedSnippet.length > 500) parsedSnippet = parsedSnippet.slice(0, 500) + '\n... (truncated)';
2212
+ }
2213
+ alertBody = [
2214
+ `# keep_processes setup REJECTED for ${agentId} (schema)`,
2215
+ '',
2216
+ `Your \`agents/${agentId}/keep-pids.json\` failed shape validation: \`${evalResult.reason}\`.`,
2217
+ 'PIDs were NOT protected and were reaped by spawn-agent. The dispatch was marked ERROR (non-retryable).',
2218
+ '',
2219
+ wiId ? `Work item: ${wiId}` : '',
2220
+ `Agent: ${agentId}`,
2221
+ `Dispatch: ${id}`,
2222
+ '',
2223
+ parsedSnippet ? '## What you wrote\n\n```json\n' + parsedSnippet + '\n```\n' : '',
2224
+ '## Canonical shape',
2225
+ '',
2226
+ canonicalHint || '(see `engine/keep-process-sweep.js` `buildKeepProcessesHint` for the canonical shape.)',
2227
+ '',
2228
+ ].filter(Boolean).join('\n');
2229
+ }
2150
2230
  writeInboxAlert(slug, alertBody);
2151
2231
  } catch (alertErr) {
2152
2232
  log('warn', `keep-processes acceptance: failed to emit inbox alert for ${agentId}: ${alertErr.message}`);
2153
2233
  }
2154
- } else if (evalResult.exists && !evalResult.accepted) {
2155
- // Non-workdir validation failure (oversize pids, bad TTL, etc.) —
2156
- // already handled by validateKeepPidsRecord; just log for audit.
2157
- log('warn', `keep-processes acceptance: ${agentId} (${id}) sidecar rejected — ${evalResult.reason} (not a workdir failure)`);
2158
2234
  }
2159
2235
  } catch (e) {
2160
2236
  log('warn', `keep-processes acceptance check failed for ${agentId} (${id}): ${e.message}`);
@@ -2171,12 +2247,13 @@ async function spawnAgent(dispatchItem, config) {
2171
2247
  // We mark the work item as failed (processWorkItemFailure NOT suppressed)
2172
2248
  // so the dispatch is not silently retried by the auto-recovery path.
2173
2249
  const nonceFail = nonceMismatch && nonceMismatch.severity === 'hard';
2174
- // W-mp6k7ywi000fa33c — keep_processes workdir rejection is a hard
2175
- // failure: the agent's claim that "everything was set up correctly" is
2176
- // structurally false. Force ERROR so the dispatch is not silently treated
2177
- // as success even when exit code is 0.
2178
- const keepProcessesWorkdirFail = !!keepProcessesWorkdirFailure;
2179
- const effectiveResult = (hardContractFail || nonceFail || keepProcessesWorkdirFail)
2250
+ // W-mp6k7ywi000fa33c / W-mp7i902u000l991f — keep_processes acceptance
2251
+ // failure is a hard failure: the agent's claim that "everything was set
2252
+ // up correctly" is structurally false. Force ERROR so the dispatch is
2253
+ // not silently treated as success even when exit code is 0. Both
2254
+ // workdir and schema rejections route here; the failure_class differs.
2255
+ const keepProcessesAcceptanceFail = !!keepProcessesAcceptanceFailure;
2256
+ const effectiveResult = (hardContractFail || nonceFail || keepProcessesAcceptanceFail)
2180
2257
  ? DISPATCH_RESULT.ERROR
2181
2258
  : (((code === 0 && !agentReportedFailure) || autoRecovered) ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR);
2182
2259
  const finalCompletionReportPath = structuredCompletion?._path || dispatchItem.meta?.completionReportPath || shared.dispatchCompletionReportPath(id);
@@ -2184,8 +2261,13 @@ async function spawnAgent(dispatchItem, config) {
2184
2261
  ...(finalCompletionReportPath ? { completionReportPath: finalCompletionReportPath } : {}),
2185
2262
  ...(structuredCompletion ? { structuredCompletion } : {}),
2186
2263
  };
2187
- const completeOpts = keepProcessesWorkdirFail
2188
- ? { ...completionOpts, failureClass: FAILURE_CLASS.INVALID_KEEP_PROCESSES_WORKDIR, agentRetryable: false }
2264
+ const _kpFailureClass = keepProcessesAcceptanceFail
2265
+ ? (keepProcessesAcceptanceFailure.isWorkdirRejection
2266
+ ? FAILURE_CLASS.INVALID_KEEP_PROCESSES_WORKDIR
2267
+ : FAILURE_CLASS.INVALID_KEEP_PROCESSES_SCHEMA)
2268
+ : null;
2269
+ const completeOpts = keepProcessesAcceptanceFail
2270
+ ? { ...completionOpts, failureClass: _kpFailureClass, agentRetryable: false }
2189
2271
  : (nonceFail
2190
2272
  ? { ...completionOpts, failureClass: nonceMismatch.failureClass, agentRetryable: false }
2191
2273
  : (hardContractFail
@@ -2198,8 +2280,12 @@ async function spawnAgent(dispatchItem, config) {
2198
2280
  } : completionOpts)));
2199
2281
  // Extract last 5 non-empty stderr lines as error context when exit code is non-zero
2200
2282
  let errorReason = '';
2201
- if (keepProcessesWorkdirFail) {
2202
- errorReason = `invalid_keep_processes_workdir: ${keepProcessesWorkdirFailure.reason} (cwd=${keepProcessesWorkdirFailure.cwd || '<unknown>'})`.slice(0, 300);
2283
+ if (keepProcessesAcceptanceFail) {
2284
+ if (keepProcessesAcceptanceFailure.isWorkdirRejection) {
2285
+ errorReason = `invalid_keep_processes_workdir: ${keepProcessesAcceptanceFailure.reason} (cwd=${keepProcessesAcceptanceFailure.cwd || '<unknown>'})`.slice(0, 300);
2286
+ } else {
2287
+ errorReason = `invalid_keep_processes_schema: ${keepProcessesAcceptanceFailure.reason}`.slice(0, 300);
2288
+ }
2203
2289
  } else if (nonceFail) {
2204
2290
  errorReason = nonceMismatch.reason || 'completion nonce mismatch';
2205
2291
  } else if (hardContractFail) {
@@ -2273,11 +2359,12 @@ async function spawnAgent(dispatchItem, config) {
2273
2359
 
2274
2360
  completeDispatch(id, effectiveResult, errorReason, resultSummary, completeOpts);
2275
2361
 
2276
- // W-mp6k7ywi000fa33c — surface the workdir-rejection on the WI so the
2277
- // dashboard pending-reason area shows the missing structure instead of
2278
- // a bare failure_class label. _pendingReason on a failed item is treated
2279
- // by the dashboard as "additional context" rather than a queue gate.
2280
- if (keepProcessesWorkdirFailure && dispatchItem.meta?.item?.id) {
2362
+ // W-mp6k7ywi000fa33c / W-mp7i902u000l991f — surface the keep_processes
2363
+ // rejection on the WI so the dashboard pending-reason area shows the
2364
+ // missing structure instead of a bare failure_class label. _pendingReason
2365
+ // on a failed item is treated by the dashboard as "additional context"
2366
+ // rather than a queue gate.
2367
+ if (keepProcessesAcceptanceFailure && dispatchItem.meta?.item?.id) {
2281
2368
  try {
2282
2369
  const wiPath = resolveWorkItemPath(dispatchItem.meta);
2283
2370
  if (wiPath) {
@@ -2285,7 +2372,10 @@ async function spawnAgent(dispatchItem, config) {
2285
2372
  if (!Array.isArray(data)) return data;
2286
2373
  const wi = data.find(i => i.id === dispatchItem.meta.item.id);
2287
2374
  if (!wi) return data;
2288
- wi._pendingReason = `invalid_keep_processes_workdir: ${keepProcessesWorkdirFailure.reason}`.slice(0, 500);
2375
+ const _pfx = keepProcessesAcceptanceFailure.isWorkdirRejection
2376
+ ? 'invalid_keep_processes_workdir'
2377
+ : 'invalid_keep_processes_schema';
2378
+ wi._pendingReason = `${_pfx}: ${keepProcessesAcceptanceFailure.reason}`.slice(0, 500);
2289
2379
  return data;
2290
2380
  });
2291
2381
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1954",
3
+ "version": "0.1.1955",
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"
@@ -148,6 +148,8 @@ curl -s http://localhost:{{dashboard_port}}/api/status
148
148
  - `POST /api/work-items`: `title` REQUIRED. `description` recommended. `project` REQUIRED when multiple projects are configured (server returns the list of known names if you guess wrong). `type` defaults to `implement`; valid values: `fix`, `implement`, `implement:large`, `explore`, `ask`, `review`, `test`, `verify`. Agent hint via `agent` (string) or `agents` (array).
149
149
  - Exempt from the `project` requirement (these run rootless or via central paths): `ask`, `explore`, `plan`, `plan-to-prd`, `meeting`, `docs`. Every other type needs a project worktree, so the server rejects project-less creates with `400 { error, knownProjects }` when ≠1 project is configured.
150
150
  - **`meta.keep_processes: true`** — opt-in flag that lets the agent leave specific descendant PIDs running after it exits (default: engine reaps EVERYTHING the agent spawned). **Set this whenever the user's intent is to leave a process alive after the agent finishes** — e.g. "spin up the dev server and exit", "start the watcher and leave it running", "set up my dev env", "keep the emulator open", "launch the daemon for me", "boot the constellation host and disconnect". Don't set it for normal build/test/run-once tasks (`npm test`, `npm run build`, one-shot scripts) — those should be reaped. Also accepts optional `meta.keep_processes_ttl_minutes` (default 60, hard-cap 1440 = 24h). When you set this flag, also make the WI title/description say something like "leave the dev server running" so the agent knows to write `agents/<id>/keep-pids.json` before exiting (the playbook injects the contract automatically when the flag is on). Example: `-d '{"title":"Spin up Constellation dev env and leave server running","type":"implement","project":"constellation","description":"Run bun install + bun run dev. Leave the dev server (port 5173) and Constellation host (port 3001) running after you exit so the user can iterate.","meta":{"keep_processes":true,"keep_processes_ttl_minutes":240}}'`. Inspect / kill kept PIDs anytime via `GET /api/keep-processes` and `POST /api/keep-processes/kill`.
151
+ - **`skipPr: true`** — opt-in flag that tells the engine NOT to enforce the PR-attachment contract for this work item, so the WI can complete `done` without the missing-PR hard-fail. **Set this when the dispatch mutates state OUTSIDE any tracked git repo and therefore cannot produce a PR** — e.g. cleaning `~/.claude/skills/`, editing runtime config under `~/.config/`, resetting the dashboard cache, mutating engine JSON state files (`engine/*.json`) the engine itself owns, or local tooling installs. **Do NOT set it for any task that touches a tracked repo's source** — even one-line diffs in a real repo should produce a PR. Type-selection rule of thumb: prefer `type: "explore"` for genuinely read-only tasks (rootless, no worktree, no PR contract); use `skipPr: true` when the task is write-side mutation but the writes don't land in a git repo. Example: `-d '{"title":"Clean up duplicate skills in ~/.claude/skills","type":"implement","description":"Audit ~/.claude/skills/ and delete the 3 obsolete entries identified in NOTE-mp7gt4iw0004b879. Pure user-machine state outside any git repo, so no PR will be produced.","skipPr":true}'`.
152
+ - **`oneShot: true`** — opt-in flag for one-off human-initiated dispatches that should NOT enroll the discovered PR into the engine's automatic review/fix loop. The PR is still tracked (status + comments are polled normally) but `discoverFromPrs` skips it for review/fix dispatch. **Set this when the user's intent is "do this single action against an existing PR, then stop"** — e.g. "review PR #2533 once", "rebase PR #2540 once and exit", "post a fix-summary comment on PR #2519". Don't set it for normal feature/fix work where the PR should keep cycling through review/fix until merged. Example: `-d '{"title":"One-off review of PR #2533","type":"review","project":"minions","description":"Single review pass on github:yemi33/minions#2533. Do not re-dispatch on subsequent comments.","oneShot":true}'`.
151
153
  - `POST /api/notes`: `title`, `what` REQUIRED.
152
154
  - `POST /api/knowledge`: `category`, `title`, `content` REQUIRED. Categories: `architecture`, `conventions`, `project-notes`, `build-reports`, `reviews`.
153
155
  - `POST /api/plan`: `title` REQUIRED. `description`, `priority`, `project`, `agent`, `branchStrategy` optional.