@yemi33/minions 0.1.2145 → 0.1.2146

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/engine/cleanup.js CHANGED
@@ -634,6 +634,15 @@ async function runCleanup(config, verbose = false) {
634
634
  if (registered.has(full)) continue;
635
635
  let stat; try { stat = fs.statSync(full); } catch { continue; }
636
636
  if (stat.mtimeMs >= _twoHoursAgo) continue;
637
+ // W-mq5rwwss000f30a7 — even an "orphan" dir (one git doesn't know
638
+ // about) may still host a live agent if dispatch persistence ran
639
+ // before `git worktree add` finished. Skip when a live dispatch
640
+ // claims it.
641
+ if (shared.isWorktreePathLive(full)) {
642
+ log('info', `Cleanup: skip orphan worktree dir ${full} — live dispatch claims it`);
643
+ shared._writeWorktreeSkipLiveInboxNote(full, 'cleanup.orphanWorktreeDirSweep');
644
+ continue;
645
+ }
637
646
  try {
638
647
  fs.rmSync(full, { recursive: true, force: true });
639
648
  cleaned.orphanWorktreeDirs++;
package/engine/shared.js CHANGED
@@ -6576,13 +6576,126 @@ function _purgeReservedFiles(dirPath) {
6576
6576
  }
6577
6577
  }
6578
6578
 
6579
- function removeWorktree(wtPath, gitRoot, worktreeRoot) {
6579
+ // ── Live-worktree guard (W-mq5rwwss000f30a7) ─────────────────────────────────
6580
+ // Single source of truth for "is some non-terminal dispatch currently using
6581
+ // this worktree?" Every code path that wants to wipe / reset / recycle /
6582
+ // quarantine a worktree MUST call isWorktreePathLive() first and skip on
6583
+ // true. Without this guard the engine has wiped agents mid-task four times
6584
+ // in a row (W-mq5n1zx5000hcfb5 post-mortem) by reaping a worktree whose
6585
+ // dispatch was still active.
6586
+ //
6587
+ // Fail-open semantics: when SQLite is unreachable or the query throws, the
6588
+ // helper returns true (assume live). Better to leak a worktree than nuke
6589
+ // an agent's unpushed work.
6590
+
6591
+ function _normalizeWorktreePath(p) {
6592
+ if (!p || typeof p !== 'string') return '';
6593
+ let resolved;
6594
+ try { resolved = path.resolve(p); }
6595
+ catch { return ''; }
6596
+ resolved = resolved.replace(/\\/g, '/').replace(/\/+$/g, '');
6597
+ if (process.platform === 'win32') resolved = resolved.toLowerCase();
6598
+ return resolved;
6599
+ }
6600
+
6601
+ function isWorktreePathLive(worktreePath, opts = {}) {
6602
+ if (!worktreePath) return false;
6603
+ const target = _normalizeWorktreePath(worktreePath);
6604
+ if (!target) return false;
6605
+ const excludeDispatchId = opts.excludeDispatchId ? String(opts.excludeDispatchId) : null;
6606
+ let db = opts.db || null;
6607
+ if (!db) {
6608
+ try { db = require('./db').getDb(); }
6609
+ catch (e) {
6610
+ log('warn', `isWorktreePathLive: SQL unavailable for ${worktreePath} (${e.message}) — fail-open (assume live)`);
6611
+ return true;
6612
+ }
6613
+ }
6614
+ if (!db) {
6615
+ log('warn', `isWorktreePathLive: no db handle for ${worktreePath} — fail-open (assume live)`);
6616
+ return true;
6617
+ }
6618
+ let rows;
6619
+ try {
6620
+ rows = db.prepare(`
6621
+ SELECT id,
6622
+ json_extract(data, '$.worktreePath') AS top_wt,
6623
+ json_extract(data, '$.meta.worktreePath') AS meta_wt
6624
+ FROM dispatches
6625
+ WHERE status IN ('pending', 'active')
6626
+ `).all();
6627
+ } catch (e) {
6628
+ log('warn', `isWorktreePathLive: query threw for ${worktreePath} (${e.message}) — fail-open (assume live)`);
6629
+ return true;
6630
+ }
6631
+ for (const row of rows || []) {
6632
+ if (excludeDispatchId && String(row.id) === excludeDispatchId) continue;
6633
+ if (row.top_wt && _normalizeWorktreePath(row.top_wt) === target) return true;
6634
+ if (row.meta_wt && _normalizeWorktreePath(row.meta_wt) === target) return true;
6635
+ }
6636
+ return false;
6637
+ }
6638
+
6639
+ // Drop a deduped inbox note when a wipe site skips due to the live guard so
6640
+ // operators can see when the guard fires. Filename is keyed on basename +
6641
+ // UTC date — a single skip per worktree per day produces one note; further
6642
+ // skips that day silently no-op.
6643
+ function _writeWorktreeSkipLiveInboxNote(worktreePath, callerTag) {
6644
+ try {
6645
+ const base = path.basename(String(worktreePath || '').replace(/[\\/]+$/g, '')) || 'unknown';
6646
+ const safeBase = base.replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 80);
6647
+ const date = new Date().toISOString().slice(0, 10);
6648
+ const fname = `engine-worktree-skip-live-${safeBase}-${date}.md`;
6649
+ const inboxDir = path.join(MINIONS_DIR, 'notes', 'inbox');
6650
+ try { fs.mkdirSync(inboxDir, { recursive: true }); } catch { /* exists */ }
6651
+ const fpath = path.join(inboxDir, fname);
6652
+ if (fs.existsSync(fpath)) return; // deduped
6653
+ const body = [
6654
+ '---',
6655
+ `id: NOTE-${crypto.randomBytes(8).toString('hex')}`,
6656
+ 'agent: engine',
6657
+ `date: ${date}`,
6658
+ '---',
6659
+ '',
6660
+ `# Engine skipped worktree wipe — live dispatch guard fired (W-mq5rwwss000f30a7)`,
6661
+ '',
6662
+ `- caller: ${callerTag || 'unknown'}`,
6663
+ `- worktree: ${worktreePath}`,
6664
+ `- timestamp: ${new Date().toISOString()}`,
6665
+ '',
6666
+ 'A non-terminal dispatch row still claims this worktree. The wipe was skipped to',
6667
+ 'protect agent state. If this fires repeatedly, inspect engine/state.db dispatches',
6668
+ "table to find which dispatch is stuck claiming the path.",
6669
+ '',
6670
+ ].join('\n');
6671
+ fs.writeFileSync(fpath, body);
6672
+ } catch { /* best-effort — never throw from the skip-note writer */ }
6673
+ }
6674
+
6675
+ function removeWorktree(wtPath, gitRoot, worktreeRoot, opts = {}) {
6580
6676
  const resolved = path.resolve(wtPath);
6581
6677
  const resolvedRoot = path.resolve(worktreeRoot) + path.sep;
6582
6678
  if (!resolved.startsWith(resolvedRoot)) {
6583
6679
  log('warn', `removeWorktree: refusing to remove ${wtPath} — not under ${worktreeRoot}`);
6584
6680
  return false;
6585
6681
  }
6682
+ // W-mq5rwwss000f30a7 — never wipe a worktree while an agent is actively
6683
+ // dispatched inside it. isWorktreePathLive fails OPEN (returns true) when
6684
+ // the dispatches table is unreachable, so we err on the side of leaking
6685
+ // the worktree rather than destroying agent work.
6686
+ //
6687
+ // PR #3133 review: callers that are themselves the owning dispatch (e.g.
6688
+ // the W-mpbqhstz001lf518 dispatch-end orphan GC, or the pool-return
6689
+ // chain) can pass `excludeDispatchId` so their OWN active row — which
6690
+ // legitimately claims the worktreePath via the pending→active persistence
6691
+ // at engine.js — is ignored by the guard. Any OTHER non-terminal row
6692
+ // still blocks the wipe.
6693
+ const excludeDispatchId = opts && opts.excludeDispatchId ? String(opts.excludeDispatchId) : null;
6694
+ if (isWorktreePathLive(resolved, excludeDispatchId ? { excludeDispatchId } : undefined)) {
6695
+ log('warn', `removeWorktree: skip — live dispatch in ${wtPath}`);
6696
+ _writeWorktreeSkipLiveInboxNote(wtPath, 'shared.removeWorktree');
6697
+ return false;
6698
+ }
6586
6699
  _pruneRemoveWorktreeFailures();
6587
6700
  // Skip paths that failed 3+ times — retry after 1 hour cooldown
6588
6701
  const prior = _removeWorktreeFailures.get(resolved);
@@ -7030,6 +7143,9 @@ module.exports = {
7030
7143
  listProcessDescendants,
7031
7144
  listProcessReachable,
7032
7145
  removeWorktree,
7146
+ isWorktreePathLive,
7147
+ _normalizeWorktreePath, // exported for testing
7148
+ _writeWorktreeSkipLiveInboxNote, // exported for testing
7033
7149
  _retryFsOp, // exported for testing (W-mq5o6bvy000x7191)
7034
7150
  bumpWorktreeGcMetric, // exported for testing (W-mq5o6bvy000x7191)
7035
7151
  _WORKTREE_RETRYABLE_CODES, // exported for testing (W-mq5o6bvy000x7191)
@@ -284,6 +284,13 @@ function gcDispatchWorktreeIfOrphan(opts) {
284
284
  worktreeRoot,
285
285
  log = _noopLog,
286
286
  removeWorktree = null,
287
+ // PR #3133 review — when the dispatch-end GC is the caller, the
288
+ // current dispatch's own active row legitimately claims worktreePath
289
+ // (persisted by engine.js at pending→active). Pass `excludeDispatchId`
290
+ // so shared.removeWorktree's live-guard ignores that row and lets the
291
+ // dispatch GC its own orphan worktree. Other live claimants still
292
+ // block the wipe.
293
+ excludeDispatchId = null,
287
294
  config = null,
288
295
  writeToInbox = null,
289
296
  } = opts || {};
@@ -295,9 +302,10 @@ function gcDispatchWorktreeIfOrphan(opts) {
295
302
  return { outcome: 'skip', reason: 'no-git-root', removed: false };
296
303
  }
297
304
  const _removeFn = typeof removeWorktree === 'function' ? removeWorktree : shared.removeWorktree;
305
+ const _rmOpts = excludeDispatchId ? { excludeDispatchId } : undefined;
298
306
  const resolved = (() => { try { return path.resolve(worktreePath); } catch { return worktreePath; } })();
299
307
  try {
300
- const removed = _removeFn(worktreePath, gitRoot, worktreeRoot);
308
+ const removed = _removeFn(worktreePath, gitRoot, worktreeRoot, _rmOpts);
301
309
  if (removed) {
302
310
  _markStuckSuccess(resolved, { writeToInbox });
303
311
  log('info', `worktree-gc: dispatch-end removed ${path.basename(worktreePath)}`);
package/engine.js CHANGED
@@ -1021,9 +1021,21 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
1021
1021
  statusError: e.message,
1022
1022
  },
1023
1023
  );
1024
- result.quarantined = true;
1025
- result.quarantinedPath = q.quarantinedPath;
1026
- result.backupRef = q.backupRef;
1024
+ // PR #3133 review — honor q.skipped instead of dishonestly
1025
+ // claiming quarantined=true with a null path. When
1026
+ // _quarantineDirtyWorktree skips because shared.isWorktreePathLive
1027
+ // fires on the worktree, no rename happened, no backup ref was
1028
+ // written, and the caller's downstream "quarantined to X" log /
1029
+ // error message would print "quarantined to null". Surface the
1030
+ // skip honestly so the spawn-error path (engine.js:2056-2066) can
1031
+ // render a truthful message and the dispatch stays non-retryable.
1032
+ if (q.skipped) {
1033
+ result.quarantineSkipped = true;
1034
+ } else {
1035
+ result.quarantined = true;
1036
+ result.quarantinedPath = q.quarantinedPath;
1037
+ result.backupRef = q.backupRef;
1038
+ }
1027
1039
  } catch (qErr) {
1028
1040
  result.quarantineError = qErr.message;
1029
1041
  log('error', `assertCleanSharedWorktree: quarantine after status-failed failed for ${worktreePath}: ${qErr.message}`);
@@ -1143,9 +1155,16 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
1143
1155
  dirtyFiles: result.dirtyFiles,
1144
1156
  },
1145
1157
  );
1146
- result.quarantined = true;
1147
- result.quarantinedPath = q.quarantinedPath;
1148
- result.backupRef = q.backupRef;
1158
+ // PR #3133 review — same honest-result fix as the status-failed
1159
+ // path above. If _quarantineDirtyWorktree was skipped by the
1160
+ // live-guard, do NOT set quarantined=true with a null path.
1161
+ if (q.skipped) {
1162
+ result.quarantineSkipped = true;
1163
+ } else {
1164
+ result.quarantined = true;
1165
+ result.quarantinedPath = q.quarantinedPath;
1166
+ result.backupRef = q.backupRef;
1167
+ }
1149
1168
  } catch (qErr) {
1150
1169
  result.quarantineError = qErr.message;
1151
1170
  log('error', `assertCleanSharedWorktree: quarantine failed for ${worktreePath}: ${qErr.message}`);
@@ -1214,6 +1233,14 @@ async function _quarantineDirtyWorktree(rootDir, worktreePath, branchName, gitOp
1214
1233
 
1215
1234
  // Rename the worktree dir. Once this succeeds the worktree is functionally
1216
1235
  // quarantined; subsequent failures only affect ref bookkeeping.
1236
+ // W-mq5rwwss000f30a7 — never quarantine (=rename out from under) a worktree
1237
+ // that a live dispatch still claims. Quarantine breaks the agent's cwd just
1238
+ // as completely as removeWorktree would.
1239
+ if (shared.isWorktreePathLive(worktreePath)) {
1240
+ log('warn', `_quarantineDirtyWorktree: skip — live dispatch in ${worktreePath}`);
1241
+ shared._writeWorktreeSkipLiveInboxNote(worktreePath, '_quarantineDirtyWorktree');
1242
+ return { quarantinedPath: null, backupRef: null, skipped: true };
1243
+ }
1217
1244
  fs.renameSync(worktreePath, quarantinedPath);
1218
1245
 
1219
1246
  // Prune git's stale worktree metadata so the next `git worktree add` for
@@ -2047,14 +2074,14 @@ async function spawnAgent(dispatchItem, config) {
2047
2074
  const failureClassName = isDivergent ? 'WORKTREE_DIVERGENT' : 'WORKTREE_DIRTY';
2048
2075
  const reasonMsg = cleanResult.quarantined
2049
2076
  ? `${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.`
2050
- : `${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 : 'was not attempted (' + cleanResult.reason + ').'}`;
2077
+ : `${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 + ').')}`;
2051
2078
  log('error', reasonMsg);
2052
2079
  _cleanupPromptFiles();
2053
2080
  completeDispatch(
2054
2081
  id,
2055
2082
  DISPATCH_RESULT.ERROR,
2056
2083
  reasonMsg.slice(0, 500),
2057
- `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.` : ''}`,
2084
+ `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.' : '')}`,
2058
2085
  { agentRetryable: isStatusProbeFailed && cleanResult.quarantined, failureClass: failureClassValue },
2059
2086
  );
2060
2087
  cleanupTempAgent(agentId);
@@ -3743,6 +3770,15 @@ async function spawnAgent(dispatchItem, config) {
3743
3770
  const _projForReturn = project?.name || 'default';
3744
3771
  const _poolSizeReturn = worktreePool.getProjectPoolSize(_projForReturn, config);
3745
3772
  if (!_keepPidsAlive && !_managedSpawnAlive && _poolSizeReturn > 0) {
3773
+ // W-mq5rwwss000f30a7 — defensive live-worktree check before the
3774
+ // destructive `git reset --hard HEAD → git clean -fd → checkout
3775
+ // --detach` chain. We pass excludeDispatchId so the active row for
3776
+ // THIS dispatch (still in dispatch.active until completeDispatch
3777
+ // fires below) is ignored. Any OTHER live dispatch claiming the
3778
+ // same path is a sign of a concurrency bug and must not be wiped.
3779
+ if (shared.isWorktreePathLive(worktreePath, { excludeDispatchId: id })) {
3780
+ log('warn', `worktree-pool: skip return — another live dispatch claims ${worktreePath}`);
3781
+ } else {
3746
3782
  try {
3747
3783
  const _mainRefRet = sanitizeBranch(shared.resolveMainBranch(rootDir, project?.mainBranch));
3748
3784
  await shared.shellSafeGit(['reset', '--hard', 'HEAD'], { ..._gitOpts, cwd: worktreePath, timeout: 30000 });
@@ -3771,6 +3807,7 @@ async function spawnAgent(dispatchItem, config) {
3771
3807
  // dispatch-end GC below will pick it up via the isPoolMember
3772
3808
  // check (which now correctly returns false).
3773
3809
  }
3810
+ }
3774
3811
  } else if (_keepPidsAlive || _managedSpawnAlive) {
3775
3812
  // Skip the pool — the worktree is in use by left-running processes
3776
3813
  // (keep_processes PIDs or managed-spawn services). Make sure no
@@ -3803,6 +3840,13 @@ async function spawnAgent(dispatchItem, config) {
3803
3840
  worktreeRoot: _wtRoot,
3804
3841
  agentId,
3805
3842
  managedSpawnSpawnedCount: Array.isArray(managedSpawnSpawned) ? managedSpawnSpawned.length : 0,
3843
+ // W-mq5rwwss000f30a7 / PR #3133 review — this dispatch's own
3844
+ // active row still claims worktreePath (persisted at pending→active,
3845
+ // line ~3998). Without excludeDispatchId, shared.removeWorktree's
3846
+ // live-guard would skip and silently neuter the GC for the
3847
+ // default worktreePoolSize:0 config. Pool-return uses the same
3848
+ // plumbing; this is the matching wiring on the orphan-GC side.
3849
+ excludeDispatchId: id,
3806
3850
  log,
3807
3851
  });
3808
3852
  if (_gcResult.outcome === 'gc') {
@@ -3973,6 +4017,11 @@ async function spawnAgent(dispatchItem, config) {
3973
4017
  // route output parsing through the right adapter. Also surfaces the choice
3974
4018
  // in dispatch.json for debugging multi-runtime fleets.
3975
4019
  item.runtimeName = runtimeName;
4020
+ // W-mq5rwwss000f30a7 — persist the worktree path so the live-worktree
4021
+ // guard (shared.isWorktreePathLive) can correlate destructive callers
4022
+ // (removeWorktree, cleanup orphan-dir sweep, pool-return, quarantine)
4023
+ // back to this active dispatch and skip the wipe.
4024
+ if (worktreePath) item.worktreePath = worktreePath;
3976
4025
  delete item.skipReason;
3977
4026
  delete item._agentBusySince;
3978
4027
  if (!dispatch.active.some(d => d.id === id)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2145",
3
+ "version": "0.1.2146",
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"