@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
@@ -17,6 +17,7 @@ const harness = require('./harness');
17
17
  const smallStateStore = require('./small-state-store');
18
18
  const prdStore = require('./prd-store');
19
19
  const { isBranchActive } = require('./cooldown');
20
+ const { resolveExecutionModel } = require('./execution-model');
20
21
  const { worktreeMatchesBranch, getWorktreeBranch, cleanupMergedPrLocalBranch } = require('./cleanup');
21
22
  const { getConfig, getInboxFiles, getNotes, getPrs, getDispatch,
22
23
  MINIONS_DIR, ENGINE_DIR, PLANS_DIR, INBOX_DIR, AGENTS_DIR } = queries;
@@ -2319,7 +2320,7 @@ function resolveReviewPrContext(pr, project, config, structuredCompletion = null
2319
2320
  : null;
2320
2321
  }
2321
2322
 
2322
- async function updatePrAfterReview(agentId, pr, project, config, resultSummary, structuredCompletion = null, dispatchItem = null) {
2323
+ async function updatePrAfterReview(agentId, pr, project, config, resultSummary, structuredCompletion = null, dispatchItem = null, executionMetadata = null) {
2323
2324
 
2324
2325
  if (!config) config = getConfig();
2325
2326
  const completionStatus = normalizeCompletionStatus(structuredCompletion?.status);
@@ -2338,6 +2339,11 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
2338
2339
  const reviewProject = reviewContext.project;
2339
2340
  const prScope = reviewContext.scope;
2340
2341
  const reviewerName = config.agents?.[agentId]?.name || agentId;
2342
+ const reviewExecutionModel = resolveExecutionModel({
2343
+ reportedModel: executionMetadata?.reportedModel,
2344
+ capturedModel: dispatchItem?.executionModel,
2345
+ requestedModel: dispatchItem?.requestedModel,
2346
+ });
2341
2347
 
2342
2348
  // Check actual review status from the platform (agent may have approved or requested changes)
2343
2349
  // If platform hasn't propagated the vote yet (returns 'pending'), keep current status unchanged.
@@ -2549,6 +2555,7 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
2549
2555
  reviewer: reviewerName,
2550
2556
  reviewedAt: ts(),
2551
2557
  note: reviewNote,
2558
+ model: reviewExecutionModel.model,
2552
2559
  dispatchId: dispatchItem?.id || structuredCompletion?.dispatchId || null,
2553
2560
  sourceItem: dispatchItem?.meta?.item?.id || null,
2554
2561
  ...(reviewThreads.length > 0 ? { threads: reviewThreads } : {}),
@@ -3134,7 +3141,6 @@ function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId =
3134
3141
  if (!options || typeof options !== 'object' || Array.isArray(options)) {
3135
3142
  options = { automationCauseKey: options, dispatchId: legacyDispatchId };
3136
3143
  }
3137
- const explicitlyChangedBranch = options.branchChanged !== false;
3138
3144
  const prScope = project || 'central';
3139
3145
  const automationCauseKey = options.automationCauseKey || options.dispatchItem?.meta?.automationCauseKey || '';
3140
3146
  const fixDispatchId = options.dispatchItem?.id || options.dispatchId || legacyDispatchId || '';
@@ -3143,6 +3149,15 @@ function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId =
3143
3149
  source,
3144
3150
  task: options.dispatchItem?.task,
3145
3151
  });
3152
+ const evidenceOnlyReviewResolution = cause === shared.PR_FIX_CAUSE.REVIEW_FEEDBACK
3153
+ && options.reviewFindingResolution;
3154
+ // Review-fix completion is governed by the live branch probe. Preserve the
3155
+ // legacy files_changed behavior for unrelated fix causes.
3156
+ let explicitlyChangedBranch = options.branchChanged !== false;
3157
+ if (cause === shared.PR_FIX_CAUSE.REVIEW_FEEDBACK && options.branchChange) {
3158
+ explicitlyChangedBranch = true;
3159
+ }
3160
+ if (evidenceOnlyReviewResolution) explicitlyChangedBranch = false;
3146
3161
  let result = null;
