@yemi33/minions 0.1.2356 → 0.1.2357

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
@@ -168,7 +168,8 @@ const { runPostCompletionHooks, updateWorkItemStatus, syncPrdItemStatus, reconci
168
168
  syncPrsFromOutput, updatePrAfterReview, updatePrAfterFix, checkForLearnings, extractSkillsFromOutput,
169
169
  updateAgentHistory, updateMetrics, createReviewFeedbackForAuthor, parseAgentOutput, syncPrdFromPrs, persistVerifyPrsToPrd,
170
170
  isItemCompleted, classifyFailure: classifyFailureFallback, diagnoseEmptyOutput, processPendingRebases, resolveWorkItemPath,
171
- mergeArtifactNotes, promoteCompletionArtifacts, pruneScopeMismatchDuplicatePrs, collapseAllDuplicatePrRecords } = require('./engine/lifecycle');
171
+ mergeArtifactNotes, promoteCompletionArtifacts, pruneScopeMismatchDuplicatePrs, collapseAllDuplicatePrRecords,
172
+ recordWorktreeHeldPause, clearWorktreeHeldPause } = require('./engine/lifecycle');
172
173
 
173
174
  // ─── Diagnostics: memory + event-loop + GC sampler (P-a1b2c3d4 / P-b2c3d4e5) ─
174
175
 
@@ -1335,7 +1336,7 @@ function _bumpQuarantineOutcome(key, delta = 1) {
1335
1336
  if (!metrics._engine.worktreeQuarantineOutcomes) {
1336
1337
  metrics._engine.worktreeQuarantineOutcomes = {
1337
1338
  attempts: 0, success: 0, successAfterRetry: 0,
1338
- fallbackForceRemove: 0, totalFailure: 0,
1339
+ fallbackForceRemove: 0, totalFailure: 0, alreadyQuarantinedConcurrent: 0,
1339
1340
  };
1340
1341
  }
1341
1342
  const o = metrics._engine.worktreeQuarantineOutcomes;
@@ -1347,6 +1348,28 @@ function _bumpQuarantineOutcome(key, delta = 1) {
1347
1348
  }
1348
1349
  }
1349
1350
 
1351
+ // W-mrcllbrx000oec11 — generic "serialize calls keyed by a string" helper.
1352
+ // Used to prevent two overlapping quarantine recovery attempts against the
1353
+ // SAME worktree path from running their destructive steps (holder-reap,
1354
+ // rename, force-remove) concurrently. `map` holds the tail promise of the
1355
+ // most recent caller for a given key; each new caller chains behind it and
1356
+ // replaces the map entry, so callers for the same key run strictly one at a
1357
+ // time while callers for different keys are unaffected. The map entry is
1358
+ // removed once a call's turn finishes, unless another caller has already
1359
+ // queued behind it (in which case that caller owns the cleanup).
1360
+ function _withPathLock(map, key, fn) {
1361
+ const prior = map.get(key) || Promise.resolve();
1362
+ const ourTurn = prior.catch(() => {}).then(fn, fn);
1363
+ const tail = ourTurn.catch(() => {});
1364
+ map.set(key, tail);
1365
+ tail.finally(() => {
1366
+ if (map.get(key) === tail) map.delete(key);
1367
+ });
1368
+ return ourTurn;
1369
+ }
1370
+
1371
+ const _quarantinePathLocks = new Map();
1372
+
1350
1373
 
1351
1374
  // ─── assertCleanSharedWorktree (#2439) ──────────────────────────────────────
1352
1375
  // Engine-side preflight that prevents shared-branch (and PR-targeted reused —
@@ -1673,14 +1696,30 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
1673
1696
  // for callers: a thrown error means the caller's `result.quarantineError`
1674
1697
  // field is populated and `result.quarantined` stays false (env-blocked
1675
1698
  // path); a normal return means quarantine succeeded.
