@yemi33/minions 0.1.2176 → 0.1.2178

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/engine.js CHANGED
@@ -32,7 +32,7 @@ const crypto = require('crypto');
32
32
  const shared = require('./engine/shared');
33
33
  const { exec, execAsync, execSilent, runFile, ts, ENGINE_DEFAULTS,
34
34
  WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, REVIEW_STATUS, DISPATCH_RESULT, AGENT_STATUS,
35
- FAILURE_CLASS } = shared;
35
+ FAILURE_CLASS, resolvePollFlag } = shared;
36
36
  const { resolveRuntime } = require('./engine/runtimes');
37
37
  const { assertStaleHeadOk } = require('./engine/spawn-agent');
38
38
  const adoGitAuth = require('./engine/ado-git-auth');
@@ -617,7 +617,11 @@ function promoteCheckpointSteeringForClose(agentId, procInfo, runtime, liveOutpu
617
617
 
618
618
  // Resolve dependency plan item IDs to their PR branches
619
619
  function resolveDependencyBranches(depIds, sourcePlan, project, config) {
620
- const results = []; // [{ branch, prId }]
620
+ // P-faea3206: each entry now carries projectName + projectRoot + isCrossRepo
621
+ // so spawnAgent's dep-fetch loop can route same-project deps through the
622
+ // existing fetch+merge path and cross-project deps through the advisory
623
+ // transfer-fetch path (no auto-merge, no escalation).
624
+ const results = []; // [{ branch, prId, projectName, projectRoot, isCrossRepo }]
621
625
  if (!depIds?.length) return results;
622
626
 
623
627
  const projects = shared.getProjects(config);
@@ -626,6 +630,8 @@ function resolveDependencyBranches(depIds, sourcePlan, project, config) {
626
630
  const allItems = queries.getWorkItems(config);
627
631
  const depWorkItems = allItems.filter(wi => depIds.includes(wi.id));
628
632
 
633
+ const currentProjectName = project?.name || null;
634
+
629
635
  // Find PR branches for each dependency work item
630
636
  for (const p of projects) {
631
637
  const prPath = shared.projectPrPath(p);
@@ -636,7 +642,16 @@ function resolveDependencyBranches(depIds, sourcePlan, project, config) {
636
642
  depWorkItems.find(w => w.id === id)
637
643
  );
638
644
  if (linked && !results.find(r => r.branch === pr.branch)) {
639
- results.push({ branch: pr.branch, prId: pr.id });
645
+ let depRoot = null;
646
+ try { depRoot = shared.resolveProjectRootDir(p.localPath, MINIONS_DIR); }
647
+ catch { depRoot = null; /* drive-root or missing localPath — caller falls back gracefully */ }
648
+ results.push({
649
+ branch: pr.branch,
650
+ prId: pr.id,
651
+ projectName: p.name || null,
652
+ projectRoot: depRoot,
653
+ isCrossRepo: !!currentProjectName && p.name !== currentProjectName,
654
+ });
640
655
  }
641
656
  }
642
657
  }
@@ -644,6 +659,48 @@ function resolveDependencyBranches(depIds, sourcePlan, project, config) {
644
659
  return results;
645
660
  }
646
661
 
662
+ // P-faea3206: render the "## Cross-repo dependencies" prompt section that
663
+ // gets appended to the prompt file when a dispatch has at least one
664
+ // cross-repo (advisory) dep. The section enumerates each dep's repo, branch,
665
+ // short SHA, and the changed-file list, plus the local ref
666
+ // (`refs/cross-repo-deps/<branch>`) the agent can `git show` / `git log` to
667
+ // inspect the contract without merging unrelated repo content.
668
+ //
669
+ // Returns "" when `deps` is empty/missing so callers can unconditionally
670
+ // concat the result without a separate guard.
671
+ function buildCrossRepoDepsSection(deps) {
672
+ if (!Array.isArray(deps) || deps.length === 0) return '';
673
+ const lines = [
674
+ '',
675
+ '## Cross-repo dependencies',
676
+ '',
677
+ 'The work items below are dependencies in OTHER repos. They are surfaced here as',
678
+ '*advisory* context only — the engine intentionally does NOT merge cross-repo dep',
679
+ 'branches into your worktree (that would commingle unrelated repo content). Use the',
680
+ 'local refs below to read the contract (`git show <ref>`, `git log <ref> -- <files>`)',
681
+ 'and update your code in this repo to honor it.',
682
+ '',
683
+ ];
684
+ for (const dep of deps) {
685
+ const branch = dep?.branch || '(unknown-branch)';
686
+ const repo = dep?.projectName || '(unknown-repo)';
687
+ const sha = typeof dep?.sha === 'string' && dep.sha ? dep.sha.slice(0, 12) : '';
688
+ const files = Array.isArray(dep?.files) ? dep.files.filter(Boolean) : [];
689
+ lines.push(`### ${repo} — \`${branch}\``);
690
+ if (sha) lines.push(`- Tip SHA: \`${sha}\``);
691
+ lines.push(`- Local ref: \`refs/cross-repo-deps/${branch}\``);
692
+ if (files.length > 0) {
693
+ lines.push('- Changed files:');
694
+ for (const f of files.slice(0, 50)) lines.push(` - \`${f}\``);
695
+ if (files.length > 50) lines.push(` - …and ${files.length - 50} more`);
696
+ } else {
697
+ lines.push('- Changed files: (not computed — inspect via `git log --stat`)');
698
+ }
699
+ lines.push('');
700
+ }
701
+ return lines.join('\n');
702
+ }
703
+
647
704
  /**
648
705
  * Sync an existing worktree from origin on reuse: probe with
649
706
  * `git ls-remote --exit-code --heads origin <branch>` first so that locally
@@ -1572,6 +1629,11 @@ async function spawnAgent(dispatchItem, config) {
1572
1629
  // milliseconds. Keys are stamped at phase boundaries below.
1573
1630
  const _phaseT = { start: Date.now() };
1574
1631
  let _depCountForLog = 0;
1632
+ // P-faea3206: collected info for the prompt-file "## Cross-repo dependencies"
1633
+ // section. Populated during the dep-fetch phase below; consumed once the
1634
+ // worktree is ready and the initial prompt has been written (alongside the
1635
+ // dirty-files inject). Stays an empty array on single-repo / no-dep dispatches.
1636
+ let _crossRepoDepsInfo = [];
1575
1637
 
1576
1638
  updateAgentStatus(id, AGENT_STATUS.SPAWNING, `Preparing ${type} task for ${agentId}`);
1577
1639
 
@@ -2438,7 +2500,26 @@ async function spawnAgent(dispatchItem, config) {
2438
2500
  if (depIds.length > 0) {
2439
2501
  _phaseT.depFetchStart = Date.now();
2440
2502
  try {
2441
- const depBranches = resolveDependencyBranches(depIds, meta?.item?.sourcePlan, project, config);
2503
+ const depBranchesAll = resolveDependencyBranches(depIds, meta?.item?.sourcePlan, project, config);
2504
+ // P-faea3206: partition deps into same-project (fetch + merge as
2505
+ // today) and cross-project (fetch into the dep's project rootDir,
2506
+ // transfer-fetch as refs/cross-repo-deps/<branch>, NEVER auto-merge,
2507
+ // NEVER escalate). Cross-repo deps without a resolvable projectRoot
2508
+ // (missing localPath on dep project) are dropped with a warn — the
2509
+ // engine can't honor them safely.
2510
+ const crossRepoDepsRaw = depBranchesAll.filter(d => d.isCrossRepo);
2511
+ const crossRepoDepsBranches = [];
2512
+ for (const dep of crossRepoDepsRaw) {
2513
+ if (!dep.projectRoot) {
2514
+ log('warn', `Skipping cross-repo dep ${dep.branch} (${dep.prId}) — dep project "${dep.projectName}" has no resolvable rootDir`);
2515
+ continue;
2516
+ }
2517
+ crossRepoDepsBranches.push(dep);
2518
+ }
2519
+ // The historical loop below operates ONLY on same-project deps.
2520
+ // Cross-repo deps are handled in their own block immediately after
2521
+ // the per-branch fetch loop.
2522
+ const depBranches = depBranchesAll.filter(d => !d.isCrossRepo);
2442
2523
  let depMergeFailed = false;
2443
2524
  let depConflictBranch = null; // track which dep branch caused the conflict
2444
2525
  let depConflictFiles = []; // conflicting file names parsed from git output
@@ -2542,6 +2623,69 @@ async function spawnAgent(dispatchItem, config) {
2542
2623
  depMergeFailed = true;
2543
2624
  }
2544
2625
  }
2626
+ // P-faea3206: Cross-repo dep fetch + transfer-fetch into the
2627
+ // current worktree. These deps are ADVISORY — we never auto-merge
2628
+ // them (cross-repo merges would commingle unrelated repo content),
2629
+ // never trigger the local-only push recovery, and never escalate
2630
+ // via depMergeFailed (the dispatch proceeds regardless of cross-
2631
+ // repo dep fetch outcome). The local ref `refs/cross-repo-deps/
2632
+ // <branch>` lets the agent inspect the contract via git show /
2633
+ // git log without touching the working tree.
2634
+ for (const dep of crossRepoDepsBranches) {
2635
+ const depBranch = dep.branch;
2636
+ const depProjectRoot = dep.projectRoot;
2637
+ try {
2638
+ // 1. Fetch the dep branch into the DEP project's rootDir
2639
+ // (the current project's origin doesn't carry that ref).
2640
+ await shared.shellSafeGit(['fetch', 'origin', depBranch], { ..._gitOpts, cwd: depProjectRoot });
2641
+ // 2. Transfer the tip into the current worktree via a
2642
+ // local-path fetch — git supports filesystem-path remotes.
2643
+ // Stamp it under refs/cross-repo-deps/<branch> so it's
2644
+ // namespaced away from refs/heads / refs/remotes and
2645
+ // cannot accidentally be checked out / pushed.
2646
+ const refSpec = `${depBranch}:refs/cross-repo-deps/${depBranch}`;
2647
+ await shared.shellSafeGit(['fetch', depProjectRoot, refSpec], { ..._gitOpts, cwd: worktreePath });
2648
+ // 3. Resolve the tip SHA + a short changed-file list (vs the
2649
+ // dep project's main) for the prompt section. These are
2650
+ // best-effort — failure here downgrades to an empty list
2651
+ // rather than failing the whole advisory block.
2652
+ let depSha = '';
2653
+ try {
2654
+ depSha = gitOutputToString(await shared.shellSafeGit(
2655
+ ['rev-parse', `refs/cross-repo-deps/${depBranch}`],
2656
+ { ..._gitOpts, cwd: worktreePath }
2657
+ )).trim();
2658
+ } catch (_) { /* best-effort */ }
2659
+ let depFiles = [];
2660
+ try {
2661
+ const depMainRef = sanitizeBranch(shared.resolveMainBranch(depProjectRoot, null));
2662
+ const diffOut = gitOutputToString(await shared.shellSafeGit(
2663
+ ['diff', '--name-only', `origin/${depMainRef}...${depBranch}`],
2664
+ { ..._gitOpts, cwd: depProjectRoot }
2665
+ ));
2666
+ depFiles = diffOut.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
2667
+ } catch (_) { /* best-effort */ }
2668
+ _crossRepoDepsInfo.push({
2669
+ branch: depBranch,
2670
+ projectName: dep.projectName,
2671
+ sha: depSha,
2672
+ files: depFiles,
2673
+ });
2674
+ log('info', `Cross-repo dep ${dep.projectName}:${depBranch} transferred as refs/cross-repo-deps/${depBranch}${depFiles.length ? ` (${depFiles.length} changed file(s))` : ''}`);
2675
+ } catch (crossErr) {
2676
+ // ADVISORY: log and move on. No depMergeFailed flip, no
2677
+ // recovery push, no failure-class escalation. Surface the
2678
+ // miss to the agent prompt anyway so it knows the dep was
2679
+ // attempted (with files: []) — better than silent omission.
2680
+ log('warn', `Cross-repo dep ${dep.projectName}:${depBranch} fetch failed (advisory — dispatch proceeds): ${crossErr.message}`);
2681
+ _crossRepoDepsInfo.push({
2682
+ branch: depBranch,
2683
+ projectName: dep.projectName,
2684
+ sha: '',
2685
+ files: [],
2686
+ });
2687
+ }
2688
+ }
2545
2689
  _phaseT.depFetchEnd = Date.now();
