@yemi33/minions 0.1.1650 → 0.1.1651
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/CHANGELOG.md +6 -3
- package/engine/ado.js +17 -30
- package/engine/copilot-models.json +1 -1
- package/engine/dispatch.js +14 -6
- package/engine/github.js +24 -22
- package/engine/lifecycle.js +147 -48
- package/engine/runtimes/claude.js +90 -0
- package/engine/runtimes/copilot.js +90 -0
- package/engine/shared.js +45 -3
- package/engine/spawn-agent.js +9 -6
- package/engine.js +108 -139
- package/package.json +1 -1
- package/playbooks/fix.md +2 -2
- package/playbooks/implement-shared.md +2 -2
- package/playbooks/review.md +2 -3
- package/playbooks/shared-rules.md +12 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.1651 (2026-05-01)
|
|
4
|
+
|
|
5
|
+
### Other
|
|
6
|
+
- refactor: delegate orchestration policy
|
|
7
|
+
|
|
8
|
+
## 0.1.1649 (2026-05-01)
|
|
4
9
|
|
|
5
10
|
### Features
|
|
6
|
-
- reject premature task_complete for nonterminal summaries
|
|
7
11
|
- ADO build poll repositoryId GUID handling
|
|
8
12
|
|
|
9
13
|
### Fixes
|
|
10
|
-
- yemi33/minions#1927
|
|
11
14
|
- yemi33/minions#1925
|
|
12
15
|
|
|
13
16
|
## 0.1.1648 (2026-05-01)
|
package/engine/ado.js
CHANGED
|
@@ -372,6 +372,7 @@ async function forEachActivePr(config, token, callback) {
|
|
|
372
372
|
}
|
|
373
373
|
|
|
374
374
|
let projectUpdated = 0;
|
|
375
|
+
const updatedRecords = [];
|
|
375
376
|
const orgBase = getAdoOrgBase(project);
|
|
376
377
|
|
|
377
378
|
// Parallelize PR polling within each project (max 5 concurrent to avoid rate limits)
|
|
@@ -381,31 +382,36 @@ async function forEachActivePr(config, token, callback) {
|
|
|
381
382
|
const results = await Promise.allSettled(batch.map(async (pr) => {
|
|
382
383
|
const prNum = shared.getPrNumber(pr);
|
|
383
384
|
if (!prNum) return false;
|
|
384
|
-
|
|
385
|
+
const before = shared.snapshotPrRecord(pr);
|
|
386
|
+
const updated = await callback(project, pr, prNum, orgBase, adoRepositoryId);
|
|
387
|
+
if (updated) return { before, after: shared.snapshotPrRecord(pr) };
|
|
388
|
+
return false;
|
|
385
389
|
}));
|
|
386
390
|
for (const r of results) {
|
|
387
|
-
if (r.status === 'fulfilled' && r.value)
|
|
391
|
+
if (r.status === 'fulfilled' && r.value) {
|
|
392
|
+
projectUpdated++;
|
|
393
|
+
updatedRecords.push(r.value);
|
|
394
|
+
}
|
|
388
395
|
if (r.status === 'rejected') log('warn', `PR poll error: ${r.reason?.message || r.reason}`);
|
|
389
396
|
}
|
|
390
397
|
}
|
|
391
398
|
|
|
392
399
|
if (projectUpdated > 0) {
|
|
393
400
|
mutateJsonFileLocked(shared.projectPrPath(project), (currentPrs) => {
|
|
394
|
-
// Merge back
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
const updatedPrNumber = shared.getPrNumber(updatedPr);
|
|
401
|
+
// Merge back only fields changed by callbacks; preserve concurrent disk updates.
|
|
402
|
+
for (const { before, after } of updatedRecords) {
|
|
403
|
+
const updatedPrNumber = shared.getPrNumber(after);
|
|
398
404
|
const idx = currentPrs.findIndex(p =>
|
|
399
|
-
p.id ===
|
|
405
|
+
p.id === after.id
|
|
400
406
|
|| (updatedPrNumber != null && shared.getPrNumber(p) === updatedPrNumber)
|
|
401
407
|
);
|
|
402
408
|
if (idx >= 0) {
|
|
403
409
|
// Never downgrade reviewStatus from 'approved' — it's a permanent terminal state
|
|
404
410
|
// The disk version may have been set to 'approved' by another writer after we read
|
|
405
|
-
if (currentPrs[idx].reviewStatus === 'approved' &&
|
|
406
|
-
|
|
411
|
+
if (currentPrs[idx].reviewStatus === 'approved' && after.reviewStatus !== 'approved') {
|
|
412
|
+
after.reviewStatus = 'approved';
|
|
407
413
|
}
|
|
408
|
-
currentPrs[idx]
|
|
414
|
+
shared.applyPrFieldDelta(currentPrs[idx], before, after);
|
|
409
415
|
}
|
|
410
416
|
// Don't push if not found — it was deleted by another writer, respect that
|
|
411
417
|
}
|
|
@@ -579,7 +585,6 @@ async function pollPrStatus(config) {
|
|
|
579
585
|
const mergeCommitId = prData.lastMergeCommit?.commitId;
|
|
580
586
|
let buildStatus = pr.buildStatus || 'none';
|
|
581
587
|
let buildFailReason = pr.buildFailReason || '';
|
|
582
|
-
let buildStatuses = []; // for error log fetching
|
|
583
588
|
let buildStatusResolved = true;
|
|
584
589
|
let buildStatusStaleDetail = '';
|
|
585
590
|
|
|
@@ -603,11 +608,6 @@ async function pollPrStatus(config) {
|
|
|
603
608
|
if (buildStatus === 'failing') {
|
|
604
609
|
const failed = prBuilds.find(b => b.result === 'failed');
|
|
605
610
|
buildFailReason = failed?.definition?.name || 'Build failed';
|
|
606
|
-
// Build fake status objects for error log fetching
|
|
607
|
-
buildStatuses = prBuilds.filter(b => b.result === 'failed').map(b => ({
|
|
608
|
-
state: 'failed', targetUrl: `${orgBase}/${project.adoProject}/_build/results?buildId=${b.id}`,
|
|
609
|
-
_buildId: String(b.id),
|
|
610
|
-
}));
|
|
611
611
|
}
|
|
612
612
|
} else if (allBuilds.length > 0 && pr.buildStatus) {
|
|
613
613
|
// Stale merge-commit fallback — ADO returned builds for this PR's merge ref
|
|
@@ -674,20 +674,7 @@ async function pollPrStatus(config) {
|
|
|
674
674
|
}
|
|
675
675
|
updated = true;
|
|
676
676
|
|
|
677
|
-
// Fetch actual compiler/build error logs when transitioning to failing
|
|
678
677
|
if (buildStatus === 'failing') {
|
|
679
|
-
const failedStatusObjs = buildStatuses.filter(s => s.state === 'failed' || s.state === 'error').slice(0, 10);
|
|
680
|
-
const logParts = [];
|
|
681
|
-
const seenBuildIds = new Set();
|
|
682
|
-
for (const failedStatusObj of failedStatusObjs) {
|
|
683
|
-
const errorLog = await fetchAdoBuildErrorLog(orgBase, project, failedStatusObj, token, pr, seenBuildIds);
|
|
684
|
-
if (errorLog) logParts.push(errorLog);
|
|
685
|
-
}
|
|
686
|
-
if (logParts.length > 0) {
|
|
687
|
-
pr.buildErrorLog = logParts.join('\n\n');
|
|
688
|
-
log('info', `PR ${pr.id}: fetched error logs from ${logParts.length} failing pipeline(s)`);
|
|
689
|
-
}
|
|
690
|
-
|
|
691
678
|
// Teams notification for build failure — non-blocking
|
|
692
679
|
try {
|
|
693
680
|
const teams = require('./teams');
|
|
@@ -810,7 +797,7 @@ async function pollPrHumanComments(config) {
|
|
|
810
797
|
const allNewDates = allHumanComments.filter(c => (new Date(c.date).getTime() || 0) > cutoffMs).map(c => c.date);
|
|
811
798
|
if (allNewDates.length > 0 && newHumanComments.length === 0) {
|
|
812
799
|
pr.humanFeedback = { ...(pr.humanFeedback || {}), lastProcessedCommentDate: allNewDates.sort().pop() };
|
|
813
|
-
return
|
|
800
|
+
return true;
|
|
814
801
|
}
|
|
815
802
|
if (newHumanComments.length === 0) return false;
|
|
816
803
|
|
package/engine/dispatch.js
CHANGED
|
@@ -20,8 +20,6 @@ const MINIONS_DIR = shared.MINIONS_DIR;
|
|
|
20
20
|
// Lazy require to break circular dependency with engine.js
|
|
21
21
|
let _lifecycle = null;
|
|
22
22
|
function lifecycle() { if (!_lifecycle) _lifecycle = require('./lifecycle'); return _lifecycle; }
|
|
23
|
-
let _recovery = null;
|
|
24
|
-
function recovery() { if (!_recovery) _recovery = require('./recovery'); return _recovery; }
|
|
25
23
|
|
|
26
24
|
// ─── Dispatch Mutation ───────────────────────────────────────────────────────
|
|
27
25
|
|
|
@@ -184,6 +182,16 @@ function isRetryableFailureReason(reason = '', failureClass = '') {
|
|
|
184
182
|
return !nonRetryable.some(s => r.includes(s));
|
|
185
183
|
}
|
|
186
184
|
|
|
185
|
+
function normalizeRetryableDecision(value) {
|
|
186
|
+
if (typeof value === 'boolean') return value;
|
|
187
|
+
if (typeof value === 'string') {
|
|
188
|
+
const normalized = value.trim().toLowerCase();
|
|
189
|
+
if (['true', 'yes', '1'].includes(normalized)) return true;
|
|
190
|
+
if (['false', 'no', '0'].includes(normalized)) return false;
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
187
195
|
function isCompletedWorkItemForFailure(item) {
|
|
188
196
|
return !!item && (
|
|
189
197
|
item.status === WI_STATUS.DONE ||
|
|
@@ -234,6 +242,7 @@ function writeFailedAgentReport(item, reason, resultSummary, failureClass) {
|
|
|
234
242
|
|
|
235
243
|
function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', resultSummary = '', opts = {}) {
|
|
236
244
|
const { processWorkItemFailure = true, failureClass } = opts;
|
|
245
|
+
const agentRetryable = normalizeRetryableDecision(opts.agentRetryable ?? opts.retryable);
|
|
237
246
|
let item = null;
|
|
238
247
|
|
|
239
248
|
mutateDispatch((dispatch) => {
|
|
@@ -273,7 +282,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
|
|
|
273
282
|
}
|
|
274
283
|
|
|
275
284
|
// Update source work item status on failure + auto-retry with backoff
|
|
276
|
-
const retryableFailure = isRetryableFailureReason(reason, failureClass);
|
|
285
|
+
const retryableFailure = agentRetryable !== undefined ? agentRetryable : isRetryableFailureReason(reason, failureClass);
|
|
277
286
|
let completedWorkItemFailure = false;
|
|
278
287
|
if (processWorkItemFailure && result === DISPATCH_RESULT.ERROR && item.meta?.item?.id) {
|
|
279
288
|
// If the live item cannot be resolved, keep the existing retry path.
|
|
@@ -295,9 +304,8 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
|
|
|
295
304
|
if (wi) retries = wi._retryCount || 0;
|
|
296
305
|
} catch (e) { log('warn', 'read retry count: ' + e.message); }
|
|
297
306
|
const maxRetries = ENGINE_DEFAULTS.maxRetries;
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if (retryableFailure && classAllowsRetry) {
|
|
307
|
+
const withinSafetyCap = retries < maxRetries;
|
|
308
|
+
if (retryableFailure && withinSafetyCap) {
|
|
301
309
|
log('info', `Dispatch error for ${item.meta.item.id} — auto-retry ${retries + 1}/${maxRetries}${failureClass ? ' [' + failureClass + ']' : ''}`);
|
|
302
310
|
lifecycle().updateWorkItemStatus(item.meta, WI_STATUS.PENDING, '');
|
|
303
311
|
// Remove this dispatch key from completed so dedupe doesn't block immediate redispatch.
|
package/engine/github.js
CHANGED
|
@@ -219,14 +219,19 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
219
219
|
resetSlugBackoff(slug);
|
|
220
220
|
|
|
221
221
|
let projectUpdated = 0;
|
|
222
|
+
const updatedRecords = [];
|
|
222
223
|
|
|
223
224
|
for (const pr of activePrs) {
|
|
224
225
|
const prNum = shared.getPrNumber(pr);
|
|
225
226
|
if (!prNum) continue;
|
|
226
227
|
|
|
227
228
|
try {
|
|
229
|
+
const before = shared.snapshotPrRecord(pr);
|
|
228
230
|
const updated = await callback(project, pr, prNum, slug);
|
|
229
|
-
if (updated)
|
|
231
|
+
if (updated) {
|
|
232
|
+
projectUpdated++;
|
|
233
|
+
updatedRecords.push({ before, after: shared.snapshotPrRecord(pr) });
|
|
234
|
+
}
|
|
230
235
|
} catch (err) {
|
|
231
236
|
log('warn', `GitHub: failed to poll PR ${pr.id}: ${err.message}`);
|
|
232
237
|
}
|
|
@@ -234,15 +239,15 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
234
239
|
|
|
235
240
|
if (projectUpdated > 0) {
|
|
236
241
|
mutateJsonFileLocked(projectPrPath(project), (currentPrs) => {
|
|
237
|
-
// Merge back
|
|
238
|
-
for (const
|
|
239
|
-
const idx = currentPrs.findIndex(p => p.id ===
|
|
242
|
+
// Merge back only fields changed by callbacks; preserve concurrent disk updates.
|
|
243
|
+
for (const { before, after } of updatedRecords) {
|
|
244
|
+
const idx = currentPrs.findIndex(p => p.id === after.id);
|
|
240
245
|
if (idx >= 0) {
|
|
241
246
|
// Never downgrade reviewStatus from 'approved' — it's a permanent terminal state
|
|
242
|
-
if (currentPrs[idx].reviewStatus === 'approved' &&
|
|
243
|
-
|
|
247
|
+
if (currentPrs[idx].reviewStatus === 'approved' && after.reviewStatus !== 'approved') {
|
|
248
|
+
after.reviewStatus = 'approved';
|
|
244
249
|
}
|
|
245
|
-
currentPrs[idx]
|
|
250
|
+
shared.applyPrFieldDelta(currentPrs[idx], before, after);
|
|
246
251
|
}
|
|
247
252
|
}
|
|
248
253
|
// Remove duplicates — prefer merged/abandoned over active
|
|
@@ -265,6 +270,7 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
265
270
|
const centralPrs = safeJson(centralPath) || [];
|
|
266
271
|
const activeCentral = centralPrs.filter(pr => PR_POLLABLE_STATUSES.has(pr.status) && pr.url);
|
|
267
272
|
let centralUpdated = 0;
|
|
273
|
+
const updatedCentralRecords = [];
|
|
268
274
|
for (const pr of activeCentral) {
|
|
269
275
|
const ghMatch = pr.url.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/);
|
|
270
276
|
if (!ghMatch) continue;
|
|
@@ -272,14 +278,17 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
272
278
|
if (isSlugInBackoff(slug)) continue;
|
|
273
279
|
const prNum = ghMatch[2];
|
|
274
280
|
try {
|
|
281
|
+
const before = shared.snapshotPrRecord(pr);
|
|
275
282
|
const updated = await callback(null, pr, prNum, slug);
|
|
276
283
|
if (updated) {
|
|
277
284
|
// Also update title/author/branch if still placeholder
|
|
278
|
-
|
|
285
|
+
const currentTitle = pr.title || '';
|
|
286
|
+
if (!currentTitle || currentTitle.includes('polling...') || pr.agent === 'human' || pr.description === undefined) {
|
|
279
287
|
const prData = await ghApi(`/pulls/${prNum}`, slug);
|
|
280
288
|
if (prData) {
|
|
281
|
-
|
|
282
|
-
|
|
289
|
+
const latestTitle = pr.title || '';
|
|
290
|
+
if (!latestTitle || latestTitle.includes('polling...') || /[{}"\[\]]/.test(latestTitle) || /^[0-9a-f-]{8,}$/i.test(latestTitle)) {
|
|
291
|
+
pr.title = (prData.title || latestTitle).slice(0, 120);
|
|
283
292
|
}
|
|
284
293
|
if (pr.description === undefined) pr.description = (prData.body || '').slice(0, 500);
|
|
285
294
|
if (pr.agent === 'human' && prData.user?.login) pr.agent = prData.user.login;
|
|
@@ -291,6 +300,7 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
291
300
|
}
|
|
292
301
|
}
|
|
293
302
|
centralUpdated++;
|
|
303
|
+
updatedCentralRecords.push({ before, after: shared.snapshotPrRecord(pr) });
|
|
294
304
|
}
|
|
295
305
|
} catch (err) {
|
|
296
306
|
log('warn', `GitHub: failed to poll central PR ${pr.id}: ${err.message}`);
|
|
@@ -299,9 +309,9 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
299
309
|
if (centralUpdated > 0) {
|
|
300
310
|
mutateJsonFileLocked(centralPath, (currentPrs) => {
|
|
301
311
|
// Only merge back central PRs that the callback actually modified
|
|
302
|
-
for (const
|
|
303
|
-
const idx = currentPrs.findIndex(p => p.id ===
|
|
304
|
-
if (idx >= 0) currentPrs[idx]
|
|
312
|
+
for (const { before, after } of updatedCentralRecords) {
|
|
313
|
+
const idx = currentPrs.findIndex(p => p.id === after.id);
|
|
314
|
+
if (idx >= 0) shared.applyPrFieldDelta(currentPrs[idx], before, after);
|
|
305
315
|
}
|
|
306
316
|
return currentPrs;
|
|
307
317
|
}, { defaultValue: [] });
|
|
@@ -487,15 +497,7 @@ async function pollPrStatus(config) {
|
|
|
487
497
|
}
|
|
488
498
|
updated = true;
|
|
489
499
|
|
|
490
|
-
// Fetch actual compiler/build error logs when transitioning to failing
|
|
491
500
|
if (buildStatus === 'failing') {
|
|
492
|
-
const failedRuns = runs.filter(r => r.conclusion === 'failure' || r.conclusion === 'timed_out');
|
|
493
|
-
const errorLog = await fetchGhBuildErrorLog(slug, failedRuns);
|
|
494
|
-
if (errorLog) {
|
|
495
|
-
pr.buildErrorLog = errorLog;
|
|
496
|
-
log('info', `PR ${pr.id}: fetched ${errorLog.split('\n').length} lines of build error log`);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
501
|
// Teams notification for build failure — non-blocking
|
|
500
502
|
try {
|
|
501
503
|
const teams = require('./teams');
|
|
@@ -611,7 +613,7 @@ async function pollPrHumanComments(config) {
|
|
|
611
613
|
const allNewDates = allCommentEntries.filter(c => (new Date(c.date).getTime() || 0) > cutoffMs).map(c => c.date);
|
|
612
614
|
if (allNewDates.length > 0 && newComments.length === 0) {
|
|
613
615
|
pr.humanFeedback = { ...(pr.humanFeedback || {}), lastProcessedCommentDate: allNewDates.sort().pop() };
|
|
614
|
-
return
|
|
616
|
+
return true; // agent comments only — persist cutoff without triggering fix
|
|
615
617
|
}
|
|
616
618
|
if (newComments.length === 0) return false;
|
|
617
619
|
|
package/engine/lifecycle.js
CHANGED
|
@@ -11,6 +11,7 @@ const { safeRead, safeJson, safeWrite, mutateJsonFileLocked, mutateWorkItems, ex
|
|
|
11
11
|
log, ts, dateStamp, WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PR_STATUS, DISPATCH_RESULT,
|
|
12
12
|
ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS, FAILURE_CLASS } = shared;
|
|
13
13
|
const { trackEngineUsage } = require('./llm');
|
|
14
|
+
const { resolveRuntime } = require('./runtimes');
|
|
14
15
|
const queries = require('./queries');
|
|
15
16
|
const { isBranchActive } = require('./cooldown');
|
|
16
17
|
const { worktreeDirMatchesBranch } = require('./cleanup');
|
|
@@ -980,36 +981,72 @@ async function findOpenPrForBranch(meta, config) {
|
|
|
980
981
|
return null;
|
|
981
982
|
}
|
|
982
983
|
|
|
983
|
-
|
|
984
|
+
// Lightweight probe for "did the agent's output contain ANY PR URL?". Used by
|
|
985
|
+
// the PR-attachment contract to distinguish silent-failure (no URL anywhere)
|
|
986
|
+
// from auto-link-miss (URL present but engine couldn't canonically attach it).
|
|
987
|
+
// Keep this regex roughly in sync with the gated detection in syncPrsFromOutput
|
|
988
|
+
// — this is yes/no only; no capture groups required.
|
|
989
|
+
function _outputContainsPrUrl(output) {
|
|
990
|
+
if (!output || typeof output !== 'string') return false;
|
|
991
|
+
const prUrlPattern = /https?:\/\/(?:github\.com\/[^\s"'\\)\]]+\/[^\s"'\\)\]]+\/pull\/\d+|(?:dev\.azure\.com|[^/\s"'\\)\]]+\.visualstudio\.com)[^\s"'\\)\]]*?pullrequest\/\d+)/i;
|
|
992
|
+
return prUrlPattern.test(output);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function markMissingPrAttachment(meta, agentId, reason, resultSummary, severity) {
|
|
984
996
|
const noPrWiPath = resolveWorkItemPath(meta);
|
|
997
|
+
const isHard = severity !== 'soft';
|
|
985
998
|
if (noPrWiPath) {
|
|
986
999
|
mutateJsonFileLocked(noPrWiPath, data => {
|
|
987
1000
|
if (!Array.isArray(data)) return data;
|
|
988
1001
|
const w = data.find(i => i.id === meta.item.id);
|
|
989
1002
|
if (!w) return data;
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1003
|
+
if (isHard) {
|
|
1004
|
+
w.status = WI_STATUS.NEEDS_REVIEW;
|
|
1005
|
+
w._missingPrAttachment = true;
|
|
1006
|
+
w.failReason = reason;
|
|
1007
|
+
w._lastReviewReason = reason;
|
|
1008
|
+
delete w.completedAt;
|
|
1009
|
+
delete w._noPr;
|
|
1010
|
+
delete w._noPrReason;
|
|
1011
|
+
} else {
|
|
1012
|
+
// Soft: don't change status or failReason — the agent did the work,
|
|
1013
|
+
// we just couldn't auto-attach the PR. Surface a flag for the dashboard
|
|
1014
|
+
// so the dispatch row can render a yellow "verify" badge.
|
|
1015
|
+
w._unverifiedPrAttachment = true;
|
|
1016
|
+
w._lastReviewReason = reason;
|
|
1017
|
+
}
|
|
997
1018
|
return data;
|
|
998
1019
|
}, { skipWriteIfUnchanged: true });
|
|
999
1020
|
}
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1021
|
+
if (isHard) {
|
|
1022
|
+
shared.writeToInbox('engine', `missing-pr-attachment-${meta.item.id}`,
|
|
1023
|
+
`# PR attachment missing for ${meta.item.id}\n\n` +
|
|
1024
|
+
`**Agent:** ${agentId}\n` +
|
|
1025
|
+
`**Work item:** \`${meta.item.id}\` — ${meta.item.title || ''}\n` +
|
|
1026
|
+
`**Type:** ${meta.item.type || 'unknown'}\n` +
|
|
1027
|
+
`**Branch:** ${meta.branch || '(none)'}\n\n` +
|
|
1028
|
+
`${reason}\n` +
|
|
1029
|
+
(resultSummary ? `\n## Agent summary\n${resultSummary}\n` : ''),
|
|
1030
|
+
null,
|
|
1031
|
+
{ sourceItem: meta.item.id, reason: 'missing-pr-attachment' });
|
|
1032
|
+
} else {
|
|
1033
|
+
shared.writeToInbox('engine', `pr-auto-link-unverified-${meta.item.id}`,
|
|
1034
|
+
`# PR auto-link unverified for ${meta.item.id}\n\n` +
|
|
1035
|
+
`**Agent:** ${agentId}\n` +
|
|
1036
|
+
`**Work item:** \`${meta.item.id}\` — ${meta.item.title || ''}\n` +
|
|
1037
|
+
`**Type:** ${meta.item.type || 'unknown'}\n` +
|
|
1038
|
+
`**Branch:** ${meta.branch || '(none)'}\n\n` +
|
|
1039
|
+
`${reason}\n\n` +
|
|
1040
|
+
`The agent's output mentioned a PR URL but the engine couldn't canonically attach it ` +
|
|
1041
|
+
`(URL detection regex miss, branch lookup race, untrusted tool_use signature, etc.). ` +
|
|
1042
|
+
`The work likely succeeded — verify against the project's PR list.\n` +
|
|
1043
|
+
(resultSummary ? `\n## Agent summary\n${resultSummary}\n` : ''),
|
|
1044
|
+
null,
|
|
1045
|
+
{ sourceItem: meta.item.id, reason: 'pr-auto-link-unverified' });
|
|
1046
|
+
}
|
|
1010
1047
|
}
|
|
1011
1048
|
|
|
1012
|
-
async function enforcePrAttachmentContract(type, meta, agentId, config, resultSummary) {
|
|
1049
|
+
async function enforcePrAttachmentContract(type, meta, agentId, config, resultSummary, output) {
|
|
1013
1050
|
if (!isPrAttachmentRequired(type, meta?.item, meta)) return null;
|
|
1014
1051
|
if (hasCanonicalPrAttachment(meta.item.id, config)) return null;
|
|
1015
1052
|
|
|
@@ -1037,10 +1074,16 @@ async function enforcePrAttachmentContract(type, meta, agentId, config, resultSu
|
|
|
1037
1074
|
if (hasCanonicalPrAttachment(meta.item.id, config)) return null;
|
|
1038
1075
|
}
|
|
1039
1076
|
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1077
|
+
// Distinguish "agent never claimed a PR" (hard — silent failure the contract
|
|
1078
|
+
// was designed to catch) from "agent claimed a PR but engine couldn't attach
|
|
1079
|
+
// it canonically" (soft — verification gap, not a failure).
|
|
1080
|
+
const severity = _outputContainsPrUrl(output) ? 'soft' : 'hard';
|
|
1081
|
+
const reason = severity === 'hard'
|
|
1082
|
+
? `${meta.item.id} completed but no PR URL was detected in the agent's output. Expected a PR — verify the agent didn't fail silently. (Branch: ${meta.branch || '(none)'}, agent: ${agentId})`
|
|
1083
|
+
: `${meta.item.id} completed and a PR URL was found in the agent's output, but it couldn't be canonically attached. The work likely succeeded — verify by checking the PR list. (Branch: ${meta.branch || '(none)'}, agent: ${agentId})`;
|
|
1084
|
+
markMissingPrAttachment(meta, agentId, reason, resultSummary, severity);
|
|
1085
|
+
log(severity === 'hard' ? 'warn' : 'info', reason);
|
|
1086
|
+
return { reason, itemId: meta.item.id, severity };
|
|
1044
1087
|
}
|
|
1045
1088
|
|
|
1046
1089
|
// ─── Post-Completion Hooks ──────────────────────────────────────────────────
|
|
@@ -1059,9 +1102,7 @@ function parseReviewVerdict(text) {
|
|
|
1059
1102
|
// Match "VERDICT: APPROVE" or "VERDICT: REQUEST_CHANGES" (case-insensitive, optional markdown bold)
|
|
1060
1103
|
const verdictMatch = text.match(/VERDICT[:\s]+\*{0,2}(APPROVE|REQUEST[_\s-]?CHANGES)\*{0,2}/i);
|
|
1061
1104
|
if (verdictMatch) {
|
|
1062
|
-
|
|
1063
|
-
if (v === 'APPROVE') return 'approved';
|
|
1064
|
-
if (v.includes('CHANGES')) return 'changes-requested';
|
|
1105
|
+
return normalizeReviewVerdict(verdictMatch[1]);
|
|
1065
1106
|
}
|
|
1066
1107
|
return null;
|
|
1067
1108
|
}
|
|
@@ -1083,7 +1124,7 @@ function isReviewBailout(text) {
|
|
|
1083
1124
|
return /bail(ing)?\s+out/i.test(text) || /already\s+posted/i.test(text);
|
|
1084
1125
|
}
|
|
1085
1126
|
|
|
1086
|
-
async function updatePrAfterReview(agentId, pr, project, config, resultSummary) {
|
|
1127
|
+
async function updatePrAfterReview(agentId, pr, project, config, resultSummary, structuredCompletion = null) {
|
|
1087
1128
|
|
|
1088
1129
|
if (!pr?.id) return;
|
|
1089
1130
|
|
|
@@ -1108,12 +1149,12 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary)
|
|
|
1108
1149
|
}
|
|
1109
1150
|
} catch (e) { log('warn', `Post-review status check for ${pr.id}: ${e.message}`); }
|
|
1110
1151
|
|
|
1111
|
-
// Fallback: if live check returned pending (e.g., GitHub self-approval blocked),
|
|
1152
|
+
// Fallback: if live check returned pending (e.g., GitHub self-approval blocked), use the agent's completion report.
|
|
1112
1153
|
if (!postReviewStatus) {
|
|
1113
|
-
const verdict = parseReviewVerdict(resultSummary);
|
|
1154
|
+
const verdict = reviewVerdictFromCompletion(structuredCompletion) || parseReviewVerdict(resultSummary);
|
|
1114
1155
|
if (verdict) {
|
|
1115
1156
|
postReviewStatus = verdict;
|
|
1116
|
-
log('info', `
|
|
1157
|
+
log('info', `Read review verdict from agent completion for ${pr.id}: ${verdict}`);
|
|
1117
1158
|
}
|
|
1118
1159
|
}
|
|
1119
1160
|
|
|
@@ -1700,6 +1741,24 @@ function parseStructuredCompletion(stdout, runtimeName) {
|
|
|
1700
1741
|
return result;
|
|
1701
1742
|
}
|
|
1702
1743
|
|
|
1744
|
+
function parseCompletionReportFile(dispatchItem) {
|
|
1745
|
+
const reportPath = dispatchItem?.meta?.completionReportPath || shared.dispatchCompletionReportPath(dispatchItem?.id);
|
|
1746
|
+
if (!reportPath || !fs.existsSync(reportPath)) return null;
|
|
1747
|
+
const report = safeJson(reportPath);
|
|
1748
|
+
if (!report || typeof report !== 'object' || Array.isArray(report)) {
|
|
1749
|
+
log('warn', `Ignoring malformed completion report for ${dispatchItem?.id || 'unknown'}: ${reportPath}`);
|
|
1750
|
+
return null;
|
|
1751
|
+
}
|
|
1752
|
+
if (!report.status && report.outcome) report.status = report.outcome;
|
|
1753
|
+
if (!report.status) {
|
|
1754
|
+
log('warn', `Ignoring completion report without status for ${dispatchItem?.id || 'unknown'}: ${reportPath}`);
|
|
1755
|
+
return null;
|
|
1756
|
+
}
|
|
1757
|
+
report._source = 'report-file';
|
|
1758
|
+
report._path = reportPath;
|
|
1759
|
+
return report;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1703
1762
|
function normalizeCompletionStatus(status) {
|
|
1704
1763
|
return String(status || '').trim().toLowerCase().replace(/[\s_]+/g, '-');
|
|
1705
1764
|
}
|
|
@@ -1817,6 +1876,28 @@ function deferNonTerminalCompletion(meta, detection) {
|
|
|
1817
1876
|
return reason;
|
|
1818
1877
|
}
|
|
1819
1878
|
|
|
1879
|
+
function parseCompletionBoolean(value) {
|
|
1880
|
+
if (typeof value === 'boolean') return value;
|
|
1881
|
+
if (typeof value === 'string') {
|
|
1882
|
+
const normalized = value.trim().toLowerCase();
|
|
1883
|
+
if (['true', 'yes', '1'].includes(normalized)) return true;
|
|
1884
|
+
if (['false', 'no', '0'].includes(normalized)) return false;
|
|
1885
|
+
}
|
|
1886
|
+
return undefined;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
function normalizeReviewVerdict(verdict) {
|
|
1890
|
+
const value = String(verdict || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
1891
|
+
if (value === 'approve' || value === 'approved') return 'approved';
|
|
1892
|
+
if (value === 'request_changes' || value === 'changes_requested' || value === 'changes-requested') return 'changes-requested';
|
|
1893
|
+
return null;
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
function reviewVerdictFromCompletion(completion) {
|
|
1897
|
+
if (!completion || typeof completion !== 'object') return null;
|
|
1898
|
+
return normalizeReviewVerdict(completion.verdict || completion.review_verdict || completion.reviewVerdict);
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1820
1901
|
function writeNonCleanAgentReport(dispatchItem, agentId, outcome, structuredCompletion, resultSummary, exitCode) {
|
|
1821
1902
|
if (!dispatchItem?.id || !outcome) {
|
|
1822
1903
|
log('warn', 'Cannot write non-clean agent report without dispatch id and outcome');
|
|
@@ -1952,22 +2033,31 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
1952
2033
|
// and for the foundation-only state of this plan item; downstream items
|
|
1953
2034
|
// (P-2a6d9c4f, P-9c4f2d6a) populate dispatchItem.meta.runtimeName at spawn time.
|
|
1954
2035
|
const runtimeName = dispatchItem.meta?.runtimeName || dispatchItem.runtimeName || 'claude';
|
|
1955
|
-
|
|
1956
|
-
const completionGateSummary = resultSummary || (typeof stdout === 'string' && !stdout.includes('"type":') ? stdout : '');
|
|
2036
|
+
let { resultSummary, taskUsage, sessionId, model } = parseAgentOutput(stdout, runtimeName);
|
|
1957
2037
|
|
|
1958
|
-
//
|
|
1959
|
-
const
|
|
2038
|
+
// Prefer the sidecar completion report; keep fenced output as a compatibility fallback.
|
|
2039
|
+
const reportCompletion = parseCompletionReportFile(dispatchItem);
|
|
2040
|
+
const structuredCompletion = reportCompletion || parseStructuredCompletion(stdout, runtimeName);
|
|
1960
2041
|
if (structuredCompletion) {
|
|
1961
|
-
|
|
2042
|
+
if (structuredCompletion.summary) resultSummary = String(structuredCompletion.summary);
|
|
2043
|
+
log('info', `Structured completion from ${agentId}: status=${structuredCompletion.status}, pr=${structuredCompletion.pr || 'N/A'}${structuredCompletion._source ? ` (${structuredCompletion._source})` : ''}`);
|
|
1962
2044
|
}
|
|
2045
|
+
const completionGateSummary = resultSummary || (typeof stdout === 'string' && !stdout.includes('"type":') ? stdout : '');
|
|
1963
2046
|
|
|
1964
2047
|
// Save session for potential resume on next dispatch
|
|
1965
2048
|
if (isSuccess && sessionId && agentId && !agentId.startsWith('temp-')) {
|
|
1966
2049
|
try {
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
2050
|
+
const runtime = resolveRuntime(runtimeName);
|
|
2051
|
+
if (runtime && typeof runtime.saveSession === 'function') {
|
|
2052
|
+
runtime.saveSession({
|
|
2053
|
+
agentId,
|
|
2054
|
+
dispatchId: dispatchItem.id,
|
|
2055
|
+
branch: dispatchItem.meta?.branch || null,
|
|
2056
|
+
sessionId,
|
|
2057
|
+
agentsDir: AGENTS_DIR,
|
|
2058
|
+
logger: { warn: (msg) => log('warn', msg) },
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
1971
2061
|
} catch (err) { log('warn', `Session save: ${err.message}`); }
|
|
1972
2062
|
}
|
|
1973
2063
|
|
|
@@ -1983,15 +2073,19 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
1983
2073
|
log('info', `Structured completion reports PR (${structuredCompletion.pr}) but regex sync found none — PR may already be tracked`);
|
|
1984
2074
|
}
|
|
1985
2075
|
|
|
2076
|
+
const completionStatus = normalizeCompletionStatus(structuredCompletion?.status);
|
|
2077
|
+
const agentNeedsRerun = parseCompletionBoolean(structuredCompletion?.needs_rerun ?? structuredCompletion?.needsRerun) === true;
|
|
2078
|
+
const agentReportedFailure = completionStatus.startsWith('fail') || agentNeedsRerun;
|
|
2079
|
+
const agentRetryable = parseCompletionBoolean(structuredCompletion?.retryable);
|
|
2080
|
+
|
|
1986
2081
|
// Auto-recover: if a failed implement/fix/test agent created PRs, it likely succeeded before the failure surfaced.
|
|
1987
2082
|
const prCreatingType = type === WORK_TYPE.IMPLEMENT || type === WORK_TYPE.IMPLEMENT_LARGE || type === WORK_TYPE.FIX || type === WORK_TYPE.TEST;
|
|
1988
|
-
const autoRecovered = !isSuccess && prsCreatedCount > 0 && prCreatingType && !!meta?.item?.id;
|
|
2083
|
+
const autoRecovered = !agentReportedFailure && !isSuccess && prsCreatedCount > 0 && prCreatingType && !!meta?.item?.id;
|
|
1989
2084
|
if (autoRecovered) {
|
|
1990
2085
|
log('info', `Auto-recovery: agent failed but created ${prsCreatedCount} PR(s) — upgrading ${meta.item.id} to done`);
|
|
1991
2086
|
}
|
|
1992
|
-
const effectiveSuccess = isSuccess || autoRecovered;
|
|
2087
|
+
const effectiveSuccess = (isSuccess && !agentReportedFailure) || autoRecovered;
|
|
1993
2088
|
|
|
1994
|
-
const completionStatus = normalizeCompletionStatus(structuredCompletion?.status);
|
|
1995
2089
|
let nonCleanReportWritten = false;
|
|
1996
2090
|
if (completionStatus.startsWith('partial') || autoRecovered || (completionStatus.startsWith('fail') && isSuccess)) {
|
|
1997
2091
|
const outcome = completionStatus.startsWith('fail') ? 'failure' : 'partial';
|
|
@@ -2019,7 +2113,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
2019
2113
|
// and after 3 such bailouts the WI flips to status=failed even though the
|
|
2020
2114
|
// original review was posted on the first run.
|
|
2021
2115
|
if (effectiveSuccess && type === WORK_TYPE.REVIEW && meta?.item?.id) {
|
|
2022
|
-
const verdict = parseReviewVerdict(resultSummary);
|
|
2116
|
+
const verdict = reviewVerdictFromCompletion(structuredCompletion) || parseReviewVerdict(resultSummary);
|
|
2023
2117
|
if (!verdict && isReviewBailout(resultSummary)) {
|
|
2024
2118
|
log('info', `Review ${meta.item.id} bailed out (review already posted) — treating as DONE without retry`);
|
|
2025
2119
|
} else if (!verdict) {
|
|
@@ -2116,8 +2210,10 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
2116
2210
|
}
|
|
2117
2211
|
|
|
2118
2212
|
if (effectiveSuccess && meta?.item?.id && !skipDoneStatus) {
|
|
2119
|
-
completionContractFailure = await enforcePrAttachmentContract(type, meta, agentId, config, resultSummary);
|
|
2120
|
-
if (completionContractFailure
|
|
2213
|
+
completionContractFailure = await enforcePrAttachmentContract(type, meta, agentId, config, resultSummary, stdout);
|
|
2214
|
+
if (completionContractFailure?.severity === 'hard' || completionContractFailure?.nonTerminal) {
|
|
2215
|
+
skipDoneStatus = true;
|
|
2216
|
+
}
|
|
2121
2217
|
}
|
|
2122
2218
|
|
|
2123
2219
|
if (effectiveSuccess && meta?.item?.id && !skipDoneStatus) {
|
|
@@ -2223,7 +2319,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
2223
2319
|
// (retryCount was being deleted by done-marking before the check could read it)
|
|
2224
2320
|
// Review verdict check similarly moved before updateWorkItemStatus(DONE) — same root cause.
|
|
2225
2321
|
|
|
2226
|
-
if (type === WORK_TYPE.REVIEW) await updatePrAfterReview(agentId, meta?.pr, meta?.project, config, resultSummary);
|
|
2322
|
+
if (type === WORK_TYPE.REVIEW) await updatePrAfterReview(agentId, meta?.pr, meta?.project, config, resultSummary, structuredCompletion);
|
|
2227
2323
|
if (type === WORK_TYPE.FIX) {
|
|
2228
2324
|
updatePrAfterFix(meta?.pr, meta?.project, meta?.source);
|
|
2229
2325
|
// (#984) Sync PRD status for PR-linked features: fix work items have a different ID
|
|
@@ -2242,7 +2338,9 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
2242
2338
|
}
|
|
2243
2339
|
}
|
|
2244
2340
|
checkForLearnings(agentId, config.agents[agentId], dispatchItem.task);
|
|
2245
|
-
const
|
|
2341
|
+
const hardContractFail = completionContractFailure?.severity === 'hard'
|
|
2342
|
+
|| completionContractFailure?.nonTerminal === true;
|
|
2343
|
+
const finalResult = hardContractFail ? DISPATCH_RESULT.ERROR : (effectiveSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR);
|
|
2246
2344
|
if (finalResult === DISPATCH_RESULT.SUCCESS) {
|
|
2247
2345
|
extractSkillsFromOutput(stdout, agentId, dispatchItem, config);
|
|
2248
2346
|
// Also scan inbox notes for skill blocks — agents often write skills to inbox, not stdout
|
|
@@ -2270,7 +2368,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
2270
2368
|
teams.teamsNotifyCompletion(dispatchItem, finalResult, agentId).catch(() => {});
|
|
2271
2369
|
} catch {}
|
|
2272
2370
|
|
|
2273
|
-
return { resultSummary, taskUsage, autoRecovered, structuredCompletion, completionContractFailure };
|
|
2371
|
+
return { resultSummary, taskUsage, autoRecovered, structuredCompletion, completionContractFailure, agentReportedFailure, agentRetryable };
|
|
2274
2372
|
}
|
|
2275
2373
|
|
|
2276
2374
|
// ─── PR → PRD Status Sync ─────────────────────────────────────────────────────
|
|
@@ -2451,6 +2549,7 @@ module.exports = {
|
|
|
2451
2549
|
isReviewBailout,
|
|
2452
2550
|
parseStructuredCompletion,
|
|
2453
2551
|
detectNonTerminalResultSummary,
|
|
2552
|
+
parseCompletionReportFile,
|
|
2454
2553
|
runPostCompletionHooks,
|
|
2455
2554
|
syncPrdFromPrs,
|
|
2456
2555
|
resolveWorkItemPath,
|