@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.
- package/bin/minions.js +41 -2
- package/dashboard/js/refresh.js +9 -3
- package/dashboard/js/render-other.js +81 -23
- package/dashboard/js/render-work-items.js +61 -20
- package/dashboard/js/settings.js +47 -25
- package/dashboard/pages/engine.html +6 -0
- package/dashboard.js +126 -62
- package/docs/README.md +1 -0
- package/docs/claude-md-propagation.md +118 -0
- package/docs/engine-restart.md +10 -0
- package/docs/live-checkout-mode.md +45 -26
- package/docs/worktree-lifecycle.md +9 -0
- package/engine/claude-md-context.js +195 -0
- package/engine/consolidation.js +47 -3
- package/engine/db/migrations/026-pre-dispatch-rejections.js +55 -0
- package/engine/dispatch.js +78 -24
- package/engine/lifecycle.js +129 -84
- package/engine/live-checkout.js +193 -149
- package/engine/pipeline.js +147 -20
- package/engine/playbook.js +47 -15
- package/engine/prd-store.js +14 -6
- package/engine/quarantine-refs.js +33 -0
- package/engine/queries.js +8 -6
- package/engine/restart-health.js +69 -4
- package/engine/routing.js +1 -1
- package/engine/runtimes/claude.js +6 -0
- package/engine/runtimes/codex.js +18 -0
- package/engine/runtimes/copilot.js +7 -0
- package/engine/shared.js +199 -106
- package/engine/supervisor.js +72 -16
- package/engine/timeout.js +87 -16
- package/engine/untrusted-fence.js +2 -1
- package/engine.js +301 -31
- package/package.json +1 -1
- package/prompts/cc-system.md +7 -7
package/engine/dispatch.js
CHANGED
|
@@ -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
|
|
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
|
|
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 {
|
|
310
|
+
if (!itemId) return { exhausted: false };
|
|
309
311
|
let wiScope;
|
|
310
312
|
try { wiScope = lifecycle().resolveWorkItemScope(meta); } catch { wiScope = null; }
|
|
311
|
-
if (!wiScope) return {
|
|
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
|
|
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
|
|
342
|
+
evaluatedAt,
|
|
334
343
|
rejectCount,
|
|
335
344
|
description: currentDescription,
|
|
345
|
+
history,
|
|
336
346
|
};
|
|
337
347
|
if (rejectCount >= maxRejections) {
|
|
338
|
-
|
|
339
|
-
wi.status = WI_STATUS.
|
|
340
|
-
wi.
|
|
341
|
-
wi.
|
|
342
|
-
|
|
343
|
-
wi.
|
|
344
|
-
wi.
|
|
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 (
|
|
352
|
-
log('warn', `pre-dispatch-eval: ${itemId} rejected ${maxRejections}+ times with unchanged description —
|
|
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)
|
|
533
|
+
if (!evaluation || evaluation.valid !== false) {
|
|
534
|
+
_clearPreDispatchRejection(item, evaluation);
|
|
535
|
+
return addToDispatch(item);
|
|
536
|
+
}
|
|
479
537
|
|
|
480
|
-
const {
|
|
481
|
-
|
|
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
|
|
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);
|
package/engine/lifecycle.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
4582
|
-
if (!completion || typeof completion !== 'object') return
|
|
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
|
-
|
|
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
|
|
5486
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
6804
|
-
//
|
|
6805
|
-
//
|
|
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([
|
|
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
|
-
|
|
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
|
|
6903
|
+
if (item?.meta && item.meta.liveValidationFor) return;
|
|
6865
6904
|
|
|
6866
|
-
//
|
|
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(
|
|
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
|
|
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
|
|
6891
|
-
if (!
|
|
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
|
|
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
|
|
6922
|
-
|
|
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
|
|
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
|
-
|
|
6962
|
-
|
|
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
|
|
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,
|