1699
+ // W-mrcllbrx000oec11 — public entry point: serializes overlapping calls for
1700
+ // the SAME worktreePath through _withPathLock before delegating to the real
1701
+ // implementation below. See _quarantineDirtyWorktreeCore for the quarantine
1702
+ // logic itself, including the ENOENT idempotency check that this locking
1703
+ // layer works alongside (belt-and-suspenders: the lock stops concurrent
1704
+ // destructive work; the ENOENT check makes a still-overlapping caller — e.g.
1705
+ // from a process that doesn't share this in-memory map — resolve cleanly
1706
+ // instead of surfacing a false WORKTREE_QUARANTINE_ENV_BLOCKED failure).
1676
1707
  async function _quarantineDirtyWorktree(rootDir, worktreePath, branchName, gitOpts, diag = {}) {
1708
+ return _withPathLock(
1709
+ _quarantinePathLocks,
1710
+ worktreePath,
1711
+ () => _quarantineDirtyWorktreeCore(rootDir, worktreePath, branchName, gitOpts, diag),
1712
+ );
1713
+ }
1714
+
1715
+ async function _quarantineDirtyWorktreeCore(rootDir, worktreePath, branchName, gitOpts, diag = {}) {
1677
1716
  // P-c7e2b405 — INTENTIONAL divergence from worktree-gc.reapAndRemoveWorktree:
1678
1717
  // this is the bespoke dirty-worktree quarantine flow (rename → branch backup-ref
1679
1718
  // → reset → fetch), NOT a plain reap-then-remove. It is deliberately NOT folded
1680
1719
  // into the shared helper. It does share the same live-guard + holder-reap
1681
1720
  // primitives (isWorktreePathLive + _reapWorktreeHolders below).
1682
1721
  const ts = Date.now();
1683
- const quarantinedPath = `${worktreePath}-quarantine-${ts}`;
1722
+ let quarantinedPath = `${worktreePath}-quarantine-${ts}`;
1684
1723
  _bumpQuarantineOutcome('attempts', 1);
1685
1724
 
1686
1725
  // Capture HEAD sha BEFORE renaming so we can back up the local branch ref.
@@ -1704,6 +1743,48 @@ async function _quarantineDirtyWorktree(rootDir, worktreePath, branchName, gitOp
1704
1743
  return { quarantinedPath: null, backupRef: null, skipped: true };
1705
1744
  }
1706
1745
 
1746
+ // W-mrcqzuxy000w676e — PRESERVE UNCOMMITTED WORK BEFORE QUARANTINE.
1747
+ // The backup ref written near the end of this function only captures the
1748
+ // worktree's COMMITTED HEAD (via `headSha` above). It does NOT stash,
1749
+ // commit, or otherwise persist UNCOMMITTED modified/untracked files —
1750
+ // so a prior agent's real, on-task, uncommitted fix + test were silently
1751
+ // and permanently destroyed once the dir was renamed/pruned
1752
+ // (W-mrciooy1000e2ac3). Snapshot any uncommitted changes to a dedicated
1753
+ // backup ref BEFORE any destructive step below (holder-reap/rename/
1754
+ // force-remove) so they survive regardless of which path the quarantine
1755
+ // takes. Best-effort and non-blocking: a failure here never stops the
1756
+ // quarantine, but always logs one of three distinct, unambiguous outcomes
1757
+ // (clean / preserved / failed-to-preserve) instead of silently no-opping.
1758
+ const sanitizedRefSegment = sanitizeBranch(branchName).replace(/\//g, '-');
1759
+ let wipRef = null;
1760
+ let wipOutcome = 'clean';
1761
+ try {
1762
+ const statusOut = ((await execAsync('git status --porcelain', { ...gitOpts, cwd: worktreePath, timeout: 15000 })) || '').toString();
1763
+ if (statusOut.trim()) {
1764
+ try {
1765
+ await shared.shellSafeGit(['add', '-A'], { ...gitOpts, cwd: worktreePath, timeout: 30000 });
1766
+ await shared.shellSafeGit(
1767
+ ['commit', '--no-verify', '-m', `quarantine: uncommitted WIP snapshot (${diag.dispatchId || 'unknown'})`],
1768
+ { ...gitOpts, cwd: worktreePath, timeout: 30000 },
1769
+ );
1770
+ const wipSha = ((await execAsync('git rev-parse HEAD', { ...gitOpts, cwd: worktreePath, timeout: 10000 })) || '').toString().trim();
1771
+ const candidateWipRef = `refs/minions/quarantine-wip/${sanitizedRefSegment}/${ts}`;
1772
+ await shared.shellSafeGit(['update-ref', candidateWipRef, wipSha], { ...gitOpts, cwd: rootDir, timeout: 10000 });
1773
+ wipRef = candidateWipRef;
1774
+ wipOutcome = 'preserved';
1775
+ log('warn', `_quarantineDirtyWorktree: preserved uncommitted WIP for ${worktreePath} at ${wipRef} (${wipSha.slice(0, 12)})`);
1776
+ } catch (e) {
1777
+ wipOutcome = 'failed';
1778
+ log('error', `_quarantineDirtyWorktree: FAILED to preserve uncommitted WIP for ${worktreePath}: ${e.message} — uncommitted changes may be lost`);
1779
+ }
1780
+ }
1781
+ } catch (e) {
1782
+ // The status probe itself failed. Treat as failed-to-preserve (never
1783
+ // block the quarantine) rather than silently assuming the tree was clean.
1784
+ wipOutcome = 'failed';
1785
+ log('warn', `_quarantineDirtyWorktree: git status --porcelain probe failed for ${worktreePath}: ${e.message} — cannot determine whether uncommitted WIP needs preservation`);
1786
+ }
1787
+
1707
1788
  // W-mq5n1zx5 / W-mqila0t5 Layer 0–2: pre-emptively reap any process holding
1708
1789
  // the worktree dir before we rename it. The status-probe child that timed out
1709
1790
  // earlier may still hold packfile handles (Layer 0: git.exe by cmdline), and —
@@ -1742,6 +1823,44 @@ async function _quarantineDirtyWorktree(rootDir, worktreePath, branchName, gitOp
1742
1823
  renameError = e;
1743
1824
  }
1744
1825
 
1826
+ // W-mrcllbrx000oec11 — IDEMPOTENT-QUARANTINE GUARD: an ENOENT on the
1827
+ // rename means the source path no longer exists. `_retryFsOp` treats
1828
+ // ENOENT as non-retryable and throws on the very first attempt (it isn't
1829
+ // in shared._WORKTREE_RETRYABLE_CODES), so this fires immediately rather
1830
+ // than after a backoff loop. The only way the source can vanish between
1831
+ // our dirty-check (which confirmed the worktree existed) and this rename
1832
+ // is that another quarantine attempt against the SAME path already won
1833
+ // the race and renamed it away first (observed ~96ms apart in production,
1834
+ // W-mrciooy1000e2ac3 — 12 recovery cycles burned before the WI was
1835
+ // wrongly marked terminally failed even though the tree was already
1836
+ // clean). Recognize "source is gone" as quarantine having already
1837
+ // succeeded (from a concurrent caller) instead of a genuine environment
1838
+ // block, so we don't increment _quarantineRecoveryCount or surface
1839
+ // WORKTREE_QUARANTINE_ENV_BLOCKED for a race that already resolved
1840
+ // successfully. Best-effort: look for the sibling <path>-quarantine-*
1841
+ // directory the winning attempt created so we can still report a path;
1842
+ // if none is found (e.g. it was later cleaned up), source-gone alone is
1843
+ // still treated as success.
1844
+ let alreadyQuarantinedConcurrently = false;
1845
+ if (renameError && renameError.code === 'ENOENT' && !fs.existsSync(worktreePath)) {
1846
+ let winningPath = null;
1847
+ try {
1848
+ const parentDir = path.dirname(worktreePath);
1849
+ const prefix = `${path.basename(worktreePath)}-quarantine-`;
1850
+ const candidates = fs.readdirSync(parentDir)
1851
+ .filter((name) => name.startsWith(prefix))
1852
+ .sort(); // timestamp suffix sorts lexicographically == chronologically
1853
+ if (candidates.length > 0) winningPath = path.join(parentDir, candidates[candidates.length - 1]);
1854
+ } catch (e) {
1855
+ log('warn', `_quarantineDirtyWorktree: sibling quarantine-dir lookup after ENOENT failed for ${worktreePath}: ${e.message}`);
1856
+ }
1857
+ log('warn', `_quarantineDirtyWorktree: rename ENOENT for ${worktreePath} but source no longer exists — treating as already quarantined by a concurrent attempt${winningPath ? ` (found ${winningPath})` : ' (no sibling quarantine dir found)'}`);
1858
+ _bumpQuarantineOutcome('alreadyQuarantinedConcurrent', 1);
1859
+ alreadyQuarantinedConcurrently = true;
1860
+ renameError = null; // clear so the force-remove / env-blocked branches below don't fire
1861
+ quarantinedPath = winningPath;
1862
+ }
1863
+
1745
1864
  // W-mq5n1zx5 Layer 2b: if the rename retries exhausted, fall back to
1746
1865
  // `git worktree remove --force`. This DESTROYS the worktree contents
1747
1866
  // (no forensics dir), so it's last-resort only — gated on the
@@ -1832,7 +1951,6 @@ async function _quarantineDirtyWorktree(rootDir, worktreePath, branchName, gitOp
1832
1951
  // starts on a clean origin tip rather than recreating the divergent state.
1833
1952
  // Both ref ops are best-effort: if the local ref machinery is in a weird
1834
1953
  // state we still want the worktree quarantine to stick.
1835
- const sanitizedRefSegment = sanitizeBranch(branchName).replace(/\//g, '-');
1836
1954
  const backupRef = `refs/minions/quarantine/${sanitizedRefSegment}/${ts}`;
1837
1955
  let backupRefCreated = false;
1838
1956
  if (headSha) {
@@ -1871,7 +1989,9 @@ async function _quarantineDirtyWorktree(rootDir, worktreePath, branchName, gitOp
1871
1989
  `- Original worktree: ${worktreePath}`,
1872
1990
  forceRemoved
1873
1991
  ? `- Force-removed via \`git worktree remove --force\` (no quarantine dir preserved — W-mq5n1zx5 Layer 2b)`
1874
- : `- Quarantined to: ${quarantinedPath}${renameAttempts > 1 ? ` (rename took ${renameAttempts} attempt(s) — W-mq5n1zx5 Layer 1a)` : ''}`,
1992
+ : alreadyQuarantinedConcurrently
1993
+ ? `- Already quarantined by a concurrent attempt${quarantinedPath ? ` — quarantined to: ${quarantinedPath}` : ' (source dir was gone by the time this recovery attempt ran; no sibling quarantine dir found)'} (W-mrcllbrx000oec11)`
1994
+ : `- Quarantined to: ${quarantinedPath}${renameAttempts > 1 ? ` (rename took ${renameAttempts} attempt(s) — W-mq5n1zx5 Layer 1a)` : ''}`,
1875
1995
  `- Reason: ${diag.reason || 'unsafe'}`,
1876
1996
  `- Local commits ahead of origin: ${diag.ahead || 0}`,
1877
1997
  `- Local commits behind origin: ${diag.behind || 0}`,
@@ -1884,6 +2004,11 @@ async function _quarantineDirtyWorktree(rootDir, worktreePath, branchName, gitOp
1884
2004
  backupRefCreated
1885
2005
  ? `The local branch HEAD was backed up to \`${backupRef}\` (${headSha.slice(0, 12)}) and \`refs/heads/${branchName}\` was reset to \`refs/remotes/origin/${branchName}\`.`
1886
2006
  : `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.'}`,
2007
+ wipOutcome === 'preserved'
2008
+ ? `Uncommitted WIP (modified/untracked files at quarantine time) was committed and backed up to \`${wipRef}\`. Recover with \`git -C "${rootDir}" show ${wipRef}\` or \`git -C "${rootDir}" cherry-pick ${wipRef}\` onto a fresh ${branchName} worktree.`
2009
+ : wipOutcome === 'failed'
2010
+ ? `⚠️ Uncommitted WIP preservation FAILED — see engine log for details. Any modified/untracked files that were NOT part of \`${backupRefCreated ? backupRef : 'the backup ref'}\` may be unrecoverable once the quarantine dir is pruned.`
2011
+ : 'No uncommitted WIP was present (working tree was clean at quarantine time) — nothing extra to preserve.',
1887
2012
  '',
1888
2013
  forceRemoved
1889
2014
  ? `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'}\`.`
@@ -1905,12 +2030,15 @@ async function _quarantineDirtyWorktree(rootDir, worktreePath, branchName, gitOp
1905
2030
  log('warn', `_quarantineDirtyWorktree: writeToInbox failed: ${e.message}`);
1906
2031
  }
1907
2032
 
1908
- 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' : ''})`);
2033
+ log('warn', `Quarantined dirty worktree ${worktreePath} → ${forceRemoved ? '<force-removed>' : (quarantinedPath || '<already quarantined concurrently>')} (branch ${branchName}, ${diag.ahead || 0} ahead, ${diag.behind || 0} behind, ${dirtyFiles.length} dirty files${renameAttempts > 1 ? `, rename retries=${renameAttempts}` : ''}${forceRemoved ? ', force-remove fallback' : ''}${alreadyQuarantinedConcurrently ? ', already-quarantined-concurrently' : ''}, wip=${wipOutcome})`);
1909
2034
  return {
1910
2035
  quarantinedPath: forceRemoved ? null : quarantinedPath,
1911
2036
  backupRef: backupRefCreated ? backupRef : null,
1912
2037
  forceRemoved,
1913
2038
  renameAttempts,
2039
+ alreadyQuarantinedConcurrently,
2040
+ wipRef,
2041
+ wipOutcome,
1914
2042
  };
1915
2043
  }
1916
2044
 
@@ -2395,6 +2523,31 @@ async function spawnAgent(dispatchItem, config) {
2395
2523
  if (rawErr && rawErr.message) {
2396
2524
  log('debug', `spawnAgent: raw worktree-add failure for ${id}: ${String(rawErr.message).split('\n')[0]}`);
2397
2525
  }
2526
+ // #764 — PR-level circuit breaker. `agentRetryable: false` above only
2527
+ // stops retries of THIS WI instance; it carries no memory at the PR
2528
+ // level, so the human-feedback / review / build-fix / merge-conflict
2529
+ // pollers in discoverFromPrs see the PR is still pendingFix (or has
2530
+ // coalesced feedback) and create a brand-new work item next cooldown
2531
+ // window — forever, until a human manually releases the branch (live
2532
+ // repro: ado:office/office/1js#5429466, 7 wasted dispatches in ~4.5h).
2533
+ // Stamp `_worktreeHeldPaused` on the live PR record for PR-sourced
2534
+ // dispatches (source is 'pr' or 'pr-human-feedback' — see buildPrDispatch
2535
+ // call sites) so discoverFromPrs's isWorktreeHeldPauseSuppressed gate can
2536
+ // suppress further dispatch until the holder releases the branch.
2537
+ if ((meta?.source === 'pr' || meta?.source === 'pr-human-feedback') && meta?.pr) {
2538
+ try {
2539
+ const pauseProject = meta.project || project;
2540
+ mutatePullRequests(projectPrPath(pauseProject), (prs) => {
2541
+ if (!Array.isArray(prs)) return prs;
2542
+ const target = shared.findPrRecord(prs, meta.pr, pauseProject);
2543
+ if (!target) return prs;
2544
+ recordWorktreeHeldPause(target, { holderPath, isProjectRoot, branch: branchName, dispatchItem });
2545
+ return prs;
2546
+ });
2547
+ } catch (e) {
2548
+ log('warn', `spawnAgent: failed to stamp worktree-held pause for ${meta.pr.id || meta.pr.url || '(unknown PR)'}: ${e.message}`);
2549
+ }
2550
+ }
2398
2551
  };
2399
2552
  _phaseT.afterPrompt = Date.now();
2400
2553
 
@@ -2417,15 +2570,23 @@ async function spawnAgent(dispatchItem, config) {
2417
2570
  // cwd = project.localPath, worktreeRootDir = null, liveMode = true.
2418
2571
  // Instead of creating an engine-managed worktree we run prepareLiveCheckout
2419
2572
  // in-place: it validates a clean tree and then checks out (or creates) the
2420
- // target branch. On a CONFIRMED dirty refusal (the helper returns
2421
- // { ok:false, reason:'dirty', dirtyFiles:[…] }) we write an inbox alert, stamp
2422
- // `_pendingReason: 'live_checkout_dirty'` on the WI, complete the dispatch
2423
- // non-retryably with FAILURE_CLASS.LIVE_CHECKOUT_DIRTY, and return the
2424
- // engine never runs `git reset`/`git clean` against the operator tree. If the
2425
- // helper instead THROWS (guard/ref-validation/transient git error), that is
2426
- // NOT proof of a dirty tree (#305): we complete with the separate
2427
- // FAILURE_CLASS.LIVE_CHECKOUT_FAILED, which is retryable with bounded backoff
2428
- // so racy branch-lock handoff or a transient git failure auto-recovers.
2573
+ // target branch. W-mrcifup2000c218a: the dirty/stale-base conditions below
2574
+ // are now caught EARLIER, at allocation time, by the pre-dispatch checks in
2575
+ // the dispatch-existing-pending loop (`checkLiveCheckoutDirty` /
2576
+ // `checkLiveCheckoutStaleBase` in engine/live-checkout.js) a positive hit
2577
+ // there leaves the WI pending with `_pendingReason` stamped and never reaches
2578
+ // spawnAgent at all, so this block is now the EXCEPTION path (defense in
2579
+ // depth for the race window between that probe and the git operations
2580
+ // below), not the common path. On a CONFIRMED dirty refusal (the helper
2581
+ // returns { ok:false, reason:'dirty', dirtyFiles:[…] }) we write an inbox
2582
+ // alert, stamp `_pendingReason: 'live_checkout_dirty'` on the WI, complete
2583
+ // the dispatch non-retryably with FAILURE_CLASS.LIVE_CHECKOUT_DIRTY, and
2584
+ // return — the engine never runs `git reset`/`git clean` against the
2585
+ // operator tree. If the helper instead THROWS (guard/ref-validation/transient
2586
+ // git error), that is NOT proof of a dirty tree (#305): we complete with the
2587
+ // separate FAILURE_CLASS.LIVE_CHECKOUT_FAILED, which is retryable with
2588
+ // bounded backoff so racy branch-lock handoff or a transient git failure
2589
+ // auto-recovers.
2429
2590
  // On success we fall through; the worktree-creation block below is gated
2430
2591
  // on `!liveMode`, so it never runs, and downstream cleanup paths
2431
2592
  // (worktreePool.returnToPool, worktree-gc.gcDispatchWorktreeIfOrphan) are
@@ -3019,6 +3180,15 @@ async function spawnAgent(dispatchItem, config) {
3019
3180
  // so it gets its own non-retryable FAILURE_CLASS instead of LIVE_CHECKOUT_FAILED's
3020
3181
  // bounded retry-storm. The engine NEVER resets/cleans the operator's local
3021
3182
  // mainRef; the operator must reconcile it themselves, then re-dispatch.
3183
+ // W-mrcifup2000c218a: this condition is now caught EARLIER, at allocation
3184
+ // time, by `checkLiveCheckoutStaleBase` in the dispatch-existing-pending
3185
+ // loop — a positive hit there leaves the WI pending (`_pendingReason:
3186
+ // 'live_checkout_stale_base'`, no retry consumed, no failure) and this WI
3187
+ // never reaches spawnAgent. This in-spawn refusal is now the EXCEPTION path
3188
+ // (the race window between that probe and the checkout below — e.g. the
3189
+ // base was pushed-then-re-diverged between allocation and spawn), not the
3190
+ // common path; it still fails non-retryably because a stale base found
3191
+ // HERE is still deterministic and un-recoverable by a bare retry.
3022
3192
  if (_liveResult && _liveResult.ok === false && _liveResult.reason === 'stale-main') {
3023
3193
  const _staleMainRef = typeof _liveResult.mainRef === 'string' && _liveResult.mainRef ? _liveResult.mainRef : 'main';
3024
3194
  const _staleAhead = Number.isFinite(_liveResult.ahead) ? _liveResult.ahead : 0;
@@ -6960,6 +7130,72 @@ function isBuildFixIneffectiveSuppressed(pr) {
6960
7130
  return true;
6961
7131
  }
6962
7132
 
7133
+ // #764 — PR-level circuit breaker for the WORKTREE_PREFLIGHT
7134
+ // branch-held-externally failure class. `spawnAgent`'s
7135
+ // `_failBranchHeldByExternalWorktree` stamps `_worktreeHeldPaused` on the
7136
+ // live PR record (via `recordWorktreeHeldPause`) whenever a PR-sourced
7137
+ // dispatch (review, re-review, human-feedback fix, review-feedback fix,
7138
+ // build-fix, merge-conflict fix — all of which create a worktree on the PR's
7139
+ // branch) hits this failure. Without this gate, `discoverFromPrs` sees the
7140
+ // PR is still `pendingFix` (or has coalesced feedback) every tick and
7141
+ // re-dispatches a brand-new work item against the identical unresolved
7142
+ // cause, forever (live repro: ado:office/office/1js#5429466, 7 wasted
7143
+ // dispatches in ~4.5h — see issue #764).
7144
+ //
7145
+ // Unlike the no-op-fix pauses above, this one performs a cheap live re-check
7146
+ // (a single `git worktree list --porcelain` against the project root) before
7147
+ // deciding to keep suppressing: the whole point of the breaker is to avoid a
7148
+ // full wasted agent dispatch, and one `git worktree list` call is orders of
7149
+ // magnitude cheaper than that. When the holder no longer holds the paused
7150
+ // branch, the pause auto-clears (no manual step needed in the common case,
7151
+ // per the issue's proposed fix) and dispatch is allowed to proceed. Any
7152
+ // failure re-checking live state is treated conservatively (stays paused) —
7153
+ // a false "still held" merely costs one more skipped tick, whereas a false
7154
+ // "released" would resume the exact dispatch storm this breaker exists to
7155
+ // stop.
7156
+ async function isWorktreeHeldPauseSuppressed(pr, project) {
7157
+ const record = pr?._worktreeHeldPaused;
7158
+ if (!record || typeof record !== 'object') return false;
7159
+ let stillHeld = true;
7160
+ try {
7161
+ const rootDir = shared.resolveProjectRootDir(project?.localPath, MINIONS_DIR);
7162
+ const out = await shared.shellSafeGit(['worktree', 'list', '--porcelain'], { cwd: rootDir, timeout: 10000 });
7163
+ const list = shared.parseWorktreePorcelain(out);
7164
+ // Compare via realpath, not a bare path.resolve() string match: git
7165
+ // resolves `worktree list` entries to their real (subst/junction-free)
7166
+ // filesystem path, but `record.holderPath` may have been captured from a
7167
+ // substituted drive letter (common on Windows CI runners that `subst` a
7168
+ // drive for TEMP) or a symlinked path. A raw string comparison then
7169
+ // false-mismatches two identical directories, wrongly auto-clearing the
7170
+ // pause and letting the exact dispatch storm this breaker exists to stop
7171
+ // resume (#764 regression — see W-mrdiq0jp000bb541).
7172
+ const holderReal = record.holderPath ? shared.realPathForComparison(record.holderPath) : null;
7173
+ const holderEntry = holderReal
7174
+ ? list.find(w => shared.realPathForComparison(w.path) === holderReal)
7175
+ : null;
7176
+ stillHeld = !!holderEntry && holderEntry.branch === record.branch;
7177
+ } catch (e) {
7178
+ log('warn', `PR ${pr.id}: worktree-held pause live re-check failed (${e.message}) — keeping automation paused`);
7179
+ }
7180
+ if (!stillHeld) {
7181
+ try {
7182
+ mutatePullRequests(projectPrPath(project), (prs) => {
7183
+ if (!Array.isArray(prs)) return prs;
7184
+ const target = shared.findPrRecord(prs, pr, project);
7185
+ if (!target) return prs;
7186
+ clearWorktreeHeldPause(target);
7187
+ return prs;
7188
+ });
7189
+ log('info', `PR ${pr.id}: worktree-held pause auto-cleared — ${record.holderPath} no longer holds branch ${record.branch}`);
7190
+ } catch (e) {
7191
+ log('warn', `PR ${pr.id}: failed to auto-clear worktree-held pause: ${e.message}`);
7192
+ }
7193
+ return false;
7194
+ }
7195
+ log('warn', `PR ${pr.id}: suppressing PR-driven review/fix automation — branch ${record.branch} still held ${record.isProjectRoot ? 'by the project root' : 'by an external worktree'} at ${record.holderPath}; waiting for the holder to release it`);
7196
+ return true;
7197
+ }
7198
+
6963
7199
  const PR_PENDING_MISSING_BRANCH = shared.PR_PENDING_REASON.MISSING_BRANCH;
6964
7200
 
6965
7201
  function normalizePrBranch(value) {
@@ -7350,6 +7586,15 @@ async function discoverFromPrs(config, project) {
7350
7586
  _logPrDispatchSkipOnce(pr, 'not-auto-managed');
7351
7587
  continue;
7352
7588
  }
7589
+ // #764 — PR-level circuit breaker: a prior dispatch against this PR's
7590
+ // branch hit WORKTREE_PREFLIGHT because the branch is held externally
7591
+ // (project root or an unreachable sibling worktree). Every dispatch path
7592
+ // below (review, re-review, human-feedback fix, review-feedback fix,
7593
+ // build-fix, merge-conflict fix) creates a worktree on the SAME branch,
7594
+ // so skip the whole PR for this tick rather than re-litigating the same
7595
+ // doomed preflight per-cause. Auto-clears itself once the holder
7596
+ // releases the branch — see isWorktreeHeldPauseSuppressed.
7597
+ if (await isWorktreeHeldPauseSuppressed(pr, project)) continue;
7353
7598
 
7354
7599
  const prNumber = shared.getPrNumber(pr);
7355
7600
  // Use reviewStatus as single source of truth (synced from ADO/GitHub votes)
@@ -9679,7 +9924,7 @@ function sweepStaleArchivedPrdBackups(prdDir, prdArchiveDir) {
9679
9924
  async function discoverWork(config) {
9680
9925
  resetClaimedAgents(); // Reset per-tick agent claims for fair distribution
9681
9926
  const projects = getProjects(config);
9682
- let allFixes = [], allReviews = [], allWorkItems = [];
9927
+ let persistedFixes = 0, persistedReviews = 0, persistedOther = 0;
9683
9928
 
9684
9929
  // Side-effect passes: materialize plans and design docs into work-items.json
9685
9930
  // These write to project work queues — picked up by discoverFromWorkItems below.
@@ -9692,19 +9937,109 @@ async function discoverWork(config) {
9692
9937
  materializePlansAsWorkItems(config);
9693
9938
  }
9694
9939
 
9940
+ const evalConcurrency = resolvePreDispatchEvalConcurrency(config);
9941
+
9942
+ // #768 — persist each batch of discovered items to the dispatch queue as
9943
+ // soon as it is found, instead of accumulating everything across every
9944
+ // project/source into local arrays that are only persisted once this whole
9945
+ // function returns. discoverWork() is invoked inside withTickTimeout(...)
9946
+ // AND the tick's outer hard timeout (TICK_TIMEOUT_MS / tickHardTimeoutMs) —
9947
+ // neither one actually cancels the in-flight promise, it just stops
9948
+ // awaiting it. A pathologically slow (or genuinely hung) per-project
9949
+ // git/worktree operation later in the sequential `for (const project of
9950
+ // projects)` loop below leaves this whole async function permanently
9951
+ // unresolved, so anything only collected into a local array and persisted
9952
+ // at the very end (the pre-fix allFixes/allReviews/allWorkItems) never
9953
+ // reaches dispatch.json — silently discarding valid work already
9954
+ // discovered for unrelated, healthy projects processed earlier in the same
9955
+ // loop. Persisting per batch means a healthy project's discoveries land in
9956
+ // dispatch.json immediately, before the loop ever reaches a slow project.
9957
+ async function persistBatch(items) {
9958
+ if (!items || items.length === 0) return;
9959
+ // Gate reviews and fixes: only when at max concurrency — idle agents should pick up reviews
9960
+ // even if implement items are in progress (implements get priority via sort order, not by blocking)
9961
+ const hasIncompleteImplements = queries.getWorkItems(config).some(i =>
9962
+ ['queued', 'pending', 'dispatched'].includes(i.status) && (i.type || '').startsWith('implement')
9963
+ );
9964
+ let toDispatch = items;
9965
+ if (hasIncompleteImplements) {
9966
+ const activeCount = (getDispatch().active || []).length;
9967
+ const maxConcurrent = resolveMaxConcurrent(config);
9968
+ const freeSlots = Math.max(0, maxConcurrent - activeCount);
9969
+ if (freeSlots === 0) {
9970
+ // PR review feedback (#771): the pre-fix code only ever gated
9971
+ // allFixes/allReviews, which were populated exclusively from
9972
+ // discoverFromPrs() output (`meta.source === 'pr'`). FIX/REVIEW-typed
9973
+ // items surfaced by discoverFromWorkItems (`meta.source ===
9974
+ // 'work-item'`), discoverCentralWorkItems (`'central-work-item'` /
9975
+ // `'central-work-item-fanout'`), or discoverMeetingWork (`'meeting'`)
9976
+ // were NEVER gated — allWorkItems/centralWork bypassed the gate
9977
+ // entirely. persistBatch() now runs on every source's batch, so the
9978
+ // type-only filter below would silently start gating those
9979
+ // previously-ungated sources too. Scope the gate back down to
9980
+ // `meta.source === 'pr'` to preserve the original behavior exactly.
9981
+ const isGateEligible = (w) => w.meta?.source === 'pr' && (w.type === WORK_TYPE.FIX || w.type === WORK_TYPE.REVIEW);
9982
+ const gated = items.filter(isGateEligible);
9983
+ if (gated.length > 0) {
9984
+ log('info', `Gating ${gated.length} fixes/reviews — implement items in progress and no free slots`);
9985
+ }
9986
+ toDispatch = items.filter(w => !isGateEligible(w));
9987
+ }
9988
+ }
9989
+ if (toDispatch.length === 0) return;
9990
+
9991
+ // W-mq9acoo800177bcb — bounded-concurrency pre-dispatch eval.
9992
+ // The validator inside addToDispatchWithValidation runs a ~25-30s LLM
9993
+ // call per item; an approved 11-item PRD used to take ~5 min to queue
9994
+ // because we awaited each item in series. Process items in chunks of
9995
+ // `preDispatchEvalConcurrency` (default 6, clamp 1-20) via
9996
+ // Promise.allSettled so a rejected branch in one item still fails open
9997
+ // and the rest of the chunk continues. Iteration order within a chunk
9998
+ // is irrelevant: addToDispatch's dedup gates are mutateDispatch-locked,
9999
+ // and the only per-item side effect (`clearPendingHumanFeedbackFlag`)
10000
+ // is an idempotent flag-clear on a single PR record.
10001
+ for (let i = 0; i < toDispatch.length; i += evalConcurrency) {
10002
+ const chunk = toDispatch.slice(i, i + evalConcurrency);
10003
+ await Promise.allSettled(chunk.map(async (item) => {
10004
+ try {
10005
+ await addToDispatchWithValidation(item, { config });
10006
+ } catch (e) {
10007
+ // Fail-open contract preserved: any validator throw here means the
10008
+ // item is dropped from this discovery batch rather than wedging the
10009
+ // whole chunk. The item stays `pending` in work-items.json and the
10010
+ // next tick re-discovers it.
10011
+ log('warn', `pre-dispatch-eval: addToDispatchWithValidation threw for ${item?.meta?.item?.id || item?.id || 'unknown'}: ${e?.message || e}`);
10012
+ }
10013
+ // W-mph8xt88000ke0fc — Cooldowns are stamped by addToDispatch ONLY on
10014
+ // successful append (post-dedup). Stamping unconditionally here used to
10015
+ // leave orphan cooldowns for items collapsed by prDedupeKey / workItem-id
10016
+ // / dispatchKey dedup or routed to the pre-dispatch review queue.
10017
+ if (item.meta?.source === 'pr-human-feedback') {
10018
+ clearPendingHumanFeedbackFlag(item.meta.project, item.meta.pr?.id);
10019
+ }
10020
+ }));
10021
+ }
10022
+ for (const w of toDispatch) {
10023
+ if (w.type === WORK_TYPE.FIX) persistedFixes++;
10024
+ else if (w.type === WORK_TYPE.REVIEW) persistedReviews++;
10025
+ else persistedOther++;
10026
+ }
10027
+ }
10028
+
9695
10029
  for (const project of projects) {
9696
10030
  const root = project.localPath ? path.resolve(project.localPath) : null;
9697
10031
  if (!root || !fs.existsSync(root)) continue;
9698
10032
 
10033
+ const projectWork = [];
10034
+
9699
10035
  // Source 1: Pull Requests → fixes, reviews, build-test
9700
10036
  // Gated by config.engine?.prDiscoveryEnabled !== false (P-d6f0a2b5) —
9701
10037
  // per-project project.workSources.pullRequests.enabled still composes
9702
10038
  // inside discoverFromPrs; a `false` at either level skips the call.
9703
10039
  if (config.engine?.prDiscoveryEnabled !== false) {
9704
10040
  const prWork = await discoverFromPrs(config, project);
9705
- allFixes.push(...prWork.filter(w => w.type === WORK_TYPE.FIX));
9706
- allReviews.push(...prWork.filter(w => w.type === WORK_TYPE.REVIEW));
9707
- allWorkItems.push(...prWork.filter(w => w.type === WORK_TYPE.TEST));
10041
+ projectWork.push(...prWork.filter(w =>
10042
+ w.type === WORK_TYPE.FIX || w.type === WORK_TYPE.REVIEW || w.type === WORK_TYPE.TEST));
9708
10043
  }
9709
10044
 
9710
10045
  // Side-effect: specs → work items (picked up below)
@@ -9715,8 +10050,14 @@ async function discoverWork(config) {
9715
10050
  // per-project project.workSources.workItems.enabled still composes
9716
10051
  // inside discoverFromWorkItems; a `false` at either level skips the call.
9717
10052
  if (config.engine?.workItemsDiscoveryEnabled !== false) {
9718
- allWorkItems.push(...discoverFromWorkItems(config, project));
10053
+ projectWork.push(...discoverFromWorkItems(config, project));
9719
10054
  }
10055
+
10056
+ // #768 — persist this project's discovered items right now rather than
10057
+ // waiting for every remaining project in the loop to also be walked. See
10058
+ // the persistBatch() comment above for why this matters on a tick
10059
+ // hard-timeout.
10060
+ await persistBatch(projectWork);
9720
10061
  }
9721
10062
 
9722
10063
  // Source 2: Minions-level PRD → implements (multi-project, called once outside project loop)
@@ -9730,6 +10071,8 @@ async function discoverWork(config) {
9730
10071
  if (config.engine?.centralWorkDiscoveryEnabled !== false) {
9731
10072
  centralWork = discoverCentralWorkItems(config);
9732
10073
  }
10074
+ // #768 — persist immediately, same rationale as the per-project loop above.
10075
+ await persistBatch([...centralWork]);
9733
10076
 
9734
10077
  // Scheduled tasks (cron-style recurring work)
9735
10078
  // Gated by config.engine?.scheduledWorkDiscoveryEnabled !== false (P-d6f0a2b5).
@@ -9798,7 +10141,8 @@ async function discoverWork(config) {
9798
10141
  try {
9799
10142
  const { discoverMeetingWork } = require('./engine/meeting');
9800
10143
  const meetingWork = discoverMeetingWork(config);
9801
- allWorkItems.push(...meetingWork);
10144
+ // #768 — persist immediately, same rationale as the per-project loop above.
10145
+ await persistBatch(meetingWork);
9802
10146
  } catch (e) { log('warn', 'discover meeting work: ' + e.message); }
9803
10147
 
9804
10148
  // Pipeline orchestration — check stage completions and start ready stages
@@ -9862,67 +10206,12 @@ async function discoverWork(config) {
9862
10206
  } catch (e) { log('warn', 'plan completion sweep: ' + e.message); }
9863
10207
  }
9864
10208
 
9865
- // Gate reviews and fixes: only when at max concurrency — idle agents should pick up reviews
9866
- // even if implement items are in progress (implements get priority via sort order, not by blocking)
9867
- const hasIncompleteImplements = queries.getWorkItems(config).some(i =>
9868
- ['queued', 'pending', 'dispatched'].includes(i.status) && (i.type || '').startsWith('implement')
9869
- );
9870
- if (hasIncompleteImplements) {
9871
- const activeCount = (getDispatch().active || []).length;
9872
- const maxConcurrent = resolveMaxConcurrent(config);
9873
- const freeSlots = Math.max(0, maxConcurrent - activeCount);
9874
- if (freeSlots === 0) {
9875
- if (allReviews.length > 0) {
9876
- log('info', `Gating ${allReviews.length} reviews — implement items in progress and no free slots`);
9877
- allReviews = [];
9878
- }
9879
- if (allFixes.length > 0) {
9880
- log('info', `Gating ${allFixes.length} fixes — implement items in progress and no free slots`);
9881
- allFixes = [];
9882
- }
9883
- }
9884
- }
9885
-
9886
- const allWork = [...allFixes, ...allReviews, ...allWorkItems, ...centralWork];
9887
-
9888
- // W-mq9acoo800177bcb — bounded-concurrency pre-dispatch eval.
9889
- // The validator inside addToDispatchWithValidation runs a ~25-30s LLM
9890
- // call per item; an approved 11-item PRD used to take ~5 min to queue
9891
- // because we awaited each item in series. Process items in chunks of
9892
- // `preDispatchEvalConcurrency` (default 6, clamp 1-20) via
9893
- // Promise.allSettled so a rejected branch in one item still fails open
9894
- // and the rest of the chunk continues. Iteration order within a chunk
9895
- // is irrelevant: addToDispatch's dedup gates are mutateDispatch-locked,
9896
- // and the only per-item side effect (`clearPendingHumanFeedbackFlag`)
9897
- // is an idempotent flag-clear on a single PR record.
9898
- const evalConcurrency = resolvePreDispatchEvalConcurrency(config);
9899
- for (let i = 0; i < allWork.length; i += evalConcurrency) {
9900
- const chunk = allWork.slice(i, i + evalConcurrency);
9901
- await Promise.allSettled(chunk.map(async (item) => {
9902
- try {
9903
- await addToDispatchWithValidation(item, { config });
9904
- } catch (e) {
9905
- // Fail-open contract preserved: any validator throw here means the
9906
- // item is dropped from this discovery tick rather than wedging the
9907
- // whole chunk. The item stays `pending` in work-items.json and the
9908
- // next tick re-discovers it.
9909
- log('warn', `pre-dispatch-eval: addToDispatchWithValidation threw for ${item?.meta?.item?.id || item?.id || 'unknown'}: ${e?.message || e}`);
9910
- }
9911
- // W-mph8xt88000ke0fc — Cooldowns are stamped by addToDispatch ONLY on
9912
- // successful append (post-dedup). Stamping unconditionally here used to
9913
- // leave orphan cooldowns for items collapsed by prDedupeKey / workItem-id
9914
- // / dispatchKey dedup or routed to the pre-dispatch review queue.
9915
- if (item.meta?.source === 'pr-human-feedback') {
9916
- clearPendingHumanFeedbackFlag(item.meta.project, item.meta.pr?.id);
9917
- }
9918
- }));
9919
- }
9920
-
9921
- if (allWork.length > 0) {
9922
- log('info', `Discovered ${allWork.length} new work items: ${allFixes.length} fixes, ${allReviews.length} reviews, ${allWorkItems.length} work-items`);
10209
+ const totalPersisted = persistedFixes + persistedReviews + persistedOther;
10210
+ if (totalPersisted > 0) {
10211
+ log('info', `Discovered ${totalPersisted} new work items: ${persistedFixes} fixes, ${persistedReviews} reviews, ${persistedOther} work-items`);
9923
10212
  }
9924
10213
 
9925
- return allWork.length;
10214
+ return totalPersisted;
9926
10215
  }
9927
10216
 
9928
10217
  function getPendingDispatchRoutingOpts(item) {
@@ -10916,6 +11205,123 @@ async function tickInner() {
10916
11205
  continue;
10917
11206
  }
10918
11207
  }
11208
+ // W-mrcifup2000c218a: Live-checkout dirty / stale-base PRE-DISPATCH checks.
11209
+ // Mirrors the live_checkout_busy gate directly above: dirty-tree and
11210
+ // stale-base are ENVIRONMENTAL blocking conditions on the operator's live
11211
+ // checkout, not failures of the task itself, so they get detected here at
11212
+ // ALLOCATION time (before spawnAgent runs) and leave the item pending with
11213
+ // a _pendingReason stamp — no retry consumed, no terminal failure — same
11214
+ // as the busy gate. Previously these were only caught INSIDE spawnAgent
11215
+ // (prepareLiveCheckout's dirty two-strike guard and STALE-BASE GUARD),
11216
+ // which meant every hit burned a dispatch attempt (dirty) or failed
11217
+ // non-retryably on the first hit (stale-base), requiring manual cleanup
11218
+ // even after the operator fixed the underlying condition. The in-spawn
11219
+ // guards (engine.js ~line 2617 dirty, ~line 3017 stale-base) remain in
11220
+ // place as a defense-in-depth fallback for the race window between this
11221
+ // probe and the actual git operations inside spawnAgent — they should now
11222
+ // fire rarely, as the exception path rather than the common path.
11223
+ if (
11224
+ itemProjName
11225
+ && !READ_ONLY_ROOT_TASK_TYPES.has(item.type)
11226
+ ) {
11227
+ const _precheckProjCfg = shared.findProjectByName(shared.getProjects(config), itemProjName);
11228
+ if (_precheckProjCfg && shared.resolveCheckoutMode(_precheckProjCfg, item.type) === 'live' && _precheckProjCfg.localPath) {
11229
+ const _precheckLocalPath = path.resolve(String(_precheckProjCfg.localPath));
11230
+ const _liveCheckoutPrecheck = require('./engine/live-checkout');
11231
+ const _persistPendingReason = (reason) => {
11232
+ item._pendingReason = reason;
11233
+ try {
11234
+ if (_isTickStale(myGeneration)) return;
11235
+ mutateDispatch((dp) => {
11236
+ const p = (dp.pending || []).find(d => d.id === item.id);
11237
+ if (p) {
11238
+ if (reason) p._pendingReason = reason;
11239
+ else delete p._pendingReason;
11240
+ }
11241
+ return dp;
11242
+ });
11243
+ } catch (e) { log('warn', `Persist live-checkout precheck pendingReason for ${item.id} failed: ${e.message}`); }
11244
+ };
11245
+ let _dirtyResult = null;
11246
+ try {
11247
+ _dirtyResult = await _liveCheckoutPrecheck.checkLiveCheckoutDirty({
11248
+ localPath: _precheckLocalPath,
11249
+ skipDirtyCheck: !!_precheckProjCfg.skipLiveCheckoutDirtyCheck,
11250
+ });
11251
+ } catch (e) { log('warn', `live-checkout precheck (dirty) failed for ${item.id}: ${e.message}`); }
11252
+ if (_dirtyResult && _dirtyResult.dirty) {
11253
+ _persistPendingReason('live_checkout_dirty');
11254
+ try {
11255
+ const _alertBody = [
11256
+ '# Live-checkout pending: dirty worktree (pre-dispatch check)',
11257
+ '',
11258
+ `**Project:** ${_precheckProjCfg.name || itemProjName}`,
11259
+ `**Local path:** ${_precheckLocalPath}`,
11260
+ `**Work item:** ${item.id}`,
11261
+ ...(_dirtyResult.branchInfo ? ['', `**Current checkout:** \`${_dirtyResult.branchInfo}\``] : []),
11262
+ '',
11263
+ `The engine detected uncommitted changes in \`${_precheckLocalPath}\` before dispatching ${item.id}. Live-checkout mode runs in your project directly — the engine never \`git reset\` or \`git clean\` your tree. The work item stays pending (no retry consumed, no failure) and will be re-checked automatically once the tree is clean.`,
11264
+ '',
11265
+ '## Dirty files (`git status --porcelain=v1 -b`)',
11266
+ '',
11267
+ '```',
11268
+ ...(Array.isArray(_dirtyResult.dirtyFiles) && _dirtyResult.dirtyFiles.length > 0
11269
+ ? _liveCheckoutPrecheck.capDirtyFileLines(_dirtyResult.dirtyFiles)
11270
+ : ['(none reported)']),
11271
+ '```',
11272
+ '',
11273
+ 'Recovery: commit, stash, or discard the changes in the project checkout — the work item will dispatch automatically on a later tick once the tree is clean.',
11274
+ ].join('\n');
11275
+ writeInboxAlert(`live-checkout-dirty-${item.id}`, _alertBody);
11276
+ } catch (e) { log('warn', `live-checkout precheck: writeInboxAlert failed: ${e.message}`); }
11277
+ continue;
11278
+ }
11279
+ // Stale-base only matters when this item would fork a NEW branch off
11280
+ // mainRef — mirror spawnAgent's allowNonMainBase resolution exactly
11281
+ // (W-mr3lunnq000o9f41: PR-targeted / shared-branch continuations opt out).
11282
+ const _itemMeta = item.meta || {};
11283
+ const _precheckAllowNonMainBase = !!(_itemMeta.useExistingBranch || _itemMeta.branchStrategy === 'shared-branch');
11284
+ const _precheckMainRef = sanitizeBranch(shared.resolveMainBranch(_precheckLocalPath, _precheckProjCfg.mainBranch));
11285
+ let _staleResult = null;
11286
+ try {
11287
+ _staleResult = await _liveCheckoutPrecheck.checkLiveCheckoutStaleBase({
11288
+ localPath: _precheckLocalPath,
11289
+ mainRef: _precheckMainRef,
11290
+ allowNonMainBase: _precheckAllowNonMainBase,
11291
+ });
11292
+ } catch (e) { log('warn', `live-checkout precheck (stale-base) failed for ${item.id}: ${e.message}`); }
11293
+ if (_staleResult && _staleResult.stale) {
11294
+ _persistPendingReason('live_checkout_stale_base');
11295
+ try {
11296
+ const _alertBody = [
11297
+ '# Live-checkout pending: local base branch has diverged (pre-dispatch check)',
11298
+ '',
11299
+ `**Project:** ${_precheckProjCfg.name || itemProjName}`,
11300
+ `**Local path:** ${_precheckLocalPath}`,
11301
+ `**Base ref:** ${_staleResult.mainRef}`,
11302
+ `**Local ${_staleResult.mainRef}:** ${_staleResult.localMainSha || '(unknown)'}`,
11303
+ `**origin/${_staleResult.mainRef}:** ${_staleResult.originMainSha || '(unknown)'}`,
11304
+ `**Unpushed commits on local ${_staleResult.mainRef}:** ${_staleResult.ahead}`,
11305
+ `**Work item:** ${item.id}`,
11306
+ '',
11307
+ `The engine detected that local \`${_staleResult.mainRef}\` is ${_staleResult.ahead} commit(s) ahead of \`origin/${_staleResult.mainRef}\` before dispatching ${item.id} — forking a new branch off it would silently inherit those unpushed commits into the new branch and its PR. The work item stays pending (no retry consumed, no failure) and will be re-checked automatically once the base is reconciled.`,
11308
+ '',
11309
+ '## Recovery',
11310
+ '',
11311
+ `Reconcile local \`${_staleResult.mainRef}\` so it matches \`origin/${_staleResult.mainRef}\` (move any real work onto its own branch first, then \`git reset --hard origin/${_staleResult.mainRef}\`). The engine never resets it for you.`,
11312
+ ].join('\n');
11313
+ writeInboxAlert(`live-checkout-stale-base-${item.id}`, _alertBody);
11314
+ } catch (e) { log('warn', `live-checkout precheck: writeInboxAlert failed: ${e.message}`); }
11315
+ continue;
11316
+ }
11317
+ // Checks passed — if a prior tick stamped a dirty/stale-base pending
11318
+ // reason on this item, clear it so the dashboard chip doesn't show a
11319
+ // stale condition once the tree is clean / base is reconciled again.
11320
+ if (item._pendingReason === 'live_checkout_dirty' || item._pendingReason === 'live_checkout_stale_base') {
11321
+ _persistPendingReason(null);
11322
+ }
11323
+ }
11324
+ }
10919
11325
  if (generalSlots <= 0) continue;
10920
11326
  seenPendingIds.add(item.id);
10921
11327
  toDispatch.push(item);