3147
3162
  shared.mutatePullRequests(prScope, (prs) => {
3148
3163
  if (!Array.isArray(prs)) return prs;
@@ -4569,8 +4584,8 @@ function parseCompletionBoolean(value) {
4569
4584
  }
4570
4585
 
4571
4586
  // Detect a deliberate no-op completion — the agent correctly declined to make
4572
- // changes (work was already shipped, dispatch premise was wrong, self-authored
4573
- // review with no actionable feedback, etc.) and should NOT be flagged as a
4587
+ // changes (work was already shipped, dispatch premise was wrong, or a finding
4588
+ // was proven resolved/invalid with current-code evidence) and should NOT be flagged as a
4574
4589
  // silent failure for missing a PR. Honored signals:
4575
4590
  // - completion.noop === true (canonical, primary)
4576
4591
  // - completion.result === 'noop' OR completion.result.type === 'noop'
@@ -4578,8 +4593,8 @@ function parseCompletionBoolean(value) {
4578
4593
  // detected, or null otherwise. The status must be a successful terminal state
4579
4594
  // — a failed/partial/in-progress completion that also claims `noop` is a
4580
4595
  // contradiction and falls through to the normal contract.
4581
- function parseCompletionNoop(completion) {
4582
- if (!completion || typeof completion !== 'object') return null;
4596
+ function completionDeclaresNoop(completion) {
4597
+ if (!completion || typeof completion !== 'object') return false;
4583
4598
  const explicit = parseCompletionBoolean(completion.noop);
4584
4599
  let resultIsNoop = false;
4585
4600
  if (typeof completion.result === 'string') {
@@ -4587,7 +4602,11 @@ function parseCompletionNoop(completion) {
4587
4602
  } else if (completion.result && typeof completion.result === 'object') {
4588
4603
  resultIsNoop = String(completion.result.type || '').trim().toLowerCase() === 'noop';
4589
4604
  }
4590
- if (explicit !== true && !resultIsNoop) return null;
4605
+ return explicit === true || resultIsNoop;
4606
+ }
4607
+
4608
+ function parseCompletionNoop(completion) {
4609
+ if (!completionDeclaresNoop(completion)) return null;
4591
4610
  const status = normalizeCompletionStatus(completion.status);
4592
4611
  if (status && status !== 'success' && status !== 'done' && status !== 'complete') return null;
4593
4612
  const reason = String(
@@ -4600,6 +4619,86 @@ function parseCompletionNoop(completion) {
4600
4619
  return reason || 'no-op completion';
4601
4620
  }
4602
4621
 
4622
+ function classifyBenignZeroTargetOutcome(dispatchItem, completion, resultSummary) {
4623
+ const item = dispatchItem?.meta?.item;
4624
+ if (!item?._scheduleId || item.createdBy !== 'scheduler') return null;
4625
+ const type = dispatchItem?.type || item.type;
4626
+ if (![WORK_TYPE.SETUP, WORK_TYPE.EXPLORE, WORK_TYPE.REVIEW].includes(type)) return null;
4627
+ if (!completionDeclaresNoop(completion)) return null;
4628
+ if (parseCompletionBoolean(completion?.needs_rerun ?? completion?.needsRerun) === true) return null;
4629
+ if (parseCompletionBoolean(completion?.retryable) === true) return null;
4630
+ if (completion?.securityFlags?.injectionAttempt === true) return null;
4631
+ const failureClass = String(completion?.failure_class || '').trim().toLowerCase();
4632
+ const nonceUnavailable = failureClass === 'completion-nonce-unavailable'
4633
+ || failureClass === 'nonce-unavailable';
4634
+ if (hasActionableFailureClass(failureClass) && !nonceUnavailable) return null;
4635
+ const status = normalizeCompletionStatus(completion?.status);
4636
+ const successfulStatus = status === 'success' || status === 'done' || status === 'complete';
4637
+ const nonceTransportFailure = nonceUnavailable && (status.startsWith('fail') || status === 'error');
4638
+ if (!successfulStatus && !nonceTransportFailure) return null;
4639
+
4640
+ const evidence = [...new Set([
4641
+ completion?.summary,
4642
+ completion?.noopReason,
4643
+ completion?.noop_reason,
4644
+ resultSummary,
4645
+ ].filter(Boolean).map(value => String(value).trim()))].join(' ').trim();
4646
+ if (!evidence) return null;
4647
+
4648
+ const mutationOccurred = [
4649
+ /\b(?:a|one|[1-9]\d*)\s+(?:new\s+)?(?:work items?|reviews?|pull requests?|prs?|issues?)\s+(?:(?:was|were)\s+)?(?:created|enrolled|dispatched|posted|deleted|removed)\b/i,
4650
+ /\b(?:created|enrolled|dispatched|posted|deleted|removed)\s+[1-9]\d*\b/i,
4651
+ ].some(pattern => pattern.test(evidence));
4652
+ if (mutationOccurred) return null;
4653
+
4654
+ const zeroTargets = [
4655
+ /\bno\s+[^.!?\n]{0,120}\b(?:needed|required|eligible|qualif(?:y|ied)|found|remain(?:ed|ing)?|to\s+(?:review|remove|dispatch|enroll|process|merge|fix))\b/i,
4656
+ /\bno\s+(?:open\s+issues?|eligible|qualifying|matching|context-only|merged|abandoned)\b/i,
4657
+ /\b(?:0|zero)\s+(?:eligible|qualifying|matching|context-only|open)\b/i,
4658
+ ].some(pattern => pattern.test(evidence));
4659
+ if (!zeroTargets) return null;
4660
+
4661
+ const noMutation = [
4662
+ /\bno\s+(?:api\s+)?(?:post|mutations?|writes?|actions?|changes?|modifications?|updates?|delete requests?|work items?|review work items?)\s+(?:(?:was|were)\s+)?(?:made|performed|taken|sent|created)?\b/i,
4663
+ /\b(?:0|zero)\s+(?:were\s+)?(?:created|enrolled|dispatched|posted|deleted|removed)\b/i,
4664
+ /\b(?:created|enrolled|dispatched|posted|deleted|removed)\s+(?:0|zero)\b/i,
4665
+ ].some(pattern => pattern.test(evidence));
4666
+ return noMutation ? evidence : null;
4667
+ }
4668
+
4669
+ function validateReviewFixCompletion({ type, meta, branchChange, structuredCompletion } = {}) {
4670
+ if (!shared.isFixLikeWorkType(type)) return { valid: true };
4671
+ const cause = shared.getPrFixAutomationCause({
4672
+ dispatchKey: meta?.dispatchKey,
4673
+ source: meta?.source,
4674
+ task: meta?.item?.title,
4675
+ });
4676
+ if (cause !== shared.PR_FIX_CAUSE.REVIEW_FEEDBACK
4677
+ || (branchChange?.changed === true && branchChange.evidence === 'remote-head')) {
4678
+ return { valid: true };
4679
+ }
4680
+
4681
+ const findingId = shared.prReviewFindingIdentity(meta?.pr);
4682
+ const resolution = structuredCompletion?.reviewFindingResolution;
4683
+ const disposition = String(resolution?.disposition || '').trim().toLowerCase();
4684
+ const reportedFindingId = String(resolution?.findingId || '').trim();
4685
+ const currentCodeEvidence = String(resolution?.currentCodeEvidence || '').trim();
4686
+ const hasCurrentCodeReference = /(?:[a-z]:[\\/])?[\w./\\-]+\.[a-z0-9]+:\d+\b/i.test(currentCodeEvidence)
4687
+ || /\bcommit\s+[0-9a-f]{7,40}\b/i.test(currentCodeEvidence);
4688
+ const validDisposition = disposition === 'already-resolved' || disposition === 'invalid';
4689
+ const explicitNoop = !!parseCompletionNoop(structuredCompletion);
4690
+
4691
+ if (explicitNoop && validDisposition && reportedFindingId === findingId && hasCurrentCodeReference) {
4692
+ return { valid: true, findingId, resolution };
4693
+ }
4694
+
4695
+ return {
4696
+ valid: false,
4697
+ findingId,
4698
+ reason: `Review-fix completion did not advance the PR branch and must explicitly prove the exact finding is already resolved or invalid with current-code evidence. Expected reviewFindingResolution.findingId="${findingId}", disposition "already-resolved" or "invalid", and a file:line or commit reference. Shared platform ownership, viewerDidAuthor, or reviewer/fixer agent equality is not evidence.`,
4699
+ };
4700
+ }
4701
+
4603
4702
  function normalizeReviewVerdict(verdict) {
4604
4703
  const value = String(verdict || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
4605
4704
  if (value === 'approve' || value === 'approved') return REVIEW_STATUS.APPROVED;
@@ -5482,11 +5581,15 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5482
5581
  // P-d2a8f6c1: a nonce mismatch always counts as an agent-reported failure
5483
5582
  // (regardless of what the discarded report claimed). This forces both the
5484
5583
  // effectiveSuccess gate below and the dispatch result in engine.js to ERROR.
5485
- const agentReportedFailure = !!nonceMismatch
5486
- || completionStatus.startsWith('fail')
5584
+ const benignNoop = nonceMismatch
5585
+ ? null
5586
+ : classifyBenignZeroTargetOutcome(dispatchItem, structuredCompletion, completionGateSummary);
5587
+ const agentReportedFailure = !!nonceMismatch || (!benignNoop && (
5588
+ completionStatus.startsWith('fail')
5487
5589
  || completionStatus === 'error'
5488
5590
  || hasActionableFailureClass(structuredCompletion?.failure_class)
5489
- || agentNeedsRerun;
5591
+ || agentNeedsRerun
5592
+ ));
5490
5593
  // Untrusted completions cannot be honored as "retryable" by the agent — its
5491
5594
  // retryable claim was discarded with the rest of the report. Force false.
5492
5595
  const agentRetryable = nonceMismatch ? false : parseCompletionBoolean(structuredCompletion?.retryable);
@@ -5499,7 +5602,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5499
5602
  if (autoRecovered) {
5500
5603
  log('info', `Auto-recovery: agent failed but created ${prsCreatedCount} PR(s) — upgrading ${meta.item.id} to done`);
5501
5604
  }
5502
- const effectiveSuccess = !nonceMismatch && ((isSuccess && !agentReportedFailure) || autoRecovered);
5605
+ const effectiveSuccess = !nonceMismatch && (benignNoop || (isSuccess && !agentReportedFailure) || autoRecovered);
5503
5606
  if (!nonceMismatch) {
5504
5607
  const episodeOutcome = effectiveSuccess
5505
5608
  ? (autoRecovered || completionStatus.startsWith('partial') ? 'partial' : 'success')
@@ -5571,7 +5674,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5571
5674
  // Without this, the second run produces no VERDICT, _retryCount increments,
5572
5675
  // and after 3 such bailouts the WI flips to status=failed even though the
5573
5676
  // original review was posted on the first run.
5574
- if (effectiveSuccess && type === WORK_TYPE.REVIEW && meta?.item?.id) {
5677
+ if (effectiveSuccess && type === WORK_TYPE.REVIEW && meta?.item?.id && !benignNoop) {
5575
5678
  const verdict = reviewVerdictFromCompletion(structuredCompletion) || parseReviewVerdict(resultSummary);
5576
5679
  if (!verdict && isReviewBailout(resultSummary)) {
5577
5680
  log('info', `Review ${meta.item.id} bailed out (review already posted) — treating as DONE without retry`);
@@ -5721,7 +5824,9 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5721
5824
 
5722
5825
  let completionContractFailure = null;
5723
5826
  if (effectiveSuccess && meta?.item?.id && !skipDoneStatus) {
5724
- const nonTerminalCompletion = detectNonTerminalResultSummary(completionGateSummary, structuredCompletion, reportCompletion, { detectPhantom });
5827
+ const nonTerminalCompletion = benignNoop
5828
+ ? null
5829
+ : detectNonTerminalResultSummary(completionGateSummary, structuredCompletion, reportCompletion, { detectPhantom });
5725
5830
  if (nonTerminalCompletion) {
5726
5831
  const isPhantomDetection = nonTerminalCompletion.phrase === 'phantom-completion';
5727
5832
  // P-e0b4f7a5 — before deferring a phantom retry, attempt to recover
@@ -5750,13 +5855,70 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5750
5855
  }
5751
5856
 
5752
5857
  // No-op signal: agent declared the work was correctly NOT done (already
5753
- // shipped, dispatch premise wrong, self-authored review, etc.). Skip the PR
5858
+ // shipped, dispatch premise wrong, evidence-backed resolved finding, etc.). Skip the PR
5754
5859
  // attachment contract — a missing PR is intentional, not a silent failure.
5755
- const noopRationale = (effectiveSuccess && meta?.item?.id && !skipDoneStatus)
5756
- ? parseCompletionNoop(structuredCompletion)
5860
+ let prFixBranchChange = null;
5861
+ let prFixBuildStillFailing = null;
5862
+ if (shared.isFixLikeWorkType(type) && effectiveSuccess && meta?.pr?.id) {
5863
+ try {
5864
+ prFixBranchChange = await detectPrFixBranchChange(meta, config);
5865
+ } catch (err) {
5866
+ log('warn', `PR fix no-op detection for ${meta.pr.id}: ${err.message}`);
5867
+ prFixBranchChange = { changed: null, reason: err.message };
5868
+ }
5869
+ if (prFixBranchChange?.changed === false) {
5870
+ const fixCause = shared.getPrFixAutomationCause({
5871
+ dispatchKey: dispatchItem?.meta?.dispatchKey,
5872
+ source: meta?.source,
5873
+ task: dispatchItem?.task,
5874
+ });
5875
+ if (fixCause === shared.PR_FIX_CAUSE.BUILD_FAILURE) {
5876
+ try {
5877
+ const liveStatus = await checkBuildStillFailingLive(meta.pr, meta.project);
5878
+ if (liveStatus === shared.BUILD_STATUS.FAILING) {
5879
+ prFixBuildStillFailing = liveStatus;
5880
+ }
5881
+ } catch (err) {
5882
+ log('warn', `Live build re-check for ${meta.pr.id}: ${err.message}`);
5883
+ }
5884
+ }
5885
+ }
5886
+ }
5887
+ let noopRationale = (effectiveSuccess && !skipDoneStatus)
5888
+ ? (parseCompletionNoop(structuredCompletion) || benignNoop)
5757
5889
  : null;
5890
+ let reviewFixResolution = null;
5891
+ if (effectiveSuccess && !skipDoneStatus) {
5892
+ const reviewFixValidation = validateReviewFixCompletion({
5893
+ type,
5894
+ meta,
5895
+ branchChange: prFixBranchChange,
5896
+ structuredCompletion,
5897
+ });
5898
+ if (!reviewFixValidation.valid) {
5899
+ skipDoneStatus = true;
5900
+ meta._agentId = agentId;
5901
+ const detection = {
5902
+ phrase: 'review-fix-resolution-evidence-missing',
5903
+ reason: reviewFixValidation.reason,
5904
+ };
5905
+ const reason = meta?.item?.id
5906
+ ? deferNonTerminalCompletion(meta, detection)
5907
+ : detection.reason;
5908
+ completionContractFailure = {
5909
+ reason,
5910
+ itemId: meta?.item?.id || dispatchItem.id,
5911
+ nonTerminal: true,
5912
+ processWorkItemFailure: false,
5913
+ };
5914
+ noopRationale = null;
5915
+ log('warn', `Review-fix completion rejected for ${meta?.item?.id || dispatchItem.id}: ${reviewFixValidation.reason}`);
5916
+ } else {
5917
+ reviewFixResolution = reviewFixValidation.resolution || null;
5918
+ }
5919
+ }
5758
5920
  if (noopRationale) {
5759
- log('info', `No-op completion for ${meta.item.id}: ${noopRationale.slice(0, 200)}`);
5921
+ log('info', `No-op completion for ${meta?.item?.id || dispatchItem.id}: ${noopRationale.slice(0, 200)}`);
5760
5922
  }
5761
5923
 
5762
5924
  if (effectiveSuccess && meta?.item?.id && !skipDoneStatus && !noopRationale) {
@@ -5994,41 +6156,6 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5994
6156
 
5995
6157
  // Archive is manual — user archives plans from the dashboard when ready
5996
6158
 
5997
- let prFixBranchChange = null;
5998
- let prFixBuildStillFailing = null;
5999
- if (shared.isFixLikeWorkType(type) && effectiveSuccess && meta?.pr?.id) {
6000
- try {
6001
- prFixBranchChange = await detectPrFixBranchChange(meta, config);
6002
- } catch (err) {
6003
- log('warn', `PR fix no-op detection for ${meta.pr.id}: ${err.message}`);
6004
- prFixBranchChange = { changed: null, reason: err.message };
6005
- }
6006
- // #639 — a BUILD_FAILURE fix that didn't change the branch is not
6007
- // necessarily a genuine no-op: the agent may have found its own (or a
6008
- // prior dispatch's) fix commit already present and concluded "nothing to
6009
- // change" without checking whether CI actually turned green on that
6010
- // commit. Re-poll live CI for the head before letting updatePrAfterFix
6011
- // accept the no-op — if it's still failing, flag it so the no-op path
6012
- // records a distinct fixIneffective signal instead of a clean no-op.
6013
- if (prFixBranchChange?.changed === false) {
6014
- const fixCause = shared.getPrFixAutomationCause({
6015
- dispatchKey: dispatchItem?.meta?.dispatchKey,
6016
- source: meta?.source,
6017
- task: dispatchItem?.task,
6018
- });
6019
- if (fixCause === shared.PR_FIX_CAUSE.BUILD_FAILURE) {
6020
- try {
6021
- const liveStatus = await checkBuildStillFailingLive(meta.pr, meta.project);
6022
- if (liveStatus === shared.BUILD_STATUS.FAILING) {
6023
- prFixBuildStillFailing = liveStatus;
6024
- }
6025
- } catch (err) {
6026
- log('warn', `Live build re-check for ${meta.pr.id}: ${err.message}`);
6027
- }
6028
- }
6029
- }
6030
- }
6031
-
6032
6159
  // Scheduled task back-reference: update SQL state and write a linked inbox note.
6033
6160
  if (meta?.item?._scheduleId) {
6034
6161
  try {
@@ -6133,12 +6260,21 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6133
6260
  const hardContractFail = completionContractFailure?.severity === 'hard'
6134
6261
  || completionContractFailure?.nonTerminal === true;
6135
6262
  const finalResult = hardContractFail ? DISPATCH_RESULT.ERROR : (effectiveSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR);
6136
- if (type === WORK_TYPE.REVIEW && finalResult === DISPATCH_RESULT.SUCCESS && !skipDoneStatus) {
6137
- await updatePrAfterReview(agentId, meta?.pr, meta?.project, config, resultSummary, structuredCompletion, dispatchItem);
6263
+ if (type === WORK_TYPE.REVIEW && finalResult === DISPATCH_RESULT.SUCCESS && !skipDoneStatus && !benignNoop) {
6264
+ await updatePrAfterReview(
6265
+ agentId,
6266
+ meta?.pr,
6267
+ meta?.project,
6268
+ config,
6269
+ resultSummary,
6270
+ structuredCompletion,
6271
+ dispatchItem,
6272
+ { reportedModel: model },
6273
+ );
6138
6274
  } else if (type === WORK_TYPE.REVIEW) {
6139
6275
  log('warn', `Skipping PR review metadata update for ${meta?.pr?.id || meta?.pr?.url || '(unknown PR)'} because review dispatch ${dispatchItem.id} did not complete cleanly`);
6140
6276
  }
6141
- if (shared.isFixLikeWorkType(type) && effectiveSuccess) {
6277
+ if (shared.isFixLikeWorkType(type) && effectiveSuccess && !skipDoneStatus) {
6142
6278
  updatePrAfterFix(meta?.pr, meta?.project, meta?.source, {
6143
6279
  branchChanged: fixCompletionChangedBranch(structuredCompletion),
6144
6280
  automationCauseKey: meta?.automationCauseKey,
@@ -6147,6 +6283,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6147
6283
  buildStillFailing: prFixBuildStillFailing,
6148
6284
  config,
6149
6285
  noopReason: noopRationale || meta?._noopReason || '',
6286
+ reviewFindingResolution: reviewFixResolution,
6150
6287
  });
6151
6288
  // W-mpg58wv3 — closure-loop dispatch. When the completed fix WI was spawned
6152
6289
  // to address a minion REQUEST_CHANGES (its meta carries
@@ -6232,7 +6369,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6232
6369
  const metricsResult = isAutoRetry ? 'retry' : finalResult;
6233
6370
  updateMetrics(agentId, dispatchItem, metricsResult, taskUsage, prsCreatedCount, model);
6234
6371
 
6235
- return { resultSummary, taskUsage, autoRecovered, structuredCompletion, completionContractFailure, agentReportedFailure, agentRetryable, nonceMismatch };
6372
+ return { resultSummary, taskUsage, autoRecovered, structuredCompletion, completionContractFailure, agentReportedFailure, agentRetryable, nonceMismatch, benignNoop };
6236
6373
  }
6237
6374
 
6238
6375
  // ─── PR → PRD Status Sync ─────────────────────────────────────────────────────
@@ -6986,6 +7123,8 @@ module.exports = {
6986
7123
  parseReviewVerdict,
6987
7124
  isReviewBailout,
6988
7125
  parseCompletionNoop,
7126
+ classifyBenignZeroTargetOutcome, // exported for testing
7127
+ validateReviewFixCompletion,
6989
7128
  detectNonTerminalResultSummary,
6990
7129
  deferNonTerminalCompletion,
6991
7130
  deferPhantomCompletion,
package/engine/llm.js CHANGED
@@ -18,7 +18,15 @@ const {
18
18
  ENGINE_DEFAULTS,
19
19
  resolveCcCli, resolveCcModel,
20
20
  } = shared;
21
- const { resolveRuntime } = require('./runtimes');
21
+ const {
22
+ resolveRuntime,
23
+ resolveRuntimeByCapability,
24
+ listRuntimes,
25
+ RUNTIME_IMAGES_FILE_OPTION,
26
+ sanitizeInvocationOptions,
27
+ resolveInvocationOptions,
28
+ buildSpawnFlags,
29
+ } = require('./runtimes');
22
30
 
23
31
  const MINIONS_DIR = shared.MINIONS_DIR;
24
32
  const ENGINE_DIR = path.join(MINIONS_DIR, 'engine');
@@ -217,15 +225,17 @@ function _runtimeInstallHint(runtimeName, runtime) {
217
225
  function _missingRuntimeMessage(runtimeName, runtime, reason) {
218
226
  const selected = runtime?.name || runtimeName || 'configured';
219
227
  if (!runtime) {
228
+ const installLines = listRuntimes().map((name) => {
229
+ const adapter = resolveRuntime(name);
230
+ return `- ${name}: ${_runtimeInstallHint(name, adapter)}`;
231
+ });
220
232
  return [
221
233
  `Minions can't run the configured runtime "${selected}".`,
222
234
  '',
223
235
  reason || 'The configured runtime is not registered.',
224
236
  '',
225
237
  'Choose a supported runtime in Settings -> Engine -> defaultCli/ccCli, then install its CLI:',
226
- '- Claude Code: npm install -g @anthropic-ai/claude-code or download from https://claude.ai/download',
227
- '- OpenAI Codex CLI: npm install -g @openai/codex or brew install --cask codex',
228
- '- GitHub Copilot CLI: install via GitHub CLI/gh-copilot or download from https://github.com/github/copilot-cli/releases',
238
+ ...installLines,
229
239
  '',
230
240
  'After installing, restart Minions so the dashboard and engine inherit the updated PATH.',
231
241
  ].join('\n');
@@ -239,10 +249,9 @@ function _missingRuntimeMessage(runtimeName, runtime, reason) {
239
249
  '',
240
250
  'After installing, restart Minions so the dashboard and engine inherit the updated PATH.',
241
251
  ];
242
- if (selected === 'claude') {
243
- lines.push('Or switch Settings -> Engine -> defaultCli/ccCli to "copilot" or "codex" after installing another supported runtime.');
244
- } else {
245
- lines.push('Or switch Settings -> Engine -> defaultCli/ccCli back to "claude" after installing Claude Code.');
252
+ const alternatives = listRuntimes().filter(name => name !== selected);
253
+ if (alternatives.length) {
254
+ lines.push(`Or switch Settings -> Engine -> defaultCli/ccCli to another installed runtime: ${alternatives.join(', ')}.`);
246
255
  }
247
256
  return lines.join('\n');
248
257
  }
@@ -337,37 +346,12 @@ function _runtimeUnavailableResult(callOpts = {}) {
337
346
  // ─── Spawn Helpers ───────────────────────────────────────────────────────────
338
347
 
339
348
  /**
340
- * Translate the unified opts bag into the named CLI flags consumed by
341
- * `engine/spawn-agent.js`. spawn-agent.js parses these back into an opts
342
- * object and calls `runtime.buildArgs(opts)` once keeping the adapter as
343
- * the single source of truth and avoiding double-flag emission.
344
- *
345
- * Capability gating (matches engine.js _buildAgentSpawnFlags from P-2a6d9c4f):
346
- * - effort/sessionId/maxBudget/bare/fallbackModel are dropped when the
347
- * runtime's matching capability is false.
348
- * - Copilot-specific opts (stream, disableBuiltinMcps,
349
- * reasoningSummaries) are emitted unconditionally; the Claude adapter
350
- * ignores them via the "tolerate unknown opts" rule.
349
+ * Encode the semantic invocation options through the stable runtime facade.
350
+ * Legacy named wrapper flags remain for compatibility; the opaque options bag
351
+ * is authoritative for adapter fields the wrapper does not know yet.
351
352
  */
352
353
  function _buildSpawnAgentFlags(runtime, opts = {}) {
353
- const caps = (runtime && runtime.capabilities) || {};
354
- const flags = ['--runtime', String(runtime?.name || 'claude')];
355
-
356
- if (opts.maxTurns != null) flags.push('--max-turns', String(opts.maxTurns));
357
- if (opts.model) flags.push('--model', String(opts.model));
358
- if (opts.allowedTools) flags.push('--allowedTools', String(opts.allowedTools));
359
-
360
- if (caps.effortLevels && opts.effort) flags.push('--effort', String(opts.effort));
361
- if (caps.sessionResume && opts.sessionId) flags.push('--resume', String(opts.sessionId));
362
- if (caps.budgetCap && opts.maxBudget != null) flags.push('--max-budget-usd', String(opts.maxBudget));
363
- if (caps.bareMode && opts.bare === true) flags.push('--bare');
364
- if (caps.fallbackModel && opts.fallbackModel) flags.push('--fallback-model', String(opts.fallbackModel));
365
-
366
- if (opts.stream === 'on' || opts.stream === 'off') flags.push('--stream', opts.stream);
367
- if (opts.disableBuiltinMcps === true) flags.push('--disable-builtin-mcps');
368
- if (opts.reasoningSummaries === true) flags.push('--enable-reasoning-summaries');
369
-
370
- return flags;
354
+ return buildSpawnFlags(runtime, opts);
371
355
  }
372
356
 
373
357
  /**
@@ -379,15 +363,12 @@ function _buildSpawnAgentFlags(runtime, opts = {}) {
379
363
  *
380
364
  * Indirect path: uses engine/spawn-agent.js — mostly a fallback when the
381
365
  * direct path can't resolve the binary cache. spawn-agent.js handles
382
- * adapter resolution itself; we just hand it `--runtime <name>` plus the
383
- * named flags it knows how to parse.
366
+ * adapter resolution itself; we hand it the runtime plus an opaque options
367
+ * bag, so adding a harness flag does not require wrapper changes.
384
368
  */
385
369
  function _spawnProcess(promptText, sysPromptText, callOpts) {
386
370
  const {
387
- direct, label, runtime, model, maxTurns, allowedTools, effort, sessionId,
388
- maxBudget, bare, fallbackModel,
389
- stream, disableBuiltinMcps, reasoningSummaries,
390
- images,
371
+ direct, label, runtime, adapterOptions = {},
391
372
  } = callOpts;
392
373
 
393
374
  const id = uid();
@@ -399,24 +380,10 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
399
380
  const cleanupFiles = [];
400
381
  const cleanupDirs = [llmTmpDir];
401
382
  const caps = (runtime && runtime.capabilities) || {};
402
- const adapterOpts = {
403
- model, maxTurns, allowedTools, effort, sessionId,
404
- maxBudget, bare, fallbackModel,
405
- stream, disableBuiltinMcps, reasoningSummaries,
406
- images,
383
+ const adapterOpts = sanitizeInvocationOptions(runtime, {
384
+ ...adapterOptions,
407
385
  tmpDir: llmTmpDir,
408
- };
409
- // Capability-gate per-flag opts before prompt construction so adapters can
410
- // make resume-aware prompt decisions from the same opts used for argv.
411
- if (!caps.effortLevels) adapterOpts.effort = undefined;
412
- if (!caps.sessionResume) adapterOpts.sessionId = undefined;
413
- if (!caps.budgetCap) adapterOpts.maxBudget = undefined;
414
- if (!caps.bareMode) adapterOpts.bare = undefined;
415
- if (!caps.fallbackModel) adapterOpts.fallbackModel = undefined;
416
- // P-7b2e4d01 — defense in depth: never forward images to a runtime that
417
- // can't read them (callLLM/callLLMStreaming already gate + surface a typed
418
- // error upstream; this protects any other _spawnProcess caller).
419
- if (!caps.imageInput) adapterOpts.images = undefined;
386
+ });
420
387
  const finalPrompt = runtime.buildPrompt(promptText, sysPromptText, adapterOpts);
421
388
 
422
389
  // ── Direct path ──
@@ -426,7 +393,7 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
426
393
  // Only write a sys-prompt tmp file when the runtime actually consumes one
427
394
  // via --system-prompt-file (Claude) AND we're not resuming (resumed sessions
428
395
  // already have the sys prompt baked in).
429
- if (!sessionId && sysPromptText && caps.systemPromptFile) {
396
+ if (!adapterOpts.sessionId && sysPromptText && caps.systemPromptFile) {
430
397
  sysTmpPath = path.join(llmTmpDir, `direct-sys-${id}.md`);
431
398
  fs.writeFileSync(sysTmpPath, sysPromptText);
432
399
  cleanupFiles.push(sysTmpPath);
@@ -472,12 +439,16 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
472
439
  const pidPath = promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid');
473
440
  cleanupFiles.push(promptPath, sysPath, pidPath);
474
441
 
475
- const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
476
- const adapterFlags = _buildSpawnAgentFlags(runtime, {
477
- model, maxTurns, allowedTools, effort, sessionId,
478
- maxBudget, bare, fallbackModel,
479
- stream, disableBuiltinMcps, reasoningSummaries,
480
- });
442
+ const spawnScript = runtime.spawnScript || path.join(ENGINE_DIR, 'spawn-agent.js');
443
+ const indirectAdapterOpts = { ...adapterOpts };
444
+ if (Array.isArray(indirectAdapterOpts.images) && indirectAdapterOpts.images.length) {
445
+ const imagesPath = path.join(llmTmpDir, `runtime-images-${id}.json`);
446
+ safeWrite(imagesPath, JSON.stringify(indirectAdapterOpts.images));
447
+ cleanupFiles.push(imagesPath);
448
+ indirectAdapterOpts[RUNTIME_IMAGES_FILE_OPTION] = imagesPath;
449
+ delete indirectAdapterOpts.images;
450
+ }
451
+ const adapterFlags = _buildSpawnAgentFlags(runtime, indirectAdapterOpts);
481
452
  const args = [spawnScript, promptPath, sysPath, ...adapterFlags];
482
453
 
483
454
  const proc = runFile(process.execPath, args, {
@@ -692,11 +663,16 @@ function _resolveModelForRuntime(runtime, callOpts) {
692
663
  function _resolveRuntimeFeatureOpts({
693
664
  stream, disableBuiltinMcps, reasoningSummaries, engineConfig,
694
665
  } = {}) {
695
- const engine = engineConfig || {};
666
+ const runtime = resolveRuntimeByCapability('acpWorkerPool');
667
+ const resolved = resolveInvocationOptions(runtime, {
668
+ stream,
669
+ disableBuiltinMcps,
670
+ reasoningSummaries,
671
+ }, { engineConfig: engineConfig || {} });
696
672
  return {
697
- stream: stream ?? engine.copilotStreamMode,
698
- disableBuiltinMcps: disableBuiltinMcps ?? engine.copilotDisableBuiltinMcps,
699
- reasoningSummaries: reasoningSummaries ?? engine.copilotReasoningSummaries,
673
+ stream: resolved.stream,
674
+ disableBuiltinMcps: resolved.disableBuiltinMcps,
675
+ reasoningSummaries: resolved.reasoningSummaries,
700
676
  };
701
677
  }
702
678
 
@@ -770,17 +746,17 @@ function callLLM(promptText, sysPromptText, opts = {}) {
770
746
  const model = _resolveModelForRuntime(runtime, { model: modelOverride, engineConfig });
771
747
  const imageGate = _resolveImageOpts(images, runtime && runtime.capabilities);
772
748
  if (imageGate.error) return _resolvedCallResult(_imageUnsupportedResult(runtime, imageGate.error));
773
- const runtimeFeatureOpts = _resolveRuntimeFeatureOpts({
774
- stream, disableBuiltinMcps, reasoningSummaries, engineConfig,
775
- });
749
+ const runtimeOptions = resolveInvocationOptions(runtime, {
750
+ model, maxTurns, allowedTools, effort, sessionId,
751
+ maxBudget, bare, fallbackModel, images: imageGate.images,
752
+ stream, disableBuiltinMcps, reasoningSummaries,
753
+ }, { engineConfig: engineConfig || {} });
776
754
 
777
755
  let _abort = null;
778
756
  const promise = new Promise((resolve) => {
779
757
  const _startMs = Date.now();
780
758
  const { proc, cleanupFiles, cleanupDirs } = _spawnProcess(promptText, sysPromptText, {
781
- direct, label, runtime, model, maxTurns, allowedTools, effort, sessionId,
782
- maxBudget, bare, fallbackModel, images: imageGate.images,
783
- ...runtimeFeatureOpts,
759
+ direct, label, runtime, adapterOptions: runtimeOptions,
784
760
  });
785
761
  let resolved = false;
786
762
  let exitFallbackTimer = null;
@@ -895,17 +871,17 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
895
871
  const model = _resolveModelForRuntime(runtime, { model: modelOverride, engineConfig });
896
872
  const imageGate = _resolveImageOpts(images, runtime && runtime.capabilities);
897
873
  if (imageGate.error) return _resolvedCallResult(_imageUnsupportedResult(runtime, imageGate.error));
898
- const runtimeFeatureOpts = _resolveRuntimeFeatureOpts({
899
- stream, disableBuiltinMcps, reasoningSummaries, engineConfig,
900
- });
874
+ const runtimeOptions = resolveInvocationOptions(runtime, {
875
+ model, maxTurns, allowedTools, effort, sessionId,
876
+ maxBudget, bare, fallbackModel, images: imageGate.images,
877
+ stream, disableBuiltinMcps, reasoningSummaries,
878
+ }, { engineConfig: engineConfig || {} });
901
879
 
902
880
  let _abort = null;
903
881
  const promise = new Promise((resolve) => {
904
882
  const _startMs = Date.now();
905
883
  const { proc, cleanupFiles, cleanupDirs } = _spawnProcess(promptText, sysPromptText, {
906
- direct, label, runtime, model, maxTurns, allowedTools, effort, sessionId,
907
- maxBudget, bare, fallbackModel, images: imageGate.images,
908
- ...runtimeFeatureOpts,
884
+ direct, label, runtime, adapterOptions: runtimeOptions,
909
885
  });
910
886
  let resolved = false;
911
887
  let exitFallbackTimer = null;
@@ -269,10 +269,12 @@ function searchMemoryRecords(query, options = {}) {
269
269
  }
270
270
  const limit = Math.max(1, Math.min(200, Number(options.limit) || 50));
271
271
  args.push(limit);
272
+ // CROSS JOIN pins FTS as the outer loop; Node 22's SQLite otherwise prefers
273
+ // the scope index and evaluates MATCH once for every scoped record.
272
274
  const sql = `
273
275
  SELECT m.*, bm25(memory_records_fts, 5.0, 1.0, 2.0, 3.0) AS fts_rank
274
276
  FROM memory_records_fts
275
- JOIN memory_records m ON m.rowid=memory_records_fts.rowid
277
+ CROSS JOIN memory_records m ON m.rowid=memory_records_fts.rowid
276
278
  WHERE ${where.join(' AND ')}
277
279
  ORDER BY fts_rank
278
280
  LIMIT ?