@yemi33/minions 0.1.2227 → 0.1.2228

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.
@@ -937,7 +937,7 @@ function _wiRenderDetail(item) {
937
937
  });
938
938
  if (!pills) return;
939
939
  var legend = anyUngrounded
940
- ? '<div style="font-size:var(--text-xs);color:var(--muted);margin-top:4px">&#x26A0; dashed = self-reported but not grounded against what the engine exposed</div>'
940
+ ? '<div style="font-size:var(--text-xs);color:var(--muted);margin-top:4px">&#x26A0; dashed = agent-reported, not verified by the engine</div>'
941
941
  : '';
942
942
  html += field('Repo harnesses used', '<div style="display:flex;flex-wrap:wrap;gap:4px">' + pills + '</div>' + legend);
943
943
  })();
package/dashboard.js CHANGED
@@ -14214,6 +14214,23 @@ if (require.main === module) {
14214
14214
  const { execSync } = require('child_process');
14215
14215
  setInterval(() => {
14216
14216
  try {
14217
+ // Respect `minions stop` / `minions uninstall` / mid-restart windows.
14218
+ // This is the third engine respawner (alongside engine/supervisor.js and
14219
+ // engine/watchdog.js) and must honor stop-intent too, or it would
14220
+ // resurrect the engine every 30s when stop-intent is set but
14221
+ // control.state is still 'running' (crash mid-stop, or stop-intent set
14222
+ // without a control-state flip). Fail-open: if isStopIntentSet is absent
14223
+ // (forks without a stop-intent producer), proceed with recovery —
14224
+ // matching engine/watchdog.js.
14225
+ let stopWanted = false;
14226
+ if (typeof shared.isStopIntentSet === 'function') {
14227
+ try { stopWanted = !!shared.isStopIntentSet(); } catch { stopWanted = false; }
14228
+ }
14229
+ if (stopWanted) {
14230
+ console.log(`[watchdog] stop-intent set — standing down (no restart)`);
14231
+ return;
14232
+ }
14233
+
14217
14234
  const control = getEngineState();
14218
14235
  if (control.state !== 'running' || !control.pid) return;
14219
14236
 
@@ -14222,7 +14239,10 @@ if (require.main === module) {
14222
14239
  try {
14223
14240
  if (process.platform === 'win32') {
14224
14241
  const out = execSync(`tasklist /FI "PID eq ${control.pid}" /NH`, { encoding: 'utf8', timeout: 3000, windowsHide: true });
14225
- alive = out.includes(String(control.pid));
14242
+ // Word-boundary + image-name match (mirrors engine/restart-health.js)
14243
+ // so a digit-substring collision in another column can't read a dead
14244
+ // engine pid as alive and suppress a needed respawn.
14245
+ alive = shared.tasklistOutputShowsPid(out, control.pid, { imageName: 'node' });
14226
14246
  } else {
14227
14247
  process.kill(control.pid, 0); // signal 0 = check existence
14228
14248
  alive = true;
@@ -47,6 +47,34 @@ recycle worktree dirs across branches.
47
47
  → `git checkout --detach origin/<main>` → mark IDLE.
48
48
  - **State** at `engine/worktree-pool.json`; git ops outside any lock.
49
49
 
50
+ ## Ownership marker is never "dirty" (#284)
51
+
52
+ The reused-worktree preflight (`assertCleanSharedWorktree`) builds its
53
+ dirty-file list from `git status --porcelain`. The engine's own
54
+ ownership marker `.minions-worktree` (`shared.WORKTREE_OWNER_MARKER`) is
55
+ gitignored in the repo's *current* tree, but a reused worktree that
56
+ checks out a branch whose tree predates that `.gitignore` line (or a
57
+ foreign repo) surfaces the marker as an untracked `?? .minions-worktree`
58
+ entry. Counting that engine-stamped artifact as filesystem-dirt used to
59
+ flip an otherwise-safe-to-reuse worktree (no upstream, HEAD at main tip)
60
+ into the conservative quarantine path → a non-retryable `WORKTREE_DIRTY`
61
+ failure for the whole work item.
62
+
63
+ `shared.isWorktreeOwnerMarkerStatusLine(porcelainLine)` recognizes a
64
+ porcelain line that refers ONLY to the root-level marker; the preflight
65
+ filters it out of `dirtyFiles` at every status read (initial probe,
66
+ post-reset re-verify) and the dirty-files prompt-injection site
67
+ (`engine.js`). **An untracked marker alone is a CLEAN tree for reuse.**
68
+ A same-named file nested in a subdir is NOT the marker and stays dirty;
69
+ the marker filter never masks a genuine user/agent edit beside it.
70
+
71
+ When the dirt is real and the worktree can't be auto-healed, the engine
72
+ still quarantines and fails non-retryably, and the quarantine
73
+ auto-recovery loop re-queues the item once in a fresh worktree (see
74
+ **Auto-recovery cap** below). The spawn-error completion report
75
+ distinguishes the **original worktree-preflight issue** from a
76
+ **retry-in-fresh-worktree** failure via `_quarantineRecoveryCount`.
77
+
50
78
  ## Quarantine path (dirty / divergent)
51
79
 
52
80
  When `discoverFromWorkItems` finds a worktree in a `WORKTREE_DIRTY`,
package/engine/cli.js CHANGED
@@ -6,7 +6,7 @@
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
  const shared = require('./shared');
9
- const { safeRead, safeJson, safeWrite, mutateControl, mutateWorkItems, ts, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, REVIEW_STATUS, DISPATCH_RESULT } = shared;
9
+ const { safeRead, safeJson, safeJsonArr, safeWrite, mutateControl, mutateWorkItems, ts, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, REVIEW_STATUS, DISPATCH_RESULT } = shared;
10
10
  const queries = require('./queries');
11
11
  const { getConfig, getControl, getDispatch, getAgentStatus,
12
12
  MINIONS_DIR, ENGINE_DIR, AGENTS_DIR, PLANS_DIR, PRD_DIR, CONTROL_PATH, DISPATCH_PATH } = queries;
@@ -782,7 +782,7 @@ const commands = {
782
782
  const projName = item.meta.project?.name;
783
783
  if (projName) {
784
784
  const prPath = path.join(MINIONS_DIR, 'projects', projName, 'pull-requests.json');
785
- const prs = safeJson(prPath) || [];
785
+ const prs = safeJsonArr(prPath);
786
786
  const matchingPr = prs.find(pr =>
787
787
  (pr.prdItems || []).includes(item.meta.item.id) &&
788
788
  pr.status !== 'abandoned' && pr.status !== 'closed'
@@ -1760,13 +1760,13 @@ const commands = {
1760
1760
  }
1761
1761
  }
1762
1762
  if (exists && name === 'pullRequests') {
1763
- const prs = safeJson(filePath) || [];
1763
+ const prs = safeJsonArr(filePath);
1764
1764
  const pending = prs.filter(p => p.status === PR_STATUS.ACTIVE && (p.reviewStatus === REVIEW_STATUS.PENDING || p.reviewStatus === REVIEW_STATUS.WAITING));
1765
1765
  const needsFix = prs.filter(p => p.status === PR_STATUS.ACTIVE && p.reviewStatus === REVIEW_STATUS.CHANGES_REQUESTED);
1766
1766
  console.log(` PRs: ${pending.length} pending review, ${needsFix.length} need fixes`);
1767
1767
  }
1768
1768
  if (exists && name === 'workItems') {
1769
- const items = safeJson(filePath) || [];
1769
+ const items = safeJsonArr(filePath);
1770
1770
  const queued = items.filter(i => i.status === WI_STATUS.QUEUED);
1771
1771
  console.log(` Items: ${queued.length} queued`);
1772
1772
  }
@@ -112,7 +112,7 @@ function buildHarnessUsedSection(harnessUsed) {
112
112
  if (lines.length === 0) return '';
113
113
 
114
114
  const legend = anyUngrounded
115
- ? `\n\n> ${HARNESS_WARN_ICON} self-reported but not grounded against what the engine exposed.`
115
+ ? `\n\n> ${HARNESS_WARN_ICON} agent-reported, not verified by the engine`
116
116
  : '';
117
117
 
118
118
  return `<details>\n<summary>${HARNESS_SUMMARY_ICON} Harnesses used (${lines.length})</summary>\n\n`
@@ -21,7 +21,7 @@
21
21
  const { execFileSync } = require('child_process');
22
22
  const path = require('path');
23
23
  const shared = require('./shared');
24
- const { safeJson, MINIONS_DIR, log } = shared;
24
+ const { safeJsonObj, MINIONS_DIR, log } = shared;
25
25
 
26
26
  const TOKEN_TTL_MS = 30 * 60 * 1000; // 30 minutes
27
27
  const FETCH_TIMEOUT_MS = 10000; // 10s — same ceiling as `gh api user`
@@ -40,7 +40,7 @@ function _readConfig(opts = {}) {
40
40
  return _cachedConfig;
41
41
  }
42
42
  const configPath = path.join(MINIONS_DIR, 'config.json');
43
- _cachedConfig = safeJson(configPath) || {};
43
+ _cachedConfig = safeJsonObj(configPath);
44
44
  _cachedConfigAt = Date.now();
45
45
  return _cachedConfig;
46
46
  }
@@ -32,9 +32,6 @@ function checkPlanCompletion(meta, config) {
32
32
  // terminal artifacts; missing primary means "gone", not "needs recovery".
33
33
  const plan = safeJsonNoRestore(planPath);
34
34
  if (!plan?.missing_features) return;
35
- if (plan.status === PLAN_STATUS.COMPLETED) {
36
- if (plan._completionNotified) return;
37
- }
38
35
 
39
36
  const projects = shared.getProjects(config);
40
37
 
@@ -43,6 +40,21 @@ function checkPlanCompletion(meta, config) {
43
40
  const planItems = allWorkItems.filter(w => w.sourcePlan === planFile && w.itemType !== 'pr' && w.itemType !== 'verify');
44
41
  if (planItems.length === 0) return;
45
42
 
43
+ // W-mqk5ld3p: verify-creation must be a pure function of work-item state,
44
+ // independent of who/what set `status: completed`. We do NOT short-circuit on
45
+ // the raw `_completionNotified` boolean — a PRD can land on disk pre-completed
46
+ // with the flag already set (out-of-band write by the plan-to-prd / pipeline
47
+ // path), and the legacy top-of-function `if (_completionNotified) return` then
48
+ // permanently skipped the aggregate build/test verify gate. Instead the flag
49
+ // gates ONLY the one-shot completion summary (below); control always falls
50
+ // through to the verify-creation block, which re-checks for an existing verify
51
+ // WI under the file lock and is therefore safe to reach every scan. The one
52
+ // exception is the REOPEN sub-path (terminal verify → re-open): that is a
53
+ // plan-modification concern (the dashboard clears `_completionNotified` when it
54
+ // re-opens a plan), so it is gated on `!alreadyNotified` to avoid bouncing an
55
+ // already-done verify on every steady-state scan.
56
+ const alreadyNotified = plan.status === PLAN_STATUS.COMPLETED && !!plan._completionNotified;
57
+
46
58
  // Hard completion gate: every PRD feature ID must have a corresponding work item in a terminal state.
47
59
  const planFeatureIds = new Set((plan.missing_features || []).map(f => f.id).filter(Boolean));
48
60
  const workItemById = {};
@@ -146,18 +158,24 @@ function checkPlanCompletion(meta, config) {
146
158
  ...uniquePrs.map(pr => `- ${pr.id}: ${pr.title || ''} ${pr.url || ''}`),
147
159
  ].filter(Boolean).join('\n');
148
160
 
149
- // Write summary to notes/inbox
150
- const summarySlug = `prd-completion-${planFile.replace('.json', '')}`;
151
- shared.writeToInbox('engine', summarySlug, summary);
152
- log('info', `PRD completion summary written to notes/inbox/${summarySlug}`);
153
-
154
- // Persist completed status + _completionNotified via file lock
155
- mutateJsonFileLocked(planPath, (data) => {
156
- data.status = PLAN_STATUS.COMPLETED;
157
- data.completedAt = plan.completedAt;
158
- data._completionNotified = true;
159
- return data;
160
- });
161
+ // Write summary to notes/inbox + flip _completionNotified — one-shot side
162
+ // effects, skipped for a pre-completed PRD that already carried the flag
163
+ // (alreadyNotified). The verify-creation block below still runs and is
164
+ // idempotent, so the plan still gets its verify gate without a duplicate
165
+ // summary or a redundant flag write.
166
+ if (!alreadyNotified) {
167
+ const summarySlug = `prd-completion-${planFile.replace('.json', '')}`;
168
+ shared.writeToInbox('engine', summarySlug, summary);
169
+ log('info', `PRD completion summary written to notes/inbox/${summarySlug}`);
170
+
171
+ // Persist completed status + _completionNotified via file lock
172
+ mutateJsonFileLocked(planPath, (data) => {
173
+ data.status = PLAN_STATUS.COMPLETED;
174
+ data.completedAt = plan.completedAt;
175
+ data._completionNotified = true;
176
+ return data;
177
+ });
178
+ }
161
179
 
162
180
  // Resolve the primary project for writing new work items (PR, verify).
163
181
  // Multi-project plans (no plan.project) derive primary from the done items —
@@ -255,6 +273,14 @@ function checkPlanCompletion(meta, config) {
255
273
  }
256
274
 
257
275
  if (isReopenableVerify(existingVerify)) {
276
+ // Re-opening a terminal verify is a plan-MODIFICATION action, not part of
277
+ // steady-state completion. The dashboard clears `_completionNotified` when
278
+ // it re-opens a plan, so only re-open here on a fresh completion; otherwise
279
+ // an already-done verify would bounce on every periodic scan (W-mqk5ld3p).
280
+ if (alreadyNotified) {
281
+ log('info', `Plan ${planFile}: verify WI ${existingVerify.id} for ${projName} already ${existingVerify.status} and plan already notified — leaving as-is`);
282
+ continue;
283
+ }
258
284
  const verifyProject = existingVerify.project || projName;
259
285
  const vProject = shared.resolveProjectSource(verifyProject, projects, { allowCentral: false }).project || p;
260
286
  const vWiPath = shared.projectWorkItemsPath(vProject);
@@ -8,7 +8,7 @@ const fs = require('fs');
8
8
  const path = require('path');
9
9
  const shared = require('./shared');
10
10
  const queries = require('./queries');
11
- const { safeJson, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, mutatePipelineRuns, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
11
+ const { safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, mutatePipelineRuns, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
12
12
  const routing = require('./routing');
13
13
  const http = require('http');
14
14
  const { shouldRunNow } = require('./scheduler');
@@ -70,7 +70,7 @@ function deletePipeline(id) {
70
70
  // ── Run State ────────────────────────────────────────────────────────────────
71
71
 
72
72
  function getPipelineRuns() {
73
- return safeJson(PIPELINE_RUNS_PATH) || {};
73
+ return safeJsonObj(PIPELINE_RUNS_PATH);
74
74
  }
75
75
 
76
76
  function getActiveRun(pipelineId) {
@@ -335,7 +335,7 @@ function evaluateCondition(condition, ctx) {
335
335
  // True when all work items created by the pipeline are done (not failed)
336
336
  if (!run) return false;
337
337
  const wiPath = CENTRAL_WI_PATH;
338
- const workItems = safeJson(wiPath) || [];
338
+ const workItems = safeJsonArr(wiPath);
339
339
  const allProjectWi = shared.getProjects(config).reduce((acc, p) => {
340
340
  return acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []);
341
341
  }, []);
@@ -876,7 +876,7 @@ function isStageComplete(stage, stageState, run, config) {
876
876
  case STAGE_TYPE.TASK: {
877
877
  // Check root + all project work-items.json (WIs may be moved to project paths)
878
878
  const wiPath = CENTRAL_WI_PATH;
879
- const workItems = safeJson(wiPath) || [];
879
+ const workItems = safeJsonArr(wiPath);
880
880
  const allProjectWi = shared.getProjects(config).reduce((acc, p) => {
881
881
  return acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []);
882
882
  }, []);
@@ -900,7 +900,7 @@ function isStageComplete(stage, stageState, run, config) {
900
900
  case STAGE_TYPE.PLAN: {
901
901
  // Plan stage completion: PRD conversion done + all materialized work items done
902
902
  const wiPath = CENTRAL_WI_PATH;
903
- const workItems = safeJson(wiPath) || [];
903
+ const workItems = safeJsonArr(wiPath);
904
904
  const allProjectWi = shared.getProjects(config).reduce((acc, p) => {
905
905
  return acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []);
906
906
  }, []);
@@ -1064,7 +1064,7 @@ async function discoverPipelineWork(config) {
1064
1064
  let output = '';
1065
1065
  if (stage.type === STAGE_TYPE.TASK) {
1066
1066
  const wiPath = CENTRAL_WI_PATH;
1067
- const workItems = safeJson(wiPath) || [];
1067
+ const workItems = safeJsonArr(wiPath);
1068
1068
  const projWi = shared.getProjects(config).reduce((acc, p) => acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []), []);
1069
1069
  const allWi = [...workItems, ...projWi];
1070
1070
  output = (stageState.artifacts?.workItems || []).map(id => {
@@ -88,7 +88,7 @@ function _centralDispatchDefaultedToProject(d, removedProject, projects) {
88
88
  function _collectProjectlessCentralDispatchItemIds(removedProject, projects) {
89
89
  const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
90
90
  const ids = new Set();
91
- const state = shared.safeJson(dispatchPath) || {};
91
+ const state = shared.safeJsonObj(dispatchPath);
92
92
  for (const queue of ['pending', 'active']) {
93
93
  for (const d of Array.isArray(state?.[queue]) ? state[queue] : []) {
94
94
  if (_centralDispatchDefaultedToProject(d, removedProject, projects)) ids.add(d.meta.item.id);
package/engine/routing.js CHANGED
@@ -8,7 +8,7 @@ const path = require('path');
8
8
  const shared = require('./shared');
9
9
  const queries = require('./queries');
10
10
 
11
- const { safeJson, safeRead, log, ts, WORK_TYPE } = shared;
11
+ const { safeJson, safeJsonObj, safeRead, log, ts, WORK_TYPE } = shared;
12
12
  const { ENGINE_DIR, DISPATCH_PATH } = queries;
13
13
 
14
14
  const MINIONS_DIR = shared.MINIONS_DIR;
@@ -84,7 +84,7 @@ function getMonthlySpend(agentId) {
84
84
 
85
85
  function getAgentErrorRate(agentId) {
86
86
  const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
87
- const metrics = safeJson(metricsPath) || {};
87
+ const metrics = safeJsonObj(metricsPath);
88
88
  const m = metrics[agentId];
89
89
  if (!m) return 0;
90
90
  const total = m.tasksCompleted + m.tasksErrored;
@@ -93,7 +93,7 @@ function getAgentErrorRate(agentId) {
93
93
 
94
94
  function isAgentIdle(agentId) {
95
95
  // Dispatch queue is the single source of truth for agent availability
96
- const dispatch = safeJson(DISPATCH_PATH) || {};
96
+ const dispatch = safeJsonObj(DISPATCH_PATH);
97
97
  return !(dispatch.active || []).some(d => d.agent === agentId);
98
98
  }
99
99
 
package/engine/shared.js CHANGED
@@ -7197,6 +7197,23 @@ function killImmediate(proc) {
7197
7197
  }
7198
7198
  }
7199
7199
 
7200
+ // Decide whether a Windows `tasklist /FI "PID eq <pid>" /NH` output proves the
7201
+ // pid is alive. A bare `out.includes(String(pid))` can read a DEAD pid as alive
7202
+ // on a digit-substring collision — the pid appears inside another column (a
7203
+ // larger PID, a memory-KB figure, a session id). Require a word-boundary match
7204
+ // (`\b<pid>\b`) and, when an `imageName` is supplied, that the expected process
7205
+ // image is present too. Mirrors the hardened check in engine/restart-health.js.
7206
+ // Pure (no shell-out) so callers can pass captured output and unit tests can
7207
+ // feed crafted samples.
7208
+ function tasklistOutputShowsPid(out, pid, { imageName } = {}) {
7209
+ if (!out) return false;
7210
+ const n = Number(pid);
7211
+ if (!Number.isInteger(n) || n <= 0) return false;
7212
+ if (!new RegExp(`\\b${n}\\b`).test(out)) return false;
7213
+ if (imageName && !out.toLowerCase().includes(String(imageName).toLowerCase())) return false;
7214
+ return true;
7215
+ }
7216
+
7200
7217
  // W-mq0e2dae000a003d — cross-platform CPU-seconds sampler used by the
7201
7218
  // spawn-phase watchdog to decide whether a process is genuinely wedged
7202
7219
  // vs busy. Returns the cumulative user+system CPU time in seconds, or
@@ -8409,6 +8426,32 @@ function hasWorktreeOwnerMarker(worktreePath) {
8409
8426
  }
8410
8427
  }
8411
8428
 
8429
+ // True when a `git status --porcelain` line refers ONLY to the engine's own
8430
+ // worktree-ownership marker (.minions-worktree) at the worktree root (#284).
8431
+ // The marker is gitignored in the repo's current tree, but a reused worktree
8432
+ // that checks out a branch whose tree predates that .gitignore line (or a
8433
+ // foreign repo) surfaces the marker as an untracked `?? .minions-worktree`
8434
+ // entry. Counting that engine-stamped artifact as "dirty" would fail (and
8435
+ // quarantine) an otherwise-safe-to-reuse worktree. Callers filter the marker
8436
+ // out of the dirty-file list before deciding a worktree is dirty.
8437
+ function isWorktreeOwnerMarkerStatusLine(porcelainLine) {
8438
+ if (!porcelainLine) return false;
8439
+ const line = String(porcelainLine).trim();
8440
+ if (!line) return false;
8441
+ // Porcelain v1: "XY <path>" — for an untracked file XY is "??". Strip the
8442
+ // status code (1-2 chars from the porcelain status alphabet) and the
8443
+ // following whitespace; fall back to the raw line if it doesn't match.
8444
+ const m = line.match(/^[ MTADRCU?!]{1,2}\s+(.*)$/);
8445
+ let p = m ? m[1] : line;
8446
+ // git quotes paths containing special chars; the marker name never needs it
8447
+ // but strip defensively. Then normalize separators and a leading "./".
8448
+ if (p.length >= 2 && p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1);
8449
+ p = p.replace(/\\/g, '/').replace(/^\.\//, '');
8450
+ // The marker is only ever stamped at the worktree root — a same-named file
8451
+ // nested in a subdir is NOT the ownership marker and stays "dirty".
8452
+ return p === WORKTREE_OWNER_MARKER;
8453
+ }
8454
+
8412
8455
  function slugify(text, maxLen = 50) {
8413
8456
  return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, maxLen);
8414
8457
  }
@@ -8860,6 +8903,7 @@ module.exports = {
8860
8903
  sleepMs,
8861
8904
  killGracefully,
8862
8905
  killImmediate,
8906
+ tasklistOutputShowsPid,
8863
8907
  getProcessCpuSeconds,
8864
8908
  killByPidImmediate,
8865
8909
  killByPidsImmediate,
@@ -8875,6 +8919,7 @@ module.exports = {
8875
8919
  WORKTREE_OWNER_MARKER,
8876
8920
  writeWorktreeOwnerMarker,
8877
8921
  hasWorktreeOwnerMarker,
8922
+ isWorktreeOwnerMarkerStatusLine,
8878
8923
  _normalizeWorktreePath, // exported for testing
8879
8924
  _writeWorktreeSkipLiveInboxNote, // exported for testing
8880
8925
  _retryFsOp, // exported for testing (W-mq5o6bvy000x7191)
@@ -103,7 +103,14 @@ function isPidAlive(pid) {
103
103
  const out = execSync(`tasklist /FI "PID eq ${pid}" /NH`, {
104
104
  encoding: 'utf8', timeout: 3000, windowsHide: true,
105
105
  });
106
- return out.includes(String(pid));
106
+ // Word-boundary + image-name match (mirrors engine/restart-health.js) so a
107
+ // digit-substring collision in another column can't read a dead pid as
108
+ // alive. Fall back to an inline regex if shared.js isn't loadable.
109
+ const shared = _sharedOrNull();
110
+ if (shared && typeof shared.tasklistOutputShowsPid === 'function') {
111
+ return shared.tasklistOutputShowsPid(out, pid, { imageName: 'node' });
112
+ }
113
+ return new RegExp(`\\b${pid}\\b`).test(out) && out.toLowerCase().includes('node');
107
114
  }
108
115
  process.kill(pid, 0);
109
116
  return true;
@@ -180,12 +187,30 @@ function _tokenizeCmdline(cmdline) {
180
187
  // byte-identical `path.join(MINIONS_DIR, '<script>.js')`, so exact script-token
181
188
  // match catches all of them while a foreign process that only *names* the path
182
189
  // is left alone (fail-safe: never kill on a loose match).
190
+ //
191
+ // ENGINE subcommand gate: for an `engine.js` target we additionally require the
192
+ // token AFTER the script to be `start`, so a short-lived `node engine.js stop`
193
+ // one-shot (spawned by `minions restart`/`down`/`update` and the `stop` verb
194
+ // during the restart window) is NEVER reaped. The long-lived daemon is ALWAYS
195
+ // spawned with `'start'` (supervisor/dashboard spawnEngine + bin/minions.js), so
196
+ // the requirement loses zero real orphans. The `dashboard.js` match stays BARE
197
+ // (no subcommand) — only the engine candidate gets the `start` requirement.
183
198
  function _cmdRunsScript(cmdline, target) {
184
199
  const toks = _tokenizeCmdline(cmdline);
200
+ const isEngineTarget = target === 'engine.js' || target.endsWith('/engine.js');
185
201
  // toks[0] is the node executable; the script is the first later .js token.
186
202
  for (let i = 1; i < toks.length; i++) {
187
203
  const norm = _normPath(toks[i]);
188
- if (norm.endsWith('.js')) return norm === target;
204
+ if (norm.endsWith('.js')) {
205
+ if (norm !== target) return false;
206
+ if (isEngineTarget) {
207
+ // Only the daemon (`engine.js start`) is reapable; one-shots like
208
+ // `engine.js stop` are left alone.
209
+ const next = toks[i + 1] ? _normPath(toks[i + 1]) : '';
210
+ return next === 'start';
211
+ }
212
+ return true;
213
+ }
189
214
  }
190
215
  return false;
191
216
  }
package/engine.js CHANGED
@@ -1454,7 +1454,13 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
1454
1454
  return result;
1455
1455
  }
1456
1456
  if (statusOut) {
1457
- result.dirtyFiles = statusOut.split('\n').map(l => l.trim()).filter(Boolean);
1457
+ result.dirtyFiles = statusOut.split('\n').map(l => l.trim()).filter(Boolean)
1458
+ // #284: the engine's own .minions-worktree ownership marker can surface
1459
+ // as an untracked entry when the checked-out tree predates the .gitignore
1460
+ // line that excludes it (or it's a foreign repo). An untracked marker
1461
+ // alone is a CLEAN tree for reuse — never let the engine fail/quarantine
1462
+ // a work item over an artifact it stamped itself.
1463
+ .filter(l => !shared.isWorktreeOwnerMarkerStatusLine(l));
1458
1464
  }
1459
1465
  const filesystemDirty = result.dirtyFiles.length > 0;
1460
1466
 
@@ -1597,9 +1603,15 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
1597
1603
  try {
1598
1604
  const r2 = await execAsync(_statusPorcelainCmd(), { ...gitOpts, cwd: worktreePath, timeout: statusTimeoutMs });
1599
1605
  const after = (r2 || '').toString().trim();
1600
- if (after) {
1606
+ // #284: ignore the ownership marker here too — `git clean -fd` removes it,
1607
+ // but the engine may re-stamp it, and it must never count as residual dirt.
1608
+ const afterFiles = after
1609
+ ? after.split('\n').map(l => l.trim()).filter(Boolean)
1610
+ .filter(l => !shared.isWorktreeOwnerMarkerStatusLine(l))
1611
+ : [];
1612
+ if (afterFiles.length) {
1601
1613
  result.reason = 'dirty-after-clean';
1602
- result.dirtyFiles = after.split('\n').map(l => l.trim()).filter(Boolean);
1614
+ result.dirtyFiles = afterFiles;
1603
1615
  return result;
1604
1616
  }
1605
1617
  } catch (e) {
@@ -2831,6 +2843,18 @@ async function spawnAgent(dispatchItem, config) {
2831
2843
  const failureClassName = isQuarantineEnvBlocked
2832
2844
  ? 'WORKTREE_QUARANTINE_ENV_BLOCKED'
2833
2845
  : (isDivergent ? 'WORKTREE_DIVERGENT' : 'WORKTREE_DIRTY');
2846
+ // #284: distinguish the ORIGINAL worktree-preflight failure from a
2847
+ // failure on the automatic fresh-worktree retry. The quarantine
2848
+ // auto-recovery loop (discoverFromWorkItems) flips a failed
2849
+ // WORKTREE_DIRTY/DIVERGENT item back to pending and stamps
2850
+ // _quarantineRecoveryCount on the WI; if this dispatch (a re-dispatch
2851
+ // of that item) hits the preflight gate AGAIN, the fresh worktree did
2852
+ // not resolve the dirt — surface that in the completion report so a
2853
+ // human/the engine can tell the two apart.
2854
+ const priorQuarantineRetry = Number(meta?.item?._quarantineRecoveryCount) || 0;
2855
+ const retryLabel = priorQuarantineRetry > 0
2856
+ ? ` [retry-in-fresh-worktree #${priorQuarantineRetry} also failed]`
2857
+ : ' [original worktree-preflight issue]';
2834
2858
  const reasonMsg = cleanResult.quarantined
2835
2859
  ? `${failureClassName}: reused worktree at ${worktreePath} was dirty/divergent (${cleanResult.reason}; ${cleanResult.ahead || 0} ahead, ${cleanResult.behind || 0} behind, ${cleanResult.dirtyFiles?.length || 0} dirty file(s)${previewFiles ? ': ' + previewFiles : ''}) — quarantined to ${cleanResult.quarantinedPath}. Next dispatch will start fresh.`
2836
2860
  : `${failureClassName}: reused worktree at ${worktreePath} is dirty/divergent (${cleanResult.reason}; ${cleanResult.ahead || 0} ahead, ${cleanResult.behind || 0} behind, ${cleanResult.dirtyFiles?.length || 0} file(s)${previewFiles ? ': ' + previewFiles : ''}). Quarantine ${cleanResult.quarantineError ? 'errored: ' + cleanResult.quarantineError : (cleanResult.quarantineSkipped ? 'was skipped — another live dispatch claims the worktree (see notes/inbox/ engine-worktree-skip-live note).' : 'was not attempted (' + cleanResult.reason + ').')}`;
@@ -2840,7 +2864,7 @@ async function spawnAgent(dispatchItem, config) {
2840
2864
  id,
2841
2865
  DISPATCH_RESULT.ERROR,
2842
2866
  reasonMsg.slice(0, 500),
2843
- `Engine preflight refused to dispatch into a dirty/divergent reused worktree (#2996). Reason: ${cleanResult.reason}.${cleanResult.quarantined ? ` Worktree quarantined to ${cleanResult.quarantinedPath}; backup ref ${cleanResult.backupRef || '(skipped)'}. See notes/inbox/ for recovery instructions.` : (cleanResult.quarantineSkipped ? ' Quarantine was skipped because another live dispatch claims this worktree path; this dispatch will not auto-retry until the live claimant clears.' : '')}${isQuarantineEnvBlocked ? ' Environmental quarantine failure (Windows EBUSY); WI auto-recovery loop will re-queue without bumping per-agent retry counter.' : ''}`,
2867
+ `Engine preflight refused to dispatch into a dirty/divergent reused worktree (#2996).${retryLabel} Reason: ${cleanResult.reason}.${cleanResult.quarantined ? ` Worktree quarantined to ${cleanResult.quarantinedPath}; backup ref ${cleanResult.backupRef || '(skipped)'}. See notes/inbox/ for recovery instructions.` : (cleanResult.quarantineSkipped ? ' Quarantine was skipped because another live dispatch claims this worktree path; this dispatch will not auto-retry until the live claimant clears.' : '')}${isQuarantineEnvBlocked ? ' Environmental quarantine failure (Windows EBUSY); WI auto-recovery loop will re-queue without bumping per-agent retry counter.' : ''}`,
2844
2868
  { agentRetryable: isStatusProbeFailed && cleanResult.quarantined, failureClass: failureClassValue },
2845
2869
  );
2846
2870
  cleanupTempAgent(agentId);
@@ -3347,8 +3371,12 @@ async function spawnAgent(dispatchItem, config) {
3347
3371
  try {
3348
3372
  const dirtyResult = await execAsync(_statusPorcelainCmd(), { ..._gitOpts, cwd: worktreePath, timeout: 10000 });
3349
3373
  const dirtyOutput = (dirtyResult.stdout || '').trim();
3350
- if (dirtyOutput) {
3351
- const dirtyFiles = dirtyOutput.split('\n').map(l => l.trim()).filter(Boolean);
3374
+ const dirtyFiles = dirtyOutput
3375
+ ? dirtyOutput.split('\n').map(l => l.trim()).filter(Boolean)
3376
+ // #284: don't surface the engine's own ownership marker as "prior work".
3377
+ .filter(l => !shared.isWorktreeOwnerMarkerStatusLine(l))
3378
+ : [];
3379
+ if (dirtyFiles.length) {
3352
3380
  const dirtySection = [
3353
3381
  '\n## Uncommitted Work in Worktree\n',
3354
3382
  'The worktree has uncommitted changes from a previous agent run. Review these files and continue from where the previous agent left off.\n',
@@ -8704,8 +8732,16 @@ async function discoverWork(config) {
8704
8732
  // readdir and read (e.g. concurrent archive), do not resurrect it
8705
8733
  // from a stale .backup sidecar (W-mouptdh1000h9f39).
8706
8734
  const plan = safeJsonNoRestore(path.join(prdDir, f));
8707
- if (!plan?.missing_features || plan.status === PLAN_STATUS.COMPLETED) {
8708
- if (plan?.status === PLAN_STATUS.COMPLETED) completedPlanCache.add(f);
8735
+ if (!plan?.missing_features) continue;
8736
+ // A completed PRD may still be missing its aggregate verify WI — e.g. it
8737
+ // landed on disk pre-completed via the plan-to-prd / pipeline path, so no
8738
+ // agent-completion event ever drove checkPlanCompletion (W-mqk5ld3p). The
8739
+ // function is idempotent (re-checks for an existing verify WI under lock),
8740
+ // so run it for completed plans too and cache only once it reports nothing
8741
+ // left to do (truthy return).
8742
+ if (plan.status === PLAN_STATUS.COMPLETED) {
8743
+ const done = lifecycle.checkPlanCompletion({ item: { sourcePlan: f } }, config);
8744
+ if (done) completedPlanCache.add(f);
8709
8745
  continue;
8710
8746
  }
8711
8747
  if (plan.status !== PLAN_STATUS.APPROVED && plan.status !== PLAN_STATUS.ACTIVE) continue;
@@ -9258,11 +9294,13 @@ async function tickInner() {
9258
9294
  continue;
9259
9295
  }
9260
9296
  const plan = safeJson(path.join(PRD_DIR, file));
9261
- if (plan && plan.missing_features && plan.status !== PLAN_STATUS.COMPLETED) {
9297
+ if (plan && plan.missing_features) {
9298
+ // Run for completed PRDs too — a pre-completed plan (flag already set,
9299
+ // items pre-marked done) still needs its aggregate verify gate created.
9300
+ // checkPlanCompletion is idempotent; cache only once it reports nothing
9301
+ // left to do (truthy return) (W-mqk5ld3p).
9262
9302
  const completed = checkPlanCompletion({ item: { sourcePlan: file } }, config);
9263
9303
  if (completed) completedPlanCache.add(file);
9264
- } else if (plan?.status === PLAN_STATUS.COMPLETED) {
9265
- completedPlanCache.add(file);
9266
9304
  }
9267
9305
  }
9268
9306
  } catch (err) { log('warn', `Plan completion check error: ${err?.message || err}`); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2227",
3
+ "version": "0.1.2228",
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"