2546
2690
  _phaseT.depPreflightStart = _phaseT.depFetchEnd;
2547
2691
  // Merge successfully-fetched + recovered (local-only pushed) branches sequentially
@@ -2864,6 +3008,20 @@ async function spawnAgent(dispatchItem, config) {
2864
3008
  _phaseT.dirtyProbeEnd = Date.now();
2865
3009
  }
2866
3010
 
3011
+ // P-faea3206: Append a "## Cross-repo dependencies" section to the prompt
3012
+ // file once the dep-fetch phase has populated _crossRepoDepsInfo. Lands
3013
+ // AFTER the shared-branch prompt refresh and the dirty-files inject so the
3014
+ // section is preserved regardless of which earlier write path ran. The
3015
+ // section is advisory: cross-repo deps were fetched into refs/cross-repo-
3016
+ // deps/<branch> but NOT merged into the working tree.
3017
+ if (_crossRepoDepsInfo.length > 0) {
3018
+ const crossSection = buildCrossRepoDepsSection(_crossRepoDepsInfo);
3019
+ if (crossSection) {
3020
+ try { fs.appendFileSync(promptPath, crossSection); } catch (e) { log('warn', `cross-repo deps inject: ${e.message}`); }
3021
+ log('info', `Injected ${_crossRepoDepsInfo.length} cross-repo dep(s) into prompt for ${id}`);
3022
+ }
3023
+ }
3024
+
2867
3025
  // Safety check: warn if a write-capable task is running in the main repo without a worktree
2868
3026
  if (cwd === rootDir && ['implement', 'implement:large', 'fix', 'test', 'verify', 'plan-to-prd'].includes(type)) {
2869
3027
  log('warn', `Agent ${agentId} running ${type} task in main repo (no worktree) for ${id} — changes may land on master directly`);
@@ -2917,6 +3075,12 @@ async function spawnAgent(dispatchItem, config) {
2917
3075
  const resolvedModel = runtime.resolveModel(shared.resolveAgentModel(agentConfig, engineConfig));
2918
3076
  const resolvedMaxBudget = shared.resolveAgentMaxBudget(agentConfig, engineConfig);
2919
3077
  const resolvedBare = shared.resolveAgentBareMode(agentConfig, engineConfig);
3078
+ // P-mcp-storm (opg#134 backport) — user-scope MCP servers to keep out of
3079
+ // this agent dispatch. Copilot loads ~/.copilot/mcp-config.json
3080
+ // unconditionally; the adapter re-emits these as --disable-mcp-server <name>.
3081
+ // Other runtimes ignore the opt (their buildSpawnFlags don't read it), so no
3082
+ // runtime.name branch here.
3083
+ const resolvedDisabledMcpServers = shared.resolveCopilotAgentDisabledMcpServers(agentConfig, engineConfig);
2920
3084
 
2921
3085
  // W-mpg6isvy000xca4d — On retry after FAILURE_CLASS.MODEL_UNAVAILABLE, swap
2922
3086
  // to the runtime-appropriate fallback model. Two paths gated on
@@ -2971,6 +3135,7 @@ async function spawnAgent(dispatchItem, config) {
2971
3135
  disableBuiltinMcps: engineConfig.copilotDisableBuiltinMcps,
2972
3136
  suppressAgentsMd: engineConfig.copilotSuppressAgentsMd,
2973
3137
  reasoningSummaries: engineConfig.copilotReasoningSummaries,
3138
+ disabledMcpServers: resolvedDisabledMcpServers,
2974
3139
  });
2975
3140
 
2976
3141
  // MCP servers: agents inherit from ~/.claude.json directly as Claude Code processes.
@@ -3365,6 +3530,7 @@ async function spawnAgent(dispatchItem, config) {
3365
3530
  disableBuiltinMcps: engineConfig?.copilotDisableBuiltinMcps,
3366
3531
  suppressAgentsMd: engineConfig?.copilotSuppressAgentsMd,
3367
3532
  reasoningSummaries: engineConfig?.copilotReasoningSummaries,
3533
+ disabledMcpServers: resolvedDisabledMcpServers,
3368
3534
  });
