@yemi33/minions 0.1.2380 → 0.1.2382

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.
@@ -53,6 +53,8 @@ const NON_MUTATING_DISPATCH_TYPES = new Set([
53
53
  // PR source branch always differs and causes phantom auto-cancellation (#369).
54
54
  const PR_BRANCH_TARGETED_TYPES = new Set([
55
55
  WORK_TYPE.FIX,
56
+ WORK_TYPE.BUILD_FIX_COMPLEX,
57
+ WORK_TYPE.TEST,
56
58
  WORK_TYPE.VERIFY,
57
59
  WORK_TYPE.DECOMPOSE,
58
60
  ]);
@@ -148,7 +150,7 @@ function getPrDispatchDedupeKey(entry) {
148
150
  if (!entry?.type) return null;
149
151
  const targetKey = getPrDispatchTargetKey(entry);
150
152
  if (!targetKey) return null;
151
- const type = entry.type === WORK_TYPE.FIX ? WORK_TYPE.FIX : entry.type;
153
+ const type = shared.isFixLikeWorkType(entry.type) ? WORK_TYPE.FIX : entry.type;
152
154
  return `${targetKey}:${type}`;
153
155
  }
154
156
 
@@ -197,7 +199,7 @@ function findActivePrOrBranchLock(dispatch, item) {
197
199
  // PR-target dedup is FIX-only: a FIX shouldn't stack on top of another FIX
198
200
  // for the same PR, but a REVIEW + FIX pair targeting the same PR is the
199
201
  // normal review-then-fix flow and must not be dedup'd here.
200
- if (item?.type === WORK_TYPE.FIX) {
202
+ if (shared.isFixLikeWorkType(item?.type)) {
201
203
  const prTargetKey = getPrDispatchTargetKey(item);
202
204
  if (prTargetKey) {
203
205
  const existing = active.find(d => getPrDispatchTargetKey(d) === prTargetKey);
@@ -305,10 +307,10 @@ function addToDispatch(item) {
305
307
  function _persistInvalidWorkItem(item, evaluation) {
306
308
  const meta = item?.meta;
307
309
  const itemId = meta?.item?.id;
308
- if (!itemId) return { stuck: false };
310
+ if (!itemId) return { exhausted: false };
309
311
  let wiScope;
310
312
  try { wiScope = lifecycle().resolveWorkItemScope(meta); } catch { wiScope = null; }
311
- if (!wiScope) return { stuck: false };
313
+ if (!wiScope) return { exhausted: false };
312
314
  // W-mrcrh5hj0017d22f — repeat-rejection cap. Compare against the
313
315
  // description snapshot from the LAST rejection so an operator edit to the
314
316
  // WI resets the counter (it deserves a fresh evaluation), while an
@@ -317,7 +319,7 @@ function _persistInvalidWorkItem(item, evaluation) {
317
319
  const maxRejections = queries.getConfig()?.engine?.preDispatchEvalMaxRejections
318
320
  ?? ENGINE_DEFAULTS.preDispatchEvalMaxRejections;
319
321
  const currentDescription = String((meta.item && meta.item.description) || '');
320
- let stuck = false;
322
+ let exhausted = false;
321
323
  try {
322
324
  mutateWorkItems(wiScope, (items) => {
323
325
  if (!Array.isArray(items)) return items;
@@ -327,31 +329,79 @@ function _persistInvalidWorkItem(item, evaluation) {
327
329
  const prior = wi._preDispatchEval;
328
330
  const sameDescription = prior && prior.description === currentDescription;
329
331
  const rejectCount = (sameDescription ? (prior.rejectCount || 0) : 0) + 1;
332
+ const history = Array.isArray(prior?.history) ? prior.history.slice(-19) : [];
333
+ const evaluatedAt = ts();
334
+ history.push({
335
+ reason: evaluation.reason || '',
336
+ evaluatedAt,
337
+ description: currentDescription,
338
+ });
330
339
  wi._preDispatchEval = {
331
340
  valid: false,
332
341
  reason: evaluation.reason || '',
333
- evaluatedAt: ts(),
342
+ evaluatedAt,
334
343
  rejectCount,
335
344
  description: currentDescription,
345
+ history,
336
346
  };
337
347
  if (rejectCount >= maxRejections) {
338
- stuck = true;
339
- wi.status = WI_STATUS.FAILED;
340
- wi._failureClass = FAILURE_CLASS.PRE_DISPATCH_EVAL_STUCK;
341
- wi.failReason = `pre-dispatch-eval rejected this work item ${rejectCount} times in a row ` +
342
- `with an unchanged description — last reason: ${evaluation.reason || 'criteria not actionable'}`;
343
- wi.failedAt = ts();
344
- wi._pendingReason = 'pre_dispatch_eval_stuck';
348
+ exhausted = true;
349
+ wi.status = WI_STATUS.PENDING;
350
+ wi._preDispatchEval.exhausted = true;
351
+ wi._preDispatchEval.exhaustedAt = evaluatedAt;
352
+ wi._pendingReason = 'pre_dispatch_eval_rejected';
353
+ delete wi._failureClass;
354
+ delete wi.failReason;
355
+ delete wi.failedAt;
345
356
  }
346
357
  return items;
347
358
  }, { skipWriteIfUnchanged: true });
348
359
  } catch (e) {
349
360
  log('warn', `pre-dispatch-eval: failed to persist reason on ${itemId}: ${e.message}`);
350
361
  }
351
- if (stuck) {
352
- log('warn', `pre-dispatch-eval: ${itemId} rejected ${maxRejections}+ times with unchanged description — flipped to failed (PRE_DISPATCH_EVAL_STUCK) instead of re-evaluating forever`);
362
+ if (exhausted) {
363
+ log('warn', `pre-dispatch-eval: ${itemId} rejected ${maxRejections}+ times with unchanged description — paused pending operator edit`);
364
+ }
365
+ return { exhausted };
366
+ }
367
+
368
+ function _clearPreDispatchRejection(item, evaluation) {
369
+ const meta = item?.meta;
370
+ const itemId = meta?.item?.id;
371
+ if (!itemId) return;
372
+ let wiScope;
373
+ try { wiScope = lifecycle().resolveWorkItemScope(meta); } catch { wiScope = null; }
374
+ if (!wiScope) return;
375
+ try {
376
+ mutateWorkItems(wiScope, (items) => {
377
+ if (!Array.isArray(items)) return items;
378
+ const wi = items.find(w => w && w.id === itemId);
379
+ if (!wi || !wi._preDispatchEval) return items;
380
+ wi._preDispatchEval = {
381
+ ...wi._preDispatchEval,
382
+ valid: true,
383
+ reason: evaluation?.reason || '',
384
+ evaluatedAt: ts(),
385
+ rejectCount: 0,
386
+ description: String(meta.item?.description || ''),
387
+ };
388
+ delete wi._preDispatchEval.exhausted;
389
+ delete wi._preDispatchEval.exhaustedAt;
390
+ if (wi._pendingReason === 'pre_dispatch_eval_rejected') delete wi._pendingReason;
391
+ return items;
392
+ }, { skipWriteIfUnchanged: true });
393
+ } catch (e) {
394
+ log('warn', `pre-dispatch-eval: failed to clear rejection on ${itemId}: ${e.message}`);
395
+ }
396
+ try {
397
+ mutateDispatch((dispatch) => {
398
+ dispatch.review = (Array.isArray(dispatch.review) ? dispatch.review : [])
399
+ .filter(entry => entry?.meta?.item?.id !== itemId);
400
+ return dispatch;
401
+ });
402
+ } catch (e) {
403
+ log('warn', `pre-dispatch-eval: failed to clear review entry for ${itemId}: ${e.message}`);
353
404
  }
354
- return { stuck };
355
405
  }
356
406
 
357
407
  function _routeToReviewQueue(item, evaluation) {
@@ -436,6 +486,11 @@ async function addToDispatchWithValidation(item, opts = {}) {
436
486
  if (!hasCriteria && !hasUsableDescription) {
437
487
  return addToDispatch(item);
438
488
  }
489
+ const priorEvaluation = wi?._preDispatchEval;
490
+ if (priorEvaluation?.exhausted === true
491
+ && priorEvaluation.description === String(wi?.description || '')) {
492
+ return null;
493
+ }
439
494
 
440
495
  // W-mq9acoo800177bcb — PRD-sourced bypass. plan-to-prd already LLM-vets
441
496
  // every item it lands in a PRD, so re-validating each materialized item
@@ -475,14 +530,13 @@ async function addToDispatchWithValidation(item, opts = {}) {
475
530
  return addToDispatch(item);
476
531
  }
477
532
 
478
- if (!evaluation || evaluation.valid !== false) return addToDispatch(item);
533
+ if (!evaluation || evaluation.valid !== false) {
534
+ _clearPreDispatchRejection(item, evaluation);
535
+ return addToDispatch(item);
536
+ }
479
537
 
480
- const { stuck } = _persistInvalidWorkItem(item, evaluation);
481
- // W-mrcrh5hj0017d22f: once the WI has been flipped to failed for
482
- // repeat-rejection, don't also push it into the review queue — the
483
- // failed status already stops future discovery ticks from re-evaluating
484
- // it, and a review-queue entry would just be a stale duplicate signal.
485
- if (!stuck) _routeToReviewQueue(item, evaluation);
538
+ const { exhausted } = _persistInvalidWorkItem(item, evaluation);
539
+ if (!exhausted) _routeToReviewQueue(item, evaluation);
486
540
  log('warn', `pre-dispatch-eval: blocked work item ${wi.id} — ${evaluation.reason || 'criteria not actionable'}`);
487
541
  return null;
488
542
  }
@@ -703,7 +757,7 @@ async function pruneStalePrDispatchesAsync(config = queries.getConfig()) {
703
757
  for (const entry of pending) {
704
758
  const reason = reasonsById.get(entry.id);
705
759
  if (!reason) continue;
706
- if (entry.type !== WORK_TYPE.FIX || !_MERGED_OR_ABANDONED_REASON_RE.test(reason)) continue;
760
+ if (!shared.isFixLikeWorkType(entry.type) || !_MERGED_OR_ABANDONED_REASON_RE.test(reason)) continue;
707
761
  try {
708
762
  const wi = _resolveWorkItemForPrunedEntry(entry);
709
763
  const scopePaths = shared.extractScopeFilePathsFromWorkItem(wi);
@@ -1559,7 +1559,7 @@ function isPrAttachmentRequired(type, item, meta = {}) {
1559
1559
  // PR — the agent updates meta.pr in place. The meta.pr short-circuit beats
1560
1560
  // the explicit-flag fallthrough so a legacy requiresPr:true fix doesn't
1561
1561
  // trigger the contract when there's already a PR attached.
1562
- if ((type === WORK_TYPE.FIX || type === WORK_TYPE.TEST) && meta?.pr) return false;
1562
+ if ((shared.isFixLikeWorkType(type) || type === WORK_TYPE.TEST) && meta?.pr) return false;
1563
1563
 
1564
1564
  // Standalone test work is usually pure build/run/verify. It should only be
1565
1565
  // PR-required when the caller explicitly marks it as file-changing work.
@@ -1568,7 +1568,7 @@ function isPrAttachmentRequired(type, item, meta = {}) {
1568
1568
  return explicit
1569
1569
  || type === WORK_TYPE.IMPLEMENT
1570
1570
  || type === WORK_TYPE.IMPLEMENT_LARGE
1571
- || type === WORK_TYPE.FIX;
1571
+ || shared.isFixLikeWorkType(type);
1572
1572
  }
1573
1573
 
1574
1574
  function readOptionalJsonStrict(filePath, label, validate) {
@@ -4578,8 +4578,8 @@ function parseCompletionBoolean(value) {
4578
4578
  // detected, or null otherwise. The status must be a successful terminal state
4579
4579
  // — a failed/partial/in-progress completion that also claims `noop` is a
4580
4580
  // contradiction and falls through to the normal contract.
4581
- function parseCompletionNoop(completion) {
4582
- if (!completion || typeof completion !== 'object') return null;
4581
+ function completionDeclaresNoop(completion) {
4582
+ if (!completion || typeof completion !== 'object') return false;
4583
4583
  const explicit = parseCompletionBoolean(completion.noop);
4584
4584
  let resultIsNoop = false;
4585
4585
  if (typeof completion.result === 'string') {
@@ -4587,7 +4587,11 @@ function parseCompletionNoop(completion) {
4587
4587
  } else if (completion.result && typeof completion.result === 'object') {
4588
4588
  resultIsNoop = String(completion.result.type || '').trim().toLowerCase() === 'noop';
4589
4589
  }
4590
- if (explicit !== true && !resultIsNoop) return null;
4590
+ return explicit === true || resultIsNoop;
4591
+ }
4592
+
4593
+ function parseCompletionNoop(completion) {
4594
+ if (!completionDeclaresNoop(completion)) return null;
4591
4595
  const status = normalizeCompletionStatus(completion.status);
4592
4596
  if (status && status !== 'success' && status !== 'done' && status !== 'complete') return null;
4593
4597
  const reason = String(
@@ -4600,6 +4604,53 @@ function parseCompletionNoop(completion) {
4600
4604
  return reason || 'no-op completion';
4601
4605
  }
4602
4606
 
4607
+ function classifyBenignZeroTargetOutcome(dispatchItem, completion, resultSummary) {
4608
+ const item = dispatchItem?.meta?.item;
4609
+ if (!item?._scheduleId || item.createdBy !== 'scheduler') return null;
4610
+ const type = dispatchItem?.type || item.type;
4611
+ if (![WORK_TYPE.SETUP, WORK_TYPE.EXPLORE, WORK_TYPE.REVIEW].includes(type)) return null;
4612
+ if (!completionDeclaresNoop(completion)) return null;
4613
+ if (parseCompletionBoolean(completion?.needs_rerun ?? completion?.needsRerun) === true) return null;
4614
+ if (parseCompletionBoolean(completion?.retryable) === true) return null;
4615
+ if (completion?.securityFlags?.injectionAttempt === true) return null;
4616
+ const failureClass = String(completion?.failure_class || '').trim().toLowerCase();
4617
+ const nonceUnavailable = failureClass === 'completion-nonce-unavailable'
4618
+ || failureClass === 'nonce-unavailable';
4619
+ if (hasActionableFailureClass(failureClass) && !nonceUnavailable) return null;
4620
+ const status = normalizeCompletionStatus(completion?.status);
4621
+ const successfulStatus = status === 'success' || status === 'done' || status === 'complete';
4622
+ const nonceTransportFailure = nonceUnavailable && (status.startsWith('fail') || status === 'error');
4623
+ if (!successfulStatus && !nonceTransportFailure) return null;
4624
+
4625
+ const evidence = [...new Set([
4626
+ completion?.summary,
4627
+ completion?.noopReason,
4628
+ completion?.noop_reason,
4629
+ resultSummary,
4630
+ ].filter(Boolean).map(value => String(value).trim()))].join(' ').trim();
4631
+ if (!evidence) return null;
4632
+
4633
+ const mutationOccurred = [
4634
+ /\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,
4635
+ /\b(?:created|enrolled|dispatched|posted|deleted|removed)\s+[1-9]\d*\b/i,
4636
+ ].some(pattern => pattern.test(evidence));
4637
+ if (mutationOccurred) return null;
4638
+
4639
+ const zeroTargets = [
4640
+ /\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,
4641
+ /\bno\s+(?:open\s+issues?|eligible|qualifying|matching|context-only|merged|abandoned)\b/i,
4642
+ /\b(?:0|zero)\s+(?:eligible|qualifying|matching|context-only|open)\b/i,
4643
+ ].some(pattern => pattern.test(evidence));
4644
+ if (!zeroTargets) return null;
4645
+
4646
+ const noMutation = [
4647
+ /\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,
4648
+ /\b(?:0|zero)\s+(?:were\s+)?(?:created|enrolled|dispatched|posted|deleted|removed)\b/i,
4649
+ /\b(?:created|enrolled|dispatched|posted|deleted|removed)\s+(?:0|zero)\b/i,
4650
+ ].some(pattern => pattern.test(evidence));
4651
+ return noMutation ? evidence : null;
4652
+ }
4653
+
4603
4654
  function normalizeReviewVerdict(verdict) {
4604
4655
  const value = String(verdict || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
4605
4656
  if (value === 'approve' || value === 'approved') return REVIEW_STATUS.APPROVED;
@@ -5482,11 +5533,15 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5482
5533
  // P-d2a8f6c1: a nonce mismatch always counts as an agent-reported failure
5483
5534
  // (regardless of what the discarded report claimed). This forces both the
5484
5535
  // effectiveSuccess gate below and the dispatch result in engine.js to ERROR.
5485
- const agentReportedFailure = !!nonceMismatch
5486
- || completionStatus.startsWith('fail')
5536
+ const benignNoop = nonceMismatch
5537
+ ? null
5538
+ : classifyBenignZeroTargetOutcome(dispatchItem, structuredCompletion, completionGateSummary);
5539
+ const agentReportedFailure = !!nonceMismatch || (!benignNoop && (
5540
+ completionStatus.startsWith('fail')
5487
5541
  || completionStatus === 'error'
5488
5542
  || hasActionableFailureClass(structuredCompletion?.failure_class)
5489
- || agentNeedsRerun;
5543
+ || agentNeedsRerun
5544
+ ));
5490
5545
  // Untrusted completions cannot be honored as "retryable" by the agent — its
5491
5546
  // retryable claim was discarded with the rest of the report. Force false.
5492
5547
  const agentRetryable = nonceMismatch ? false : parseCompletionBoolean(structuredCompletion?.retryable);
@@ -5494,12 +5549,12 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5494
5549
  // Auto-recover: if a failed implement/fix/test agent created PRs, it likely succeeded before the failure surfaced.
5495
5550
  // P-d2a8f6c1: skip auto-recovery for nonce-mismatched dispatches — a forged
5496
5551
  // report shouldn't be able to ride PRs into a "done" status.
5497
- const prCreatingType = type === WORK_TYPE.IMPLEMENT || type === WORK_TYPE.IMPLEMENT_LARGE || type === WORK_TYPE.FIX || type === WORK_TYPE.TEST;
5552
+ const prCreatingType = type === WORK_TYPE.IMPLEMENT || type === WORK_TYPE.IMPLEMENT_LARGE || shared.isFixLikeWorkType(type) || type === WORK_TYPE.TEST;
5498
5553
  const autoRecovered = !nonceMismatch && !agentReportedFailure && !isSuccess && prsCreatedCount > 0 && prCreatingType && !!meta?.item?.id;
5499
5554
  if (autoRecovered) {
5500
5555
  log('info', `Auto-recovery: agent failed but created ${prsCreatedCount} PR(s) — upgrading ${meta.item.id} to done`);
5501
5556
  }
5502
- const effectiveSuccess = !nonceMismatch && ((isSuccess && !agentReportedFailure) || autoRecovered);
5557
+ const effectiveSuccess = !nonceMismatch && (benignNoop || (isSuccess && !agentReportedFailure) || autoRecovered);
5503
5558
  if (!nonceMismatch) {
5504
5559
  const episodeOutcome = effectiveSuccess
5505
5560
  ? (autoRecovered || completionStatus.startsWith('partial') ? 'partial' : 'success')
@@ -5571,7 +5626,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5571
5626
  // Without this, the second run produces no VERDICT, _retryCount increments,
5572
5627
  // and after 3 such bailouts the WI flips to status=failed even though the
5573
5628
  // original review was posted on the first run.
5574
- if (effectiveSuccess && type === WORK_TYPE.REVIEW && meta?.item?.id) {
5629
+ if (effectiveSuccess && type === WORK_TYPE.REVIEW && meta?.item?.id && !benignNoop) {
5575
5630
  const verdict = reviewVerdictFromCompletion(structuredCompletion) || parseReviewVerdict(resultSummary);
5576
5631
  if (!verdict && isReviewBailout(resultSummary)) {
5577
5632
  log('info', `Review ${meta.item.id} bailed out (review already posted) — treating as DONE without retry`);
@@ -5721,7 +5776,9 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5721
5776
 
5722
5777
  let completionContractFailure = null;
5723
5778
  if (effectiveSuccess && meta?.item?.id && !skipDoneStatus) {
5724
- const nonTerminalCompletion = detectNonTerminalResultSummary(completionGateSummary, structuredCompletion, reportCompletion, { detectPhantom });
5779
+ const nonTerminalCompletion = benignNoop
5780
+ ? null
5781
+ : detectNonTerminalResultSummary(completionGateSummary, structuredCompletion, reportCompletion, { detectPhantom });
5725
5782
  if (nonTerminalCompletion) {
5726
5783
  const isPhantomDetection = nonTerminalCompletion.phrase === 'phantom-completion';
5727
5784
  // P-e0b4f7a5 — before deferring a phantom retry, attempt to recover
@@ -5753,7 +5810,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5753
5810
  // shipped, dispatch premise wrong, self-authored review, etc.). Skip the PR
5754
5811
  // attachment contract — a missing PR is intentional, not a silent failure.
5755
5812
  const noopRationale = (effectiveSuccess && meta?.item?.id && !skipDoneStatus)
5756
- ? parseCompletionNoop(structuredCompletion)
5813
+ ? (parseCompletionNoop(structuredCompletion) || benignNoop)
5757
5814
  : null;
5758
5815
  if (noopRationale) {
5759
5816
  log('info', `No-op completion for ${meta.item.id}: ${noopRationale.slice(0, 200)}`);
@@ -5792,14 +5849,16 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5792
5849
  try { stampPrdItemWorkItemId(meta.item.id, meta.item.sourcePlan); } catch (err) { log('warn', `stampPrdItemWorkItemId: ${err.message}`); }
5793
5850
  }
5794
5851
  promoteCompletionArtifacts(meta, agentId, dispatchItem.id, structuredCompletion, { resultSummary });
5795
- // M003 — auto-dispatch a live-validation WI when a coding WI completes.
5796
- try { autoDispatchLiveValidationWi(meta, config); } catch (err) { log('warn', `autoDispatchLiveValidationWi: ${err.message}`); }
5797
5852
  // P-mqyp0009y025z6a7 — goal invalidation: cancel any pending/queued WIs
5798
5853
  // listed in the completion report's optional `invalidates[]` field.
5799
5854
  if (structuredCompletion && Array.isArray(structuredCompletion.invalidates) && structuredCompletion.invalidates.length > 0) {
5800
5855
  try { applyGoalInvalidation(meta.item.id, structuredCompletion.invalidates, config); } catch (err) { log('warn', `applyGoalInvalidation: ${err.message}`); }
5801
5856
  }
5802
5857
  }
5858
+ if (effectiveSuccess && !skipDoneStatus) {
5859
+ // Coding WIs and direct PR-sourced fix dispatches share this follow-up path.
5860
+ try { autoDispatchLiveValidationWi(meta, config, dispatchItem); } catch (err) { log('warn', `autoDispatchLiveValidationWi: ${err.message}`); }
5861
+ }
5803
5862
  // Failure retry is handled by completeDispatch in dispatch.js — not duplicated here.
5804
5863
  // Only clear _decomposing flag on failure so decompose items don't get permanently stuck.
5805
5864
  if (!effectiveSuccess && meta?.item?.id && type === WORK_TYPE.DECOMPOSE) {
@@ -5994,7 +6053,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5994
6053
 
5995
6054
  let prFixBranchChange = null;
5996
6055
  let prFixBuildStillFailing = null;
5997
- if (type === WORK_TYPE.FIX && effectiveSuccess && meta?.pr?.id) {
6056
+ if (shared.isFixLikeWorkType(type) && effectiveSuccess && meta?.pr?.id) {
5998
6057
  try {
5999
6058
  prFixBranchChange = await detectPrFixBranchChange(meta, config);
6000
6059
  } catch (err) {
@@ -6131,12 +6190,12 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6131
6190
  const hardContractFail = completionContractFailure?.severity === 'hard'
6132
6191
  || completionContractFailure?.nonTerminal === true;
6133
6192
  const finalResult = hardContractFail ? DISPATCH_RESULT.ERROR : (effectiveSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR);
6134
- if (type === WORK_TYPE.REVIEW && finalResult === DISPATCH_RESULT.SUCCESS && !skipDoneStatus) {
6193
+ if (type === WORK_TYPE.REVIEW && finalResult === DISPATCH_RESULT.SUCCESS && !skipDoneStatus && !benignNoop) {
6135
6194
  await updatePrAfterReview(agentId, meta?.pr, meta?.project, config, resultSummary, structuredCompletion, dispatchItem);
6136
6195
  } else if (type === WORK_TYPE.REVIEW) {
6137
6196
  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`);
6138
6197
  }
6139
- if (type === WORK_TYPE.FIX && effectiveSuccess) {
6198
+ if (shared.isFixLikeWorkType(type) && effectiveSuccess) {
6140
6199
  updatePrAfterFix(meta?.pr, meta?.project, meta?.source, {
6141
6200
  branchChanged: fixCompletionChangedBranch(structuredCompletion),
6142
6201
  automationCauseKey: meta?.automationCauseKey,
@@ -6173,7 +6232,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6173
6232
  }
6174
6233
  } catch (err) { log('warn', `PRD sync after fix: ${err.message}`); }
6175
6234
  }
6176
- } else if (type === WORK_TYPE.FIX && meta?.pr?.id) {
6235
+ } else if (shared.isFixLikeWorkType(type) && meta?.pr?.id) {
6177
6236
  // W-mph6br6a0006a2b9 (F9): the fix dispatch did not complete cleanly
6178
6237
  // (agent crash, non-zero exit, hard contract failure, or nonce mismatch),
6179
6238
  // so updatePrAfterFix above was skipped. Without a write here the PR's
@@ -6230,7 +6289,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6230
6289
  const metricsResult = isAutoRetry ? 'retry' : finalResult;
6231
6290
  updateMetrics(agentId, dispatchItem, metricsResult, taskUsage, prsCreatedCount, model);
6232
6291
 
6233
- return { resultSummary, taskUsage, autoRecovered, structuredCompletion, completionContractFailure, agentReportedFailure, agentRetryable, nonceMismatch };
6292
+ return { resultSummary, taskUsage, autoRecovered, structuredCompletion, completionContractFailure, agentReportedFailure, agentRetryable, nonceMismatch, benignNoop };
6234
6293
  }
6235
6294
 
6236
6295
  // ─── PR → PRD Status Sync ─────────────────────────────────────────────────────
@@ -6800,37 +6859,9 @@ function collapseAllDuplicatePrRecords(config) {
6800
6859
  // a coding WI (not one of the validation types itself). Skips if the coding WI
6801
6860
  // has no PR.
6802
6861
  //
6803
- // W-mrdq22j700056c6c — gated to real code-authoring completions only. Only
6804
- // item.type in {implement, implement:large, fix} ever spawns a follow-up
6805
- // validation WI. Every other work-item type (review, test, verify, setup,
6806
- // explore, ask, docs, decompose, ...) is excluded even when it resolves a PR
6807
- // ref and autoDispatch is on, because those completions don't author a code
6808
- // diff worth re-validating — e.g. a `review` dispatch that merely comments on
6809
- // someone else's PR previously spawned a spurious "Validate: Review opg PR
6810
- // #792 ..." follow-up. See the ELIGIBLE_TYPES check below.
6811
- //
6812
- // Files exactly ONE validation WI per coding-WI completion (W-mrhybval0001).
6813
- // `liveValidation.type` still governs CHECKOUT ROUTING — the set of work-item
6814
- // types that run in-place on the live checkout (see resolveCheckoutMode's
6815
- // membership check) — but auto-validation no longer fans that array out into
6816
- // one validation WI per type. That earlier behavior (W-mr2m1ute000a9c01)
6817
- // conflated "which types run live" with "how many validation tasks to file";
6818
- // a multi-select routing config would silently spawn N validation WIs after
6819
- // every coding completion. Validation is a single downstream step, so we
6820
- // collapse to one WI.
6821
- //
6822
- // liveValidation.type may be a single string (legacy / back-compat) or an
6823
- // array of strings (multi-select hybrid types). We choose ONE validation type
6824
- // for the single WI. It MUST be a member of lvTypes, otherwise resolveCheckoutMode
6825
- // would route the validation WI to a worktree instead of the live checkout
6826
- // (defeating hybrid validation). Preference order: the canonical `test` type
6827
- // when configured, else the first configured live type. The completing item's
6828
- // own type is excluded so a validation-type WI can't recursively validate
6829
- // itself (anti-loop).
6830
- //
6831
- // Only these item.type values ever trigger this hook (see ELIGIBLE_TYPES
6832
- // below) — real code-authoring completions, not review/test/verify/setup/
6833
- // explore/ask/docs/decompose completions.
6862
+ // Only real code-authoring completions trigger the hook. The follow-up is
6863
+ // always a canonical `test` WI with the PR-focused `build-and-test` playbook;
6864
+ // liveValidation.type controls routing, not the semantic type of the task.
6834
6865
  //
6835
6866
  // Deduplicates per codingWiId: any non-terminal WI with
6836
6867
  // meta.liveValidationFor === codingWiId blocks a second dispatch, so only one
@@ -6852,22 +6883,30 @@ function collapseAllDuplicatePrRecords(config) {
6852
6883
  // verify, setup, explore, ask, docs, decompose, ...) is excluded even when it
6853
6884
  // resolves a PR ref, because those completions don't author a code diff
6854
6885
  // worth re-validating.
6855
- const ELIGIBLE_LIVE_VALIDATION_TYPES = new Set([WORK_TYPE.IMPLEMENT, WORK_TYPE.IMPLEMENT_LARGE, WORK_TYPE.FIX]);
6886
+ const ELIGIBLE_LIVE_VALIDATION_TYPES = new Set([
6887
+ WORK_TYPE.IMPLEMENT,
6888
+ WORK_TYPE.IMPLEMENT_LARGE,
6889
+ WORK_TYPE.FIX,
6890
+ WORK_TYPE.BUILD_FIX_COMPLEX,
6891
+ ]);
6856
6892
 
6857
- function autoDispatchLiveValidationWi(meta, config) {
6858
- const item = meta?.item;
6859
- if (!item?.id || !item?.type) return;
6893
+ function autoDispatchLiveValidationWi(meta, config, dispatchItem = null) {
6894
+ const item = meta?.item || null;
6895
+ const sourceType = item?.type || dispatchItem?.type || null;
6896
+ const sourceKey = item?.id || dispatchItem?.id || null;
6897
+ if (!sourceKey || !sourceType) return;
6898
+ if (!item && !meta?.pr) return;
6860
6899
 
6861
6900
  // Never re-trigger off the completion of a WI that was itself auto-created
6862
6901
  // as a live-validation follow-up — that's what causes the infinite
6863
6902
  // "Validate: Validate: ..." cascade (W-mr9u39az000db5c2 / issue #708).
6864
- if (item.meta && item.meta.liveValidationFor) return;
6903
+ if (item?.meta && item.meta.liveValidationFor) return;
6865
6904
 
6866
- // W-mrdq22j700056c6c — only implement/implement:large/fix completions ever
6905
+ // Only implement/implement:large/fix/build-fix-complex completions ever
6867
6906
  // author a real code diff worth validating. Review, test, verify, setup,
6868
6907
  // explore, ask, docs, decompose, etc. completions must never spawn a
6869
6908
  // "Validate: ..." follow-up, even when they resolve a PR reference.
6870
- if (!ELIGIBLE_LIVE_VALIDATION_TYPES.has(item.type)) return;
6909
+ if (!ELIGIBLE_LIVE_VALIDATION_TYPES.has(sourceType)) return;
6871
6910
 
6872
6911
  // Issue #716 — QA Session infrastructure WIs (SETUP/DRAFT/EXECUTE, tagged
6873
6912
  // via meta.sessionId and always dispatched with skipPr:true) never own a
@@ -6877,7 +6916,7 @@ function autoDispatchLiveValidationWi(meta, config) {
6877
6916
  // for all three phases) spawns spurious "Validate: QA Session ..." WIs the
6878
6917
  // moment any PR reference resolves against it (see the skipPr guard below
6879
6918
  // resolveWorkItemPrRecord in engine.js for the matching pre-dispatch fix).
6880
- if (item.skipPr || (item.meta && item.meta.sessionId)) return;
6919
+ if (item?.skipPr || (item?.meta && item.meta.sessionId)) return;
6881
6920
 
6882
6921
  const projectName = meta?.project?.name;
6883
6922
  if (!projectName) return;
@@ -6887,23 +6926,8 @@ function autoDispatchLiveValidationWi(meta, config) {
6887
6926
  const project = projects.find(p => p && p.name === projectName);
6888
6927
  if (!project) return;
6889
6928
 
6890
- const lv = project.liveValidation;
6891
- if (!lv || lv.autoDispatch !== true || !lv.type) return;
6892
-
6893
- const lvTypes = Array.isArray(lv.type) ? lv.type : [lv.type];
6894
-
6895
- // Candidate validation types = configured live types minus the completing
6896
- // item's own type (avoid an infinite validate-the-validation loop).
6897
- const candidates = lvTypes.filter(t => t && t !== item.type);
6898
- if (candidates.length === 0) return;
6899
-
6900
- // Collapse to a SINGLE validation type: prefer the canonical `test` type when
6901
- // it is a configured live type, else the first configured live type. Either
6902
- // way the chosen type is a member of lvTypes, so the validation WI routes to
6903
- // the live checkout (not a worktree).
6904
- const validationType = candidates.includes(WORK_TYPE.TEST)
6905
- ? WORK_TYPE.TEST
6906
- : candidates[0];
6929
+ const validationType = shared.resolveLiveValidationAutoDispatchType(project);
6930
+ if (!validationType) return;
6907
6931
 
6908
6932
  // Resolve PR reference: prefer the canonical stamped _pr field (set by
6909
6933
  // stampWiPrRef which runs earlier in runPostCompletionHooks), then fall
@@ -6915,11 +6939,22 @@ function autoDispatchLiveValidationWi(meta, config) {
6915
6939
  // (W-mq18ec6h000p7b87) and would otherwise let a non-fix WI (e.g.
6916
6940
  // 'implement'/'docs') that merely *mentions* an unrelated PR number in
6917
6941
  // prose trigger a live-validation dispatch against the wrong PR.
6918
- const prRef = item._pr || item._prUrl || shared.extractStructuredWorkItemPrRef(item) || null;
6942
+ const prRef = item
6943
+ ? (item._pr || item._prUrl || shared.extractStructuredWorkItemPrRef(item) || null)
6944
+ : (meta?.pr || null);
6919
6945
  if (!prRef) return;
6920
6946
 
6921
- const codingWiId = item.id;
6922
- const wiScope = resolveWorkItemScope(meta);
6947
+ const prUrl = item?._prUrl || (typeof prRef === 'object'
6948
+ ? (prRef.url || prRef.webUrl || prRef.remoteUrl || null)
6949
+ : (/^https?:\/\//i.test(String(prRef)) ? String(prRef) : null));
6950
+ const prId = typeof prRef === 'object'
6951
+ ? (prRef.id || prRef.pr_id || prRef.prId || prRef.pullRequestId || shared.getPrNumber(prRef))
6952
+ : String(prRef);
6953
+ const prTarget = prUrl || (prId != null ? String(prId) : null);
6954
+ if (!prTarget) return;
6955
+
6956
+ const codingWiId = sourceKey;
6957
+ const wiScope = item ? resolveWorkItemScope(meta) : projectName;
6923
6958
  if (!wiScope) return;
6924
6959
 
6925
6960
  try {
@@ -6942,7 +6977,7 @@ function autoDispatchLiveValidationWi(meta, config) {
6942
6977
  return items;
6943
6978
  }
6944
6979
 
6945
- const proposedTitle = 'Validate: ' + (item.title || codingWiId);
6980
+ const proposedTitle = 'Validate: ' + (item?.title || meta?.pr?.title || codingWiId);
6946
6981
  // Defense-in-depth (belt-and-suspenders alongside the meta.liveValidationFor
6947
6982
  // guard above): never create a doubly-nested "Validate: Validate: ..."
6948
6983
  // title even if some future caller manages to invoke this function
@@ -6957,11 +6992,20 @@ function autoDispatchLiveValidationWi(meta, config) {
6957
6992
  title: proposedTitle,
6958
6993
  type: validationType,
6959
6994
  status: WI_STATUS.PENDING,
6960
- depends_on: [codingWiId],
6961
- references: [prRef],
6962
- meta: { liveValidationFor: codingWiId, liveValidationType: validationType },
6995
+ depends_on: item?.id ? [codingWiId] : [],
6996
+ targetPr: prTarget,
6997
+ pr_id: prId != null ? String(prId) : prTarget,
6998
+ references: prUrl
6999
+ ? [{ url: prUrl, label: 'Source PR' }]
7000
+ : [prTarget],
7001
+ meta: {
7002
+ liveValidationFor: codingWiId,
7003
+ liveValidationType: validationType,
7004
+ playbook: 'build-and-test',
7005
+ ...(dispatchItem?.id ? { sourceDispatchId: dispatchItem.id } : {}),
7006
+ },
6963
7007
  project: projectName,
6964
- priority: item.priority || 'medium',
7008
+ priority: item?.priority || 'medium',
6965
7009
  created: ts(),
6966
7010
  createdBy: 'lifecycle:live-validation-auto-dispatch',
6967
7011
  };
@@ -6999,6 +7043,7 @@ module.exports = {
6999
7043
  parseReviewVerdict,
7000
7044
  isReviewBailout,
7001
7045
  parseCompletionNoop,
7046
+ classifyBenignZeroTargetOutcome, // exported for testing
7002
7047
  detectNonTerminalResultSummary,
7003
7048
  deferNonTerminalCompletion,
7004
7049
  deferPhantomCompletion,