@yemi33/minions 0.1.2381 → 0.1.2383
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/bin/minions.js +42 -2
- package/dashboard/js/refresh.js +8 -2
- package/dashboard/js/render-other.js +38 -0
- package/dashboard/js/render-work-items.js +61 -20
- package/dashboard/js/settings.js +7 -8
- package/dashboard/pages/engine.html +6 -0
- package/dashboard.js +110 -27
- package/docs/README.md +1 -0
- package/docs/claude-md-propagation.md +118 -0
- package/docs/completion-reports.md +20 -1
- package/docs/engine-restart.md +10 -0
- package/docs/runtime-adapters.md +54 -22
- package/docs/skills.md +2 -0
- package/docs/workspace-manifests.md +1 -1
- package/docs/worktree-lifecycle.md +23 -0
- package/engine/acp-transport.js +63 -35
- package/engine/ado-comment.js +3 -1
- package/engine/agent-worker-pool.js +11 -4
- package/engine/cc-worker-pool.js +4 -2
- package/engine/claude-md-context.js +195 -0
- package/engine/cli.js +9 -1
- package/engine/comment-format.js +51 -14
- package/engine/consolidation.js +47 -3
- package/engine/db/migrations/026-pre-dispatch-rejections.js +55 -0
- package/engine/dispatch.js +80 -23
- package/engine/execution-model.js +68 -0
- package/engine/gh-comment.js +6 -3
- package/engine/github.js +6 -7
- package/engine/lifecycle.js +195 -56
- package/engine/llm.js +59 -83
- package/engine/memory-store.js +3 -1
- package/engine/pipeline.js +147 -20
- package/engine/playbook.js +39 -6
- package/engine/pooled-agent-process.js +28 -26
- package/engine/prd-store.js +14 -6
- package/engine/quarantine-refs.js +33 -0
- package/engine/queries.js +8 -6
- package/engine/restart-health.js +69 -4
- package/engine/runtimes/claude.js +100 -25
- package/engine/runtimes/codex.js +19 -0
- package/engine/runtimes/contract.js +239 -0
- package/engine/runtimes/copilot.js +85 -8
- package/engine/runtimes/index.js +37 -9
- package/engine/shared.js +157 -64
- package/engine/spawn-agent.js +79 -113
- package/engine/spawn-phase-watchdog.js +8 -37
- package/engine/supervisor.js +72 -16
- package/engine/timeout.js +87 -16
- package/engine/untrusted-fence.js +2 -1
- package/engine.js +434 -125
- package/package.json +1 -1
- package/playbooks/fix.md +20 -0
- package/playbooks/review.md +2 -0
- package/skills/check-self-authored-review-comment/SKILL.md +34 -0
package/engine.js
CHANGED
|
@@ -34,14 +34,20 @@ const shared = require('./engine/shared');
|
|
|
34
34
|
const { exec, execAsync, execSilent, runFile, ts, ENGINE_DEFAULTS,
|
|
35
35
|
WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, REVIEW_STATUS, DISPATCH_RESULT, AGENT_STATUS,
|
|
36
36
|
FAILURE_CLASS, resolvePollFlag } = shared;
|
|
37
|
-
const {
|
|
38
|
-
|
|
37
|
+
const {
|
|
38
|
+
resolveRuntime,
|
|
39
|
+
resolveInvocationOptions,
|
|
40
|
+
buildSpawnFlags,
|
|
41
|
+
prepareWorkspace,
|
|
42
|
+
} = require('./engine/runtimes');
|
|
43
|
+
const { assertStaleHeadOk } = require('./engine/spawn-agent');
|
|
39
44
|
// P-1d8f0b93 — fleet ACP worker pool + its child_process-shaped facade.
|
|
40
45
|
// NOTE: engine/agent-worker-pool.js is a DIFFERENT module from the
|
|
41
46
|
// dashboard's single-tab CC pool (engine.js must never import that one —
|
|
42
47
|
// see the cc-tab-pool wiring regression guard test).
|
|
43
48
|
const agentWorkerPool = require('./engine/agent-worker-pool');
|
|
44
49
|
const { PooledAgentProcess } = require('./engine/pooled-agent-process');
|
|
50
|
+
const { normalizeExecutionModel } = require('./engine/execution-model');
|
|
45
51
|
const adoGitAuth = require('./engine/ado-git-auth');
|
|
46
52
|
const queries = require('./engine/queries');
|
|
47
53
|
const dispatchEvents = require('./engine/dispatch-events');
|
|
@@ -436,22 +442,7 @@ function _maxTurnsForType(type, engineConfig = {}) {
|
|
|
436
442
|
// ─── Runtime adapter integration (P-2a6d9c4f) ────────────────────────────────
|
|
437
443
|
|
|
438
444
|
function _buildAgentSpawnFlags(runtime, opts = {}) {
|
|
439
|
-
|
|
440
|
-
const caps = (runtime && runtime.capabilities) || {};
|
|
441
|
-
const flags = ['--runtime', String(runtime?.name || 'claude')];
|
|
442
|
-
if (opts.maxTurns != null) flags.push('--max-turns', String(opts.maxTurns));
|
|
443
|
-
if (opts.model) flags.push('--model', String(opts.model));
|
|
444
|
-
if (opts.allowedTools) flags.push('--allowedTools', String(opts.allowedTools));
|
|
445
|
-
if (caps.effortLevels && opts.effort) flags.push('--effort', String(opts.effort));
|
|
446
|
-
if (caps.sessionResume && opts.sessionId) flags.push('--resume', String(opts.sessionId));
|
|
447
|
-
if (caps.budgetCap && opts.maxBudget != null) flags.push('--max-budget-usd', String(opts.maxBudget));
|
|
448
|
-
if (caps.bareMode && opts.bare === true) flags.push('--bare');
|
|
449
|
-
if (caps.fallbackModel && opts.fallbackModel) flags.push('--fallback-model', String(opts.fallbackModel));
|
|
450
|
-
if (opts.stream != null && opts.stream !== '') flags.push('--stream', String(opts.stream));
|
|
451
|
-
if (opts.disableBuiltinMcps === true) flags.push('--disable-builtin-mcps');
|
|
452
|
-
if (opts.reasoningSummaries === true) flags.push('--enable-reasoning-summaries');
|
|
453
|
-
|
|
454
|
-
return flags;
|
|
445
|
+
return buildSpawnFlags(runtime, opts);
|
|
455
446
|
}
|
|
456
447
|
|
|
457
448
|
function _runtimeLogger() {
|
|
@@ -603,10 +594,48 @@ function captureSessionIdFromStdoutChunk(agentId, dispatchId, branchName, runtim
|
|
|
603
594
|
logger: _runtimeLogger(),
|
|
604
595
|
});
|
|
605
596
|
}
|
|
597
|
+
|
|
606
598
|
return;
|
|
607
599
|
}
|
|
608
600
|
}
|
|
609
601
|
|
|
602
|
+
function _runtimeModelFromOutputLine(runtime, line) {
|
|
603
|
+
if (!runtime || typeof runtime.parseOutput !== 'function') return null;
|
|
604
|
+
try {
|
|
605
|
+
return normalizeExecutionModel(runtime.parseOutput(String(line), { maxTextLength: 0 })?.model);
|
|
606
|
+
} catch {
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function captureExecutionModelFromStdoutChunk(dispatchItem, dispatchId, runtime, procInfo, chunk, state) {
|
|
612
|
+
if (!procInfo || procInfo.executionModel || !chunk) return null;
|
|
613
|
+
const text = String(state.modelLineBuffer || '') + String(chunk);
|
|
614
|
+
const lines = text.split('\n');
|
|
615
|
+
state.modelLineBuffer = /[\r\n]$/.test(text) ? '' : (lines.pop() || '');
|
|
616
|
+
if (state.modelLineBuffer.length > 65536) state.modelLineBuffer = '';
|
|
617
|
+
|
|
618
|
+
for (const line of lines) {
|
|
619
|
+
const model = _runtimeModelFromOutputLine(runtime, line);
|
|
620
|
+
if (!model) continue;
|
|
621
|
+
procInfo.executionModel = model;
|
|
622
|
+
dispatchItem.executionModel = model;
|
|
623
|
+
try {
|
|
624
|
+
mutateDispatch((dispatch) => {
|
|
625
|
+
for (const section of ['pending', 'active']) {
|
|
626
|
+
const item = (dispatch[section] || []).find(entry => entry.id === dispatchId);
|
|
627
|
+
if (item) item.executionModel = model;
|
|
628
|
+
}
|
|
629
|
+
return dispatch;
|
|
630
|
+
});
|
|
631
|
+
} catch (err) {
|
|
632
|
+
log('warn', `Could not persist runtime model for ${dispatchId}: ${err.message}`);
|
|
633
|
+
}
|
|
634
|
+
return model;
|
|
635
|
+
}
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
|
|
610
639
|
function mergePendingSteeringEntries(...groups) {
|
|
611
640
|
const merged = [];
|
|
612
641
|
const seen = new Set();
|
|
@@ -905,16 +934,14 @@ function _isTransientGitNetworkError(err) {
|
|
|
905
934
|
}
|
|
906
935
|
|
|
907
936
|
// Wraps shellSafeGit(['fetch', ...]) with a single retry on transient
|
|
908
|
-
// network errors.
|
|
909
|
-
//
|
|
910
|
-
//
|
|
911
|
-
//
|
|
912
|
-
// most "couldn't find remote ref" cascades downstream caused by a 0-second
|
|
913
|
-
// network blip during the initial fetch.
|
|
937
|
+
// network errors. Most existing callers keep the historical non-fatal
|
|
938
|
+
// behavior and receive false on terminal failure. Callers that cannot safely
|
|
939
|
+
// use a stale local ref (ordinary fresh-branch creation) pass throwOnFailure
|
|
940
|
+
// so the terminal git error remains available for accurate classification.
|
|
914
941
|
//
|
|
915
942
|
// `args` is the array passed to git AFTER 'fetch' (e.g. ['origin', branch]).
|
|
916
943
|
// `label` is a short string used in the log line for triage.
|
|
917
|
-
async function _fetchWithTransientRetry(args, opts, label) {
|
|
944
|
+
async function _fetchWithTransientRetry(args, opts, label, { throwOnFailure = false } = {}) {
|
|
918
945
|
const _attempt = async () => shared.shellSafeGit(['fetch', ...args], opts);
|
|
919
946
|
try {
|
|
920
947
|
await _attempt();
|
|
@@ -930,6 +957,7 @@ async function _fetchWithTransientRetry(args, opts, label) {
|
|
|
930
957
|
// incident. _redactBearer keeps any bearer header echoed in the failing
|
|
931
958
|
// git command out of engine logs.
|
|
932
959
|
log('warn', `git fetch ${label}: ${adoGitAuth.redactBearer(String(e.message || e))}`);
|
|
960
|
+
if (throwOnFailure) throw e;
|
|
933
961
|
return false; // swallow non-transient — caller falls back to local ref
|
|
934
962
|
}
|
|
935
963
|
const transientMsg = adoGitAuth.redactBearer(String(e.message || e));
|
|
@@ -941,12 +969,32 @@ async function _fetchWithTransientRetry(args, opts, label) {
|
|
|
941
969
|
return true;
|
|
942
970
|
} catch (e2) {
|
|
943
971
|
const retryMsg = adoGitAuth.redactBearer(String(e2.message || e2));
|
|
944
|
-
log('warn', `git fetch ${label}: retry also failed (${retryMsg}) — falling back to local ref`);
|
|
972
|
+
log('warn', `git fetch ${label}: retry also failed (${retryMsg})${throwOnFailure ? ' — surfacing error' : ' — falling back to local ref'}`);
|
|
973
|
+
if (throwOnFailure) throw e2;
|
|
945
974
|
return false;
|
|
946
975
|
}
|
|
947
976
|
}
|
|
948
977
|
}
|
|
949
978
|
|
|
979
|
+
function _classifyFreshBaseFetchFailure(err) {
|
|
980
|
+
return adoGitAuth.isAdoAuthFailure(err)
|
|
981
|
+
? FAILURE_CLASS.AUTH
|
|
982
|
+
: FAILURE_CLASS.NETWORK_ERROR;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
async function _probeBranchLocally(rootDir, branchName, gitOpts) {
|
|
986
|
+
try {
|
|
987
|
+
await shared.shellSafeGit(
|
|
988
|
+
['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}`],
|
|
989
|
+
{ ...gitOpts, cwd: rootDir, timeout: 10000 },
|
|
990
|
+
);
|
|
991
|
+
return true;
|
|
992
|
+
} catch (e) {
|
|
993
|
+
if (e?.code === 1) return false;
|
|
994
|
+
throw e;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
950
998
|
// W-mr3tayu4 — thin wrapper around shared.removeStaleIndexLock. The age-gated
|
|
951
999
|
// (>300000ms) stale-lock removal now lives in engine/shared.js so both the
|
|
952
1000
|
// worktree-add retry paths here and engine/live-checkout.js#prepareLiveCheckout
|
|
@@ -1430,15 +1478,15 @@ const _quarantinePathLocks = new Map();
|
|
|
1430
1478
|
// (Pure shared-branch callers pass quarantineOnUnsafe=false and DO NOT
|
|
1431
1479
|
// treat ahead-of-origin as dirty — that path's existing semantics are
|
|
1432
1480
|
// preserved. PR-targeted reused worktrees pass quarantineOnUnsafe=true
|
|
1433
|
-
// and
|
|
1434
|
-
// (#
|
|
1481
|
+
// and preserve clean strictly-ahead commits by pushing them before any
|
|
1482
|
+
// quarantine fallback (#853).)
|
|
1435
1483
|
// 3. If unsafe, decide:
|
|
1436
1484
|
// - Block (preserve dirty tree for inspection) when ANOTHER active
|
|
1437
1485
|
// dispatch claims the same branch (ownership ambiguity — NEVER
|
|
1438
1486
|
// quarantine, even when quarantineOnUnsafe=true).
|
|
1439
|
-
// -
|
|
1440
|
-
//
|
|
1441
|
-
//
|
|
1487
|
+
// - When clean local commits are strictly ahead, push them to origin.
|
|
1488
|
+
// If that push fails or the branch is genuinely diverged, quarantine:
|
|
1489
|
+
// rename worktree to <path>-quarantine-<ts>,
|
|
1442
1490
|
// prune git's stale metadata, back up the local branch HEAD to
|
|
1443
1491
|
// refs/minions/quarantine/<sanitized>/<ts>, reset
|
|
1444
1492
|
// refs/heads/<branch> → origin/<branch>, and write a notes/inbox/
|
|
@@ -1458,6 +1506,124 @@ const _quarantinePathLocks = new Map();
|
|
|
1458
1506
|
// quarantined, quarantinedPath, backupRef } so the caller can fail
|
|
1459
1507
|
// fast with a first-class DIRTY_WORKTREE / WORKTREE_DIRTY /
|
|
1460
1508
|
// WORKTREE_DIVERGENT reason and retry semantics.
|
|
1509
|
+
async function pushCleanAheadBranch({ rootDir, worktreePath, branchName, mainBranch, project, gitOpts = {} } = {}) {
|
|
1510
|
+
const result = { pushed: false, reason: null, ahead: 0, behind: 0 };
|
|
1511
|
+
if (!rootDir || !worktreePath || !branchName || !fs.existsSync(worktreePath)) {
|
|
1512
|
+
return { ...result, reason: 'invalid-worktree' };
|
|
1513
|
+
}
|
|
1514
|
+
if (mainBranch && sanitizeBranch(branchName) === sanitizeBranch(mainBranch)) {
|
|
1515
|
+
return { ...result, reason: 'main-branch' };
|
|
1516
|
+
}
|
|
1517
|
+
const { gitExtraArgs: _staleGitExtraArgs, ...networkGitOpts } = gitOpts;
|
|
1518
|
+
const runNetworkGit = (args, opts) => project
|
|
1519
|
+
? adoGitAuth.runAdoGit(project, args, { ...networkGitOpts, ...opts })
|
|
1520
|
+
: shared.shellSafeGit(args, { ...gitOpts, ...opts });
|
|
1521
|
+
try {
|
|
1522
|
+
const current = String(await shared.shellSafeGit(
|
|
1523
|
+
['symbolic-ref', '--quiet', '--short', 'HEAD'],
|
|
1524
|
+
{ ...gitOpts, cwd: worktreePath, timeout: 10000 },
|
|
1525
|
+
) || '').trim();
|
|
1526
|
+
if (current !== branchName) return { ...result, reason: 'branch-mismatch' };
|
|
1527
|
+
|
|
1528
|
+
const status = String(await shared.shellSafeGit(
|
|
1529
|
+
['--no-optional-locks', 'status', '--porcelain'],
|
|
1530
|
+
{ ...gitOpts, cwd: worktreePath, timeout: 15000 },
|
|
1531
|
+
) || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean)
|
|
1532
|
+
.filter(line => !shared.isWorktreeOwnerMarkerStatusLine(line));
|
|
1533
|
+
if (status.length > 0) return { ...result, reason: 'dirty' };
|
|
1534
|
+
|
|
1535
|
+
let remoteExists = false;
|
|
1536
|
+
let trackingRefExists = false;
|
|
1537
|
+
try {
|
|
1538
|
+
await shared.shellSafeGit(
|
|
1539
|
+
['show-ref', '--verify', '--quiet', `refs/remotes/origin/${branchName}`],
|
|
1540
|
+
{ ...gitOpts, cwd: rootDir, timeout: 10000 },
|
|
1541
|
+
);
|
|
1542
|
+
remoteExists = true;
|
|
1543
|
+
trackingRefExists = true;
|
|
1544
|
+
} catch {
|
|
1545
|
+
// A first-attempt branch may not have a local remote-tracking ref. Only
|
|
1546
|
+
// pay for ls-remote in that uncommon case; aligned dispatch closes stay
|
|
1547
|
+
// local-only and do not add a network round trip.
|
|
1548
|
+
try {
|
|
1549
|
+
const remote = String(await runNetworkGit(
|
|
1550
|
+
['ls-remote', '--exit-code', '--heads', 'origin', branchName],
|
|
1551
|
+
{ cwd: rootDir, timeout: 15000 },
|
|
1552
|
+
) || '').trim();
|
|
1553
|
+
remoteExists = !!remote;
|
|
1554
|
+
} catch (err) {
|
|
1555
|
+
if (err?.code !== 2 && err?.status !== 2) {
|
|
1556
|
+
return { ...result, reason: 'remote-probe-failed', error: err.message };
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
if (remoteExists) {
|
|
1562
|
+
if (trackingRefExists) {
|
|
1563
|
+
const localCounts = String(await shared.shellSafeGit(
|
|
1564
|
+
['rev-list', '--left-right', '--count', `refs/remotes/origin/${branchName}...HEAD`],
|
|
1565
|
+
{ ...gitOpts, cwd: worktreePath, timeout: 15000 },
|
|
1566
|
+
) || '').trim().split(/\s+/).map(Number);
|
|
1567
|
+
const locallyAhead = Number.isFinite(localCounts[1]) ? localCounts[1] : 0;
|
|
1568
|
+
if (locallyAhead === 0) {
|
|
1569
|
+
return {
|
|
1570
|
+
...result,
|
|
1571
|
+
behind: Number.isFinite(localCounts[0]) ? localCounts[0] : 0,
|
|
1572
|
+
reason: 'aligned',
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
try {
|
|
1577
|
+
await runNetworkGit(
|
|
1578
|
+
['fetch', 'origin', branchName],
|
|
1579
|
+
{ cwd: rootDir, timeout: 30000 },
|
|
1580
|
+
);
|
|
1581
|
+
} catch (err) {
|
|
1582
|
+
return { ...result, reason: 'fetch-failed', error: err.message };
|
|
1583
|
+
}
|
|
1584
|
+
const counts = String(await shared.shellSafeGit(
|
|
1585
|
+
['rev-list', '--left-right', '--count', `refs/remotes/origin/${branchName}...HEAD`],
|
|
1586
|
+
{ ...gitOpts, cwd: worktreePath, timeout: 15000 },
|
|
1587
|
+
) || '').trim().split(/\s+/).map(Number);
|
|
1588
|
+
result.behind = Number.isFinite(counts[0]) ? counts[0] : 0;
|
|
1589
|
+
result.ahead = Number.isFinite(counts[1]) ? counts[1] : 0;
|
|
1590
|
+
if (result.behind > 0) return { ...result, reason: 'diverged' };
|
|
1591
|
+
if (result.ahead === 0) return { ...result, reason: 'aligned' };
|
|
1592
|
+
} else {
|
|
1593
|
+
if (!mainBranch) return { ...result, reason: 'no-upstream-no-main' };
|
|
1594
|
+
try {
|
|
1595
|
+
result.ahead = Number(String(await shared.shellSafeGit(
|
|
1596
|
+
['rev-list', '--count', `refs/remotes/origin/${mainBranch}..HEAD`],
|
|
1597
|
+
{ ...gitOpts, cwd: worktreePath, timeout: 15000 },
|
|
1598
|
+
) || '').trim());
|
|
1599
|
+
} catch (err) {
|
|
1600
|
+
return { ...result, reason: 'main-compare-failed', error: err.message };
|
|
1601
|
+
}
|
|
1602
|
+
if (!Number.isFinite(result.ahead) || result.ahead <= 0) {
|
|
1603
|
+
return { ...result, ahead: 0, reason: 'no-local-commits' };
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
try {
|
|
1608
|
+
await runNetworkGit(
|
|
1609
|
+
['push', 'origin', `HEAD:refs/heads/${branchName}`],
|
|
1610
|
+
{ cwd: worktreePath, timeout: 30000 },
|
|
1611
|
+
);
|
|
1612
|
+
log('info', `Preserved ${result.ahead} clean local commit(s) by pushing ${branchName} to origin`);
|
|
1613
|
+
return {
|
|
1614
|
+
...result,
|
|
1615
|
+
pushed: true,
|
|
1616
|
+
reason: remoteExists ? 'pushed-ahead' : 'pushed-new-branch',
|
|
1617
|
+
};
|
|
1618
|
+
} catch (err) {
|
|
1619
|
+
log('warn', `Failed to preserve clean ahead branch ${branchName}: ${err.message}`);
|
|
1620
|
+
return { ...result, reason: 'push-failed', error: err.message };
|
|
1621
|
+
}
|
|
1622
|
+
} catch (err) {
|
|
1623
|
+
return { ...result, reason: 'probe-failed', error: err.message };
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1461
1627
|
async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, dispatchId, gitOpts = {}, opts = {}) {
|
|
1462
1628
|
const quarantineOnUnsafe = !!opts.quarantineOnUnsafe;
|
|
1463
1629
|
// W-mq1habhf: status probe timeout is now configurable so large/GVFS repos
|
|
@@ -1638,6 +1804,41 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
|
|
|
1638
1804
|
}
|
|
1639
1805
|
}
|
|
1640
1806
|
|
|
1807
|
+
// #853 — a clean branch that is strictly ahead contains completed agent
|
|
1808
|
+
// commits, not contamination. Preserve them on the PR/work branch before
|
|
1809
|
+
// quarantine can move the work to a local-only backup ref. A rejected or
|
|
1810
|
+
// non-fast-forward push falls through to the existing quarantine path.
|
|
1811
|
+
if (!filesystemDirty && quarantineOnUnsafe && (result.ahead > 0 || !upstreamKnown)) {
|
|
1812
|
+
const preservation = await pushCleanAheadBranch({
|
|
1813
|
+
rootDir,
|
|
1814
|
+
worktreePath,
|
|
1815
|
+
branchName,
|
|
1816
|
+
mainBranch: opts.mainBranch,
|
|
1817
|
+
project: opts.project,
|
|
1818
|
+
gitOpts,
|
|
1819
|
+
});
|
|
1820
|
+
const preservationProvedClean = preservation.pushed ||
|
|
1821
|
+
['aligned', 'no-local-commits'].includes(preservation.reason);
|
|
1822
|
+
if (preservationProvedClean) {
|
|
1823
|
+
result.clean = true;
|
|
1824
|
+
result.pushed = preservation.pushed;
|
|
1825
|
+
result.ahead = preservation.ahead;
|
|
1826
|
+
result.behind = preservation.behind;
|
|
1827
|
+
result.reason = preservation.reason;
|
|
1828
|
+
return result;
|
|
1829
|
+
}
|
|
1830
|
+
result.pushFailure = preservation.reason;
|
|
1831
|
+
result.pushError = preservation.error;
|
|
1832
|
+
if (preservation.reason !== 'diverged') {
|
|
1833
|
+
// A network/auth failure does not prove the branch is unsafe. Leave the
|
|
1834
|
+
// clean ahead worktree intact so a later retry can push the same commits.
|
|
1835
|
+
result.reason = preservation.reason;
|
|
1836
|
+
return result;
|
|
1837
|
+
}
|
|
1838
|
+
result.ahead = preservation.ahead;
|
|
1839
|
+
result.behind = preservation.behind;
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1641
1842
|
// 5. Unpushed-commit check — refuse to reset when local work would be lost.
|
|
1642
1843
|
// Use the ahead count we already computed (cheaper + correct vs.
|
|
1643
1844
|
// `git log @{u}..HEAD` which needs an upstream config and silently
|
|
@@ -2162,7 +2363,6 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2162
2363
|
// (large prompts are written to engine/contexts/<id>.md to keep dispatch rows
|
|
2163
2364
|
// small — see shared.sidecarDispatchPrompt / #1167).
|
|
2164
2365
|
let taskPrompt = shared.resolveDispatchPrompt(dispatchItem);
|
|
2165
|
-
const claudeConfig = config.claude || {};
|
|
2166
2366
|
const engineConfig = config.engine || {};
|
|
2167
2367
|
const startedAt = ts();
|
|
2168
2368
|
// Phase timing — raw timestamps, emitted as one structured `[spawn-timing]`
|
|
@@ -2430,6 +2630,28 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2430
2630
|
});
|
|
2431
2631
|
} catch (e) { log('warn', `spawnAgent: failed to persist tmpDir for ${id}: ${e.message}`); }
|
|
2432
2632
|
const _cleanupPromptFiles = () => { shared.removeDispatchTmpDir(dispatchTmpDir); };
|
|
2633
|
+
const _failWorktreeRemotePreparation = (remoteErr, reasonContext, unavailableContext) => {
|
|
2634
|
+
const classificationErr = remoteErr?._probeFailed && remoteErr._cause
|
|
2635
|
+
? remoteErr._cause
|
|
2636
|
+
: remoteErr;
|
|
2637
|
+
const failureClass = _classifyFreshBaseFetchFailure(classificationErr);
|
|
2638
|
+
const safeRemoteError = adoGitAuth.redactBearer(String(remoteErr?.message || remoteErr));
|
|
2639
|
+
const reason = `${failureClass.toUpperCase().replace(/-/g, '_')}: ${reasonContext}: ${safeRemoteError}`;
|
|
2640
|
+
log('error', `spawnAgent: ${reason}`);
|
|
2641
|
+
_cleanupPromptFiles();
|
|
2642
|
+
completeDispatch(
|
|
2643
|
+
id,
|
|
2644
|
+
DISPATCH_RESULT.ERROR,
|
|
2645
|
+
reason.slice(0, 800),
|
|
2646
|
+
`No worktree or agent was started. The work item remains retryable and will not count against the selected agent because ${unavailableContext}.`,
|
|
2647
|
+
{
|
|
2648
|
+
failureClass,
|
|
2649
|
+
agentRetryable: true,
|
|
2650
|
+
countAgentRetryFailure: false,
|
|
2651
|
+
},
|
|
2652
|
+
);
|
|
2653
|
+
cleanupTempAgent(agentId);
|
|
2654
|
+
};
|
|
2433
2655
|
// Convert a WORKTREE_NESTED_IN_PROJECT throw into a fail-fast non-retryable
|
|
2434
2656
|
// dispatch failure (W-mp62taw2000ubcc3). The error's `.code` is set by
|
|
2435
2657
|
// shared.assertWorktreeOutsideProject so we don't have to parse the message.
|
|
@@ -3506,7 +3728,17 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3506
3728
|
// ahead of main) — two consecutive fix dispatches errored on
|
|
3507
3729
|
// STALE_HEAD over 10 min and the engine then starved the PR for
|
|
3508
3730
|
// the cooldown window (60 min effective).
|
|
3509
|
-
|
|
3731
|
+
let _branchOnRemote;
|
|
3732
|
+
try {
|
|
3733
|
+
_branchOnRemote = await probeBranchOnRemote(rootDir, branchName, _gitOpts);
|
|
3734
|
+
} catch (probeErr) {
|
|
3735
|
+
_failWorktreeRemotePreparation(
|
|
3736
|
+
probeErr,
|
|
3737
|
+
`could not determine whether origin has branch ${branchName}`,
|
|
3738
|
+
`its required origin/${branchName} existence check could not complete`,
|
|
3739
|
+
);
|
|
3740
|
+
return null;
|
|
3741
|
+
}
|
|
3510
3742
|
|
|
3511
3743
|
if (_branchOnRemote) {
|
|
3512
3744
|
// Mirror shared-branch fetch+add (~line 1157-1159).
|
|
@@ -3556,7 +3788,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3556
3788
|
} else { throw eRemote; }
|
|
3557
3789
|
}
|
|
3558
3790
|
} else {
|
|
3559
|
-
// Branch
|
|
3791
|
+
// Branch is confirmed absent on origin — start a fresh local
|
|
3560
3792
|
// branch from origin/<mainRef>. This is the dominant path for new
|
|
3561
3793
|
// implement/decompose dispatches.
|
|
3562
3794
|
// W-mph6n4p00006ce38: mirror the pool-borrow path (~line 1110-1114)
|
|
@@ -3564,17 +3796,33 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3564
3796
|
// not the local ref. Without this, fresh-create dispatches inherit
|
|
3565
3797
|
// whatever stale local master the engine clone happens to be on
|
|
3566
3798
|
// (most painful: long-lived engine processes between restarts).
|
|
3567
|
-
//
|
|
3568
|
-
//
|
|
3569
|
-
//
|
|
3570
|
-
//
|
|
3799
|
+
// Fail closed: an ordinary brand-new branch must never inherit a
|
|
3800
|
+
// stale local mainRef when origin/<mainRef> could not be refreshed.
|
|
3801
|
+
// Existing/PR/shared branches are handled above and retain their
|
|
3802
|
+
// continuation semantics.
|
|
3803
|
+
// A local branch with no upstream is an intentional retry/WIP
|
|
3804
|
+
// continuation. Preserve the old `-b` -> "already exists" fallback
|
|
3805
|
+
// below; only truly brand-new branches require a fresh base.
|
|
3806
|
+
const _branchExistsLocally = await _probeBranchLocally(rootDir, branchName, _gitOpts);
|
|
3571
3807
|
let _freshCreateBase = mainRef;
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3808
|
+
if (!_branchExistsLocally) {
|
|
3809
|
+
try {
|
|
3810
|
+
await _fetchWithTransientRetry(
|
|
3811
|
+
['origin', mainRef],
|
|
3812
|
+
{ ..._gitOpts, cwd: rootDir, timeout: 30000 },
|
|
3813
|
+
`origin/${mainRef} (fresh-create base for ${branchName})`,
|
|
3814
|
+
{ throwOnFailure: true },
|
|
3815
|
+
);
|
|
3816
|
+
_freshCreateBase = `origin/${mainRef}`;
|
|
3817
|
+
} catch (freshBaseErr) {
|
|
3818
|
+
_failWorktreeRemotePreparation(
|
|
3819
|
+
freshBaseErr,
|
|
3820
|
+
`could not refresh origin/${mainRef} before creating brand-new branch ${branchName}`,
|
|
3821
|
+
`its required fresh origin/${mainRef} base was unavailable`,
|
|
3822
|
+
);
|
|
3823
|
+
return null;
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3578
3826
|
try {
|
|
3579
3827
|
await runWorktreeAdd(rootDir, worktreePath, ['-b', branchName, _freshCreateBase], _worktreeGitOpts, worktreeCreateRetries);
|
|
3580
3828
|
} catch (e1) {
|
|
@@ -3787,12 +4035,14 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3787
4035
|
{
|
|
3788
4036
|
quarantineOnUnsafe: true,
|
|
3789
4037
|
mainBranch: shared.resolveMainBranch(rootDir, project.mainBranch),
|
|
4038
|
+
project,
|
|
3790
4039
|
statusTimeoutMs: engineConfig.assertCleanStatusTimeoutMs ?? ENGINE_DEFAULTS.assertCleanStatusTimeoutMs,
|
|
3791
4040
|
},
|
|
3792
4041
|
);
|
|
3793
4042
|
_phaseT.dirtyReusedCheckEnd = Date.now();
|
|
3794
4043
|
if (!cleanResult.clean) {
|
|
3795
4044
|
const previewFiles = (cleanResult.dirtyFiles || []).slice(0, 5).join(', ');
|
|
4045
|
+
const isPushBlocked = ['remote-probe-failed', 'fetch-failed', 'push-failed'].includes(cleanResult.reason);
|
|
3796
4046
|
const isDivergent = (cleanResult.ahead || 0) > 0 || cleanResult.reason === 'no-upstream';
|
|
3797
4047
|
// W-mq1habhf: status-failed is a different beast than
|
|
3798
4048
|
// has-unpushed-commits / no-upstream. We couldn't even read the
|
|
@@ -3815,12 +4065,21 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3815
4065
|
// the existing _quarantineRecoveryCount cap. This stops env failures
|
|
3816
4066
|
// from burning the per-agent retry budget the way they did before.
|
|
3817
4067
|
const isQuarantineEnvBlocked = !!(cleanResult.quarantineError && !cleanResult.quarantined);
|
|
3818
|
-
const
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
4068
|
+
const isPushAuthBlocked = isPushBlocked && adoGitAuth.isAdoAuthFailure(
|
|
4069
|
+
new Error(cleanResult.pushError || ''),
|
|
4070
|
+
);
|
|
4071
|
+
let failureClassValue = FAILURE_CLASS.WORKTREE_DIRTY;
|
|
4072
|
+
let failureClassName = 'WORKTREE_DIRTY';
|
|
4073
|
+
if (isPushBlocked) {
|
|
4074
|
+
failureClassValue = isPushAuthBlocked ? FAILURE_CLASS.AUTH : FAILURE_CLASS.NETWORK_ERROR;
|
|
4075
|
+
failureClassName = isPushAuthBlocked ? 'AUTH' : 'NETWORK_ERROR';
|
|
4076
|
+
} else if (isQuarantineEnvBlocked) {
|
|
4077
|
+
failureClassValue = FAILURE_CLASS.WORKTREE_QUARANTINE_ENV_BLOCKED;
|
|
4078
|
+
failureClassName = 'WORKTREE_QUARANTINE_ENV_BLOCKED';
|
|
4079
|
+
} else if (isDivergent) {
|
|
4080
|
+
failureClassValue = FAILURE_CLASS.WORKTREE_DIVERGENT;
|
|
4081
|
+
failureClassName = 'WORKTREE_DIVERGENT';
|
|
4082
|
+
}
|
|
3824
4083
|
// #284: distinguish the ORIGINAL worktree-preflight failure from a
|
|
3825
4084
|
// failure on the automatic fresh-worktree retry. The quarantine
|
|
3826
4085
|
// auto-recovery loop (discoverFromWorkItems) flips a failed
|
|
@@ -3833,7 +4092,9 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3833
4092
|
const retryLabel = priorQuarantineRetry > 0
|
|
3834
4093
|
? ` [retry-in-fresh-worktree #${priorQuarantineRetry} also failed]`
|
|
3835
4094
|
: ' [original worktree-preflight issue]';
|
|
3836
|
-
const reasonMsg =
|
|
4095
|
+
const reasonMsg = isPushBlocked
|
|
4096
|
+
? `${failureClassName}: clean ahead worktree at ${worktreePath} could not be pushed (${cleanResult.reason}: ${cleanResult.pushError || 'unknown error'}). The worktree and local commits were preserved for retry.`
|
|
4097
|
+
: cleanResult.quarantined
|
|
3837
4098
|
? `${failureClassName}: reused worktree at ${worktreePath} was dirty/divergent (${cleanResult.reason}; ${cleanResult.ahead || 0} ahead, ${cleanResult.behind || 0} behind, ${cleanResult.dirtyFiles?.length || 0} dirty file(s)${previewFiles ? ': ' + previewFiles : ''}) — quarantined to ${cleanResult.quarantinedPath}. Next dispatch will start fresh.`
|
|
3838
4099
|
: `${failureClassName}: reused worktree at ${worktreePath} is dirty/divergent (${cleanResult.reason}; ${cleanResult.ahead || 0} ahead, ${cleanResult.behind || 0} behind, ${cleanResult.dirtyFiles?.length || 0} file(s)${previewFiles ? ': ' + previewFiles : ''}). Quarantine ${cleanResult.quarantineError ? 'errored: ' + cleanResult.quarantineError : (cleanResult.quarantineSkipped ? 'was skipped — another live dispatch claims the worktree (see notes/inbox/ engine-worktree-skip-live note).' : 'was not attempted (' + cleanResult.reason + ').')}`;
|
|
3839
4100
|
log('error', reasonMsg);
|
|
@@ -3843,7 +4104,11 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3843
4104
|
DISPATCH_RESULT.ERROR,
|
|
3844
4105
|
reasonMsg.slice(0, 500),
|
|
3845
4106
|
`Engine preflight refused to dispatch into a dirty/divergent reused worktree (#2996).${retryLabel} Reason: ${cleanResult.reason}.${cleanResult.quarantined ? ` Worktree quarantined to ${cleanResult.quarantinedPath}; backup ref ${cleanResult.backupRef || '(skipped)'}. See notes/inbox/ for recovery instructions.` : (cleanResult.quarantineSkipped ? ' Quarantine was skipped because another live dispatch claims this worktree path; this dispatch will not auto-retry until the live claimant clears.' : '')}${isQuarantineEnvBlocked ? ' Environmental quarantine failure (Windows EBUSY); WI auto-recovery loop will re-queue without bumping per-agent retry counter.' : ''}`,
|
|
3846
|
-
{
|
|
4107
|
+
{
|
|
4108
|
+
agentRetryable: (isStatusProbeFailed && cleanResult.quarantined) ||
|
|
4109
|
+
(isPushBlocked && !isPushAuthBlocked),
|
|
4110
|
+
failureClass: failureClassValue,
|
|
4111
|
+
},
|
|
3847
4112
|
);
|
|
3848
4113
|
cleanupTempAgent(agentId);
|
|
3849
4114
|
return null;
|
|
@@ -4455,11 +4720,8 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4455
4720
|
const resolvedModel = runtime.resolveModel(shared.resolveAgentModel(agentConfig, engineConfig));
|
|
4456
4721
|
const resolvedMaxBudget = shared.resolveAgentMaxBudget(agentConfig, engineConfig);
|
|
4457
4722
|
const resolvedBare = shared.resolveAgentBareMode(agentConfig, engineConfig);
|
|
4458
|
-
//
|
|
4459
|
-
//
|
|
4460
|
-
// process, when the capability + config gate allows it. Structurally
|
|
4461
|
-
// copilot-only (runtime.capabilities.acpWorkerPool) — see
|
|
4462
|
-
// shared.resolveAgentUseWorkerPool for the exact precedence chain.
|
|
4723
|
+
// Pool through the persistent ACP worker transport when the selected adapter
|
|
4724
|
+
// opts into that capability and config allows it.
|
|
4463
4725
|
const useAgentPool = shared.resolveAgentUseWorkerPool(engineConfig, runtime);
|
|
4464
4726
|
if (useAgentPool) {
|
|
4465
4727
|
// Idempotent — cheap to call on every pooled dispatch so a live config
|
|
@@ -4468,27 +4730,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4468
4730
|
agentWorkerPool.setPoolSize(shared.resolveAgentAcpPoolSize(engineConfig));
|
|
4469
4731
|
}
|
|
4470
4732
|
|
|
4471
|
-
// W-mpg6isvy000xca4d — On retry after FAILURE_CLASS.MODEL_UNAVAILABLE, swap
|
|
4472
|
-
// to the runtime-appropriate fallback model. Two paths gated on
|
|
4473
|
-
// `runtime.capabilities.fallbackModel` (no `runtime.name === ...` branches):
|
|
4474
|
-
// - Capability TRUE (Claude): the CLI's own --fallback-model handles
|
|
4475
|
-
// the swap on 429. We keep `engineConfig.claudeFallbackModel` plumbed
|
|
4476
|
-
// unconditionally via the fallbackModel opt below; no model override
|
|
4477
|
-
// needed at the engine layer.
|
|
4478
|
-
// - Capability FALSE (Copilot): no --fallback-model flag exists, so we
|
|
4479
|
-
// OVERRIDE the --model arg directly with engine.copilotFallbackModel
|
|
4480
|
-
// for this retry attempt only. The work item's _lastFailureClass is
|
|
4481
|
-
// written by dispatch.js's retry block; the next normal dispatch loop
|
|
4482
|
-
// pick-up re-reads it through meta.item.
|
|
4483
|
-
let effectiveModel = resolvedModel;
|
|
4484
4733
|
const prevFailureClass = meta?.item?._lastFailureClass || null;
|
|
4485
|
-
if (prevFailureClass === FAILURE_CLASS.MODEL_UNAVAILABLE
|
|
4486
|
-
&& runtime.capabilities?.fallbackModel === false
|
|
4487
|
-
&& engineConfig.copilotFallbackModel) {
|
|
4488
|
-
effectiveModel = engineConfig.copilotFallbackModel;
|
|
4489
|
-
log('info', `MODEL_UNAVAILABLE retry: ${runtimeName} ${id} — overriding --model to ${effectiveModel}`);
|
|
4490
|
-
}
|
|
4491
|
-
|
|
4492
4734
|
const requestedEffort = engineConfig.agentEffort || null;
|
|
4493
4735
|
|
|
4494
4736
|
let cachedSessionId = null;
|
|
@@ -4497,53 +4739,49 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4497
4739
|
agentId,
|
|
4498
4740
|
branchName,
|
|
4499
4741
|
agentsDir: AGENTS_DIR,
|
|
4500
|
-
//
|
|
4501
|
-
//
|
|
4502
|
-
// the agent died before checkpoint (W-mouugzow00068741).
|
|
4742
|
+
// Adapters may use cwd to verify that their backing session still exists
|
|
4743
|
+
// before returning a resumable ID.
|
|
4503
4744
|
cwd,
|
|
4504
4745
|
logger: _runtimeLogger(),
|
|
4505
4746
|
});
|
|
4506
4747
|
}
|
|
4507
4748
|
|
|
4508
|
-
const
|
|
4509
|
-
model:
|
|
4749
|
+
const runtimeOptions = resolveInvocationOptions(runtime, {
|
|
4750
|
+
model: resolvedModel,
|
|
4510
4751
|
maxTurns: _maxTurnsForType(type, engineConfig),
|
|
4511
|
-
allowedTools: shared.
|
|
4512
|
-
claudeConfig.allowedTools,
|
|
4513
|
-
shared.resolveAgentManifest(_manifestAgent).allowed_tools,
|
|
4514
|
-
),
|
|
4752
|
+
allowedTools: shared.resolveAgentAllowedTools(config),
|
|
4515
4753
|
effort: requestedEffort,
|
|
4516
4754
|
sessionId: cachedSessionId,
|
|
4517
4755
|
maxBudget: resolvedMaxBudget,
|
|
4518
4756
|
bare: resolvedBare,
|
|
4519
|
-
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
4757
|
+
}, {
|
|
4758
|
+
config,
|
|
4759
|
+
engineConfig,
|
|
4760
|
+
failureClass: prevFailureClass,
|
|
4523
4761
|
});
|
|
4762
|
+
runtimeOptions.allowedTools = shared.mergeManifestAllowedTools(
|
|
4763
|
+
runtimeOptions.allowedTools,
|
|
4764
|
+
shared.resolveAgentManifest(_manifestAgent).allowed_tools,
|
|
4765
|
+
);
|
|
4766
|
+
const effectiveModel = runtimeOptions.model;
|
|
4767
|
+
if (prevFailureClass === FAILURE_CLASS.MODEL_UNAVAILABLE
|
|
4768
|
+
&& effectiveModel && effectiveModel !== resolvedModel) {
|
|
4769
|
+
log('info', `MODEL_UNAVAILABLE retry: ${runtimeName} ${id} — adapter selected model ${effectiveModel}`);
|
|
4770
|
+
}
|
|
4771
|
+
const args = _buildAgentSpawnFlags(runtime, runtimeOptions);
|
|
4524
4772
|
|
|
4525
|
-
//
|
|
4526
|
-
//
|
|
4527
|
-
//
|
|
4528
|
-
// P-7d31a06b — When the runtime is Claude AND the worktree has a `.mcp.json`,
|
|
4529
|
-
// pre-warm `~/.claude.json` projects.<worktreePath>.enabledMcpjsonServers so
|
|
4530
|
-
// Claude's first call doesn't show the project-MCP trust prompt (which
|
|
4531
|
-
// --dangerously-skip-permissions silently suppresses → agent runs without the
|
|
4532
|
-
// workspace MCPs connected). Helper internally no-ops for non-Claude runtimes
|
|
4533
|
-
// and when engine.claudePreApproveWorkspaceMcps is false (no runtime.name
|
|
4534
|
-
// check at this call site, per CLAUDE.md rule). Best-effort: NEVER block dispatch.
|
|
4535
|
-
//
|
|
4773
|
+
// Let the adapter prepare any runtime-owned workspace state. Best-effort:
|
|
4774
|
+
// harness setup must never block dispatch.
|
|
4536
4775
|
try {
|
|
4537
|
-
const result =
|
|
4538
|
-
runtimeName,
|
|
4776
|
+
const result = prepareWorkspace(runtime, {
|
|
4539
4777
|
worktreePath,
|
|
4540
4778
|
homeDir: os.homedir(),
|
|
4541
4779
|
engineConfig,
|
|
4542
4780
|
mutateJsonFileLocked: shared.mutateJsonFileLocked,
|
|
4543
4781
|
});
|
|
4544
4782
|
if (result.wrote) {
|
|
4545
|
-
log('info', `
|
|
4546
|
-
} else if (result.reason !== 'no-workspace-mcp' && result.reason !== '
|
|
4783
|
+
log('info', `Runtime workspace prepared for ${id}${result.target ? ` (${result.target})` : ''}`);
|
|
4784
|
+
} else if (result.reason !== 'no-workspace-mcp' && result.reason !== 'unsupported-runtime' && result.reason !== 'no-worktree') {
|
|
4547
4785
|
log('debug', `Skipped workspace MCP pre-approval for ${id} (reason=${result.reason})`);
|
|
4548
4786
|
}
|
|
4549
4787
|
} catch (err) {
|
|
@@ -4557,7 +4795,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4557
4795
|
|
|
4558
4796
|
// Agent status is derived from dispatch state.
|
|
4559
4797
|
|
|
4560
|
-
// Spawn the
|
|
4798
|
+
// Spawn the selected harness runtime.
|
|
4561
4799
|
const childEnv = shared.cleanChildEnv();
|
|
4562
4800
|
if (completionReportPath) childEnv.MINIONS_COMPLETION_REPORT = completionReportPath;
|
|
4563
4801
|
if (completionNonce) childEnv.MINIONS_COMPLETION_NONCE = completionNonce;
|
|
@@ -4617,7 +4855,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4617
4855
|
|
|
4618
4856
|
// Spawn via wrapper script — node directly (no bash intermediary)
|
|
4619
4857
|
// spawn-agent.js handles CLAUDECODE env cleanup and claude binary resolution
|
|
4620
|
-
const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
|
|
4858
|
+
const spawnScript = runtime.spawnScript || path.join(ENGINE_DIR, 'spawn-agent.js');
|
|
4621
4859
|
|
|
4622
4860
|
const spawnArgs = [spawnScript, promptPath, sysPromptPath, ...args];
|
|
4623
4861
|
// P-1d8f0b93 — the pooled-dispatch branch further down (agentWorkerPool
|
|
@@ -4730,6 +4968,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4730
4968
|
}
|
|
4731
4969
|
proc = new PooledAgentProcess({
|
|
4732
4970
|
pool: agentWorkerPool,
|
|
4971
|
+
runtime,
|
|
4733
4972
|
dispatchId: id,
|
|
4734
4973
|
cwd,
|
|
4735
4974
|
model: effectiveModel,
|
|
@@ -4873,7 +5112,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4873
5112
|
let stdout = '';
|
|
4874
5113
|
let stderr = '';
|
|
4875
5114
|
let steeringAckStdout = '';
|
|
4876
|
-
const sessionCaptureState = { sessionLineBuffer: '' };
|
|
5115
|
+
const sessionCaptureState = { sessionLineBuffer: '', modelLineBuffer: '' };
|
|
4877
5116
|
let _trustCheckDone = false;
|
|
4878
5117
|
const _spawnTime = Date.now();
|
|
4879
5118
|
|
|
@@ -4908,6 +5147,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4908
5147
|
// Copilot emits sessionId, so use the runtime-neutral steering helper.
|
|
4909
5148
|
const procInfo = activeProcesses.get(id);
|
|
4910
5149
|
markRuntimeResumeOutputSeen(procInfo);
|
|
5150
|
+
captureExecutionModelFromStdoutChunk(dispatchItem, id, runtime, procInfo, chunk, sessionCaptureState);
|
|
4911
5151
|
captureSessionIdFromStdoutChunk(agentId, id, branchName, runtime, procInfo, chunk, sessionCaptureState);
|
|
4912
5152
|
|
|
4913
5153
|
ackPendingSteeringFiles(agentId, procInfo, chunk);
|
|
@@ -4943,6 +5183,25 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4943
5183
|
ackPendingSteeringFiles(agentId, procInfo, steeringAckStdout);
|
|
4944
5184
|
promoteCheckpointSteeringForClose(agentId, procInfo, runtime, liveOutputPath);
|
|
4945
5185
|
|
|
5186
|
+
// #853 — the child is gone, so its clean committed state is stable. Push
|
|
5187
|
+
// any strictly-ahead dispatch branch before steering resume, pool return,
|
|
5188
|
+
// quarantine, or dispatch-end GC can detach/remove the worktree.
|
|
5189
|
+
const branchPath = worktreePath || (liveMode ? project?.localPath : null);
|
|
5190
|
+
const branchRootDir = liveMode ? project?.localPath : rootDir;
|
|
5191
|
+
if (branchPath && branchRootDir && branchName && fs.existsSync(branchPath)) {
|
|
5192
|
+
const preservation = await pushCleanAheadBranch({
|
|
5193
|
+
rootDir: branchRootDir,
|
|
5194
|
+
worktreePath: branchPath,
|
|
5195
|
+
branchName,
|
|
5196
|
+
mainBranch: shared.resolveMainBranch(branchRootDir, project?.mainBranch),
|
|
5197
|
+
project,
|
|
5198
|
+
gitOpts: _gitOpts,
|
|
5199
|
+
});
|
|
5200
|
+
if (!preservation.pushed && !['aligned', 'dirty', 'no-local-commits'].includes(preservation.reason)) {
|
|
5201
|
+
log('warn', `Dispatch-close branch preservation skipped for ${id}: ${preservation.reason}`);
|
|
5202
|
+
}
|
|
5203
|
+
}
|
|
5204
|
+
|
|
4946
5205
|
// Check if this was a steering kill — re-spawn with resume
|
|
4947
5206
|
if (procInfo?._steeringMessage) {
|
|
4948
5207
|
const steerMsg = procInfo._steeringMessage;
|
|
@@ -5010,22 +5269,17 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5010
5269
|
return;
|
|
5011
5270
|
}
|
|
5012
5271
|
|
|
5013
|
-
const
|
|
5014
|
-
|
|
5272
|
+
const resumeRuntimeOptions = resolveInvocationOptions(runtime, {
|
|
5273
|
+
...runtimeOptions,
|
|
5015
5274
|
maxTurns: engineConfig?.maxTurns || ENGINE_DEFAULTS.maxTurns,
|
|
5016
|
-
allowedTools: shared.mergeManifestAllowedTools(
|
|
5017
|
-
claudeConfig?.allowedTools,
|
|
5018
|
-
shared.resolveAgentManifest(_manifestAgent).allowed_tools,
|
|
5019
|
-
),
|
|
5020
5275
|
sessionId: steerSessionId,
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
disableBuiltinMcps: engineConfig?.copilotDisableBuiltinMcps,
|
|
5026
|
-
reasoningSummaries: engineConfig?.copilotReasoningSummaries,
|
|
5276
|
+
}, {
|
|
5277
|
+
config,
|
|
5278
|
+
engineConfig,
|
|
5279
|
+
failureClass: prevFailureClass,
|
|
5027
5280
|
});
|
|
5028
|
-
|
|
5281
|
+
const resumeArgs = _buildAgentSpawnFlags(runtime, resumeRuntimeOptions);
|
|
5282
|
+
if (runtime.capabilities?.sessionResume !== true) {
|
|
5029
5283
|
log('warn', `Steering: runtime ${runtime.name} did not accept session resume — skipping for ${agentId}`);
|
|
5030
5284
|
try { fs.appendFileSync(liveOutputPath, `\n[steering-failed] Runtime ${runtime.name} does not support session resume. Message was: ${steerMsg}\n`); } catch {}
|
|
5031
5285
|
try { fs.unlinkSync(steerPromptPath); } catch {}
|
|
@@ -5035,7 +5289,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5035
5289
|
return;
|
|
5036
5290
|
}
|
|
5037
5291
|
|
|
5038
|
-
const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
|
|
5292
|
+
const spawnScript = runtime.spawnScript || path.join(ENGINE_DIR, 'spawn-agent.js');
|
|
5039
5293
|
const childEnv = shared.cleanChildEnv();
|
|
5040
5294
|
if (completionReportPath) childEnv.MINIONS_COMPLETION_REPORT = completionReportPath;
|
|
5041
5295
|
// P-d2a8f6c1: preserve the per-dispatch nonce across steering resume so
|
|
@@ -5132,6 +5386,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5132
5386
|
stderr = '';
|
|
5133
5387
|
}
|
|
5134
5388
|
sessionCaptureState.sessionLineBuffer = '';
|
|
5389
|
+
sessionCaptureState.modelLineBuffer = '';
|
|
5135
5390
|
// Re-wire stdout/stderr handlers (same as original)
|
|
5136
5391
|
resumeProc.stdout.on('data', (data) => {
|
|
5137
5392
|
const chunk = data.toString();
|
|
@@ -5159,6 +5414,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5159
5414
|
} catch { /* best-effort */ }
|
|
5160
5415
|
}
|
|
5161
5416
|
markRuntimeResumeOutputSeen(resumeInfo);
|
|
5417
|
+
captureExecutionModelFromStdoutChunk(dispatchItem, id, runtime, resumeInfo, chunk, sessionCaptureState);
|
|
5162
5418
|
captureSessionIdFromStdoutChunk(agentId, id, branchName, runtime, resumeInfo, chunk, sessionCaptureState);
|
|
5163
5419
|
ackPendingSteeringFiles(agentId, resumeInfo, chunk);
|
|
5164
5420
|
});
|
|
@@ -5272,7 +5528,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5272
5528
|
// delete clears the entry.
|
|
5273
5529
|
const expectedNonce = activeProcesses.get(id)?._completionNonce || null;
|
|
5274
5530
|
const completionNonceRequired = engineConfig.completionNonceRequired ?? ENGINE_DEFAULTS.completionNonceRequired;
|
|
5275
|
-
const { resultSummary, autoRecovered, completionContractFailure, structuredCompletion, agentReportedFailure, agentRetryable, nonceMismatch } = await runPostCompletionHooks(dispatchItem, agentId, code, stdout, config, { expectedNonce, completionNonceRequired });
|
|
5531
|
+
const { resultSummary, autoRecovered, completionContractFailure, structuredCompletion, agentReportedFailure, agentRetryable, nonceMismatch, benignNoop } = await runPostCompletionHooks(dispatchItem, agentId, code, stdout, config, { expectedNonce, completionNonceRequired });
|
|
5276
5532
|
const retryableDecision = typeof agentRetryable === 'boolean' ? agentRetryable : failureInfo.retryable;
|
|
5277
5533
|
|
|
5278
5534
|
// W-mp6k7ywi000fa33c — keep_processes acceptance gate. When the work
|
|
@@ -5678,7 +5934,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5678
5934
|
const managedSpawnHealthcheckFail = !!managedSpawnHealthcheckFailure;
|
|
5679
5935
|
const effectiveResult = (hardContractFail || nonceFail || keepProcessesAcceptanceFail || managedSpawnAcceptanceFail || managedSpawnHealthcheckFail)
|
|
5680
5936
|
? DISPATCH_RESULT.ERROR
|
|
5681
|
-
: (((code === 0 && !agentReportedFailure) || autoRecovered) ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR);
|
|
5937
|
+
: (((code === 0 && !agentReportedFailure) || autoRecovered || benignNoop) ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR);
|
|
5682
5938
|
const finalCompletionReportPath = structuredCompletion?._path || dispatchItem.meta?.completionReportPath || shared.dispatchCompletionReportPath(id);
|
|
5683
5939
|
const completionOpts = {
|
|
5684
5940
|
...(finalCompletionReportPath ? { completionReportPath: finalCompletionReportPath } : {}),
|
|
@@ -6070,6 +6326,10 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
6070
6326
|
// route output parsing through the right adapter. Also surfaces the choice
|
|
6071
6327
|
// in dispatch state for debugging multi-runtime fleets.
|
|
6072
6328
|
item.runtimeName = runtimeName;
|
|
6329
|
+
if (effectiveModel) item.requestedModel = effectiveModel;
|
|
6330
|
+
else delete item.requestedModel;
|
|
6331
|
+
if (dispatchItem.executionModel) item.executionModel = dispatchItem.executionModel;
|
|
6332
|
+
else delete item.executionModel;
|
|
6073
6333
|
// P-3f5b7d26 — persist pooled on the DISK record (activeProcesses/_pooled
|
|
6074
6334
|
// is wiped on restart); cli.js's reattach path needs this since a pooled
|
|
6075
6335
|
// ACP worker isn't spawned detached and a fresh pool cold-starts empty —
|
|
@@ -7801,6 +8061,7 @@ async function discoverFromPrs(config, project) {
|
|
|
7801
8061
|
const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
|
|
7802
8062
|
pr_id: pr.id, pr_branch: prBranch,
|
|
7803
8063
|
review_note: pr.minionsReview?.note || pr.reviewNote || 'See PR thread comments',
|
|
8064
|
+
review_finding_id: shared.prReviewFindingIdentity(pr),
|
|
7804
8065
|
}, `Fix ${pr.id}: ${pr.title || ''} — review feedback`, {
|
|
7805
8066
|
dispatchKey: key, cooldownKey: key, automationCauseKey: reviewCauseKey, source: 'pr', pr, branch: prBranch, project: projMeta,
|
|
7806
8067
|
// W-mpg58wv3 — closure-loop binding. Carries the originating minion review
|
|
@@ -8174,6 +8435,35 @@ function _buildRunnerBriefVars(item, project) {
|
|
|
8174
8435
|
/**
|
|
8175
8436
|
* Scan a project work-item scope for manually queued tasks.
|
|
8176
8437
|
*/
|
|
8438
|
+
/**
|
|
8439
|
+
* W-mrdon0pe000l045a — collect directory/file path hints for CLAUDE.md
|
|
8440
|
+
* propagation. These are the paths most relevant to a dispatch; the
|
|
8441
|
+
* CLAUDE.md-context module walks UP from each toward the project root collecting
|
|
8442
|
+
* the nearest applicable CLAUDE.md files. Sources (best-effort, cheap):
|
|
8443
|
+
* - item.meta.workdir (explicit sub-project directory)
|
|
8444
|
+
* - item.references[*].path (changed/relevant files)
|
|
8445
|
+
* Returns a de-duplicated array of repo-relative path strings. Empty when no
|
|
8446
|
+
* hints exist — the module still falls back to the project-root CLAUDE.md.
|
|
8447
|
+
*/
|
|
8448
|
+
function _deriveClaudeMdPathHints(item) {
|
|
8449
|
+
const hints = [];
|
|
8450
|
+
const seen = new Set();
|
|
8451
|
+
const add = (p) => {
|
|
8452
|
+
if (typeof p !== 'string') return;
|
|
8453
|
+
const v = p.trim();
|
|
8454
|
+
if (!v || seen.has(v)) return;
|
|
8455
|
+
seen.add(v);
|
|
8456
|
+
hints.push(v);
|
|
8457
|
+
};
|
|
8458
|
+
if (item && item.meta && typeof item.meta.workdir === 'string') add(item.meta.workdir);
|
|
8459
|
+
if (item && Array.isArray(item.references)) {
|
|
8460
|
+
for (const r of item.references) {
|
|
8461
|
+
if (r && typeof r.path === 'string') add(r.path);
|
|
8462
|
+
}
|
|
8463
|
+
}
|
|
8464
|
+
return hints;
|
|
8465
|
+
}
|
|
8466
|
+
|
|
8177
8467
|
function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, project, root, branchName, options = {}) {
|
|
8178
8468
|
const worktreePath = options.worktreePath || path.resolve(root, config.engine?.worktreeRoot || '../worktrees', `${branchName}`);
|
|
8179
8469
|
const vars = {
|
|
@@ -8327,6 +8617,14 @@ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, pr
|
|
|
8327
8617
|
// failure via the qa-session-draft-failed / qa-session-execute-failed
|
|
8328
8618
|
// path. (See playbooks/qa-session-draft.md → "Failure path" section.)
|
|
8329
8619
|
..._buildRunnerBriefVars(item, project),
|
|
8620
|
+
// W-mrdon0pe000l045a — CLAUDE.md propagation context for non-Claude runtimes.
|
|
8621
|
+
// renderPlaybook resolves the runtime capability + config flag and, when the
|
|
8622
|
+
// runtime doesn't auto-load CLAUDE.md (Copilot/Codex), bounded-discovers the
|
|
8623
|
+
// nearest applicable CLAUDE.md files walking up from these path hints toward
|
|
8624
|
+
// the project root. runtime_cli lets renderPlaybook check the adapter's
|
|
8625
|
+
// claudeMdNativeDiscovery capability without re-resolving the agent config.
|
|
8626
|
+
runtime_cli: shared.resolveAgentCli(config.agents?.[agentId] || null, config.engine),
|
|
8627
|
+
claude_md_path_hints: _deriveClaudeMdPathHints(item),
|
|
8330
8628
|
// M006 — typed handoff envelope. Set on follow-up dispatch metas by
|
|
8331
8629
|
// lifecycle.js when queuing an item after a completing agent. The
|
|
8332
8630
|
// renderPlaybook path injects a "Handoff Context" section when truthy
|
|
@@ -8571,6 +8869,15 @@ function discoverFromWorkItems(config, project) {
|
|
|
8571
8869
|
}
|
|
8572
8870
|
|
|
8573
8871
|
if (item.status !== WI_STATUS.QUEUED && item.status !== WI_STATUS.PENDING) continue;
|
|
8872
|
+
if (item._preDispatchEval?.exhausted === true
|
|
8873
|
+
&& item._preDispatchEval.description === String(item.description || '')) {
|
|
8874
|
+
if (item._pendingReason !== 'pre_dispatch_eval_rejected') {
|
|
8875
|
+
item._pendingReason = 'pre_dispatch_eval_rejected';
|
|
8876
|
+
needsWrite = true;
|
|
8877
|
+
}
|
|
8878
|
+
skipped.gated++;
|
|
8879
|
+
continue;
|
|
8880
|
+
}
|
|
8574
8881
|
|
|
8575
8882
|
// Dependency gate: skip items whose depends_on are not yet met; propagate failure
|
|
8576
8883
|
if (item.depends_on && item.depends_on.length > 0) {
|
|
@@ -11396,13 +11703,15 @@ module.exports = {
|
|
|
11396
11703
|
gitOutputToString, gitErrorOutput, classifyDepMergeFailureOutput, listUnmergedFiles, // exported for testing
|
|
11397
11704
|
buildDepConflictFixItem, deriveConflictFixKey, // exported for testing (W-mpcwojgr000a0244)
|
|
11398
11705
|
runWorktreeAdd, // exported for testing (W-mqvaxv65000m76f2 — GVFS partial-checkout behavioral test)
|
|
11399
|
-
|
|
11706
|
+
_fetchWithTransientRetry, _classifyFreshBaseFetchFailure, _probeBranchLocally, // exported for testing
|
|
11707
|
+
isWorktreeRetryableError, removeStaleIndexLock, syncReusedWorktree, pushCleanAheadBranch, assertCleanSharedWorktree, _quarantineDirtyWorktree, // exported for testing
|
|
11400
11708
|
_statusPorcelainCmd, _killGitDescendantsForWorktree, _bumpQuarantineOutcome, // exported for testing (W-mq5n1zx5)
|
|
11401
11709
|
_reapWorktreeHolders, _findTerminalWorktreeOwners, // exported for testing (W-mqila0t5 — CWD-pinned holder reap)
|
|
11402
11710
|
pruneStaleWorktreeForBranch, // exported for testing
|
|
11403
11711
|
findExistingWorktree, // exported for testing
|
|
11404
11712
|
probeBranchOnRemote, // exported for testing (W-mphnm6a1000281b8)
|
|
11405
11713
|
_maxTurnsForType, buildProjectContext, normalizeAc, _buildAgentSpawnFlags, _classifyAgentFailure, // exported for testing
|
|
11714
|
+
_runtimeModelFromOutputLine, captureExecutionModelFromStdoutChunk, // exported for testing
|
|
11406
11715
|
_tryAutoResolveLiveCheckoutWorktreeConflict, // exported for testing (W-mr28h2j2000y0de1 review — auto-resolve worktree conflicts)
|
|
11407
11716
|
_isOutputTruncated, AGENT_OUTPUT_CAP_BYTES, // P-8e4c2a17: exported for testing (OUTPUT_TRUNCATED detection)
|
|
11408
11717
|
promoteCheckpointSteeringForClose, // exported for testing
|