@yemi33/minions 0.1.2147 → 0.1.2149

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.js CHANGED
@@ -918,6 +918,109 @@ async function pruneStaleWorktreeForBranch(rootDir, branchName, gitOpts) {
918
918
  return removed;
919
919
  }
920
920
 
921
+ // W-mq5n1zx5 — Layer 1b: status-probe command builder. Honors the
922
+ // ENGINE_DEFAULTS.statusProbeUseNoOptionalLocks toggle so operators can
923
+ // disable the flag if a future git release breaks something. Default ON:
924
+ // `--no-optional-locks` tells git to skip the .git/index.lock acquire
925
+ // around the untracked-cache refresh that runs inside `status`. Without
926
+ // the flag, a 6–12s probe under aggressive AV scanning is normal; with
927
+ // it, typical probes drop to <500ms, which removes the timeout that
928
+ // leaks the git.exe descendant that later pins packfile handles and
929
+ // breaks the quarantine rename.
930
+ function _statusPorcelainCmd() {
931
+ return ENGINE_DEFAULTS.statusProbeUseNoOptionalLocks
932
+ ? 'git --no-optional-locks status --porcelain'
933
+ : 'git status --porcelain';
934
+ }
935
+
936
+ // W-mq5n1zx5 — Layer 1a: rename worktree dir with jittered backoff. The
937
+ // raw `fs.renameSync` used to throw Windows EBUSY/EPERM/EACCES if a
938
+ // lingering `git.exe` descendant (typically leaked by a status-probe
939
+ // timeout) still held a packfile handle. We retry with capped exponential
940
+ // backoff + random jitter so the descendant has time to exit on its own.
941
+ // Worst-case wall time ≈ baseMs * (2^attempts) ≈ 16s when attempts=6,
942
+ // baseMs=250 — small enough not to wedge a tick, large enough to clear
943
+ // the typical race. Throws the LAST error on exhaustion so the caller
944
+ // can decide whether to fall back to `git worktree remove --force`.
945
+ async function _renameWithRetry(src, dst, opts = {}) {
946
+ const attempts = Number(opts.attempts) > 0
947
+ ? Number(opts.attempts)
948
+ : ENGINE_DEFAULTS.quarantineRenameRetryAttempts;
949
+ const baseMs = Number(opts.baseMs) > 0
950
+ ? Number(opts.baseMs)
951
+ : ENGINE_DEFAULTS.quarantineRenameRetryBaseMs;
952
+ let lastErr;
953
+ for (let i = 0; i < attempts; i++) {
954
+ try { fs.renameSync(src, dst); return { attempts: i + 1 }; }
955
+ catch (e) {
956
+ if (!['EBUSY', 'EPERM', 'EACCES', 'ENOTEMPTY'].includes(e.code)) throw e;
957
+ lastErr = e;
958
+ if (i < attempts - 1) {
959
+ const delay = baseMs * (2 ** i) + Math.random() * 200;
960
+ await new Promise(r => setTimeout(r, delay));
961
+ }
962
+ }
963
+ }
964
+ throw lastErr;
965
+ }
966
+
967
+ // W-mq5n1zx5 — Layer 2a: on Windows, kill any live `git.exe` descendants
968
+ // whose command line points at the quarantine target path. We never
969
+ // tracked the PID of the `git status --porcelain` child that the probe
970
+ // timed out on, so we can't kill by PID — instead we shell out to
971
+ // PowerShell's CIM cmdlets to find matching processes and Stop-Process
972
+ // them. Cheap (<2s) and idempotent — if no descendants are alive, the
973
+ // CIM query returns nothing and exits 0. POSIX is a no-op (the EBUSY
974
+ // race is Windows-specific). Best-effort; failure is logged but never
975
+ // blocks the quarantine.
976
+ function _killGitDescendantsForWorktree(worktreePath) {
977
+ if (process.platform !== 'win32') return { killed: 0, skipped: true };
978
+ if (!ENGINE_DEFAULTS.statusProbeKillDescendantsWin32) return { killed: 0, skipped: true };
979
+ if (!worktreePath) return { killed: 0, skipped: true };
980
+ // PowerShell expects single-quoted literals; escape any embedded single
981
+ // quote by doubling it (PowerShell's standard single-quote escape).
982
+ const safePath = String(worktreePath).replace(/'/g, "''");
983
+ const ps = [
984
+ "$ErrorActionPreference='SilentlyContinue';",
985
+ `$matches = Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'git.exe' -and $_.CommandLine -like '*${safePath}*' };`,
986
+ "if ($matches) { $matches | ForEach-Object { Stop-Process -Id $_.ProcessId -Force; $_.ProcessId }; }",
987
+ ].join(' ');
988
+ try {
989
+ const out = shared.execSilent(`powershell -NoProfile -Command "${ps.replace(/"/g, '\\"')}"`, { timeout: 2000, encoding: 'utf8' });
990
+ const killed = String(out || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean).length;
991
+ if (killed > 0) log('info', `_killGitDescendantsForWorktree: killed ${killed} git.exe descendant(s) holding ${worktreePath}`);
992
+ return { killed };
993
+ } catch (e) {
994
+ log('warn', `_killGitDescendantsForWorktree: powershell probe failed: ${e.message}`);
995
+ return { killed: 0, error: e.message };
996
+ }
997
+ }
998
+
999
+ // W-mq5n1zx5 — Layer 3a: bump the rolling counters for quarantine rename
1000
+ // outcomes (attempts, success, successAfterRetry, fallbackForceRemove,
1001
+ // totalFailure). Best-effort; metrics failures must not break the
1002
+ // quarantine path. The shape lives under metrics._engine.worktreeQuarantineOutcomes
1003
+ // so it sits beside the other _engine.* engine-internal telemetry.
1004
+ function _bumpQuarantineOutcome(key, delta = 1) {
1005
+ try {
1006
+ shared.mutateMetrics((metrics) => {
1007
+ if (!metrics._engine) metrics._engine = {};
1008
+ if (!metrics._engine.worktreeQuarantineOutcomes) {
1009
+ metrics._engine.worktreeQuarantineOutcomes = {
1010
+ attempts: 0, success: 0, successAfterRetry: 0,
1011
+ fallbackForceRemove: 0, totalFailure: 0,
1012
+ };
1013
+ }
1014
+ const o = metrics._engine.worktreeQuarantineOutcomes;
1015
+ o[key] = (o[key] || 0) + delta;
1016
+ return metrics;
1017
+ });
1018
+ } catch (e) {
1019
+ log('warn', `_bumpQuarantineOutcome(${key}): ${e.message}`);
1020
+ }
1021
+ }
1022
+
1023
+
921
1024
  // ─── assertCleanSharedWorktree (#2439) ──────────────────────────────────────
922
1025
  // Engine-side preflight that prevents shared-branch (and PR-targeted reused —
923
1026
  // see opts.quarantineOnUnsafe, issue #2996) dispatches from spawning into a
@@ -980,7 +1083,7 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
980
1083
  // 1. Status probe (filesystem)
981
1084
  let statusOut = '';
982
1085
  try {
983
- const r = await execAsync('git status --porcelain', { ...gitOpts, cwd: worktreePath, timeout: statusTimeoutMs });
1086
+ const r = await execAsync(_statusPorcelainCmd(), { ...gitOpts, cwd: worktreePath, timeout: statusTimeoutMs });
984
1087
  statusOut = (r || '').toString().trim();
985
1088
  } catch (e) {
986
1089
  // W-mq1habhf: previously this bailed out with the bad worktree intact,
@@ -1186,7 +1289,7 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
1186
1289
 
1187
1290
  // 7. Re-verify
1188
1291
  try {
1189
- const r2 = await execAsync('git status --porcelain', { ...gitOpts, cwd: worktreePath, timeout: statusTimeoutMs });
1292
+ const r2 = await execAsync(_statusPorcelainCmd(), { ...gitOpts, cwd: worktreePath, timeout: statusTimeoutMs });
1190
1293
  const after = (r2 || '').toString().trim();
1191
1294
  if (after) {
1192
1295
  result.reason = 'dirty-after-clean';
@@ -1218,9 +1321,23 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
1218
1321
  // branch (assertCleanSharedWorktree returns 'other-dispatch-active' before
1219
1322
  // invoking this). Renaming an active worktree out from under a running
1220
1323
  // agent would be catastrophic.
1324
+ //
1325
+ // W-mq5n1zx5 — layered defense against Windows EBUSY: (Layer 2a) kill
1326
+ // lingering git.exe descendants whose command-line points at the worktree
1327
+ // before touching the path; (Layer 1a) retry the rename with jittered
1328
+ // backoff; (Layer 2b) if every retry still fails, fall back to
1329
+ // `git worktree remove --force` so git tears down its own metadata + dir
1330
+ // rather than leaving a half-quarantined tree behind; (Layer 3a) emit
1331
+ // outcome counters to metrics._engine.worktreeQuarantineOutcomes;
1332
+ // (Layer 3b) when EVERY path fails, write a dedicated inbox alert naming
1333
+ // the manual recovery command. The throw-or-return contract is unchanged
1334
+ // for callers: a thrown error means the caller's `result.quarantineError`
1335
+ // field is populated and `result.quarantined` stays false (env-blocked
1336
+ // path); a normal return means quarantine succeeded.
1221
1337
  async function _quarantineDirtyWorktree(rootDir, worktreePath, branchName, gitOpts, diag = {}) {
1222
1338
  const ts = Date.now();
1223
1339
  const quarantinedPath = `${worktreePath}-quarantine-${ts}`;
1340
+ _bumpQuarantineOutcome('attempts', 1);
1224
1341
 
1225
1342
  // Capture HEAD sha BEFORE renaming so we can back up the local branch ref.
1226
1343
  let headSha = '';
@@ -1231,24 +1348,112 @@ async function _quarantineDirtyWorktree(rootDir, worktreePath, branchName, gitOp
1231
1348
  log('warn', `_quarantineDirtyWorktree: rev-parse HEAD failed for ${worktreePath}: ${e.message} — backup ref will be skipped`);
1232
1349
  }
1233
1350
 
1234
- // Rename the worktree dir. Once this succeeds the worktree is functionally
1235
- // quarantined; subsequent failures only affect ref bookkeeping.
1236
1351
  // W-mq5rwwss000f30a7 — never quarantine (=rename out from under) a worktree
1237
1352
  // that a live dispatch still claims. Quarantine breaks the agent's cwd just
1238
- // as completely as removeWorktree would.
1353
+ // as completely as removeWorktree would. Check before any destructive work
1354
+ // (including the kill-descendants sweep below) so we don't disturb a live
1355
+ // agent's git child processes either.
1239
1356
  if (shared.isWorktreePathLive(worktreePath)) {
1240
1357
  log('warn', `_quarantineDirtyWorktree: skip — live dispatch in ${worktreePath}`);
1241
1358
  shared._writeWorktreeSkipLiveInboxNote(worktreePath, '_quarantineDirtyWorktree');
1242
1359
  return { quarantinedPath: null, backupRef: null, skipped: true };
1243
1360
  }
1244
- fs.renameSync(worktreePath, quarantinedPath);
1245
1361
 
1246
- // Prune git's stale worktree metadata so the next `git worktree add` for
1247
- // the same branch isn't blocked by "branch is already used by worktree".
1362
+ // W-mq5n1zx5 Layer 2a: pre-emptively kill any git.exe descendants whose
1363
+ // command line points at the worktree. The status-probe child that timed
1364
+ // out earlier may still be alive holding packfile handles; if so, the
1365
+ // rename below will fail with EBUSY/EPERM until the descendant exits.
1366
+ // POSIX is a no-op. Best-effort; failure here is non-fatal.
1367
+ _killGitDescendantsForWorktree(worktreePath);
1368
+
1369
+ // W-mq5n1zx5 Layer 1a: rename with jittered backoff. Replaces the bare
1370
+ // `fs.renameSync(worktreePath, quarantinedPath)` that used to throw
1371
+ // Windows EBUSY uncatchably and burn the WI's auto-recovery budget.
1372
+ let renameAttempts = 0;
1373
+ let renameError = null;
1248
1374
  try {
1249
- await shared.shellSafeGit(['worktree', 'prune'], { ...gitOpts, cwd: rootDir, timeout: 15000 });
1375
+ const r = await _renameWithRetry(worktreePath, quarantinedPath);
1376
+ renameAttempts = r.attempts || 1;
1250
1377
  } catch (e) {
1251
- log('warn', `_quarantineDirtyWorktree: worktree prune after rename: ${e.message}`);
1378
+ renameError = e;
1379
+ }
1380
+
1381
+ // W-mq5n1zx5 Layer 2b: if the rename retries exhausted, fall back to
1382
+ // `git worktree remove --force`. This DESTROYS the worktree contents
1383
+ // (no forensics dir), so it's last-resort only — gated on the
1384
+ // quarantineForceRemoveFallback engine default. The next dispatch will
1385
+ // create a fresh worktree on the origin tip, which is the same outcome
1386
+ // the rename path would produce, just without the recovery dir.
1387
+ let forceRemoved = false;
1388
+ let forceRemoveError = null;
1389
+ if (renameError && ENGINE_DEFAULTS.quarantineForceRemoveFallback) {
1390
+ log('warn', `_quarantineDirtyWorktree: rename failed after ${renameAttempts || ENGINE_DEFAULTS.quarantineRenameRetryAttempts} attempt(s) (${renameError.code || renameError.message}); falling back to git worktree remove --force`);
1391
+ try {
1392
+ await shared.shellSafeGit(['worktree', 'remove', '--force', worktreePath], { ...gitOpts, cwd: rootDir, timeout: 30000 });
1393
+ forceRemoved = true;
1394
+ log('warn', `_quarantineDirtyWorktree: git worktree remove --force succeeded for ${worktreePath} (no quarantine dir preserved)`);
1395
+ } catch (e) {
1396
+ forceRemoveError = e;
1397
+ log('error', `_quarantineDirtyWorktree: git worktree remove --force ALSO failed: ${e.message}`);
1398
+ }
1399
+ }
1400
+
1401
+ // If neither the rename retries nor the force-remove fallback succeeded,
1402
+ // we are env-blocked: the worktree dir is still on disk and git still
1403
+ // thinks it owns it. Write a dedicated inbox alert (Layer 3b) and rethrow
1404
+ // the original rename error so the caller's quarantineError branch fires.
1405
+ if (renameError && !forceRemoved) {
1406
+ _bumpQuarantineOutcome('totalFailure', 1);
1407
+ const sanitizedRefSegmentEnv = sanitizeBranch(branchName).replace(/\//g, '-');
1408
+ const failBody = [
1409
+ '# Engine quarantine TOTAL FAILURE — manual recovery required (W-mq5n1zx5)',
1410
+ '',
1411
+ `- Dispatch: ${diag.dispatchId || '<unknown>'}`,
1412
+ `- Branch: ${branchName}`,
1413
+ `- Worktree path (STILL PRESENT): ${worktreePath}`,
1414
+ `- Reason: ${diag.reason || 'unsafe'}`,
1415
+ `- Rename attempts: ${renameAttempts || ENGINE_DEFAULTS.quarantineRenameRetryAttempts}`,
1416
+ `- Last rename error: ${renameError.code || ''} ${renameError.message}`,
1417
+ `- Force-remove fallback: ${ENGINE_DEFAULTS.quarantineForceRemoveFallback ? `tried, failed (${forceRemoveError ? forceRemoveError.message : 'unknown'})` : 'disabled'}`,
1418
+ '',
1419
+ '## Manual recovery',
1420
+ '',
1421
+ '1. Find any live `git.exe` (or other) processes still holding the dir:',
1422
+ ` \`Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*${worktreePath}*' }\``,
1423
+ '2. Stop them: `Stop-Process -Id <pid> -Force`',
1424
+ '3. Force-remove the dir:',
1425
+ ` Windows: \`Remove-Item -Recurse -Force "${worktreePath}"\` (or \`rmdir /s /q "${worktreePath}"\` from cmd.exe)`,
1426
+ ` POSIX: \`rm -rf "${worktreePath}"\``,
1427
+ `4. Prune git's stale worktree metadata: \`git -C "${rootDir}" worktree prune\``,
1428
+ '',
1429
+ 'After this, the next dispatch for the branch will create a fresh worktree.',
1430
+ ].join('\n');
1431
+ try {
1432
+ shared.writeToInbox('engine', `worktree-quarantine-failed-${diag.dispatchId || sanitizedRefSegmentEnv}`, failBody);
1433
+ } catch (e) {
1434
+ log('warn', `_quarantineDirtyWorktree: totalFailure writeToInbox failed: ${e.message}`);
1435
+ }
1436
+ throw renameError;
1437
+ }
1438
+
1439
+ // Track outcome bucket for the successful path (rename or force-remove).
1440
+ if (forceRemoved) {
1441
+ _bumpQuarantineOutcome('fallbackForceRemove', 1);
1442
+ } else if (renameAttempts > 1) {
1443
+ _bumpQuarantineOutcome('successAfterRetry', 1);
1444
+ }
1445
+ _bumpQuarantineOutcome('success', 1);
1446
+
1447
+ // Prune git's stale worktree metadata so the next `git worktree add` for
1448
+ // the same branch isn't blocked by "branch is already used by worktree".
1449
+ // Skipped on the force-removed path — `git worktree remove --force` already
1450
+ // wipes its own metadata entry.
1451
+ if (!forceRemoved) {
1452
+ try {
1453
+ await shared.shellSafeGit(['worktree', 'prune'], { ...gitOpts, cwd: rootDir, timeout: 15000 });
1454
+ } catch (e) {
1455
+ log('warn', `_quarantineDirtyWorktree: worktree prune after rename: ${e.message}`);
1456
+ }
1252
1457
  }
1253
1458
 
1254
1459
  // Backup the local branch HEAD to refs/minions/quarantine/<sanitized>/<ts>
@@ -1293,7 +1498,9 @@ async function _quarantineDirtyWorktree(rootDir, worktreePath, branchName, gitOp
1293
1498
  `- Dispatch: ${diag.dispatchId || '<unknown>'}`,
1294
1499
  `- Branch: ${branchName}`,
1295
1500
  `- Original worktree: ${worktreePath}`,
1296
- `- Quarantined to: ${quarantinedPath}`,
1501
+ forceRemoved
1502
+ ? `- Force-removed via \`git worktree remove --force\` (no quarantine dir preserved — W-mq5n1zx5 Layer 2b)`
1503
+ : `- Quarantined to: ${quarantinedPath}${renameAttempts > 1 ? ` (rename took ${renameAttempts} attempt(s) — W-mq5n1zx5 Layer 1a)` : ''}`,
1297
1504
  `- Reason: ${diag.reason || 'unsafe'}`,
1298
1505
  `- Local commits ahead of origin: ${diag.ahead || 0}`,
1299
1506
  `- Local commits behind origin: ${diag.behind || 0}`,
@@ -1305,12 +1512,20 @@ async function _quarantineDirtyWorktree(rootDir, worktreePath, branchName, gitOp
1305
1512
  '',
1306
1513
  backupRefCreated
1307
1514
  ? `The local branch HEAD was backed up to \`${backupRef}\` (${headSha.slice(0, 12)}) and \`refs/heads/${branchName}\` was reset to \`refs/remotes/origin/${branchName}\`.`
1308
- : `Backup ref was NOT created (HEAD sha unavailable). The local branch ref was still reset to \`refs/remotes/origin/${branchName}\` if possible. Inspect the quarantined directory directly to recover unpushed work.`,
1515
+ : `Backup ref was NOT created (HEAD sha unavailable). The local branch ref was still reset to \`refs/remotes/origin/${branchName}\` if possible.${forceRemoved ? ' Worktree contents were destroyed by the force-remove fallback — no quarantine dir to inspect.' : ' Inspect the quarantined directory directly to recover unpushed work.'}`,
1309
1516
  '',
1310
- `1. Inspect the quarantined diff: \`git -C "${rootDir}" diff refs/remotes/origin/${branchName}${backupRefCreated ? ` ${backupRef}` : ''} -- .\``,
1311
- `2. Cherry-pick the commits you want from \`${backupRefCreated ? backupRef : `<inspect ${quarantinedPath}>`}\` onto a fresh ${branchName} worktree.`,
1312
- `3. Inspect uncommitted edits inside the quarantined dir directly: \`${quarantinedPath}\`.`,
1313
- `4. Delete the quarantined dir when done: \`Remove-Item -Recurse -Force "${quarantinedPath}"\` (Windows) or \`rm -rf "${quarantinedPath}"\` (POSIX).`,
1517
+ forceRemoved
1518
+ ? `1. The worktree dir was force-removed; there is no quarantine dir to inspect. If the local branch ref still has divergent commits, recover from \`${backupRefCreated ? backupRef : 'reflog'}\`.`
1519
+ : `1. Inspect the quarantined diff: \`git -C "${rootDir}" diff refs/remotes/origin/${branchName}${backupRefCreated ? ` ${backupRef}` : ''} -- .\``,
1520
+ forceRemoved
1521
+ ? ''
1522
+ : `2. Cherry-pick the commits you want from \`${backupRefCreated ? backupRef : `<inspect ${quarantinedPath}>`}\` onto a fresh ${branchName} worktree.`,
1523
+ forceRemoved
1524
+ ? ''
1525
+ : `3. Inspect uncommitted edits inside the quarantined dir directly: \`${quarantinedPath}\`.`,
1526
+ forceRemoved
1527
+ ? ''
1528
+ : `4. Delete the quarantined dir when done: \`Remove-Item -Recurse -Force "${quarantinedPath}"\` (Windows) or \`rm -rf "${quarantinedPath}"\` (POSIX).`,
1314
1529
  backupRefCreated ? `5. Delete the backup ref when done: \`git -C "${rootDir}" update-ref -d ${backupRef}\`.` : '',
1315
1530
  ].filter(Boolean).join('\n');
1316
1531
  try {
@@ -1319,8 +1534,13 @@ async function _quarantineDirtyWorktree(rootDir, worktreePath, branchName, gitOp
1319
1534
  log('warn', `_quarantineDirtyWorktree: writeToInbox failed: ${e.message}`);
1320
1535
  }
1321
1536
 
1322
- log('warn', `Quarantined dirty worktree ${worktreePath} → ${quarantinedPath} (branch ${branchName}, ${diag.ahead || 0} ahead, ${diag.behind || 0} behind, ${dirtyFiles.length} dirty files)`);
1323
- return { quarantinedPath, backupRef: backupRefCreated ? backupRef : null };
1537
+ log('warn', `Quarantined dirty worktree ${worktreePath} → ${forceRemoved ? '<force-removed>' : quarantinedPath} (branch ${branchName}, ${diag.ahead || 0} ahead, ${diag.behind || 0} behind, ${dirtyFiles.length} dirty files${renameAttempts > 1 ? `, rename retries=${renameAttempts}` : ''}${forceRemoved ? ', force-remove fallback' : ''})`);
1538
+ return {
1539
+ quarantinedPath: forceRemoved ? null : quarantinedPath,
1540
+ backupRef: backupRefCreated ? backupRef : null,
1541
+ forceRemoved,
1542
+ renameAttempts,
1543
+ };
1324
1544
  }
1325
1545
 
1326
1546
  async function recoverPartialWorktree(rootDir, worktreePath, branchName, gitOpts) {
@@ -2070,8 +2290,23 @@ async function spawnAgent(dispatchItem, config) {
2070
2290
  // no-upstream / dirty-after-clean stay non-retryable because they
2071
2291
  // protect potentially-unpushed agent work.
2072
2292
  const isStatusProbeFailed = cleanResult.reason === 'status-failed';
2073
- const failureClassValue = isDivergent ? FAILURE_CLASS.WORKTREE_DIVERGENT : FAILURE_CLASS.WORKTREE_DIRTY;
2074
- const failureClassName = isDivergent ? 'WORKTREE_DIVERGENT' : 'WORKTREE_DIRTY';
2293
+ // W-mq5n1zx5 Layer 1c: when quarantine itself errored (rename retries
2294
+ // + force-remove fallback all failed) the worktree dir is still on
2295
+ // disk and the failure is purely environmental — a lingering git.exe
2296
+ // descendant or AV scan holding handles. Route through a dedicated
2297
+ // WORKTREE_QUARANTINE_ENV_BLOCKED class so the dispatch-side retry
2298
+ // counter doesn't bump (agentRetryable:false → non-retryable branch
2299
+ // → no _retriesByAgent / _retryCount mutation) and the WI auto-
2300
+ // recovery loop in discoverFromWorkItems re-queues this item under
2301
+ // the existing _quarantineRecoveryCount cap. This stops env failures
2302
+ // from burning the per-agent retry budget the way they did before.
2303
+ const isQuarantineEnvBlocked = !!(cleanResult.quarantineError && !cleanResult.quarantined);
2304
+ const failureClassValue = isQuarantineEnvBlocked
2305
+ ? FAILURE_CLASS.WORKTREE_QUARANTINE_ENV_BLOCKED
2306
+ : (isDivergent ? FAILURE_CLASS.WORKTREE_DIVERGENT : FAILURE_CLASS.WORKTREE_DIRTY);
2307
+ const failureClassName = isQuarantineEnvBlocked
2308
+ ? 'WORKTREE_QUARANTINE_ENV_BLOCKED'
2309
+ : (isDivergent ? 'WORKTREE_DIVERGENT' : 'WORKTREE_DIRTY');
2075
2310
  const reasonMsg = cleanResult.quarantined
2076
2311
  ? `${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.`
2077
2312
  : `${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 + ').')}`;
@@ -2081,7 +2316,7 @@ async function spawnAgent(dispatchItem, config) {
2081
2316
  id,
2082
2317
  DISPATCH_RESULT.ERROR,
2083
2318
  reasonMsg.slice(0, 500),
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.' : '')}`,
2319
+ `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.' : ''}`,
2085
2320
  { agentRetryable: isStatusProbeFailed && cleanResult.quarantined, failureClass: failureClassValue },
2086
2321
  );
2087
2322
  cleanupTempAgent(agentId);
@@ -2260,7 +2495,10 @@ async function spawnAgent(dispatchItem, config) {
2260
2495
  let stashed = false;
2261
2496
  if (!depMergeFailed && !skipDepMerge && prunedDeps.length > 0) {
2262
2497
  try {
2263
- const statusOut = gitOutputToString(await shared.shellSafeGit(['status', '--porcelain'], { ..._gitOpts, cwd: worktreePath })).trim();
2498
+ const statusArgs = ENGINE_DEFAULTS.statusProbeUseNoOptionalLocks
2499
+ ? ['--no-optional-locks', 'status', '--porcelain']
2500
+ : ['status', '--porcelain'];
2501
+ const statusOut = gitOutputToString(await shared.shellSafeGit(statusArgs, { ..._gitOpts, cwd: worktreePath })).trim();
2264
2502
  if (statusOut) {
2265
2503
  await shared.shellSafeGit(['stash', 'push', '--include-untracked', '-m', 'engine: stash before dep re-merge'], { ..._gitOpts, cwd: worktreePath });
2266
2504
  stashed = true;
@@ -2501,7 +2739,7 @@ async function spawnAgent(dispatchItem, config) {
2501
2739
  if (worktreePath && fs.existsSync(worktreePath)) {
2502
2740
  _phaseT.dirtyProbeStart = Date.now();
2503
2741
  try {
2504
- const dirtyResult = await execAsync('git status --porcelain', { ..._gitOpts, cwd: worktreePath, timeout: 10000 });
2742
+ const dirtyResult = await execAsync(_statusPorcelainCmd(), { ..._gitOpts, cwd: worktreePath, timeout: 10000 });
2505
2743
  const dirtyOutput = (dirtyResult.stdout || '').trim();
2506
2744
  if (dirtyOutput) {
2507
2745
  const dirtyFiles = dirtyOutput.split('\n').map(l => l.trim()).filter(Boolean);
@@ -5666,19 +5904,28 @@ async function discoverFromPrs(config, project) {
5666
5904
  // starvation guarantee.
5667
5905
  const conflictCauseKey = getPrAutomationCauseKey('merge-conflict', pr);
5668
5906
  const key = getPrAutomationDispatchKey(`conflict-fix-${project?.name || 'default'}-${prDisplayId}`, conflictCauseKey);
5669
- // W-mpritzcr0004afc5 (#2955): per-cause same-head guard mirroring the
5670
- // build-failure block above. `_conflictFixedAt` is a 10-min wall-clock
5671
- // suppression for ADO/GH mergeStatus lag; `_lastDispatchByCause` is a
5672
- // headSha-pinned suppression for repeated agent noops on an unchanged
5673
- // base+head pair. Both must fire (their windows are independent).
5907
+ // W-mpritzcr0004afc5 (#2955) + #3079: per-cause same-head guard mirroring
5908
+ // the build-failure block above. `_conflictFixedAt` is a 10-min wall-clock
5909
+ // suppression for ADO/GH mergeStatus lag; `_lastDispatchByCause` is now
5910
+ // a (head+base+targetRef)-pinned suppression for repeated agent noops on
5911
+ // an unchanged source+base+target tuple. #3079: prior version compared
5912
+ // only headSha, so retargeting a PR (e.g. parent-branch → main after
5913
+ // parent merge) never released the pause even though the base SHA and
5914
+ // target ref had changed and conflicts may have become resolvable.
5915
+ const currentGuardKey = shared.prMergeConflictGuardKey(pr);
5674
5916
  const currentHeadSha = String(pr.headSha || pr._adoSourceCommit || pr._adoHeadCommit || '').trim();
5675
5917
  const lastConflictDispatch = pr._lastDispatchByCause?.[shared.PR_FIX_CAUSE.MERGE_CONFLICT];
5676
- const skipConflictFix = !!(lastConflictDispatch?.outcome === 'noop'
5677
- && lastConflictDispatch.headSha
5918
+ // Prefer the new mergeConflictKey (post-#3079); fall back to legacy
5919
+ // headSha compare so PRs paused before this fix don't loop forever.
5920
+ const lastGuardKey = lastConflictDispatch?.mergeConflictKey;
5921
+ const guardKeyMatches = !!(lastGuardKey && currentGuardKey && lastGuardKey === currentGuardKey);
5922
+ const legacyHeadMatches = !lastGuardKey && !!(lastConflictDispatch?.headSha
5678
5923
  && currentHeadSha
5679
5924
  && lastConflictDispatch.headSha === currentHeadSha);
5925
+ const skipConflictFix = !!(lastConflictDispatch?.outcome === 'noop'
5926
+ && (guardKeyMatches || legacyHeadMatches));
5680
5927
  if (skipConflictFix) {
5681
- log('info', `Skipping conflict-fix for ${pr.id}: last merge-conflict dispatch was noop on the same head ${currentHeadSha.slice(0, 8)} (${(lastConflictDispatch.reason || '').slice(0, 120)})`);
5928
+ log('info', `Skipping conflict-fix for ${pr.id}: last merge-conflict dispatch was noop on the same source+base+target (${(lastConflictDispatch.reason || '').slice(0, 120)})`);
5682
5929
  continue;
5683
5930
  }
5684
5931
  // Suppress re-dispatch for 10 min after last attempt — ADO/GitHub recomputes
@@ -5966,6 +6213,18 @@ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, pr
5966
6213
  // failure via the qa-session-draft-failed / qa-session-execute-failed
5967
6214
  // path. (See playbooks/qa-session-draft.md → "Failure path" section.)
5968
6215
  ..._buildRunnerBriefVars(item, project),
6216
+ // W-mq16xtdx001a347e + W-mq1cczi90006b21f — escape hatches for dispatches
6217
+ // that should NOT be steered toward project-local skills (e.g. when the
6218
+ // diff under review IS that skill, so a meta-review needs first-principles).
6219
+ // Default OFF — the project skills block is the whole point of these WIs
6220
+ // and should surface on every applicable dispatch by default.
6221
+ //
6222
+ // Both meta names are honored:
6223
+ // - meta.skipProjectReviewSkills (PR-82 alias, review-only suppression)
6224
+ // - meta.skipProjectSkills (W-mq1cczi90006b21f, generic suppression)
6225
+ // Either flag suppresses both blocks on the dispatch.
6226
+ skip_project_review_skills: !!(item.meta && item.meta.skipProjectReviewSkills),
6227
+ skip_project_skills: !!(item.meta && (item.meta.skipProjectSkills || item.meta.skipProjectReviewSkills)),
5969
6228
  };
5970
6229
  const cpResult = buildWorkItemDispatchVars(item, vars, config, {
5971
6230
  worktreePath: vars.worktree_path || root,
@@ -6139,8 +6398,10 @@ function discoverFromWorkItems(config, project) {
6139
6398
  const fr = String(item.failReason || '');
6140
6399
  const isQuarantineFail = item._failureClass === FAILURE_CLASS.WORKTREE_DIRTY
6141
6400
  || item._failureClass === FAILURE_CLASS.WORKTREE_DIVERGENT
6401
+ || item._failureClass === FAILURE_CLASS.WORKTREE_QUARANTINE_ENV_BLOCKED
6142
6402
  || /\bWORKTREE_DIRTY\b/.test(fr)
6143
- || /\bWORKTREE_DIVERGENT\b/.test(fr);
6403
+ || /\bWORKTREE_DIVERGENT\b/.test(fr)
6404
+ || /\bWORKTREE_QUARANTINE_ENV_BLOCKED\b/.test(fr);
6144
6405
  if (isQuarantineFail) {
6145
6406
  item._quarantineRecoveryCount = (item._quarantineRecoveryCount || 0) + 1;
6146
6407
  const cap = ENGINE_DEFAULTS.quarantineAutoRecoveryMax || 2;
@@ -8270,6 +8531,7 @@ module.exports = {
8270
8531
  gitOutputToString, gitErrorOutput, classifyDepMergeFailureOutput, listUnmergedFiles, // exported for testing
8271
8532
  buildDepConflictFixItem, deriveConflictFixKey, // exported for testing (W-mpcwojgr000a0244)
8272
8533
  isWorktreeRetryableError, removeStaleIndexLock, syncReusedWorktree, assertCleanSharedWorktree, _quarantineDirtyWorktree, // exported for testing
8534
+ _renameWithRetry, _statusPorcelainCmd, _killGitDescendantsForWorktree, _bumpQuarantineOutcome, // exported for testing (W-mq5n1zx5)
8273
8535
  pruneStaleWorktreeForBranch, // exported for testing
8274
8536
  findExistingWorktree, // exported for testing
8275
8537
  probeBranchOnRemote, // exported for testing (W-mphnm6a1000281b8)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2147",
3
+ "version": "0.1.2149",
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"
package/playbooks/fix.md CHANGED
@@ -44,6 +44,10 @@ Before editing, split the feedback into:
44
44
 
45
45
  ## Health Check
46
46
 
47
+ {{#project_skills_block}}
48
+ {{project_skills_block}}
49
+
50
+ {{/project_skills_block}}
47
51
  Before starting work, run `git status` and verify the worktree is clean and on the expected branch (`{{pr_branch}}`). If the worktree is dirty or on the wrong branch, report the issue and stop.
48
52
 
49
53
  ### Branch-mismatch guard (issue #2999)
@@ -45,6 +45,10 @@ Do ALL work in the worktree.
45
45
 
46
46
  ## Health Check
47
47
 
48
+ {{#project_skills_block}}
49
+ {{project_skills_block}}
50
+
51
+ {{/project_skills_block}}
48
52
  Before starting work, run `git status` and verify the worktree is clean and on the expected branch (`{{branch_name}}`). If the worktree is dirty or on the wrong branch, report the issue and stop.
49
53
 
50
54
  ## Working Style
@@ -38,6 +38,10 @@ If this feature spans multiple projects, inspect the relevant repos, make change
38
38
 
39
39
  ## Health Check
40
40
 
41
+ {{#project_skills_block}}
42
+ {{project_skills_block}}
43
+
44
+ {{/project_skills_block}}
41
45
  Before starting work, run `git status` and verify the worktree is clean and on the expected branch. If the worktree is dirty or on the wrong branch, report the issue and stop.
42
46
 
43
47
  ## Working Style
@@ -17,6 +17,10 @@ A user has provided a plan. Analyze it against the codebase and produce a struct
17
17
 
18
18
  ## Instructions
19
19
 
20
+ {{#project_skills_block}}
21
+ {{project_skills_block}}
22
+
23
+ {{/project_skills_block}}
20
24
  1. **Read the plan carefully** — understand the goals, scope, and requirements
21
25
  - If the plan declares `Project: <name>` (including `**Project:** <name>`), the engine has resolved `{{project_name}}` from that declaration. Preserve `{{project_name}}` for the top-level `project`, default item `project`, filename, and implementation framing; do not let contextual mentions of another product or repository override it.
22
26
  2. **Check for an existing PRD** — if the engine provides `existing_prd_json` below, a PRD already exists for this plan. See "Reusing an Existing PRD" section for how to preserve item IDs and done statuses. If no existing PRD is provided, this is a fresh run — all items start as `"missing"`.
package/playbooks/plan.md CHANGED
@@ -27,6 +27,10 @@ A user has described a feature they want built. Your job is to create a detailed
27
27
  - Identify the core goal, constraints, and success criteria
28
28
  - Note any ambiguities that need to be called out
29
29
 
30
+ {{#project_skills_block}}
31
+ {{project_skills_block}}
32
+
33
+ {{/project_skills_block}}
30
34
  ### 2. Explore the Codebase
31
35
  - Read `CLAUDE.md` at repo root and relevant directories
32
36
  - Map the areas of code that this feature will touch
@@ -27,10 +27,10 @@ Use subagents only for genuinely parallel, independent tasks (e.g., reviewing un
27
27
  git diff {{main_branch}}...origin/{{pr_branch}}
28
28
  ```
29
29
 
30
- {{#project_review_skills_block}}
31
- {{project_review_skills_block}}
30
+ {{#project_skills_block}}
31
+ {{project_skills_block}}
32
32
 
33
- {{/project_review_skills_block}}
33
+ {{/project_skills_block}}
34
34
  2. Think about deploy risk before commenting:
35
35
  - What user-visible behavior changed?
36
36
  - What dependencies, callers, or tests could be affected?
@@ -1,16 +0,0 @@
1
- diff a/bin/minions.js b/bin/minions.js (rejected hunks)
2
- @@ -852,6 +852,14 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
3
-
4
- Dashboard:
5
- minions dash Start web dashboard (default :7331)
6
- +
7
- + Watchdog (out-of-process recovery):
8
- + minions watchdog install [--interval=5]
9
- + Register OS scheduler task to probe + heal every N minutes
10
- + (Windows Task Scheduler / macOS launchd / Linux systemd --user)
11
- + minions watchdog uninstall Remove the scheduled task (idempotent)
12
- + minions watchdog status Show registration + last-run details from the OS scheduler
13
- + minions watchdog tick One-shot probe + recovery (used by the scheduler; safe to run by hand)
14
- ${fs.existsSync(path.join(PKG_ROOT, '.git')) ? `
15
- Dev mode (this checkout, contributors only):
16
- minions --dev <cmd> Run against this checkout instead of ~/.minions/
@@ -1,11 +0,0 @@
1
- diff a/dashboard/slim/body.html b/dashboard/slim/body.html (rejected hunks)
2
- @@ -8,7 +8,8 @@
3
- silently break (handler attaches before crash, button still renders,
4
- click fires but no global handler). addEventListener attaches inside
5
- the same scope and is observable in DevTools when wiring fails. -->
6
- - <button id="slim-new-chat-btn" class="icon-btn" title="New chat (opens a new tab)">&#x270E;</button>
7
- + <button id="slim-back-classic-btn" class="topbar-back-btn" title="Return to the classic dashboard">&#8592; Classic dashboard</button>
8
- + <button id="slim-report-bug-btn" class="topbar-back-btn" title="Report a bug in Minions">Report Bug</button>
9
- <button id="slim-settings-btn" class="icon-btn" title="Settings">&#9881;</button>
10
- </div>
11
- </div>
@@ -1,12 +0,0 @@
1
- diff a/dashboard/slim/js/command-send.js b/dashboard/slim/js/command-send.js (rejected hunks)
2
- @@ -195,8 +195,8 @@
3
- }
4
-
5
- // ── Wiring ──────────────────────────────────────────────────────
6
- - var newChatBtn = document.getElementById('slim-new-chat-btn');
7
- - if (newChatBtn) newChatBtn.addEventListener('click', function() { newTab(); });
8
- + // (The header "new chat" button was replaced by "Report Bug"; new tabs are
9
- + // created via the "+" affordance in the chat tab bar — see renderTabBar.)
10
- sendBtn.addEventListener('click', sendMessage);
11
- stopBtn.addEventListener('click', abortActive);
12
- inputEl.addEventListener('keydown', function(ev) {
@@ -1,26 +0,0 @@
1
- diff a/dashboard/slim/js/history.js b/dashboard/slim/js/history.js (rejected hunks)
2
- @@ -96,7 +83,12 @@
3
- chip.title = 'review';
4
- } else {
5
- chip.className = 'completions-card-type-chip';
6
- - chip.textContent = typeEmoji(c.type);
7
- + // Known types → Fluent icon via CSS mask (data-type); unknown → "•" glyph.
8
- + if (c.type && TYPE_ICON_SET[c.type]) {
9
- + chip.setAttribute('data-type', c.type);
10
- + } else {
11
- + chip.textContent = '•';
12
- + }
13
- if (c.type) chip.title = c.type;
14
- }
15
- railTop.appendChild(chip);
16
- @@ -105,7 +97,9 @@
17
- railBottom.className = 'completions-card-rail-bottom';
18
- var icon = document.createElement('span');
19
- icon.className = 'completions-card-status-icon ' + status;
20
- - icon.textContent = status === 'active' ? '●' : (status === 'ok' ? '✓' : (status === 'warn' ? '⚠' : '✕'));
21
- + // ok/warn/fail render as Fluent icons via the status class (CSS mask in
22
- + // styles.css); the live 'active' state keeps its pulsing dot.
23
- + if (status === 'active') icon.textContent = '●';
24
- icon.title = status === 'active' ? 'Running' : (status === 'ok' ? 'Success' : (status === 'warn' ? 'Partial' : 'Failure'));
25
- var sep = document.createElement('span');
26
- sep.className = 'completions-card-rail-sep';