@yemi33/minions 0.1.2356 → 0.1.2358
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/dashboard/js/render-pipelines.js +12 -3
- package/dashboard/js/render-prs.js +55 -1
- package/dashboard.js +78 -1
- package/docs/README.md +1 -0
- package/docs/live-checkout-mode.md +6 -0
- package/docs/worktree-lifecycle.md +77 -1
- package/engine/ado.js +1 -1
- package/engine/cleanup.js +4 -4
- package/engine/dispatch.js +37 -5
- package/engine/lifecycle.js +72 -0
- package/engine/live-checkout.js +141 -0
- package/engine/pipeline.js +95 -32
- package/engine/playbook.js +6 -5
- package/engine/queries.js +34 -22
- package/engine/shared.js +53 -1
- package/engine.js +486 -80
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
:
|
|
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.
|
|
2421
|
-
//
|
|
2422
|
-
//
|
|
2423
|
-
//
|
|
2424
|
-
//
|
|
2425
|
-
//
|
|
2426
|
-
//
|
|
2427
|
-
//
|
|
2428
|
-
//
|
|
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
|
|
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
|
-
|
|
9706
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9866
|
-
|
|
9867
|
-
|
|
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
|
|
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);
|