@yemi33/minions 0.1.2380 → 0.1.2382

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
@@ -1430,15 +1430,15 @@ const _quarantinePathLocks = new Map();
1430
1430
  // (Pure shared-branch callers pass quarantineOnUnsafe=false and DO NOT
1431
1431
  // treat ahead-of-origin as dirty — that path's existing semantics are
1432
1432
  // preserved. PR-targeted reused worktrees pass quarantineOnUnsafe=true
1433
- // and DO treat ahead-of-origin as unsafe because the underlying bug
1434
- // (#2996) was a 157-commit-ahead local branch.)
1433
+ // and preserve clean strictly-ahead commits by pushing them before any
1434
+ // quarantine fallback (#853).)
1435
1435
  // 3. If unsafe, decide:
1436
1436
  // - Block (preserve dirty tree for inspection) when ANOTHER active
1437
1437
  // dispatch claims the same branch (ownership ambiguity — NEVER
1438
1438
  // quarantine, even when quarantineOnUnsafe=true).
1439
- // - Block when local commits exist that aren't on origin/<branch>
1440
- // (would lose unpushed work). When quarantineOnUnsafe=true,
1441
- // QUARANTINE instead: rename worktree to <path>-quarantine-<ts>,
1439
+ // - When clean local commits are strictly ahead, push them to origin.
1440
+ // If that push fails or the branch is genuinely diverged, quarantine:
1441
+ // rename worktree to <path>-quarantine-<ts>,
1442
1442
  // prune git's stale metadata, back up the local branch HEAD to
1443
1443
  // refs/minions/quarantine/<sanitized>/<ts>, reset
1444
1444
  // refs/heads/<branch> → origin/<branch>, and write a notes/inbox/
@@ -1458,6 +1458,124 @@ const _quarantinePathLocks = new Map();
1458
1458
  // quarantined, quarantinedPath, backupRef } so the caller can fail
1459
1459
  // fast with a first-class DIRTY_WORKTREE / WORKTREE_DIRTY /
1460
1460
  // WORKTREE_DIVERGENT reason and retry semantics.