3369
3535
  if (!resumeArgs.includes('--resume')) {
3370
3536
  log('warn', `Steering: runtime ${runtime.name} did not accept session resume — skipping for ${agentId}`);
@@ -5163,21 +5329,43 @@ function materializePlansAsWorkItems(config) {
5163
5329
  if (totalCreated > 0) {
5164
5330
  log('info', `Plan discovery: ${totalCreated} total item(s) from ${file} across ${itemsByProject.size} project(s)`);
5165
5331
 
5166
- // Pre-create shared feature branch if branch_strategy is shared-branch
5332
+ // Pre-create shared feature branch in every touched project (P-1c0f5e84).
5333
+ // Cross-repo plans materialize work items into multiple projects; each
5334
+ // one needs the shared branch created off its own main and pushed to
5335
+ // its own origin so the dispatcher can later `git worktree add ... origin/<branch>`
5336
+ // per project. Idempotent — `git rev-parse --verify` skips the create
5337
+ // when the branch already exists locally, and `git push -u origin` is
5338
+ // a no-op when the remote already has the same tip.
5167
5339
  if (plan.branch_strategy === 'shared-branch' && plan.feature_branch) {
5168
- try {
5169
- const firstProject = itemsByProject.values().next().value?.project;
5170
- if (!firstProject?.localPath) throw new Error('no project with localPath');
5171
- const root = path.resolve(firstProject.localPath);
5172
- const mainBranch = shared.resolveMainBranch(root, firstProject.mainBranch);
5173
- const branch = sanitizeBranch(plan.feature_branch);
5174
- // Create branch from main (idempotent — ignores if exists)
5175
- // P-a7c4d2e8 (F3): argv-form (shell:false) replaces shell-piped exec.
5176
- try { shared.shellSafeGitSync(['branch', branch, mainBranch], { cwd: root }); } catch { /* idempotent — branch may already exist */ }
5177
- try { shared.shellSafeGitSync(['push', '-u', 'origin', branch], { cwd: root }); } catch (e) { log('warn', `git push -u origin ${branch} (pre-create): ${e.message?.split('\n')[0]}`); }
5178
- log('info', `Shared branch pre-created: ${branch} for plan ${file}`);
5179
- } catch (err) {
5180
- log('warn', `Failed to pre-create shared branch for ${file}: ${err.message}`);
5340
+ const branch = sanitizeBranch(plan.feature_branch);
5341
+ for (const [projName, { project: itProject }] of itemsByProject) {
5342
+ // Skip the _central bucket — those items have no project root.
5343
+ if (!itProject?.localPath) continue;
5344
+ try {
5345
+ const root = path.resolve(itProject.localPath);
5346
+ const mainBranch = shared.resolveMainBranch(root, itProject.mainBranch);
5347
+ // Idempotency guard: rev-parse --verify exits 0 when the branch
5348
+ // already exists locally. Only attempt the branch-create when
5349
+ // it returns a nonzero exit (caught below).
5350
+ let branchExists = false;
5351
+ try {
5352
+ shared.shellSafeGitSync(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], { cwd: root });
5353
+ branchExists = true;
5354
+ } catch { /* branch does not exist — fall through to create */ }
5355
+ if (!branchExists) {
5356
+ try { shared.shellSafeGitSync(['branch', branch, mainBranch], { cwd: root }); }
5357
+ catch (e) { log('warn', `Shared branch ${branch} create in ${projName} skipped: ${e.message?.split('\n')[0]}`); }
5358
+ }
5359
+ // Always push — no-op when origin already has the tip; surfaces
5360
+ // the new branch when it was just created.
5361
+ try { shared.shellSafeGitSync(['push', '-u', 'origin', branch], { cwd: root }); }
5362
+ catch (e) { log('warn', `git push -u origin ${branch} (pre-create in ${projName}): ${e.message?.split('\n')[0]}`); }
5363
+ log('info', `Shared branch pre-created: ${branch} in ${projName} for plan ${file}`);
5364
+ } catch (err) {
5365
+ // Per-project failure isolation — one project's broken
5366
+ // localPath / git config must not block the others.
5367
+ log('warn', `Failed to pre-create shared branch ${branch} in ${projName} for ${file}: ${err.message}`);
5368
+ }
5181
5369
  }
5182
5370
  }
5183
5371
 
@@ -5415,6 +5603,44 @@ function isPrAutomationCauseHandledOrPending(project, pr, causeKey) {
5415
5603
  }
5416
5604
 
5417
5605
 
5606
+ /**
5607
+ * P-a1f3c2d4 — `engine.pollingPaused` is a hard-stop kill-switch that fires
5608
+ * every tick when ON. Without this guard the engine would emit an info log on
5609
+ * every tick (~6/min) while paused, drowning the rest of the log. We log once
5610
+ * on the transition `unpaused → paused`, then stay quiet until the operator
5611
+ * flips it back off (transition `paused → unpaused` is silent — the engine's
5612
+ * routine "PR status poll" log entries resume on their own and visibly signal
5613
+ * the unpause).
5614
+ */
5615
+ let _pollingPausedLastState = false;
5616
+ function _shouldLogPollingPausedOnce() {
5617
+ if (_pollingPausedLastState) return false;
5618
+ _pollingPausedLastState = true;
5619
+ return true;
5620
+ }
5621
+ function _resetPollingPausedLogState() {
5622
+ // Only flips back to "log next time we see paused=true" when we observe
5623
+ // paused=false. Called from the polling-gate resolution site below
5624
+ // whenever pollingPaused is currently false.
5625
+ _pollingPausedLastState = false;
5626
+ }
5627
+
5628
+ /**
5629
+ * P-b2e5d8c7 — `engine.autoFixPaused` mirrors the pollingPaused log-throttle
5630
+ * pattern above. Same once-per-transition contract: log on unpaused → paused,
5631
+ * stay quiet while paused, silent on paused → unpaused (routine discovery
5632
+ * resumes and signals the unpause).
5633
+ */
5634
+ let _autoFixPausedLastState = false;
5635
+ function _shouldLogAutoFixPausedOnce() {
5636
+ if (_autoFixPausedLastState) return false;
5637
+ _autoFixPausedLastState = true;
5638
+ return true;
5639
+ }
5640
+ function _resetAutoFixPausedLogState() {
5641
+ _autoFixPausedLastState = false;
5642
+ }
5643
+
5418
5644
  // Tracks per-process which silent-discovery warnings have already been logged
5419
5645
  // so we don't spam the log every tick. Cleared on process exit (no need to
5420
5646
  // persist — the warning is for the operator at engine startup/run time).
@@ -5467,17 +5693,31 @@ async function discoverFromPrs(config, project) {
5467
5693
  const newWork = [];
5468
5694
 
5469
5695
  const projMeta = { name: project?.name, localPath: project?.localPath };
5470
- // Resolve poll-enabled per project — stale reviewStatus is untrustworthy without poller
5696
+ // Resolve poll-enabled per project — stale reviewStatus is untrustworthy without poller.
5697
+ // P-a1f3c2d4: engine.pollingPaused is a hard-stop master switch that wins over
5698
+ // both adoPollEnabled and ghPollEnabled. Forcing pollEnabled=false here
5699
+ // suppresses every per-PR auto-dispatch gate below (reviewEnabled,
5700
+ // autoFixHumanComments, autoFixReviewFeedback, autoFixBuilds, autoFixConflicts)
5701
+ // without us having to add a check at each call site.
5702
+ const pollingPaused = config.engine?.pollingPaused === true;
5471
5703
  const isAdoProject = project?.repoHost !== 'github';
5472
- const pollEnabled = isAdoProject
5704
+ const pollEnabled = !pollingPaused && (isAdoProject
5473
5705
  ? (config.engine?.adoPollEnabled ?? ENGINE_DEFAULTS.adoPollEnabled)
5474
- : (config.engine?.ghPollEnabled ?? ENGINE_DEFAULTS.ghPollEnabled);
5706
+ : (config.engine?.ghPollEnabled ?? ENGINE_DEFAULTS.ghPollEnabled));
5475
5707
  const evalLoopEnabled = config.engine?.evalLoop !== false;
5476
5708
  const fixThrottled = isAdoProject ? isAdoThrottled() : isGhThrottled();
5477
5709
  const autoReviewPrs = config.engine?.autoReviewPrs ?? ENGINE_DEFAULTS.autoReviewPrs;
5478
5710
  const autoReReviewPrs = config.engine?.autoReReviewPrs ?? ENGINE_DEFAULTS.autoReReviewPrs;
5479
- const autoFixReviewFeedback = config.engine?.autoFixReviewFeedback ?? ENGINE_DEFAULTS.autoFixReviewFeedback;
5480
- const autoFixHumanComments = config.engine?.autoFixHumanComments ?? ENGINE_DEFAULTS.autoFixHumanComments;
5711
+ // P-b2e5d8c7: engine.autoFixPaused is a hard-stop master switch over every
5712
+ // auto-fix gate. Composing `!autoFixPaused && …` into each autoFix* gate
5713
+ // here makes the kill-switch single-point: every downstream
5714
+ // `pollEnabled && autoFix* && …` site inherits the gate without us touching
5715
+ // the per-cause dispatch blocks. Review / re-review dispatch (autoReviewPrs
5716
+ // / autoReReviewPrs) is intentionally NOT gated — operators can pause the
5717
+ // fix-storm during an incident while still seeing fresh review verdicts.
5718
+ const autoFixPaused = config.engine?.autoFixPaused === true;
5719
+ const autoFixReviewFeedback = !autoFixPaused && (config.engine?.autoFixReviewFeedback ?? ENGINE_DEFAULTS.autoFixReviewFeedback);
5720
+ const autoFixHumanComments = !autoFixPaused && (config.engine?.autoFixHumanComments ?? ENGINE_DEFAULTS.autoFixHumanComments);
5481
5721
 
5482
5722
  // Collect active PR dispatches to prevent simultaneous review+fix on same PR
5483
5723
  const dispatch = getDispatch();
@@ -5861,7 +6101,7 @@ async function discoverFromPrs(config, project) {
5861
6101
  const gracePeriodMs = config.engine?.buildFixGracePeriod ?? ENGINE_DEFAULTS.buildFixGracePeriod;
5862
6102
  if (Date.now() - new Date(pr._buildFixPushedAt).getTime() < gracePeriodMs) continue;
5863
6103
  }
5864
- const autoFixBuilds = config.engine?.autoFixBuilds ?? ENGINE_DEFAULTS.autoFixBuilds;
6104
+ const autoFixBuilds = !autoFixPaused && (config.engine?.autoFixBuilds ?? ENGINE_DEFAULTS.autoFixBuilds);
5865
6105
  if (pollEnabled && autoFixBuilds && pr.status === PR_STATUS.ACTIVE && pr.buildStatus === 'failing'
5866
6106
  && !fixDispatched
5867
6107
  && !isPrNoOpFixCauseSuppressed(pr, shared.PR_FIX_CAUSE.BUILD_FAILURE)) {
@@ -6022,7 +6262,7 @@ async function discoverFromPrs(config, project) {
6022
6262
  }
6023
6263
 
6024
6264
  // PRs with merge conflicts — dispatch fix to resolve (gated by provider polling + autoFixConflicts)
6025
- const autoFixConflicts = config.engine?.autoFixConflicts ?? ENGINE_DEFAULTS.autoFixConflicts;
6265
+ const autoFixConflicts = !autoFixPaused && (config.engine?.autoFixConflicts ?? ENGINE_DEFAULTS.autoFixConflicts);
6026
6266
  if (pollEnabled && autoFixConflicts && pr.status === PR_STATUS.ACTIVE && pr._mergeConflict && !fixDispatched
6027
6267
  && !isPrNoOpFixCauseSuppressed(pr, shared.PR_FIX_CAUSE.MERGE_CONFLICT)) {
6028
6268
  // W-mpritzcr0004afc5 (#2955): "don't fan out a parallel conflict-fix
@@ -7342,15 +7582,42 @@ function discoverCentralWorkItems(config) {
7342
7582
  let planFileContent = null;
7343
7583
  let planReadError = null;
7344
7584
  let declaredPlanProject = '';
7585
+ let planTargetProjects = [];
7345
7586
  if (workType === WORK_TYPE.PLAN_TO_PRD && item.planFile) {
7346
7587
  const planPath = path.join(PLANS_DIR, item.planFile);
7347
7588
  try {
7348
7589
  planFileContent = fs.readFileSync(planPath, 'utf8');
7349
7590
  declaredPlanProject = shared.extractPlanDeclaredProject(planFileContent);
7591
+ // P-9af2eb37 — also parse the cross-repo target list from the same
7592
+ // plan content. extractPlanTargetProjects (P-7a3f1c08) returns
7593
+ // [] when the plan has no <!-- minions:targetProjects=… --> marker
7594
+ // and no **Projects:** line, so single-project plans are a no-op.
7595
+ planTargetProjects = shared.extractPlanTargetProjects(planFileContent);
7350
7596
  } catch (e) {
7351
7597
  planReadError = e;
7352
7598
  }
7353
7599
  }
7600
+ // P-9af2eb37 — a cross-repo plan-to-prd has NO singular **Project:**
7601
+ // header (declaredPlanProject === '') but ≥2 entries in the parsed
7602
+ // target list. When that happens, attach the dispatch to the first
7603
+ // listed project (read-only worktree, no mutation, no collision risk)
7604
+ // so vars.project_path resolves and worktree-requiring carve-outs
7605
+ // behave. The cross-repo case is signaled to the playbook via
7606
+ // vars.target_projects (set further down in the var-injection block).
7607
+ const isCrossRepoPlan = workType === WORK_TYPE.PLAN_TO_PRD
7608
+ && !declaredPlanProject
7609
+ && Array.isArray(planTargetProjects)
7610
+ && planTargetProjects.length >= 2;
7611
+ let crossRepoFallbackProject = null;
7612
+ let crossRepoFallbackError = null;
7613
+ if (isCrossRepoPlan) {
7614
+ const firstResolution = shared.resolveConfiguredProject(planTargetProjects[0], projects);
7615
+ if (firstResolution.project) {
7616
+ crossRepoFallbackProject = firstResolution.project;
7617
+ } else if (firstResolution.error) {
7618
+ crossRepoFallbackError = firstResolution.error;
7619
+ }
7620
+ }
7354
7621
  const requestedProjectResolution = declaredPlanProject
7355
7622
  ? shared.resolveConfiguredProject(declaredPlanProject, projects)
7356
7623
  : itemProjectResolution;
@@ -7365,7 +7632,22 @@ function discoverCentralWorkItems(config) {
7365
7632
  log('warn', `central work item ${item.id}: ${error}`);
7366
7633
  continue;
7367
7634
  }
7368
- const targetProject = requestedProjectResolution.project || (projects.length === 1 ? projects[0] : null);
7635
+ if (isCrossRepoPlan && crossRepoFallbackError && !crossRepoFallbackProject) {
7636
+ // First listed cross-repo target doesn't match any configured project
7637
+ // — fail with a clear reason instead of silently degrading to a
7638
+ // null targetProject and an empty vars.project_path.
7639
+ mutations.set(item.id, {
7640
+ status: WI_STATUS.FAILED,
7641
+ failReason: crossRepoFallbackError,
7642
+ failedAt: ts(),
7643
+ _crossRepoTargetProjects: planTargetProjects.slice(),
7644
+ });
7645
+ log('warn', `central work item ${item.id} (cross-repo plan-to-prd): ${crossRepoFallbackError}`);
7646
+ continue;
7647
+ }
7648
+ const targetProject = requestedProjectResolution.project
7649
+ || crossRepoFallbackProject
7650
+ || (projects.length === 1 ? projects[0] : null);
7369
7651
  if (declaredPlanProject) {
7370
7652
  const projectMutation = { project: targetProject.name, _declaredPlanProject: declaredPlanProject };
7371
7653
  mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, projectMutation));
@@ -7442,6 +7724,16 @@ function discoverCentralWorkItems(config) {
7442
7724
  // Notes already populated by buildWorkItemDispatchVars — no need to re-read
7443
7725
  // Track expected plan filename for artifacts and follow-up plan-to-prd prompts.
7444
7726
  mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, { _planFileName: planFileName }));
7727
+ // P-4d6e2af3 — surface cross-repo intent to the plan playbook.
7728
+ // dashboard.js#buildPlanWorkItem (~line 759) sets item._targetProjects
7729
+ // to a string[] when the operator picked ≥2 projects in the Create-
7730
+ // Plan modal. The playbook ({{target_projects}}, gated by P-c1f87a92)
7731
+ // renders a plural "Projects:" header + minions:targetProjects=… marker
7732
+ // so plan-to-prd can route each PRD item per-project. Single-project
7733
+ // plans leave the var unset; PLAYBOOK_OPTIONAL_VARS defaults it to ''.
7734
+ if (Array.isArray(item._targetProjects) && item._targetProjects.length > 0) {
7735
+ vars.target_projects = item._targetProjects.join(', ');
7736
+ }
7445
7737
  }
7446
7738
 
7447
7739
  // Inject plan-to-prd variables — read the plan file content for the playbook
@@ -7458,6 +7750,23 @@ function discoverCentralWorkItems(config) {
7458
7750
  vars.plan_file = item.planFile || '';
7459
7751
  vars.project_name_lower = (targetProject?.name || 'project').toLowerCase();
7460
7752
  vars.project_filename_slug = safePrdProjectSlug(targetProject?.name || 'project');
7753
+ // P-9af2eb37 — cross-repo override. When the plan has NO singular
7754
+ // **Project:** header but ≥2 entries in the parsed target list,
7755
+ // (a) the per-project slug from safePrdProjectSlug would collide
7756
+ // across sibling cross-repo PRDs dispatched on the same day
7757
+ // (every cross-repo PRD on `minions-opg`-as-fallback would
7758
+ // resolve to `minions-opg-<date>.json`); use `cross-<uid>-…`
7759
+ // instead so each cross-repo PRD gets a unique filename and
7760
+ // the disambiguation in the existing prdExisting loop below
7761
+ // never has to disambiguate cross-repo siblings.
7762
+ // (b) surface the parsed list to the plan-to-prd.md playbook via
7763
+ // vars.target_projects so the agent can route each PRD item to
7764
+ // the right repo (PLAYBOOK_OPTIONAL_VARS already includes
7765
+ // target_projects — set in P-4d6e2af3 for the plan playbook).
7766
+ if (isCrossRepoPlan) {
7767
+ vars.project_filename_slug = 'cross-' + shared.uid();
7768
+ vars.target_projects = planTargetProjects.join(', ');
7769
+ }
7461
7770
  // Default empty string so the {{existing_prd_json}} token always resolves —
7462
7771
  // playbook treats empty as "no existing PRD, fresh run". Without this default
7463
7772
  // the renderPlaybook pass logs an "unresolved template variables" warning
@@ -7603,91 +7912,116 @@ async function discoverWork(config) {
7603
7912
 
7604
7913
  // Side-effect passes: materialize plans and design docs into work-items.json
7605
7914
  // These write to project work queues — picked up by discoverFromWorkItems below.
7606
- reconcilePrdStatuses(config); // Backward-scan: correct "missing" PRD items that have done work items (#929)
7607
- materializePlansAsWorkItems(config);
7915
+ // Gated by config.engine?.planMaterializationEnabled !== false (P-d6f0a2b5)
7916
+ // operators can suppress the PRD reconcile + plan materialization pair
7917
+ // without disabling the rest of discoverWork (e.g. when running a long
7918
+ // migration that must not auto-create work items).
7919
+ if (config.engine?.planMaterializationEnabled !== false) {
7920
+ reconcilePrdStatuses(config); // Backward-scan: correct "missing" PRD items that have done work items (#929)
7921
+ materializePlansAsWorkItems(config);
7922
+ }
7608
7923
 
7609
7924
  for (const project of projects) {
7610
7925
  const root = project.localPath ? path.resolve(project.localPath) : null;
7611
7926
  if (!root || !fs.existsSync(root)) continue;
7612
7927
 
7613
7928
  // Source 1: Pull Requests → fixes, reviews, build-test
7614
- const prWork = await discoverFromPrs(config, project);
7615
- allFixes.push(...prWork.filter(w => w.type === WORK_TYPE.FIX));
7616
- allReviews.push(...prWork.filter(w => w.type === WORK_TYPE.REVIEW));
7617
- allWorkItems.push(...prWork.filter(w => w.type === WORK_TYPE.TEST));
7929
+ // Gated by config.engine?.prDiscoveryEnabled !== false (P-d6f0a2b5)
7930
+ // per-project project.workSources.pullRequests.enabled still composes
7931
+ // inside discoverFromPrs; a `false` at either level skips the call.
7932
+ if (config.engine?.prDiscoveryEnabled !== false) {
7933
+ const prWork = await discoverFromPrs(config, project);
7934
+ allFixes.push(...prWork.filter(w => w.type === WORK_TYPE.FIX));
7935
+ allReviews.push(...prWork.filter(w => w.type === WORK_TYPE.REVIEW));
7936
+ allWorkItems.push(...prWork.filter(w => w.type === WORK_TYPE.TEST));
7937
+ }
7618
7938
 
7619
7939
  // Side-effect: specs → work items (picked up below)
7620
7940
  materializeSpecsAsWorkItems(config, project);
7621
7941
 
7622
7942
  // Source 3: Work items (includes auto-filed from plans, design docs, build failures)
7623
- allWorkItems.push(...discoverFromWorkItems(config, project));
7943
+ // Gated by config.engine?.workItemsDiscoveryEnabled !== false (P-d6f0a2b5)
7944
+ // per-project project.workSources.workItems.enabled still composes
7945
+ // inside discoverFromWorkItems; a `false` at either level skips the call.
7946
+ if (config.engine?.workItemsDiscoveryEnabled !== false) {
7947
+ allWorkItems.push(...discoverFromWorkItems(config, project));
7948
+ }
7624
7949
  }
7625
7950
 
7626
7951
  // Source 2: Minions-level PRD → implements (multi-project, called once outside project loop)
7627
7952
  // PRD items (prd/*.json), materialized from plans/*.md, flow through materializePlansAsWorkItems → discoverFromWorkItems
7628
7953
 
7629
7954
  // Central work items (project-agnostic — agent decides where to work)
7630
- const centralWork = discoverCentralWorkItems(config);
7955
+ // Gated by config.engine?.centralWorkDiscoveryEnabled !== false (P-d6f0a2b5)
7956
+ // `let centralWork = []` keeps the downstream `...centralWork` spread
7957
+ // iterable when the gate is off.
7958
+ let centralWork = [];
7959
+ if (config.engine?.centralWorkDiscoveryEnabled !== false) {
7960
+ centralWork = discoverCentralWorkItems(config);
7961
+ }
7631
7962
 
7632
7963
  // Scheduled tasks (cron-style recurring work)
7633
- try {
7634
- const { discoverScheduledWork } = require('./engine/scheduler');
7635
- const scheduledWork = discoverScheduledWork(config);
7636
- if (scheduledWork.length > 0) {
7637
- const { createMeeting, getMeetings } = require('./engine/meeting');
7638
- const centralPath = path.join(MINIONS_DIR, 'work-items.json');
7639
- // Separate meetings (no work-items write) from task items
7640
- const taskItems = [];
7641
- for (const item of scheduledWork) {
7642
- if (item.type === WORK_TYPE.MEETING) {
7643
- const sched = (config.schedules || []).find(s => s.id === item._scheduleId);
7644
- const participants = (sched && sched.participants) || [];
7645
- const meeting = createMeeting({ title: item.title, agenda: item.description, participants });
7646
- log('info', `Scheduled meeting created: ${item._scheduleId} → ${meeting.id} (${participants.length} participants)`);
7647
- } else {
7648
- taskItems.push(item);
7649
- }
7650
- }
7651
- if (taskItems.length > 0) {
7652
- // Atomic write — prevents race with dispatch status updates on central work-items.json
7653
- mutateJsonFileLocked(centralPath, (items) => {
7654
- if (!Array.isArray(items)) items = [];
7655
- let added = 0;
7656
- // Snapshot active dedup keys BEFORE the loop so multiple items in the
7657
- // same harness mission (same _missionId) all land in one tick. Without
7658
- // this snapshot, the first item's push would block subsequent items
7659
- // in the same mission from joining (W-mq07a9gf000jbc2b — tri-agent
7660
- // harness mode requires Planner+Generator+Evaluator to land together).
7661
- const activeMissionIds = new Set();
7662
- const activeScheduleIds = new Set();
7663
- for (const existing of items) {
7664
- if (existing.status === WI_STATUS.DONE || existing.status === WI_STATUS.FAILED) continue;
7665
- if (existing._missionId) activeMissionIds.add(existing._missionId);
7666
- if (existing._scheduleId) activeScheduleIds.add(existing._scheduleId);
7964
+ // Gated by config.engine?.scheduledWorkDiscoveryEnabled !== false (P-d6f0a2b5).
7965
+ if (config.engine?.scheduledWorkDiscoveryEnabled !== false) {
7966
+ try {
7967
+ const { discoverScheduledWork } = require('./engine/scheduler');
7968
+ const scheduledWork = discoverScheduledWork(config);
7969
+ if (scheduledWork.length > 0) {
7970
+ const { createMeeting, getMeetings } = require('./engine/meeting');
7971
+ const centralPath = path.join(MINIONS_DIR, 'work-items.json');
7972
+ // Separate meetings (no work-items write) from task items
7973
+ const taskItems = [];
7974
+ for (const item of scheduledWork) {
7975
+ if (item.type === WORK_TYPE.MEETING) {
7976
+ const sched = (config.schedules || []).find(s => s.id === item._scheduleId);
7977
+ const participants = (sched && sched.participants) || [];
7978
+ const meeting = createMeeting({ title: item.title, agenda: item.description, participants });
7979
+ log('info', `Scheduled meeting created: ${item._scheduleId} → ${meeting.id} (${participants.length} participants)`);
7980
+ } else {
7981
+ taskItems.push(item);
7667
7982
  }
7668
- const addedScheduleIdsThisTick = new Set();
7669
- for (const item of taskItems) {
7670
- // Mission items dedup by _missionId against pre-existing rows only
7671
- // (the trio's other items added later in this loop must not block
7672
- // each other). Plain scheduled items keep the original scheduleId
7673
- // dedup AND skip if a sibling item from the same tick already
7674
- // claimed the schedule slot.
7675
- if (item._missionId) {
7676
- if (activeMissionIds.has(item._missionId)) continue;
7677
- } else {
7678
- if (activeScheduleIds.has(item._scheduleId)) continue;
7679
- if (addedScheduleIdsThisTick.has(item._scheduleId)) continue;
7983
+ }
7984
+ if (taskItems.length > 0) {
7985
+ // Atomic write prevents race with dispatch status updates on central work-items.json
7986
+ mutateJsonFileLocked(centralPath, (items) => {
7987
+ if (!Array.isArray(items)) items = [];
7988
+ let added = 0;
7989
+ // Snapshot active dedup keys BEFORE the loop so multiple items in the
7990
+ // same harness mission (same _missionId) all land in one tick. Without
7991
+ // this snapshot, the first item's push would block subsequent items
7992
+ // in the same mission from joining (W-mq07a9gf000jbc2b — tri-agent
7993
+ // harness mode requires Planner+Generator+Evaluator to land together).
7994
+ const activeMissionIds = new Set();
7995
+ const activeScheduleIds = new Set();
7996
+ for (const existing of items) {
7997
+ if (existing.status === WI_STATUS.DONE || existing.status === WI_STATUS.FAILED) continue;
7998
+ if (existing._missionId) activeMissionIds.add(existing._missionId);
7999
+ if (existing._scheduleId) activeScheduleIds.add(existing._scheduleId);
7680
8000
  }
7681
- items.push(item);
7682
- if (!item._missionId && item._scheduleId) addedScheduleIdsThisTick.add(item._scheduleId);
7683
- added++;
7684
- log('info', `Scheduled task fired: ${item._scheduleId} ${item.title}`);
7685
- }
7686
- return items;
7687
- }, { defaultValue: [] });
8001
+ const addedScheduleIdsThisTick = new Set();
8002
+ for (const item of taskItems) {
8003
+ // Mission items dedup by _missionId against pre-existing rows only
8004
+ // (the trio's other items added later in this loop must not block
8005
+ // each other). Plain scheduled items keep the original scheduleId
8006
+ // dedup AND skip if a sibling item from the same tick already
8007
+ // claimed the schedule slot.
8008
+ if (item._missionId) {
8009
+ if (activeMissionIds.has(item._missionId)) continue;
8010
+ } else {
8011
+ if (activeScheduleIds.has(item._scheduleId)) continue;
8012
+ if (addedScheduleIdsThisTick.has(item._scheduleId)) continue;
8013
+ }
8014
+ items.push(item);
8015
+ if (!item._missionId && item._scheduleId) addedScheduleIdsThisTick.add(item._scheduleId);
8016
+ added++;
8017
+ log('info', `Scheduled task fired: ${item._scheduleId} → ${item.title}`);
8018
+ }
8019
+ return items;
8020
+ }, { defaultValue: [] });
8021
+ }
7688
8022
  }
7689
- }
7690
- } catch (e) { log('warn', 'discover scheduled work: ' + e.message); }
8023
+ } catch (e) { log('warn', 'discover scheduled work: ' + e.message); }
8024
+ }
7691
8025
 
7692
8026
  // Meeting work (multi-round team discussions)
7693
8027
  try {
@@ -7768,15 +8102,37 @@ async function discoverWork(config) {
7768
8102
 
7769
8103
  const allWork = [...allFixes, ...allReviews, ...allWorkItems, ...centralWork];
7770
8104
 
7771
- for (const item of allWork) {
7772
- await addToDispatchWithValidation(item, { config });
7773
- // W-mph8xt88000ke0fc Cooldowns are stamped by addToDispatch ONLY on
7774
- // successful append (post-dedup). Stamping unconditionally here used to
7775
- // leave orphan cooldowns for items collapsed by prDedupeKey / workItem-id
7776
- // / dispatchKey dedup or routed to the pre-dispatch review queue.
7777
- if (item.meta?.source === 'pr-human-feedback') {
7778
- clearPendingHumanFeedbackFlag(item.meta.project, item.meta.pr?.id);
7779
- }
8105
+ // W-mq9acoo800177bcb bounded-concurrency pre-dispatch eval.
8106
+ // The validator inside addToDispatchWithValidation runs a ~25-30s LLM
8107
+ // call per item; an approved 11-item PRD used to take ~5 min to queue
8108
+ // because we awaited each item in series. Process items in chunks of
8109
+ // `preDispatchEvalConcurrency` (default 6, clamp 1-20) via
8110
+ // Promise.allSettled so a rejected branch in one item still fails open
8111
+ // and the rest of the chunk continues. Iteration order within a chunk
8112
+ // is irrelevant: addToDispatch's dedup gates are mutateDispatch-locked,
8113
+ // and the only per-item side effect (`clearPendingHumanFeedbackFlag`)
8114
+ // is an idempotent flag-clear on a single PR record.
8115
+ const evalConcurrency = resolvePreDispatchEvalConcurrency(config);
8116
+ for (let i = 0; i < allWork.length; i += evalConcurrency) {
8117
+ const chunk = allWork.slice(i, i + evalConcurrency);
8118
+ await Promise.allSettled(chunk.map(async (item) => {
8119
+ try {
8120
+ await addToDispatchWithValidation(item, { config });
8121
+ } catch (e) {
8122
+ // Fail-open contract preserved: any validator throw here means the
8123
+ // item is dropped from this discovery tick rather than wedging the
8124
+ // whole chunk. The item stays `pending` in work-items.json and the
8125
+ // next tick re-discovers it.
8126
+ log('warn', `pre-dispatch-eval: addToDispatchWithValidation threw for ${item?.meta?.item?.id || item?.id || 'unknown'}: ${e?.message || e}`);
8127
+ }
8128
+ // W-mph8xt88000ke0fc — Cooldowns are stamped by addToDispatch ONLY on
8129
+ // successful append (post-dedup). Stamping unconditionally here used to
8130
+ // leave orphan cooldowns for items collapsed by prDedupeKey / workItem-id
8131
+ // / dispatchKey dedup or routed to the pre-dispatch review queue.
8132
+ if (item.meta?.source === 'pr-human-feedback') {
8133
+ clearPendingHumanFeedbackFlag(item.meta.project, item.meta.pr?.id);
8134
+ }
8135
+ }));
7780
8136
  }
7781
8137
 
7782
8138
  if (allWork.length > 0) {
@@ -7873,6 +8229,20 @@ function resolveMaxConcurrent(config) {
7873
8229
  return Number.isFinite(value) && value >= 0 ? value : ENGINE_DEFAULTS.maxConcurrent;
7874
8230
  }
7875
8231
 
8232
+ // W-mq9acoo800177bcb — clamp the per-tick pre-dispatch validator concurrency.
8233
+ // Read-side mirror of the [1, 20] clamp dashboard.js#handleSettingsUpdate
8234
+ // applies on writes, so a hand-edited config.json or an engine-only test
8235
+ // fixture can't smuggle in a value that DOSes the LLM provider or starves
8236
+ // the rest of the tick. Falls back to ENGINE_DEFAULTS.preDispatchEvalConcurrency
8237
+ // for unset / non-finite / out-of-range values.
8238
+ function resolvePreDispatchEvalConcurrency(config) {
8239
+ const raw = config?.engine?.preDispatchEvalConcurrency;
8240
+ if (raw === undefined || raw === null || raw === '') return ENGINE_DEFAULTS.preDispatchEvalConcurrency;
8241
+ const value = Number(raw);
8242
+ if (!Number.isFinite(value)) return ENGINE_DEFAULTS.preDispatchEvalConcurrency;
8243
+ return Math.max(1, Math.min(20, Math.floor(value)));
8244
+ }
8245
+
7876
8246
  // ─── Main Tick ──────────────────────────────────────────────────────────────
7877
8247
 
7878
8248
  let tickCount = 0;
@@ -8152,8 +8522,45 @@ async function tickInner() {
8152
8522
  });
8153
8523
  }
8154
8524
 
8155
- const adoPollEnabled = config.engine?.adoPollEnabled ?? ENGINE_DEFAULTS.adoPollEnabled;
8156
- const ghPollEnabled = config.engine?.ghPollEnabled ?? ENGINE_DEFAULTS.ghPollEnabled;
8525
+ // P-a1f3c2d4: engine.pollingPaused is a hard-stop master switch — it overrides
8526
+ // both per-provider toggles. When ON, adoPollEnabled and ghPollEnabled are
8527
+ // forced to false so the status-poll and human-comment-poll gates below
8528
+ // short-circuit without editing every per-call gate. Reconciliation (the
8529
+ // final block below) is NOT gated — it's a recovery sweep, not a
8530
+ // convenience poll. The same gate is mirrored inside discoverFromPrs.
8531
+ const pollingPaused = config.engine?.pollingPaused === true;
8532
+ if (pollingPaused && _shouldLogPollingPausedOnce()) {
8533
+ log('info', '[engine] PR polling paused — engine.pollingPaused=true overrides adoPollEnabled/ghPollEnabled');
8534
+ } else if (!pollingPaused) {
8535
+ _resetPollingPausedLogState();
8536
+ }
8537
+ // P-b2e5d8c7: log once on the transition unpaused → paused for autoFixPaused
8538
+ // too. The gate itself is enforced inside discoverFromPrs (composed into
8539
+ // autoFixBuilds / autoFixConflicts / autoFixReviewFeedback /
8540
+ // autoFixHumanComments). Same throttling contract as pollingPaused above.
8541
+ const autoFixPaused = config.engine?.autoFixPaused === true;
8542
+ if (autoFixPaused && _shouldLogAutoFixPausedOnce()) {
8543
+ log('info', '[engine] auto-fix paused — engine.autoFixPaused=true inerts autoFixBuilds/autoFixConflicts/autoFixReviewFeedback/autoFixHumanComments');
8544
+ } else if (!autoFixPaused) {
8545
+ _resetAutoFixPausedLogState();
8546
+ }
8547
+ const adoPollEnabled = !pollingPaused && (config.engine?.adoPollEnabled ?? ENGINE_DEFAULTS.adoPollEnabled);
8548
+ const ghPollEnabled = !pollingPaused && (config.engine?.ghPollEnabled ?? ENGINE_DEFAULTS.ghPollEnabled);
8549
+ // P-c4d8e1a3 — granular per-poller flags. Each of the 6 PR-poll axes
8550
+ // (ADO/GH × status/comments/reconcile) plus processPendingRebases gets
8551
+ // its own resolution via shared.resolvePollFlag(...). Explicit granular
8552
+ // values win; the legacy `adoPollEnabled` / `ghPollEnabled` macros
8553
+ // silence their whole bundle as a fallback (including reconcile — a
8554
+ // documented breaking change). Defaults: all true. pollingPaused (master
8555
+ // killswitch) still composes with status + comments gates; reconcile and
8556
+ // the rebase processor are recovery-sweep paths and are NOT gated by it.
8557
+ const adoStatusPollEnabled = !pollingPaused && resolvePollFlag(config.engine, 'adoPrStatusPollEnabled', 'adoPollEnabled');
8558
+ const ghStatusPollEnabled = !pollingPaused && resolvePollFlag(config.engine, 'ghPrStatusPollEnabled', 'ghPollEnabled');
8559
+ const adoCommentsPollEnabled = !pollingPaused && resolvePollFlag(config.engine, 'adoPrCommentsPollEnabled', 'adoPollEnabled');
8560
+ const ghCommentsPollEnabled = !pollingPaused && resolvePollFlag(config.engine, 'ghPrCommentsPollEnabled', 'ghPollEnabled');
8561
+ const adoReconcileEnabled = resolvePollFlag(config.engine, 'adoPrReconcileEnabled', 'adoPollEnabled');
8562
+ const ghReconcileEnabled = resolvePollFlag(config.engine, 'ghPrReconcileEnabled', 'ghPollEnabled');
8563
+ const rebaseProcessorEnabled = resolvePollFlag(config.engine, 'processPendingRebasesEnabled', null);
8157
8564
  const prPollStatusEvery = Math.max(
8158
8565
  1,
8159
8566
  Number(config.engine?.prPollStatusEvery) || ENGINE_DEFAULTS.prPollStatusEvery
@@ -8173,7 +8580,7 @@ async function tickInner() {
8173
8580
  lastPrStatusPollAt = now;
8174
8581
  // Build promise array — enabled+unthrottled polls run concurrently via Promise.allSettled
8175
8582
  const statusPolls = [];
8176
- if (adoPollEnabled) {
8583
+ if (adoStatusPollEnabled) {
8177
8584
  // Per-org throttle skip happens inside forEachActivePr (one log line per skipped project).
8178
8585
  // Top-level short-circuit: when every known ADO org is throttled, skip the whole phase
8179
8586
  // with one log line to avoid the per-project iteration cost.
@@ -8186,14 +8593,16 @@ async function tickInner() {
8186
8593
  statusPolls.push(pollPrStatus(config).catch(err => { log('warn', `ADO PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
8187
8594
  }
8188
8595
  }
8189
- if (ghPollEnabled && !isGhThrottled()) {
8596
+ if (ghStatusPollEnabled && !isGhThrottled()) {
8190
8597
  statusPolls.push(ghPollPrStatus(config).catch(err => { log('warn', `GitHub PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
8191
- } else if (ghPollEnabled && isGhThrottled()) {
8598
+ } else if (ghStatusPollEnabled && isGhThrottled()) {
8192
8599
  log('info', '[gh] PR status poll skipped — throttled');
8193
8600
  }
8194
8601
  if (statusPolls.length) await Promise.allSettled(statusPolls);
8195
8602
  if (_isTickStale(myGeneration)) return;
8196
- try { await processPendingRebases(config); } catch (err) { log('warn', `Pending rebase processing error: ${err?.message || err}`); }
8603
+ if (rebaseProcessorEnabled) {
8604
+ try { await processPendingRebases(config); } catch (err) { log('warn', `Pending rebase processing error: ${err?.message || err}`); }
8605
+ }
8197
8606
  if (_isTickStale(myGeneration)) return;
8198
8607
  // Sync PR status back to PRD items (missing → done when active PR exists)
8199
8608
  try { syncPrdFromPrs(config); } catch (err) { log('warn', `PRD sync error: ${err?.message || err}`); }
@@ -8227,7 +8636,7 @@ async function tickInner() {
8227
8636
  lastPrCommentsPollAt = now;
8228
8637
  // Build promise array — enabled+unthrottled comment polls run concurrently via Promise.allSettled
8229
8638
  const commentPolls = [];
8230
- if (adoPollEnabled) {
8639
+ if (adoCommentsPollEnabled) {
8231
8640
  // Per-org throttle skip happens inside forEachActivePr (one log line per skipped project).
8232
8641
  // Top-level short-circuit: when every known ADO org is throttled, skip the whole phase
8233
8642
  // with one log line to avoid the per-project iteration cost.
@@ -8240,19 +8649,26 @@ async function tickInner() {
8240
8649
  commentPolls.push(pollPrHumanComments(config).catch(err => { log('warn', `ADO PR comment poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
8241
8650
  }
8242
8651
  }
8243
- if (ghPollEnabled && !isGhThrottled()) {
8652
+ if (ghCommentsPollEnabled && !isGhThrottled()) {
8244
8653
  commentPolls.push(ghPollPrHumanComments(config).catch(err => { log('warn', `GitHub PR comment poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
8245
- } else if (ghPollEnabled && isGhThrottled()) {
8654
+ } else if (ghCommentsPollEnabled && isGhThrottled()) {
8246
8655
  log('info', '[gh] PR comment poll skipped — throttled');
8247
8656
  }
8248
8657
  if (commentPolls.length) await Promise.allSettled(commentPolls);
8249
8658
  if (_isTickStale(myGeneration)) return;
8250
- // Reconciliation runs regardless of poll flagsit's a recovery sweep, not a convenience poll
8251
- // Reconciliation also parallelized ADO and GitHub reconciliation are independent
8659
+ // P-c4d8e1a3: reconcile gated by *PrReconcileEnabled (default true) ungated
8660
+ // by ADO/GH throttle trackers (recovery-sweep contract intact). Legacy
8661
+ // adoPollEnabled/ghPollEnabled=false now also silences reconcile via
8662
+ // resolvePollFlag's legacy-macro fallback (documented breaking change).
8663
+ // ADO and GitHub reconciliation are independent and run in parallel.
8252
8664
  const reconcilePolls = [];
8253
- reconcilePolls.push(reconcilePrs(config).catch(err => { log('warn', `ADO PR reconciliation error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
8254
- reconcilePolls.push(ghReconcilePrs(config).catch(err => { log('warn', `GitHub PR reconciliation error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
8255
- await Promise.allSettled(reconcilePolls);
8665
+ if (adoReconcileEnabled) {
8666
+ reconcilePolls.push(reconcilePrs(config).catch(err => { log('warn', `ADO PR reconciliation error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
8667
+ }
8668
+ if (ghReconcileEnabled) {
8669
+ reconcilePolls.push(ghReconcilePrs(config).catch(err => { log('warn', `GitHub PR reconciliation error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
8670
+ }
8671
+ if (reconcilePolls.length) await Promise.allSettled(reconcilePolls);
8256
8672
  if (_isTickStale(myGeneration)) return;
8257
8673
  }
8258
8674
 
@@ -8832,6 +9248,7 @@ module.exports = {
8832
9248
  reconcileItemsWithPrs, detectDependencyCycles,
8833
9249
  areDependenciesMet, // exported for testing (P-bf04-decompose-zero-children)
8834
9250
  parseConflictFiles, pruneAncestorDeps, preflightMergeSimulation, // exported for testing
9251
+ resolveDependencyBranches, buildCrossRepoDepsSection, // exported for testing (P-faea3206)
8835
9252
  gitOutputToString, gitErrorOutput, classifyDepMergeFailureOutput, listUnmergedFiles, // exported for testing
8836
9253
  buildDepConflictFixItem, deriveConflictFixKey, // exported for testing (W-mpcwojgr000a0244)
8837
9254
  isWorktreeRetryableError, removeStaleIndexLock, syncReusedWorktree, assertCleanSharedWorktree, _quarantineDirtyWorktree, // exported for testing
@@ -8866,6 +9283,8 @@ module.exports = {
8866
9283
  // Tick
8867
9284
  tick,
8868
9285
  resolveMaxConcurrent, _pollIntervalMsFromTicks, _shouldRunPeriodicPhase, // exported for testing
9286
+ // W-mq9acoo800177bcb — exported for testing the clamped concurrency resolver
9287
+ resolvePreDispatchEvalConcurrency,
8869
9288
  // P-c2e5a1d9-a — exported for testing the tick-generation force-release path
8870
9289
  _isTickStale,
8871
9290
  get tickGeneration() { return tickGeneration; },