@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.
Files changed (54) hide show
  1. package/bin/minions.js +42 -2
  2. package/dashboard/js/refresh.js +8 -2
  3. package/dashboard/js/render-other.js +38 -0
  4. package/dashboard/js/render-work-items.js +61 -20
  5. package/dashboard/js/settings.js +7 -8
  6. package/dashboard/pages/engine.html +6 -0
  7. package/dashboard.js +110 -27
  8. package/docs/README.md +1 -0
  9. package/docs/claude-md-propagation.md +118 -0
  10. package/docs/completion-reports.md +20 -1
  11. package/docs/engine-restart.md +10 -0
  12. package/docs/runtime-adapters.md +54 -22
  13. package/docs/skills.md +2 -0
  14. package/docs/workspace-manifests.md +1 -1
  15. package/docs/worktree-lifecycle.md +23 -0
  16. package/engine/acp-transport.js +63 -35
  17. package/engine/ado-comment.js +3 -1
  18. package/engine/agent-worker-pool.js +11 -4
  19. package/engine/cc-worker-pool.js +4 -2
  20. package/engine/claude-md-context.js +195 -0
  21. package/engine/cli.js +9 -1
  22. package/engine/comment-format.js +51 -14
  23. package/engine/consolidation.js +47 -3
  24. package/engine/db/migrations/026-pre-dispatch-rejections.js +55 -0
  25. package/engine/dispatch.js +80 -23
  26. package/engine/execution-model.js +68 -0
  27. package/engine/gh-comment.js +6 -3
  28. package/engine/github.js +6 -7
  29. package/engine/lifecycle.js +195 -56
  30. package/engine/llm.js +59 -83
  31. package/engine/memory-store.js +3 -1
  32. package/engine/pipeline.js +147 -20
  33. package/engine/playbook.js +39 -6
  34. package/engine/pooled-agent-process.js +28 -26
  35. package/engine/prd-store.js +14 -6
  36. package/engine/quarantine-refs.js +33 -0
  37. package/engine/queries.js +8 -6
  38. package/engine/restart-health.js +69 -4
  39. package/engine/runtimes/claude.js +100 -25
  40. package/engine/runtimes/codex.js +19 -0
  41. package/engine/runtimes/contract.js +239 -0
  42. package/engine/runtimes/copilot.js +85 -8
  43. package/engine/runtimes/index.js +37 -9
  44. package/engine/shared.js +157 -64
  45. package/engine/spawn-agent.js +79 -113
  46. package/engine/spawn-phase-watchdog.js +8 -37
  47. package/engine/supervisor.js +72 -16
  48. package/engine/timeout.js +87 -16
  49. package/engine/untrusted-fence.js +2 -1
  50. package/engine.js +434 -125
  51. package/package.json +1 -1
  52. package/playbooks/fix.md +20 -0
  53. package/playbooks/review.md +2 -0
  54. 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 { resolveRuntime } = require('./engine/runtimes');