1461
+ async function pushCleanAheadBranch({ rootDir, worktreePath, branchName, mainBranch, project, gitOpts = {} } = {}) {
1462
+ const result = { pushed: false, reason: null, ahead: 0, behind: 0 };
1463
+ if (!rootDir || !worktreePath || !branchName || !fs.existsSync(worktreePath)) {
1464
+ return { ...result, reason: 'invalid-worktree' };
1465
+ }
1466
+ if (mainBranch && sanitizeBranch(branchName) === sanitizeBranch(mainBranch)) {
1467
+ return { ...result, reason: 'main-branch' };
1468
+ }
1469
+ const { gitExtraArgs: _staleGitExtraArgs, ...networkGitOpts } = gitOpts;
1470
+ const runNetworkGit = (args, opts) => project
1471
+ ? adoGitAuth.runAdoGit(project, args, { ...networkGitOpts, ...opts })
1472
+ : shared.shellSafeGit(args, { ...gitOpts, ...opts });
1473
+ try {
1474
+ const current = String(await shared.shellSafeGit(
1475
+ ['symbolic-ref', '--quiet', '--short', 'HEAD'],
1476
+ { ...gitOpts, cwd: worktreePath, timeout: 10000 },
1477
+ ) || '').trim();
1478
+ if (current !== branchName) return { ...result, reason: 'branch-mismatch' };
1479
+
1480
+ const status = String(await shared.shellSafeGit(
1481
+ ['--no-optional-locks', 'status', '--porcelain'],
1482
+ { ...gitOpts, cwd: worktreePath, timeout: 15000 },
1483
+ ) || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean)
1484
+ .filter(line => !shared.isWorktreeOwnerMarkerStatusLine(line));
1485
+ if (status.length > 0) return { ...result, reason: 'dirty' };
1486
+
1487
+ let remoteExists = false;
1488
+ let trackingRefExists = false;
1489
+ try {
1490
+ await shared.shellSafeGit(
1491
+ ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${branchName}`],
1492
+ { ...gitOpts, cwd: rootDir, timeout: 10000 },
1493
+ );
1494
+ remoteExists = true;
1495
+ trackingRefExists = true;
1496
+ } catch {
1497
+ // A first-attempt branch may not have a local remote-tracking ref. Only
1498
+ // pay for ls-remote in that uncommon case; aligned dispatch closes stay
1499
+ // local-only and do not add a network round trip.
1500
+ try {
1501
+ const remote = String(await runNetworkGit(
1502
+ ['ls-remote', '--exit-code', '--heads', 'origin', branchName],
1503
+ { cwd: rootDir, timeout: 15000 },
1504
+ ) || '').trim();
1505
+ remoteExists = !!remote;
1506
+ } catch (err) {
1507
+ if (err?.code !== 2 && err?.status !== 2) {
1508
+ return { ...result, reason: 'remote-probe-failed', error: err.message };
1509
+ }
1510
+ }
1511
+ }
1512
+
1513
+ if (remoteExists) {
1514
+ if (trackingRefExists) {
1515
+ const localCounts = String(await shared.shellSafeGit(
1516
+ ['rev-list', '--left-right', '--count', `refs/remotes/origin/${branchName}...HEAD`],
1517
+ { ...gitOpts, cwd: worktreePath, timeout: 15000 },
1518
+ ) || '').trim().split(/\s+/).map(Number);
1519
+ const locallyAhead = Number.isFinite(localCounts[1]) ? localCounts[1] : 0;
1520
+ if (locallyAhead === 0) {
1521
+ return {
1522
+ ...result,
1523
+ behind: Number.isFinite(localCounts[0]) ? localCounts[0] : 0,
1524
+ reason: 'aligned',
1525
+ };
1526
+ }
1527
+ }
1528
+ try {
1529
+ await runNetworkGit(
1530
+ ['fetch', 'origin', branchName],
1531
+ { cwd: rootDir, timeout: 30000 },
1532
+ );
1533
+ } catch (err) {
1534
+ return { ...result, reason: 'fetch-failed', error: err.message };
1535
+ }
1536
+ const counts = String(await shared.shellSafeGit(
1537
+ ['rev-list', '--left-right', '--count', `refs/remotes/origin/${branchName}...HEAD`],
1538
+ { ...gitOpts, cwd: worktreePath, timeout: 15000 },
1539
+ ) || '').trim().split(/\s+/).map(Number);
1540
+ result.behind = Number.isFinite(counts[0]) ? counts[0] : 0;
1541
+ result.ahead = Number.isFinite(counts[1]) ? counts[1] : 0;
1542
+ if (result.behind > 0) return { ...result, reason: 'diverged' };
1543
+ if (result.ahead === 0) return { ...result, reason: 'aligned' };
1544
+ } else {
1545
+ if (!mainBranch) return { ...result, reason: 'no-upstream-no-main' };
1546
+ try {
1547
+ result.ahead = Number(String(await shared.shellSafeGit(
1548
+ ['rev-list', '--count', `refs/remotes/origin/${mainBranch}..HEAD`],
1549
+ { ...gitOpts, cwd: worktreePath, timeout: 15000 },
1550
+ ) || '').trim());
1551
+ } catch (err) {
1552
+ return { ...result, reason: 'main-compare-failed', error: err.message };
1553
+ }
1554
+ if (!Number.isFinite(result.ahead) || result.ahead <= 0) {
1555
+ return { ...result, ahead: 0, reason: 'no-local-commits' };
1556
+ }
1557
+ }
1558
+
1559
+ try {
1560
+ await runNetworkGit(
1561
+ ['push', 'origin', `HEAD:refs/heads/${branchName}`],
1562
+ { cwd: worktreePath, timeout: 30000 },
1563
+ );
1564
+ log('info', `Preserved ${result.ahead} clean local commit(s) by pushing ${branchName} to origin`);
1565
+ return {
1566
+ ...result,
1567
+ pushed: true,
1568
+ reason: remoteExists ? 'pushed-ahead' : 'pushed-new-branch',
1569
+ };
1570
+ } catch (err) {
1571
+ log('warn', `Failed to preserve clean ahead branch ${branchName}: ${err.message}`);
1572
+ return { ...result, reason: 'push-failed', error: err.message };
1573
+ }
1574
+ } catch (err) {
1575
+ return { ...result, reason: 'probe-failed', error: err.message };
1576
+ }
1577
+ }
1578
+
1461
1579
  async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, dispatchId, gitOpts = {}, opts = {}) {
1462
1580
  const quarantineOnUnsafe = !!opts.quarantineOnUnsafe;
1463
1581
  // W-mq1habhf: status probe timeout is now configurable so large/GVFS repos
@@ -1638,6 +1756,41 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
1638
1756
  }
1639
1757
  }
1640
1758
 
1759
+ // #853 — a clean branch that is strictly ahead contains completed agent
1760
+ // commits, not contamination. Preserve them on the PR/work branch before
1761
+ // quarantine can move the work to a local-only backup ref. A rejected or
1762
+ // non-fast-forward push falls through to the existing quarantine path.
1763
+ if (!filesystemDirty && quarantineOnUnsafe && (result.ahead > 0 || !upstreamKnown)) {
1764
+ const preservation = await pushCleanAheadBranch({
1765
+ rootDir,
1766
+ worktreePath,
1767
+ branchName,
1768
+ mainBranch: opts.mainBranch,
1769
+ project: opts.project,
1770
+ gitOpts,
1771
+ });
1772
+ const preservationProvedClean = preservation.pushed ||
1773
+ ['aligned', 'no-local-commits'].includes(preservation.reason);
1774
+ if (preservationProvedClean) {
1775
+ result.clean = true;
1776
+ result.pushed = preservation.pushed;
1777
+ result.ahead = preservation.ahead;
1778
+ result.behind = preservation.behind;
1779
+ result.reason = preservation.reason;
1780
+ return result;
1781
+ }
1782
+ result.pushFailure = preservation.reason;
1783
+ result.pushError = preservation.error;
1784
+ if (preservation.reason !== 'diverged') {
1785
+ // A network/auth failure does not prove the branch is unsafe. Leave the
1786
+ // clean ahead worktree intact so a later retry can push the same commits.
1787
+ result.reason = preservation.reason;
1788
+ return result;
1789
+ }
1790
+ result.ahead = preservation.ahead;
1791
+ result.behind = preservation.behind;
1792
+ }
1793
+
1641
1794
  // 5. Unpushed-commit check — refuse to reset when local work would be lost.
1642
1795
  // Use the ahead count we already computed (cheaper + correct vs.
1643
1796
  // `git log @{u}..HEAD` which needs an upstream config and silently
@@ -2711,7 +2864,7 @@ async function spawnAgent(dispatchItem, config) {
2711
2864
  // auto-stash is disabled, or because `applyLiveCheckoutAutoStash` itself
2712
2865
  // reported `outcome:'unchanged'` (the `git stash push` call failed) — and
2713
2866
  // the fleet/project auto-reset policy resolves true, NOW perform the
2714
- // destructive `fetch` + `reset --hard origin/<ref>` as a fallback, reusing
2867
+ // destructive `reset --hard HEAD` + `clean -fd` WIP cleanup as a fallback, reusing
2715
2868
  // prepareLiveCheckout's existing reset logic by re-invoking it with an
2716
2869
  // explicit `autoReset: true` (bypassing config resolution, which we've
2717
2870
  // already evaluated). This only fires when the first prepareLiveCheckout
@@ -2761,6 +2914,27 @@ async function spawnAgent(dispatchItem, config) {
2761
2914
  if (_liveResult && _liveResult.ok === false && _liveResult.reason === 'dirty') {
2762
2915
  const _dirtyFiles = Array.isArray(_liveResult.dirtyFiles) ? _liveResult.dirtyFiles : [];
2763
2916
  const _branchInfo = typeof _liveResult.branchInfo === 'string' ? _liveResult.branchInfo : '';
2917
+ const _autoResetAttempt = _liveResult.autoResetAttempt;
2918
+ const _resetAuditSlug = typeof _autoResetAttempt?.auditSlug === 'string'
2919
+ ? _autoResetAttempt.auditSlug
2920
+ : `live-checkout-autoreset-${id}`;
2921
+ let _cleanAttemptStatus = 'not run';
2922
+ let _statusCheckStatus = 'failed';
2923
+ if (_autoResetAttempt?.cleanAttempted) {
2924
+ _cleanAttemptStatus = _autoResetAttempt.cleanSucceeded ? 'succeeded' : 'failed or partially applied';
2925
+ }
2926
+ if (_autoResetAttempt?.recheckSucceeded) {
2927
+ _statusCheckStatus = `${_dirtyFiles.length} dirty path(s) remain`;
2928
+ }
2929
+ const _resetAttemptSummary = _autoResetAttempt
2930
+ ? [
2931
+ 'Automatic reset cleanup was attempted before this refusal:',
2932
+ `- \`git reset --hard HEAD\`: ${_autoResetAttempt.resetSucceeded ? 'succeeded' : 'failed'}`,
2933
+ `- \`git clean -fd\`: ${_cleanAttemptStatus}`,
2934
+ `- post-cleanup status check: ${_statusCheckStatus}`,
2935
+ `See the \`${_resetAuditSlug}\` inbox note for the full before/after audit.`,
2936
+ ].join('\n')
2937
+ : 'No automatic reset/clean recovery was attempted before this refusal.';
2764
2938
  // #329 + PL-live-checkout-reliability-hardening: two-strike dirty guard.
2765
2939
  // First dirty failure is retried once (a transient or engine-owned dirty
2766
2940
  // state — e.g. an agent's own leftover WIP, which dispatch-end restore now
@@ -2800,7 +2974,7 @@ async function spawnAgent(dispatchItem, config) {
2800
2974
  `**Dispatch:** ${id}`,
2801
2975
  ...(_branchInfo ? ['', `**Current checkout:** \`${_branchInfo}\``] : []),
2802
2976
  '',
2803
- `The engine refused to spawn the agent because \`${cwd}\` has uncommitted changes. Live-checkout mode runs in your project directly — the engine never \`git reset\` or \`git clean\` your tree.`,
2977
+ `The engine refused to spawn the agent because \`${cwd}\` has uncommitted changes. Live-checkout mode runs in your project directly.`,
2804
2978
  '',
2805
2979
  '## Dirty files (`git status --porcelain=v1 -b`)',
2806
2980
  '',
@@ -2808,6 +2982,8 @@ async function spawnAgent(dispatchItem, config) {
2808
2982
  ...(_dirtyFiles.length > 0 ? _liveCheckout.capDirtyFileLines(_dirtyFiles) : ['(none reported)']),
2809
2983
  '```',
2810
2984
  '',
2985
+ _resetAttemptSummary,
2986
+ '',
2811
2987
  _autoCleanupEnabled
2812
2988
  ? 'Auto-cleanup (`liveCheckoutAutoReset`/`liveCheckoutAutoStash`) is active — the engine re-cleans the tree on each attempt, so this dispatch will be retried automatically (subject to the normal retry cap).'
2813
2989
  : (_alreadyDirtyFailed
@@ -3764,12 +3940,14 @@ async function spawnAgent(dispatchItem, config) {
3764
3940
  {
3765
3941
  quarantineOnUnsafe: true,
3766
3942
  mainBranch: shared.resolveMainBranch(rootDir, project.mainBranch),
3943
+ project,
3767
3944
  statusTimeoutMs: engineConfig.assertCleanStatusTimeoutMs ?? ENGINE_DEFAULTS.assertCleanStatusTimeoutMs,
3768
3945
  },
3769
3946
  );
3770
3947
  _phaseT.dirtyReusedCheckEnd = Date.now();
3771
3948
  if (!cleanResult.clean) {
3772
3949
  const previewFiles = (cleanResult.dirtyFiles || []).slice(0, 5).join(', ');
3950
+ const isPushBlocked = ['remote-probe-failed', 'fetch-failed', 'push-failed'].includes(cleanResult.reason);
3773
3951
  const isDivergent = (cleanResult.ahead || 0) > 0 || cleanResult.reason === 'no-upstream';
3774
3952
  // W-mq1habhf: status-failed is a different beast than
3775
3953
  // has-unpushed-commits / no-upstream. We couldn't even read the
@@ -3792,12 +3970,21 @@ async function spawnAgent(dispatchItem, config) {
3792
3970
  // the existing _quarantineRecoveryCount cap. This stops env failures
3793
3971
  // from burning the per-agent retry budget the way they did before.
3794
3972
  const isQuarantineEnvBlocked = !!(cleanResult.quarantineError && !cleanResult.quarantined);
3795
- const failureClassValue = isQuarantineEnvBlocked
3796
- ? FAILURE_CLASS.WORKTREE_QUARANTINE_ENV_BLOCKED
3797
- : (isDivergent ? FAILURE_CLASS.WORKTREE_DIVERGENT : FAILURE_CLASS.WORKTREE_DIRTY);
3798
- const failureClassName = isQuarantineEnvBlocked
3799
- ? 'WORKTREE_QUARANTINE_ENV_BLOCKED'
3800
- : (isDivergent ? 'WORKTREE_DIVERGENT' : 'WORKTREE_DIRTY');
3973
+ const isPushAuthBlocked = isPushBlocked && adoGitAuth.isAdoAuthFailure(
3974
+ new Error(cleanResult.pushError || ''),
3975
+ );
3976
+ let failureClassValue = FAILURE_CLASS.WORKTREE_DIRTY;
3977
+ let failureClassName = 'WORKTREE_DIRTY';
3978
+ if (isPushBlocked) {
3979
+ failureClassValue = isPushAuthBlocked ? FAILURE_CLASS.AUTH : FAILURE_CLASS.NETWORK_ERROR;
3980
+ failureClassName = isPushAuthBlocked ? 'AUTH' : 'NETWORK_ERROR';
3981
+ } else if (isQuarantineEnvBlocked) {
3982
+ failureClassValue = FAILURE_CLASS.WORKTREE_QUARANTINE_ENV_BLOCKED;
3983
+ failureClassName = 'WORKTREE_QUARANTINE_ENV_BLOCKED';
3984
+ } else if (isDivergent) {
3985
+ failureClassValue = FAILURE_CLASS.WORKTREE_DIVERGENT;
3986
+ failureClassName = 'WORKTREE_DIVERGENT';
3987
+ }
3801
3988
  // #284: distinguish the ORIGINAL worktree-preflight failure from a
3802
3989
  // failure on the automatic fresh-worktree retry. The quarantine
3803
3990
  // auto-recovery loop (discoverFromWorkItems) flips a failed
@@ -3810,7 +3997,9 @@ async function spawnAgent(dispatchItem, config) {
3810
3997
  const retryLabel = priorQuarantineRetry > 0
3811
3998
  ? ` [retry-in-fresh-worktree #${priorQuarantineRetry} also failed]`
3812
3999
  : ' [original worktree-preflight issue]';
3813
- const reasonMsg = cleanResult.quarantined
4000
+ const reasonMsg = isPushBlocked
4001
+ ? `${failureClassName}: clean ahead worktree at ${worktreePath} could not be pushed (${cleanResult.reason}: ${cleanResult.pushError || 'unknown error'}). The worktree and local commits were preserved for retry.`
4002
+ : cleanResult.quarantined
3814
4003
  ? `${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.`
3815
4004
  : `${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 + ').')}`;
3816
4005
  log('error', reasonMsg);
@@ -3820,7 +4009,11 @@ async function spawnAgent(dispatchItem, config) {
3820
4009
  DISPATCH_RESULT.ERROR,
3821
4010
  reasonMsg.slice(0, 500),
3822
4011
  `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.' : ''}`,
3823
- { agentRetryable: isStatusProbeFailed && cleanResult.quarantined, failureClass: failureClassValue },
4012
+ {
4013
+ agentRetryable: (isStatusProbeFailed && cleanResult.quarantined) ||
4014
+ (isPushBlocked && !isPushAuthBlocked),
4015
+ failureClass: failureClassValue,
4016
+ },
3824
4017
  );
3825
4018
  cleanupTempAgent(agentId);
3826
4019
  return null;
@@ -4394,7 +4587,7 @@ async function spawnAgent(dispatchItem, config) {
4394
4587
  // agent never gets a chance to push over the rebased tip.
4395
4588
  // Read-only and non-fix dispatches are out of scope — implement tasks cut
4396
4589
  // their own branch from main, and review/verify don't push.
4397
- if (type === WORK_TYPE.FIX && branchName && worktreePath && cwd === worktreePath) {
4590
+ if (shared.isFixLikeWorkType(type) && branchName && worktreePath && cwd === worktreePath) {
4398
4591
  _phaseT.staleHeadStart = Date.now();
4399
4592
  try {
4400
4593
  const guard = await assertStaleHeadOk({
@@ -4920,6 +5113,25 @@ async function spawnAgent(dispatchItem, config) {
4920
5113
  ackPendingSteeringFiles(agentId, procInfo, steeringAckStdout);
4921
5114
  promoteCheckpointSteeringForClose(agentId, procInfo, runtime, liveOutputPath);
4922
5115
 
5116
+ // #853 — the child is gone, so its clean committed state is stable. Push
5117
+ // any strictly-ahead dispatch branch before steering resume, pool return,
5118
+ // quarantine, or dispatch-end GC can detach/remove the worktree.
5119
+ const branchPath = worktreePath || (liveMode ? project?.localPath : null);
5120
+ const branchRootDir = liveMode ? project?.localPath : rootDir;
5121
+ if (branchPath && branchRootDir && branchName && fs.existsSync(branchPath)) {
5122
+ const preservation = await pushCleanAheadBranch({
5123
+ rootDir: branchRootDir,
5124
+ worktreePath: branchPath,
5125
+ branchName,
5126
+ mainBranch: shared.resolveMainBranch(branchRootDir, project?.mainBranch),
5127
+ project,
5128
+ gitOpts: _gitOpts,
5129
+ });
5130
+ if (!preservation.pushed && !['aligned', 'dirty', 'no-local-commits'].includes(preservation.reason)) {
5131
+ log('warn', `Dispatch-close branch preservation skipped for ${id}: ${preservation.reason}`);
5132
+ }
5133
+ }
5134
+
4923
5135
  // Check if this was a steering kill — re-spawn with resume
4924
5136
  if (procInfo?._steeringMessage) {
4925
5137
  const steerMsg = procInfo._steeringMessage;
@@ -5249,7 +5461,7 @@ async function spawnAgent(dispatchItem, config) {
5249
5461
  // delete clears the entry.
5250
5462
  const expectedNonce = activeProcesses.get(id)?._completionNonce || null;
5251
5463
  const completionNonceRequired = engineConfig.completionNonceRequired ?? ENGINE_DEFAULTS.completionNonceRequired;
5252
- const { resultSummary, autoRecovered, completionContractFailure, structuredCompletion, agentReportedFailure, agentRetryable, nonceMismatch } = await runPostCompletionHooks(dispatchItem, agentId, code, stdout, config, { expectedNonce, completionNonceRequired });
5464
+ const { resultSummary, autoRecovered, completionContractFailure, structuredCompletion, agentReportedFailure, agentRetryable, nonceMismatch, benignNoop } = await runPostCompletionHooks(dispatchItem, agentId, code, stdout, config, { expectedNonce, completionNonceRequired });
5253
5465
  const retryableDecision = typeof agentRetryable === 'boolean' ? agentRetryable : failureInfo.retryable;
5254
5466
 
5255
5467
  // W-mp6k7ywi000fa33c — keep_processes acceptance gate. When the work
@@ -5655,7 +5867,7 @@ async function spawnAgent(dispatchItem, config) {
5655
5867
  const managedSpawnHealthcheckFail = !!managedSpawnHealthcheckFailure;
5656
5868
  const effectiveResult = (hardContractFail || nonceFail || keepProcessesAcceptanceFail || managedSpawnAcceptanceFail || managedSpawnHealthcheckFail)
5657
5869
  ? DISPATCH_RESULT.ERROR
5658
- : (((code === 0 && !agentReportedFailure) || autoRecovered) ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR);
5870
+ : (((code === 0 && !agentReportedFailure) || autoRecovered || benignNoop) ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR);
5659
5871
  const finalCompletionReportPath = structuredCompletion?._path || dispatchItem.meta?.completionReportPath || shared.dispatchCompletionReportPath(id);
5660
5872
  const completionOpts = {
5661
5873
  ...(finalCompletionReportPath ? { completionReportPath: finalCompletionReportPath } : {}),
@@ -8151,6 +8363,35 @@ function _buildRunnerBriefVars(item, project) {
8151
8363
  /**
8152
8364
  * Scan a project work-item scope for manually queued tasks.
8153
8365
  */
8366
+ /**
8367
+ * W-mrdon0pe000l045a — collect directory/file path hints for CLAUDE.md
8368
+ * propagation. These are the paths most relevant to a dispatch; the
8369
+ * CLAUDE.md-context module walks UP from each toward the project root collecting
8370
+ * the nearest applicable CLAUDE.md files. Sources (best-effort, cheap):
8371
+ * - item.meta.workdir (explicit sub-project directory)
8372
+ * - item.references[*].path (changed/relevant files)
8373
+ * Returns a de-duplicated array of repo-relative path strings. Empty when no
8374
+ * hints exist — the module still falls back to the project-root CLAUDE.md.
8375
+ */
8376
+ function _deriveClaudeMdPathHints(item) {
8377
+ const hints = [];
8378
+ const seen = new Set();
8379
+ const add = (p) => {
8380
+ if (typeof p !== 'string') return;
8381
+ const v = p.trim();
8382
+ if (!v || seen.has(v)) return;
8383
+ seen.add(v);
8384
+ hints.push(v);
8385
+ };
8386
+ if (item && item.meta && typeof item.meta.workdir === 'string') add(item.meta.workdir);
8387
+ if (item && Array.isArray(item.references)) {
8388
+ for (const r of item.references) {
8389
+ if (r && typeof r.path === 'string') add(r.path);
8390
+ }
8391
+ }
8392
+ return hints;
8393
+ }
8394
+
8154
8395
  function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, project, root, branchName, options = {}) {
8155
8396
  const worktreePath = options.worktreePath || path.resolve(root, config.engine?.worktreeRoot || '../worktrees', `${branchName}`);
8156
8397
  const vars = {
@@ -8304,6 +8545,14 @@ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, pr
8304
8545
  // failure via the qa-session-draft-failed / qa-session-execute-failed
8305
8546
  // path. (See playbooks/qa-session-draft.md → "Failure path" section.)
8306
8547
  ..._buildRunnerBriefVars(item, project),
8548
+ // W-mrdon0pe000l045a — CLAUDE.md propagation context for non-Claude runtimes.
8549
+ // renderPlaybook resolves the runtime capability + config flag and, when the
8550
+ // runtime doesn't auto-load CLAUDE.md (Copilot/Codex), bounded-discovers the
8551
+ // nearest applicable CLAUDE.md files walking up from these path hints toward
8552
+ // the project root. runtime_cli lets renderPlaybook check the adapter's
8553
+ // claudeMdNativeDiscovery capability without re-resolving the agent config.
8554
+ runtime_cli: shared.resolveAgentCli(config.agents?.[agentId] || null, config.engine),
8555
+ claude_md_path_hints: _deriveClaudeMdPathHints(item),
8307
8556
  // M006 — typed handoff envelope. Set on follow-up dispatch metas by
8308
8557
  // lifecycle.js when queuing an item after a completing agent. The
8309
8558
  // renderPlaybook path injects a "Handoff Context" section when truthy
@@ -8548,6 +8797,15 @@ function discoverFromWorkItems(config, project) {
8548
8797
  }
8549
8798
 
8550
8799
  if (item.status !== WI_STATUS.QUEUED && item.status !== WI_STATUS.PENDING) continue;
8800
+ if (item._preDispatchEval?.exhausted === true
8801
+ && item._preDispatchEval.description === String(item.description || '')) {
8802
+ if (item._pendingReason !== 'pre_dispatch_eval_rejected') {
8803
+ item._pendingReason = 'pre_dispatch_eval_rejected';
8804
+ needsWrite = true;
8805
+ }
8806
+ skipped.gated++;
8807
+ continue;
8808
+ }
8551
8809
 
8552
8810
  // Dependency gate: skip items whose depends_on are not yet met; propagate failure
8553
8811
  if (item.depends_on && item.depends_on.length > 0) {
@@ -8681,7 +8939,7 @@ function discoverFromWorkItems(config, project) {
8681
8939
  return b && b > 0 && getMonthlySpend(id) >= b && isAgentIdle(id);
8682
8940
  });
8683
8941
  if (!agentId) {
8684
- if (!budgetBlocked && !hardPinRequested && workType !== WORK_TYPE.FIX) {
8942
+ if (!budgetBlocked && !hardPinRequested && !shared.isFixLikeWorkType(workType)) {
8685
8943
  reservedAgentId = resolveAgentReservation(workType, config, { agentHints });
8686
8944
  agentId = reservedAgentId && !hasAgentHints ? routing.ANY_AGENT : reservedAgentId;
8687
8945
  }
@@ -8723,7 +8981,7 @@ function discoverFromWorkItems(config, project) {
8723
8981
  const linkedPr = item.skipPr ? null : resolveWorkItemPrRecord(item, project);
8724
8982
  const promptItem = linkedPr ? withWorkItemPrContext(item, linkedPr) : item;
8725
8983
  const prBranch = linkedPr?.branch || '';
8726
- const isPrTargeted = !!(linkedPr && (workType === WORK_TYPE.FIX || workType === WORK_TYPE.REVIEW || workType === WORK_TYPE.TEST));
8984
+ const isPrTargeted = !!(linkedPr && (shared.isFixLikeWorkType(workType) || workType === WORK_TYPE.REVIEW || workType === WORK_TYPE.TEST));
8727
8985
  // Issue #607 — `linkedPr` above resolves through the LOOSE
8728
8986
  // extractWorkItemPrRef (title/first-paragraph-of-description text scan),
8729
8987
  // which is fine for prompt-context enrichment (promptItem, above) that
@@ -8745,7 +9003,7 @@ function discoverFromWorkItems(config, project) {
8745
9003
  // create-time stamp path keeps using the loose form — gate uses
8746
9004
  // structured-only; stamp uses loose. See engine/shared.js
8747
9005
  // extractStructuredWorkItemPrRef for the structured-source list.
8748
- if (!linkedPr && getStructuredWorkItemPrRef(item) && (workType === WORK_TYPE.FIX || workType === WORK_TYPE.REVIEW || workType === WORK_TYPE.TEST)) {
9006
+ if (!linkedPr && getStructuredWorkItemPrRef(item) && (shared.isFixLikeWorkType(workType) || workType === WORK_TYPE.REVIEW || workType === WORK_TYPE.TEST)) {
8749
9007
  if (item._pendingReason !== 'pr_not_found') { item._pendingReason = 'pr_not_found'; needsWrite = true; }
8750
9008
  log('warn', `Work item ${item.id} references PR ${getStructuredWorkItemPrRef(item)} but no tracked PR record was found`);
8751
9009
  continue;
@@ -9048,7 +9306,7 @@ function buildWorkItemDispatchVars(item, vars, config, options = {}) {
9048
9306
  // SHERLOC (P-mqyp0008v022w3x4): inject prior explore context for fix dispatches.
9049
9307
  // When a completed explore WI has a reference to this fix WI, surface its
9050
9308
  // resultSummary so the fix agent starts with pre-localized fault context.
9051
- if (workType === WORK_TYPE.FIX) {
9309
+ if (shared.isFixLikeWorkType(workType)) {
9052
9310
  vars.prior_explore_context = resolvePriorExploreContext(item, config);
9053
9311
  }
9054
9312
 
@@ -9375,7 +9633,7 @@ function discoverCentralWorkItems(config) {
9375
9633
  const hardPinRequested = !!hardPinnedAgent;
9376
9634
  const agentId = hardPinnedAgent
9377
9635
  || resolveAgent(workType, config, { agentHints })
9378
- || (!hardPinRequested && workType !== WORK_TYPE.FIX ? resolveAgentReservation(workType, config, { agentHints }) : null);
9636
+ || (!hardPinRequested && !shared.isFixLikeWorkType(workType) ? resolveAgentReservation(workType, config, { agentHints }) : null);
9379
9637
  if (!agentId) continue;
9380
9638
 
9381
9639
  const agentName = config.agents[agentId]?.name || agentId;
@@ -9757,7 +10015,7 @@ async function discoverWork(config) {
9757
10015
  // type-only filter below would silently start gating those
9758
10016
  // previously-ungated sources too. Scope the gate back down to
9759
10017
  // `meta.source === 'pr'` to preserve the original behavior exactly.
9760
- const isGateEligible = (w) => w.meta?.source === 'pr' && (w.type === WORK_TYPE.FIX || w.type === WORK_TYPE.REVIEW);
10018
+ const isGateEligible = (w) => w.meta?.source === 'pr' && (shared.isFixLikeWorkType(w.type) || w.type === WORK_TYPE.REVIEW);
9761
10019
  const gated = items.filter(isGateEligible);
9762
10020
  if (gated.length > 0) {
9763
10021
  log('info', `Gating ${gated.length} fixes/reviews — implement items in progress and no free slots`);
@@ -9799,7 +10057,7 @@ async function discoverWork(config) {
9799
10057
  }));
9800
10058
  }
9801
10059
  for (const w of toDispatch) {
9802
- if (w.type === WORK_TYPE.FIX) persistedFixes++;
10060
+ if (shared.isFixLikeWorkType(w.type)) persistedFixes++;
9803
10061
  else if (w.type === WORK_TYPE.REVIEW) persistedReviews++;
9804
10062
  else persistedOther++;
9805
10063
  }
@@ -9818,7 +10076,7 @@ async function discoverWork(config) {
9818
10076
  if (config.engine?.prDiscoveryEnabled !== false) {
9819
10077
  const prWork = await discoverFromPrs(config, project);
9820
10078
  projectWork.push(...prWork.filter(w =>
9821
- w.type === WORK_TYPE.FIX || w.type === WORK_TYPE.REVIEW || w.type === WORK_TYPE.TEST));
10079
+ shared.isFixLikeWorkType(w.type) || w.type === WORK_TYPE.REVIEW || w.type === WORK_TYPE.TEST));
9822
10080
  }
9823
10081
 
9824
10082
  // Side-effect: specs → work items (picked up below)
@@ -10038,7 +10296,7 @@ function persistPendingDispatchAgent(item) {
10038
10296
  }
10039
10297
 
10040
10298
  function isSoftFixDispatch(item) {
10041
- return item?.type === WORK_TYPE.FIX && !routing.isAgentHardPinned(item.meta?.item);
10299
+ return shared.isFixLikeWorkType(item?.type) && !routing.isAgentHardPinned(item.meta?.item);
10042
10300
  }
10043
10301
 
10044
10302
  function resolveMaxConcurrent(config) {
@@ -10945,7 +11203,15 @@ async function tickInner() {
10945
11203
  skipDirtyCheck: !!_precheckProjCfg.skipLiveCheckoutDirtyCheck,
10946
11204
  });
10947
11205
  } catch (e) { log('warn', `live-checkout precheck (dirty) failed for ${item.id}: ${e.message}`); }
10948
- if (_dirtyResult && _dirtyResult.dirty) {
11206
+ const _autoStashEnabled = _liveCheckoutPrecheck.resolveLiveCheckoutAutoStash({
11207
+ project: _precheckProjCfg,
11208
+ engine: config.engine || {},
11209
+ });
11210
+ const _autoResetEnabled = shared.resolveLiveCheckoutAutoReset(
11211
+ _precheckProjCfg,
11212
+ config.engine || {},
11213
+ );
11214
+ if (_dirtyResult && _dirtyResult.dirty && !_autoStashEnabled && !_autoResetEnabled) {
10949
11215
  _persistPendingReason('live_checkout_dirty');
10950
11216
  try {
10951
11217
  const _alertBody = [
@@ -11131,11 +11397,12 @@ async function tickInner() {
11131
11397
  // post-dispatch active list so the annotation loop's reason is consistent
11132
11398
  // with whatever actually got spawned this tick.
11133
11399
  const postLiveProjectsInUse = new Set();
11400
+ const postProjects = shared.getProjects(config);
11134
11401
  for (const d of (postDispatch.active || [])) {
11135
11402
  if (READ_ONLY_ROOT_TASK_TYPES.has(d.type)) continue;
11136
11403
  const projName = d.project || d.meta?.project?.name || null;
11137
11404
  if (!projName) continue;
11138
- const projCfg = shared.findProjectByName(shared.getProjects(config), projName);
11405
+ const projCfg = shared.findProjectByName(postProjects, projName);
11139
11406
  if (shared.resolveCheckoutMode(projCfg, d.type) === 'live') {
11140
11407
  postLiveProjectsInUse.add(projName);
11141
11408
  }
@@ -11162,7 +11429,10 @@ async function tickInner() {
11162
11429
  && !READ_ONLY_ROOT_TASK_TYPES.has(item.type)
11163
11430
  && postLiveProjectsInUse.has(itemProjName)
11164
11431
  ) {
11165
- reason = 'live_checkout_busy';
11432
+ const projCfg = shared.findProjectByName(postProjects, itemProjName);
11433
+ if (shared.resolveCheckoutMode(projCfg, item.type) === 'live') {
11434
+ reason = 'live_checkout_busy';
11435
+ }
11166
11436
  }
11167
11437
  }
11168
11438
  }
@@ -11361,7 +11631,7 @@ module.exports = {
11361
11631
  gitOutputToString, gitErrorOutput, classifyDepMergeFailureOutput, listUnmergedFiles, // exported for testing
11362
11632
  buildDepConflictFixItem, deriveConflictFixKey, // exported for testing (W-mpcwojgr000a0244)
11363
11633
  runWorktreeAdd, // exported for testing (W-mqvaxv65000m76f2 — GVFS partial-checkout behavioral test)
11364
- isWorktreeRetryableError, removeStaleIndexLock, syncReusedWorktree, assertCleanSharedWorktree, _quarantineDirtyWorktree, // exported for testing
11634
+ isWorktreeRetryableError, removeStaleIndexLock, syncReusedWorktree, pushCleanAheadBranch, assertCleanSharedWorktree, _quarantineDirtyWorktree, // exported for testing
11365
11635
  _statusPorcelainCmd, _killGitDescendantsForWorktree, _bumpQuarantineOutcome, // exported for testing (W-mq5n1zx5)
11366
11636
  _reapWorktreeHolders, _findTerminalWorktreeOwners, // exported for testing (W-mqila0t5 — CWD-pinned holder reap)
11367
11637
  pruneStaleWorktreeForBranch, // exported for testing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2380",
3
+ "version": "0.1.2382",
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"
@@ -92,7 +92,7 @@ When genuinely in doubt about the size, delegate — agents have isolated worktr
92
92
  ### HARD STOP — never edit files yourself in a live/hybrid project
93
93
  This overrides everything above, **including the direct-handling override**. For any project whose effective checkout mode is `live` (or `hybrid` for a code-authoring work-item type — `implement`/`fix`/`docs`/`decompose`), you MUST NOT use `Edit`/`Write`/`Bash` (or any tool) to mutate files inside that project's `localPath`, **regardless of task size** — not even a Small 1-line change, and not even when the human explicitly says to do it yourself. The Step-3 "do it yourself" allowance and the Step-1 direct-handling override do **not** apply to writes inside a live/hybrid checkout.
94
94
 
95
- Why: live-mode projects have no worktree isolation — the engine runs agents in-place inside `localPath`, caps dispatch to one mutating operation at a time, and refuses to dispatch on a dirty tree (`live-checkout-dirty`, non-retryable). A stray CC edit leaves the checkout dirty in a way the dispatch bookkeeping does not track, and that dirty state then **blocks every subsequent live-mode dispatch** on the project until a human manually cleans it up. It is NOT covered by the engine's dispatch-time auto-stash/auto-reset recovery those only run as part of an actual dispatch preflight, never for stray CC edits.
95
+ Why: live-mode projects have no worktree isolation — the engine runs agents in-place inside `localPath` and caps dispatch to one mutating operation at a time. A stray CC edit is outside dispatch ownership: with dirty recovery disabled it causes a `live-checkout-dirty` refusal that blocks live work, while the default auto-stash policy can silently park that edit before the next dispatch, so the requested change is no longer present for the agent to validate or commit. Auto-stash/reset is a dispatch recovery mechanism, not ownership bookkeeping for CC edits.
96
96
 
97
97
  Instead, when a change is needed in a live/hybrid project, **dispatch a normal work item** (`POST /api/work-items`, `type: "implement"` or `"fix"` as usual) and let the engine own the live-checkout dispatch path (dirty-tree refusal, single-mutating-dispatch cap, auto-stash/auto-reset). Do not try to shortcut it with your own edit.
98
98
 
@@ -333,23 +333,23 @@ The `X-CC-Turn-Id` header is the audit trail — the handler stamps it onto the
333
333
  Every configured project has an effective **checkout mode** — surfaced in your state snapshot next to the project as `[worktree]`, `[live]`, or `[hybrid (live-validation: <type>)]`. It controls where dispatched agents run:
334
334
 
335
335
  - **`worktree`** (default) — each dispatch gets its own isolated `git worktree`. Agents run fully in parallel; nothing touches the operator's working tree. Use for normal repos.
336
- - **`live`** — agents run **in-place** inside the project's `localPath` (no worktree). The engine caps this to **one mutating dispatch at a time** per project and **refuses on a dirty tree**. Use only when worktrees are unworkable (e.g. Android `repo`, submodules, deep Windows paths, emulators that bind the real checkout).
337
- - **`hybrid`** — `live` **plus** a `liveValidation: { type, autoDispatch }` block. Coding work items (`implement`/`fix`/`docs`/`decompose`) author in **isolated worktrees** (full parallelism), while **only** work items whose type matches `liveValidation.type` (e.g. `build-and-test`) run **in-place on the live checkout**. This is the best of both: parallel code authoring + a real on-disk build/validation that can't run in a worktree. `type` accepts a single string (e.g. `"build-and-test"`) or an array of strings (e.g. `["implement","fix"]`) to route multiple work-item types to the live checkout at once.
336
+ - **`live`** — agents run **in-place** inside the project's `localPath` (no worktree). The engine caps this to **one mutating dispatch at a time** per project. Dirty-tree behavior follows the configured auto-stash/reset policy (auto-stash defaults on); with recovery disabled, the item remains pending until the tree is clean. Use only when worktrees are unworkable.
337
+ - **`hybrid`** — `live` **plus** a `liveValidation: { type, autoDispatch }` block. Work items not listed in `type` use isolated worktrees; listed live-capable canonical work-item types run in-place. The normal validation type is `test`. `build-and-test` is a playbook name, not a work-item type. `type` accepts one string or an array. When `autoDispatch:true`, `test` must be included; the engine creates one PR-targeted `test` WI with the `build-and-test` playbook after each eligible coding WI completes.
338
338
 
339
- **CC never writes into a live/hybrid checkout itself.** Because `live` (and the code-authoring surface of `hybrid`) runs in-place with no worktree isolation, a one-off CC edit inside such a project's `localPath` leaves the tree dirty and blocks every future live-mode dispatch (the engine refuses on a dirty tree, `live-checkout-dirty`, non-retryable — and it does not track or auto-recover stray CC edits). So for any needed change in a `live`/`hybrid` project you **dispatch a work item** and let the engine own the live-checkout path; you never `Edit`/`Write`/`Bash`-mutate files there yourself, regardless of task size or a direct-handling override. Read-only inspection remains fine. This is the same rule stated up top under "HARD STOP — never edit files yourself in a live/hybrid project"; the two sections agree by design.
339
+ **CC never writes into a live/hybrid checkout itself.** A one-off CC edit leaves untracked operator dirt that the next dispatch may unexpectedly stash or may block on when recovery is disabled. For any needed change, **dispatch a work item** and let the engine own the live-checkout path; never `Edit`/`Write`/`Bash`-mutate files there yourself. Read-only inspection remains fine.
340
340
 
341
341
  **When to recommend hybrid:** the project's build/test/validation genuinely cannot run in an isolated worktree (it needs the real checkout — submodules, a `repo`-managed tree, an emulator/dev-server bound to `localPath`, deep-path tooling), **but** you still want coding agents to work in parallel rather than serialize through the single live checkout. If the *whole* workflow must run on the real tree, use plain `live`. If nothing needs the real tree, stay on `worktree`.
342
342
 
343
343
  **Configuring it** (via the projects array on `POST /api/settings` — never hand-edit `config.json`):
344
344
  ```bash
345
- # Switch a project to hybrid mode (live checkout + deferred build-and-test validation)
345
+ # Switch a project to hybrid mode (live checkout + deferred test validation)
346
346
  curl -s -X POST http://localhost:{{dashboard_port}}/api/settings \
347
347
  -H 'Content-Type: application/json' -H 'X-CC-Turn-Id: {{cc_turn_id}}' \
348
- -d '{"projects":[{"name":"<project>","checkoutMode":"live","liveValidation":{"type":"build-and-test","autoDispatch":true}}]}'
348
+ -d '{"projects":[{"name":"<project>","checkoutMode":"live","liveValidation":{"type":"test","autoDispatch":true}}]}'
349
349
  # Clear hybrid (back to plain live): pass "liveValidation": null
350
350
  # Clear live entirely (back to worktree): pass "checkoutMode": "worktree" (also clear liveValidation)
351
351
  ```
352
- The server validates: `liveValidation` requires `checkoutMode:"live"` (400 otherwise) and a non-empty `type`. With `autoDispatch:true`, the engine auto-creates a `<type>` validation WI on the live checkout after each coding WI completes with a PR. **Always confirm the mode switch with the user before applying it** — it changes how every future dispatch on that project runs.
352
+ The server validates: `liveValidation` requires `checkoutMode:"live"`, canonical live-capable work-item type(s), and a boolean `autoDispatch`; automatic validation additionally requires `test` in `type`. **Always confirm the mode switch with the user before applying it** — it changes how future dispatches run.
353
353
 
354
354
  ## GitHub auth
355
355