38
- const { assertStaleHeadOk, preApproveWorkspaceMcps } = require('./engine/spawn-agent');
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
- if (runtime && typeof runtime.buildSpawnFlags === 'function') return runtime.buildSpawnFlags(opts);
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. Preserves the pre-fix convention that fetch failures
909
- // are NON-fatal here: we log + swallow the terminal error so the caller
910
- // falls back to the local ref. The retry just gives transient errors one
911
- // shot to recover before that fallback kicks in empirically eliminates
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 DO treat ahead-of-origin as unsafe because the underlying bug
1434
- // (#2996) was a 157-commit-ahead local branch.)
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
- // - Block when local commits exist that aren't on origin/<branch>
1440
- // (would lose unpushed work). When quarantineOnUnsafe=true,
1441
- // QUARANTINE instead: rename worktree to <path>-quarantine-<ts>,
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
- const _branchOnRemote = await probeBranchOnRemote(rootDir, branchName, _gitOpts);
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 isn't on origin (or probe failed) — start a fresh local
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
- // Non-fatal: if the fetch fails (network blip, transient auth),
3568
- // fall back to local mainRef so the dispatch still progresses;
3569
- // the dep-merge phase's own fetch + the on-failure
3570
- // `git reset --hard origin/<mainRef>` recovery remain as safety nets.
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
- const _baseFetchOk = await _fetchWithTransientRetry(
3573
- ['origin', mainRef],
3574
- { ..._gitOpts, cwd: rootDir, timeout: 30000 },
3575
- `origin/${mainRef} (fresh-create base for ${branchName})`
3576
- );
3577
- if (_baseFetchOk) _freshCreateBase = `origin/${mainRef}`;
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 failureClassValue = isQuarantineEnvBlocked
3819
- ? FAILURE_CLASS.WORKTREE_QUARANTINE_ENV_BLOCKED
3820
- : (isDivergent ? FAILURE_CLASS.WORKTREE_DIVERGENT : FAILURE_CLASS.WORKTREE_DIRTY);
3821
- const failureClassName = isQuarantineEnvBlocked
3822
- ? 'WORKTREE_QUARANTINE_ENV_BLOCKED'
3823
- : (isDivergent ? 'WORKTREE_DIVERGENT' : 'WORKTREE_DIRTY');
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 = cleanResult.quarantined
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
- { agentRetryable: isStatusProbeFailed && cleanResult.quarantined, failureClass: failureClassValue },
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
- // P-1d8f0b93 pool a copilot dispatch through the persistent ACP worker
4459
- // pool (engine/agent-worker-pool.js) instead of cold-spawning a fresh CLI
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
- // Pass the working directory so the Claude adapter can probe for the
4501
- // conversation jsonl and avoid `--resume <dead-uuid>` retry loops when
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 args = _buildAgentSpawnFlags(runtime, {
4509
- model: effectiveModel,
4749
+ const runtimeOptions = resolveInvocationOptions(runtime, {
4750
+ model: resolvedModel,
4510
4751
  maxTurns: _maxTurnsForType(type, engineConfig),
4511
- allowedTools: shared.mergeManifestAllowedTools(
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
- fallbackModel: engineConfig.claudeFallbackModel,
4520
- stream: engineConfig.copilotStreamMode,
4521
- disableBuiltinMcps: engineConfig.copilotDisableBuiltinMcps,
4522
- reasoningSummaries: engineConfig.copilotReasoningSummaries,
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
- // MCP servers: agents inherit from ~/.claude.json directly as Claude Code processes.
4526
- // No --mcp-config needed avoids redundant config and ensures agents always have latest servers.
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 = preApproveWorkspaceMcps({
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', `Pre-approved ${result.servers.length} workspace MCP server(s) in ~/.claude.json for ${worktreePath}: ${result.servers.join(', ')}`);
4546
- } else if (result.reason !== 'no-workspace-mcp' && result.reason !== 'not-claude' && result.reason !== 'no-worktree') {
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 claude process
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 resumeArgs = _buildAgentSpawnFlags(runtime, {
5014
- model: effectiveModel,
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
- maxBudget: resolvedMaxBudget,
5022
- bare: resolvedBare,
5023
- fallbackModel: engineConfig?.claudeFallbackModel,
5024
- stream: engineConfig?.copilotStreamMode,
5025
- disableBuiltinMcps: engineConfig?.copilotDisableBuiltinMcps,
5026
- reasoningSummaries: engineConfig?.copilotReasoningSummaries,
5276
+ }, {
5277
+ config,
5278
+ engineConfig,
5279
+ failureClass: prevFailureClass,
5027
5280
  });
5028
- if (!resumeArgs.includes('--resume')) {
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
- isWorktreeRetryableError, removeStaleIndexLock, syncReusedWorktree, assertCleanSharedWorktree, _quarantineDirtyWorktree, // exported for testing
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