@yemi33/minions 0.1.1639 → 0.1.1641
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 +11 -2
- package/dashboard/js/render-prs.js +4 -1
- package/dashboard/styles.css +1 -0
- package/dashboard.js +54 -56
- package/engine/ado.js +26 -11
- package/engine/copilot-models.json +1 -1
- package/engine/github.js +27 -5
- package/engine/lifecycle.js +170 -170
- package/engine/shared.js +75 -0
- package/engine.js +116 -63
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.1641 (2026-04-30)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
- harden PR attachment contract (#1908)
|
|
7
|
+
|
|
8
|
+
## 0.1.1640 (2026-04-30)
|
|
9
|
+
|
|
10
|
+
### Fixes
|
|
11
|
+
- surface unresolved pr branches
|
|
12
|
+
|
|
13
|
+
## 0.1.1638 (2026-04-30)
|
|
4
14
|
|
|
5
15
|
### Fixes
|
|
6
|
-
- auto-link agent-created PRs to work items (#1904)
|
|
7
16
|
- Playbook 'fix' / 'review' gates items forever when pr_branch is unresolved (closes #1899) (#1901)
|
|
8
17
|
|
|
9
18
|
## 0.1.1637 (2026-04-30)
|
|
@@ -18,6 +18,9 @@ function prRow(pr) {
|
|
|
18
18
|
const buildLabel = pr.buildFixEscalated ? 'escalated (' + (pr.buildFixAttempts || '?') + ' fixes)' : (pr.buildStatus || 'none') + (pr._buildStatusStale ? ' (stale)' : '');
|
|
19
19
|
const statusClass = pr.status === 'merged' ? 'merged' : pr.status === 'abandoned' ? 'rejected' : pr.status === 'active' ? 'active' : 'draft';
|
|
20
20
|
const statusLabel = pr.status || 'active';
|
|
21
|
+
const branchError = pr._branchResolutionError?.reason || '';
|
|
22
|
+
const branchLabel = pr.branch || (branchError ? 'missing branch' : '—');
|
|
23
|
+
const branchClass = 'pr-branch' + (branchError ? ' branch-missing' : '');
|
|
21
24
|
const url = pr.url || '#';
|
|
22
25
|
const prId = pr.id || '—';
|
|
23
26
|
const pendingReason = pr._pendingReason ? String(pr._pendingReason) : '';
|
|
@@ -28,7 +31,7 @@ function prRow(pr) {
|
|
|
28
31
|
'<td><span class="pr-id">' + escapeHtml(String(prId)) + '</span></td>' +
|
|
29
32
|
'<td><a class="pr-title" href="' + escapeHtml(safeUrl(url)) + '" target="_blank" rel="noopener">' + escapeHtml(pr.title || 'Untitled') + '</a>' + (pr.description ? '<div class="pr-desc">' + escapeHtml(pr.description.length > 120 ? pr.description.slice(0, 120) + '...' : pr.description) + '</div>' : '') + '</td>' +
|
|
30
33
|
'<td><span class="pr-agent">' + escapeHtml(pr.agent || '—') + '</span></td>' +
|
|
31
|
-
'<td><span class="
|
|
34
|
+
'<td><span class="' + branchClass + '"' + (branchError ? ' title="' + escapeHtml(branchError) + '"' : '') + '>' + escapeHtml(branchLabel) + '</span>' + pendingReasonHtml + '</td>' +
|
|
32
35
|
'<td><span class="pr-badge ' + reviewClass + '"' + (reviewTitle ? ' title="' + escapeHtml(reviewTitle) + '"' : '') + '>' + escapeHtml(reviewLabel) + '</span></td>' +
|
|
33
36
|
'<td>' + (sq.reviewer && sq.status !== 'waiting' ? '<span class="pr-agent" title="' + escapeHtml(sq.note || '') + '">' + escapeHtml(sq.reviewer) + '</span>' : sq.reviewer && sq.status === 'waiting' ? '<span class="pr-agent" style="color:var(--muted)" title="Vote pending confirmation">' + escapeHtml(sq.reviewer) + '…</span>' : pr.reviewedBy && pr.reviewedBy.length ? '<span class="pr-agent">' + escapeHtml(pr.reviewedBy.join(', ')) + '</span>' : '<span style="color:var(--muted);font-size:11px">—</span>') + '</td>' +
|
|
34
37
|
'<td><span class="pr-badge ' + buildClass + '">' + escapeHtml(buildLabel) + '</span></td>' +
|
package/dashboard/styles.css
CHANGED
|
@@ -262,6 +262,7 @@
|
|
|
262
262
|
.pr-id { color: var(--muted); font-family: Consolas, monospace; font-size: var(--text-base); }
|
|
263
263
|
.pr-agent { font-size: var(--text-base); color: var(--text); }
|
|
264
264
|
.pr-branch { font-family: Consolas, monospace; font-size: var(--text-sm); color: var(--muted); background: var(--bg); padding: var(--space-1) var(--space-3); border-radius: 3px; border: 1px solid var(--border); }
|
|
265
|
+
.pr-branch.branch-missing { color: var(--red); border-color: var(--red); background: rgba(248,81,73,0.12); }
|
|
265
266
|
.pr-badge { font-size: var(--text-sm); font-weight: 600; padding: var(--space-1) var(--space-4); border-radius: var(--radius-xl); text-transform: uppercase; letter-spacing: 0.5px; white-space: nowrap; }
|
|
266
267
|
.pr-badge.draft { background: rgba(139,148,158,0.15); color: var(--muted); border: 1px solid var(--border); }
|
|
267
268
|
.pr-badge.active { background: rgba(88,166,255,0.15); color: var(--blue); border: 1px solid var(--blue); }
|
package/dashboard.js
CHANGED
|
@@ -63,6 +63,55 @@ function reloadConfig() {
|
|
|
63
63
|
PROJECTS = _getProjects(CONFIG);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
function getWorkItemIdFromPrLinkContext(context, workItemId) {
|
|
67
|
+
if (typeof workItemId === 'string' && workItemId.trim()) return workItemId.trim();
|
|
68
|
+
if (!context) return null;
|
|
69
|
+
if (typeof context === 'object' && typeof context.workItemId === 'string' && context.workItemId.trim()) return context.workItemId.trim();
|
|
70
|
+
if (typeof context === 'string') {
|
|
71
|
+
const match = context.match(/\b(P-[a-z0-9]{6,}|W-[a-z0-9]{6,}|PL-[a-z0-9]{6,})\b/i);
|
|
72
|
+
return match ? match[1] : null;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function linkPullRequestForTracking({ url, title, project: projectName, autoObserve, context, workItemId }, config = CONFIG) {
|
|
78
|
+
if (!url) {
|
|
79
|
+
const err = new Error('url required');
|
|
80
|
+
err.statusCode = 400;
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
const projects = shared.getProjects(config);
|
|
84
|
+
const targetProject = projectName ? projects.find(p => p.name?.toLowerCase() === projectName.toLowerCase()) : (projects[0] || null);
|
|
85
|
+
const prPath = targetProject ? shared.projectPrPath(targetProject) : path.join(MINIONS_DIR, 'pull-requests.json');
|
|
86
|
+
|
|
87
|
+
const prNumMatch = url.match(/\/pull\/(\d+)|pullrequest\/(\d+)/);
|
|
88
|
+
const prNum = prNumMatch ? (prNumMatch[1] || prNumMatch[2]) : Date.now().toString().slice(-6);
|
|
89
|
+
const prId = shared.getCanonicalPrId(targetProject, prNum, url);
|
|
90
|
+
const linkedWorkItemId = getWorkItemIdFromPrLinkContext(context, workItemId);
|
|
91
|
+
const contextText = typeof context === 'string' ? context : (context == null ? '' : JSON.stringify(context));
|
|
92
|
+
const result = shared.upsertPullRequestRecord(prPath, {
|
|
93
|
+
id: prId,
|
|
94
|
+
prNumber: parseInt(prNum, 10) || null,
|
|
95
|
+
title: (title || 'PR #' + prNum + ' (polling...)').slice(0, 120),
|
|
96
|
+
description: '',
|
|
97
|
+
agent: 'human',
|
|
98
|
+
branch: '',
|
|
99
|
+
reviewStatus: 'pending',
|
|
100
|
+
status: 'active',
|
|
101
|
+
created: new Date().toISOString(),
|
|
102
|
+
url,
|
|
103
|
+
prdItems: linkedWorkItemId ? [linkedWorkItemId] : [],
|
|
104
|
+
_manual: true,
|
|
105
|
+
_contextOnly: !autoObserve,
|
|
106
|
+
_autoObserve: !!autoObserve,
|
|
107
|
+
_context: contextText,
|
|
108
|
+
}, {
|
|
109
|
+
project: targetProject,
|
|
110
|
+
itemId: linkedWorkItemId,
|
|
111
|
+
});
|
|
112
|
+
return { ...result, prPath, targetProject, prNum };
|
|
113
|
+
}
|
|
114
|
+
|
|
66
115
|
function _normalizeSkillDirForCompare(dir) {
|
|
67
116
|
const resolved = path.resolve(String(dir || '').replace(/\//g, path.sep));
|
|
68
117
|
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
@@ -5840,66 +5889,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
5840
5889
|
// Agents
|
|
5841
5890
|
{ method: 'POST', path: '/api/pull-requests/link', desc: 'Manually link an external PR for tracking', params: 'url, title?, project?, autoObserve?, context?, workItemId?', handler: async (req, res) => {
|
|
5842
5891
|
const body = await readBody(req);
|
|
5843
|
-
const { url
|
|
5892
|
+
const { url } = body;
|
|
5844
5893
|
if (!url) return jsonReply(res, 400, { error: 'url required' });
|
|
5845
5894
|
|
|
5846
|
-
// Determine project
|
|
5847
5895
|
reloadConfig();
|
|
5848
|
-
const
|
|
5849
|
-
const targetProject = projectName ? projects.find(p => p.name?.toLowerCase() === projectName.toLowerCase()) : (projects[0] || null);
|
|
5850
|
-
const prPath = targetProject ? shared.projectPrPath(targetProject) : path.join(MINIONS_DIR, 'pull-requests.json');
|
|
5851
|
-
|
|
5852
|
-
// Extract PR number from URL
|
|
5853
|
-
const prNumMatch = url.match(/\/pull\/(\d+)|pullrequest\/(\d+)/);
|
|
5854
|
-
const prNum = prNumMatch ? (prNumMatch[1] || prNumMatch[2]) : Date.now().toString().slice(-6);
|
|
5855
|
-
const prId = shared.getCanonicalPrId(targetProject, prNum, url);
|
|
5856
|
-
const contextText = typeof context === 'string' ? context : (context == null ? '' : JSON.stringify(context));
|
|
5857
|
-
|
|
5858
|
-
// Resolve a work-item association from either the top-level workItemId
|
|
5859
|
-
// field (preferred) or context.workItemId (legacy CC payload shape).
|
|
5860
|
-
// Without this, manually-linked PRs end up with prdItems=[] and the
|
|
5861
|
-
// Work Items page renders no PR even though _context records the ID.
|
|
5862
|
-
const linkedItemId = (typeof workItemId === 'string' && workItemId.trim())
|
|
5863
|
-
|| (context && typeof context === 'object' && typeof context.workItemId === 'string' && context.workItemId.trim())
|
|
5864
|
-
|| '';
|
|
5865
|
-
|
|
5866
|
-
// Atomic check-and-insert to prevent duplicates and races with polling loops
|
|
5867
|
-
let duplicate = false;
|
|
5868
|
-
mutateJsonFileLocked(prPath, (prs) => {
|
|
5869
|
-
if (!Array.isArray(prs)) prs = [];
|
|
5870
|
-
if (prs.some(p => p.id === prId || p.url === url)) { duplicate = true; return prs; }
|
|
5871
|
-
prs.push({
|
|
5872
|
-
id: prId,
|
|
5873
|
-
prNumber: parseInt(prNum, 10) || null,
|
|
5874
|
-
title: (title || 'PR #' + prNum + ' (polling...)').slice(0, 120),
|
|
5875
|
-
description: '',
|
|
5876
|
-
agent: 'human',
|
|
5877
|
-
branch: '',
|
|
5878
|
-
reviewStatus: 'pending',
|
|
5879
|
-
status: 'active',
|
|
5880
|
-
created: new Date().toISOString(),
|
|
5881
|
-
url,
|
|
5882
|
-
prdItems: linkedItemId ? [linkedItemId] : [],
|
|
5883
|
-
_manual: true,
|
|
5884
|
-
_contextOnly: !autoObserve,
|
|
5885
|
-
_autoObserve: !!autoObserve,
|
|
5886
|
-
_context: contextText,
|
|
5887
|
-
});
|
|
5888
|
-
return prs;
|
|
5889
|
-
}, { defaultValue: [] });
|
|
5890
|
-
if (duplicate) return jsonReply(res, 400, { error: 'PR already tracked' });
|
|
5891
|
-
// Persist the work-item ↔ PR association in pr-links.json so
|
|
5892
|
-
// queries.getWorkItems() can render item._pr / item._prUrl. addPrLink
|
|
5893
|
-
// is idempotent and handles the central / project-scoped split.
|
|
5894
|
-
if (linkedItemId) {
|
|
5895
|
-
try {
|
|
5896
|
-
shared.addPrLink(prId, linkedItemId, { project: targetProject, prNumber: parseInt(prNum, 10) || null, url });
|
|
5897
|
-
} catch (e) {
|
|
5898
|
-
shared.log('warn', `PR link addPrLink failed for ${prId} → ${linkedItemId}: ${e.message}`);
|
|
5899
|
-
}
|
|
5900
|
-
}
|
|
5896
|
+
const { id: prId, prPath, targetProject, prNum, created, linked } = linkPullRequestForTracking(body, CONFIG);
|
|
5901
5897
|
invalidateStatusCache();
|
|
5902
|
-
jsonReply(res, 200, { ok: true, id: prId });
|
|
5898
|
+
jsonReply(res, 200, { ok: true, id: prId, created, linked });
|
|
5903
5899
|
|
|
5904
5900
|
// Async-enrich: fetch title, description, branch, author from GitHub/ADO API
|
|
5905
5901
|
(async () => {
|
|
@@ -5927,6 +5923,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
5927
5923
|
if (prData.description) pr.description = prData.description.slice(0, 500);
|
|
5928
5924
|
if (!pr.branch && prData.branch) {
|
|
5929
5925
|
pr.branch = prData.branch;
|
|
5926
|
+
if (pr._branchResolutionError) delete pr._branchResolutionError;
|
|
5930
5927
|
if (pr._pendingReason === 'missing_pr_branch') delete pr._pendingReason;
|
|
5931
5928
|
}
|
|
5932
5929
|
if (pr.agent === 'human' && prData.author) pr.agent = prData.author;
|
|
@@ -6425,6 +6422,7 @@ module.exports = {
|
|
|
6425
6422
|
_parseDocChatResultText,
|
|
6426
6423
|
_messageRequestsOrchestration,
|
|
6427
6424
|
_formatDocChatContext,
|
|
6425
|
+
_linkPullRequestForTracking: linkPullRequestForTracking,
|
|
6428
6426
|
_resolveSkillReadPath,
|
|
6429
6427
|
DOC_CHAT_DOCUMENT_DELIMITER,
|
|
6430
6428
|
};
|
package/engine/ado.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const shared = require('./shared');
|
|
8
|
-
const { exec, execAsync, getAdoOrgBase,
|
|
8
|
+
const { exec, execAsync, getAdoOrgBase, log, ts, dateStamp, PR_STATUS, createThrottleTracker } = shared;
|
|
9
9
|
const { getPrs } = require('./queries');
|
|
10
10
|
const { mutateJsonFileLocked } = shared;
|
|
11
11
|
|
|
@@ -357,6 +357,14 @@ async function pollPrStatus(config) {
|
|
|
357
357
|
|
|
358
358
|
const prData = await adoFetch(`${repoBase}?api-version=7.1`, token);
|
|
359
359
|
|
|
360
|
+
const sourceBranch = stripRefsHeads(prData.sourceRefName);
|
|
361
|
+
if (sourceBranch && pr.branch !== sourceBranch) {
|
|
362
|
+
pr.branch = sourceBranch;
|
|
363
|
+
if (pr._branchResolutionError) delete pr._branchResolutionError;
|
|
364
|
+
if (pr._pendingReason === 'missing_pr_branch') delete pr._pendingReason;
|
|
365
|
+
updated = true;
|
|
366
|
+
}
|
|
367
|
+
|
|
360
368
|
let newStatus = pr.status;
|
|
361
369
|
if (prData.status === 'completed') newStatus = PR_STATUS.MERGED;
|
|
362
370
|
else if (prData.status === 'abandoned') newStatus = PR_STATUS.ABANDONED;
|
|
@@ -405,12 +413,6 @@ async function pollPrStatus(config) {
|
|
|
405
413
|
pr._adoSourceCommit = sourceCommit;
|
|
406
414
|
updated = true;
|
|
407
415
|
}
|
|
408
|
-
const sourceBranch = stripRefsHeads(prData.sourceRefName);
|
|
409
|
-
if (!pr.branch && sourceBranch) {
|
|
410
|
-
pr.branch = sourceBranch;
|
|
411
|
-
if (pr._pendingReason === 'missing_pr_branch') delete pr._pendingReason;
|
|
412
|
-
updated = true;
|
|
413
|
-
}
|
|
414
416
|
|
|
415
417
|
const reviewers = prData.reviewers || [];
|
|
416
418
|
const votes = reviewers.map(r => r.vote).filter(v => v !== undefined);
|
|
@@ -795,12 +797,24 @@ async function reconcilePrs(config) {
|
|
|
795
797
|
}
|
|
796
798
|
if (existing && !existing.branch && branch) {
|
|
797
799
|
existing.branch = branch;
|
|
800
|
+
if (existing._branchResolutionError) delete existing._branchResolutionError;
|
|
798
801
|
if (existing._pendingReason === 'missing_pr_branch') delete existing._pendingReason;
|
|
799
802
|
metadataUpdated++;
|
|
800
803
|
}
|
|
801
804
|
// PR already tracked — write link to pr-links.json if we can extract an ID
|
|
802
805
|
if (confirmedItemId) {
|
|
803
|
-
|
|
806
|
+
shared.upsertPullRequestRecord(prPath, existing || {
|
|
807
|
+
id: prId,
|
|
808
|
+
prNumber: adoPr.pullRequestId,
|
|
809
|
+
title: (adoPr.title || `PR #${adoPr.pullRequestId}`).slice(0, 120),
|
|
810
|
+
agent: (linkedItem?.dispatched_to || adoPr.createdBy?.displayName || 'unknown').toLowerCase(),
|
|
811
|
+
branch,
|
|
812
|
+
reviewStatus: 'pending',
|
|
813
|
+
status: 'active',
|
|
814
|
+
created: adoPr.creationDate || ts(),
|
|
815
|
+
url: prUrl,
|
|
816
|
+
prdItems: [],
|
|
817
|
+
}, { project, itemId: confirmedItemId });
|
|
804
818
|
if (existing && !(existing.prdItems || []).includes(confirmedItemId)) {
|
|
805
819
|
existing.prdItems = Array.isArray(existing.prdItems) ? existing.prdItems : [];
|
|
806
820
|
existing.prdItems.push(confirmedItemId);
|
|
@@ -815,7 +829,7 @@ async function reconcilePrs(config) {
|
|
|
815
829
|
// are human-authored and should not be auto-tracked or auto-reviewed.
|
|
816
830
|
if (!confirmedItemId) continue;
|
|
817
831
|
|
|
818
|
-
|
|
832
|
+
const entry = {
|
|
819
833
|
id: prId,
|
|
820
834
|
prNumber: adoPr.pullRequestId,
|
|
821
835
|
title: (adoPr.title || `PR #${adoPr.pullRequestId}`).slice(0, 120),
|
|
@@ -826,8 +840,9 @@ async function reconcilePrs(config) {
|
|
|
826
840
|
created: adoPr.creationDate || ts(),
|
|
827
841
|
url: prUrl,
|
|
828
842
|
prdItems: [confirmedItemId],
|
|
829
|
-
}
|
|
830
|
-
|
|
843
|
+
};
|
|
844
|
+
const upserted = shared.upsertPullRequestRecord(prPath, entry, { project, itemId: confirmedItemId });
|
|
845
|
+
existingPrs.push(upserted.record || entry);
|
|
831
846
|
existingIds.add(prId);
|
|
832
847
|
projectAdded++;
|
|
833
848
|
log('info', `PR reconciliation: added ${prId} (branch: ${branch}, linked to ${confirmedItemId}) to ${project.name}`);
|
package/engine/github.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
const shared = require('./shared');
|
|
8
|
-
const { exec, execAsync, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeWrite, mutateJsonFileLocked, MINIONS_DIR,
|
|
8
|
+
const { exec, execAsync, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeWrite, mutateJsonFileLocked, MINIONS_DIR, getPrLinks, backfillPrPrdItems, log, ts, dateStamp, PR_STATUS, PR_POLLABLE_STATUSES, createThrottleTracker } = shared;
|
|
9
9
|
const { getPrs } = require('./queries');
|
|
10
10
|
const path = require('path');
|
|
11
11
|
|
|
@@ -284,6 +284,7 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
284
284
|
if (pr.agent === 'human' && prData.user?.login) pr.agent = prData.user.login;
|
|
285
285
|
if (!pr.branch && prData.head?.ref) {
|
|
286
286
|
pr.branch = prData.head.ref;
|
|
287
|
+
if (pr._branchResolutionError) delete pr._branchResolutionError;
|
|
287
288
|
if (pr._pendingReason === 'missing_pr_branch') delete pr._pendingReason;
|
|
288
289
|
}
|
|
289
290
|
}
|
|
@@ -327,6 +328,14 @@ async function pollPrStatus(config) {
|
|
|
327
328
|
|
|
328
329
|
let updated = false;
|
|
329
330
|
|
|
331
|
+
const headBranch = prData.head?.ref ? String(prData.head.ref).trim() : '';
|
|
332
|
+
if (headBranch && pr.branch !== headBranch) {
|
|
333
|
+
pr.branch = headBranch;
|
|
334
|
+
if (pr._branchResolutionError) delete pr._branchResolutionError;
|
|
335
|
+
if (pr._pendingReason === 'missing_pr_branch') delete pr._pendingReason;
|
|
336
|
+
updated = true;
|
|
337
|
+
}
|
|
338
|
+
|
|
330
339
|
// Map GitHub PR state to minions status
|
|
331
340
|
let newStatus = pr.status;
|
|
332
341
|
if (prData.merged) newStatus = PR_STATUS.MERGED;
|
|
@@ -703,11 +712,23 @@ async function reconcilePrs(config) {
|
|
|
703
712
|
}
|
|
704
713
|
if (existing && !existing.branch && branch) {
|
|
705
714
|
existing.branch = branch;
|
|
715
|
+
if (existing._branchResolutionError) delete existing._branchResolutionError;
|
|
706
716
|
if (existing._pendingReason === 'missing_pr_branch') delete existing._pendingReason;
|
|
707
717
|
metadataUpdated++;
|
|
708
718
|
}
|
|
709
719
|
if (confirmedItemId) {
|
|
710
|
-
|
|
720
|
+
shared.upsertPullRequestRecord(prPath, existing || {
|
|
721
|
+
id: prId,
|
|
722
|
+
prNumber: ghPr.number,
|
|
723
|
+
title: (ghPr.title || `PR #${ghPr.number}`).slice(0, 120),
|
|
724
|
+
agent: (linkedItem?.dispatched_to || ghPr.user?.login || 'unknown').toLowerCase(),
|
|
725
|
+
branch,
|
|
726
|
+
reviewStatus: 'pending',
|
|
727
|
+
status: 'active',
|
|
728
|
+
created: ghPr.created_at || ts(),
|
|
729
|
+
url: prUrl,
|
|
730
|
+
prdItems: [],
|
|
731
|
+
}, { project, itemId: confirmedItemId });
|
|
711
732
|
if (existing && !(existing.prdItems || []).includes(confirmedItemId)) {
|
|
712
733
|
existing.prdItems = Array.isArray(existing.prdItems) ? existing.prdItems : [];
|
|
713
734
|
existing.prdItems.push(confirmedItemId);
|
|
@@ -722,7 +743,7 @@ async function reconcilePrs(config) {
|
|
|
722
743
|
// Only auto-track PRs linked to a minions work item — skip human-authored PRs
|
|
723
744
|
if (!confirmedItemId && !isE2eBranch) continue;
|
|
724
745
|
|
|
725
|
-
|
|
746
|
+
const entry = {
|
|
726
747
|
id: prId,
|
|
727
748
|
prNumber: ghPr.number,
|
|
728
749
|
title: (ghPr.title || `PR #${ghPr.number}`).slice(0, 120),
|
|
@@ -733,8 +754,9 @@ async function reconcilePrs(config) {
|
|
|
733
754
|
created: ghPr.created_at || ts(),
|
|
734
755
|
url: prUrl,
|
|
735
756
|
prdItems: [confirmedItemId],
|
|
736
|
-
}
|
|
737
|
-
|
|
757
|
+
};
|
|
758
|
+
const upserted = shared.upsertPullRequestRecord(prPath, entry, { project, itemId: confirmedItemId });
|
|
759
|
+
currentPrs.push(upserted.record || entry);
|
|
738
760
|
existingIds.add(prId);
|
|
739
761
|
projectAdded++;
|
|
740
762
|
|
package/engine/lifecycle.js
CHANGED
|
@@ -7,7 +7,7 @@ const fs = require('fs');
|
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const os = require('os');
|
|
9
9
|
const shared = require('./shared');
|
|
10
|
-
const { safeRead, safeJson, safeWrite, mutateJsonFileLocked, mutateWorkItems, execSilent, execAsync, projectPrPath, getPrLinks,
|
|
10
|
+
const { safeRead, safeJson, safeWrite, mutateJsonFileLocked, mutateWorkItems, execSilent, execAsync, projectPrPath, getPrLinks,
|
|
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');
|
|
@@ -853,53 +853,172 @@ function syncPrsFromOutput(output, agentId, meta, config) {
|
|
|
853
853
|
const entryBranch = meta?.branch || '';
|
|
854
854
|
|
|
855
855
|
for (const [prPath, { name, project: targetProject, entries }] of newPrsByPath) {
|
|
856
|
-
const
|
|
857
|
-
|
|
858
|
-
const
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
// Branch-level dedup: skip if an active PR already exists on the same branch.
|
|
867
|
-
// This prevents duplicate PRs when an agent retries and calls `gh pr create` again
|
|
868
|
-
// on the same branch (GitHub allows multiple PRs from one branch).
|
|
869
|
-
// Only block when the existing PR is active — abandoned/merged PRs don't conflict.
|
|
870
|
-
const branch = entry.branch || entryBranch;
|
|
871
|
-
if (branch) {
|
|
872
|
-
const existingOnBranch = prs.find(p => p.branch === branch && p.status === PR_STATUS.ACTIVE && p.id !== fullId);
|
|
873
|
-
if (existingOnBranch) {
|
|
874
|
-
log('warn', `Duplicate PR detected: ${fullId} on branch ${branch} — already tracked as ${existingOnBranch.id}. Skipping.`);
|
|
875
|
-
// Best-effort close the duplicate on GitHub (non-blocking, fire-and-forget)
|
|
876
|
-
try {
|
|
877
|
-
const ghSlug = output.match(/github\.com\/([^/]+\/[^/]+)/)?.[1];
|
|
878
|
-
if (ghSlug) {
|
|
879
|
-
execAsync(`gh pr close ${prId} --repo ${ghSlug} --comment "Closing duplicate — ${existingOnBranch.id} already tracks this branch."`, { timeout: 15000 })
|
|
880
|
-
.catch(() => {});
|
|
881
|
-
}
|
|
882
|
-
} catch { /* best-effort */ }
|
|
883
|
-
continue;
|
|
856
|
+
for (const { prId, fullId, entry } of entries) {
|
|
857
|
+
let duplicateOnBranch = null;
|
|
858
|
+
const result = shared.upsertPullRequestRecord(prPath, entry, {
|
|
859
|
+
project: targetProject,
|
|
860
|
+
itemId: meta?.item?.id || null,
|
|
861
|
+
beforeInsert: (prs, normalizedEntry) => {
|
|
862
|
+
// Normalize legacy YYYY-MM-DD created dates to ISO while the file is locked.
|
|
863
|
+
for (const p of prs) {
|
|
864
|
+
if (p.created && p.created.length === 10) p.created = p.created + 'T00:00:00.000Z';
|
|
884
865
|
}
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
866
|
+
// Branch-level dedup: skip if an active PR already exists on the same branch.
|
|
867
|
+
// This prevents duplicate PRs when an agent retries and calls `gh pr create` again
|
|
868
|
+
// on the same branch (GitHub allows multiple PRs from one branch).
|
|
869
|
+
// Only block when the existing PR is active — abandoned/merged PRs don't conflict.
|
|
870
|
+
const branch = normalizedEntry.branch || entryBranch;
|
|
871
|
+
if (!branch) return true;
|
|
872
|
+
duplicateOnBranch = prs.find(p => p.branch === branch && p.status === PR_STATUS.ACTIVE && p.id !== normalizedEntry.id) || null;
|
|
873
|
+
return !duplicateOnBranch;
|
|
874
|
+
},
|
|
875
|
+
});
|
|
876
|
+
if (duplicateOnBranch) {
|
|
877
|
+
log('warn', `Duplicate PR detected: ${fullId} on branch ${entry.branch || entryBranch} — already tracked as ${duplicateOnBranch.id}. Skipping.`);
|
|
878
|
+
// Best-effort close the duplicate on GitHub (non-blocking, fire-and-forget)
|
|
879
|
+
try {
|
|
880
|
+
const ghSlug = output.match(/github\.com\/([^/]+\/[^/]+)/)?.[1];
|
|
881
|
+
if (ghSlug) {
|
|
882
|
+
execAsync(`gh pr close ${prId} --repo ${ghSlug} --comment "Closing duplicate — ${duplicateOnBranch.id} already tracks this branch."`, { timeout: 15000 })
|
|
883
|
+
.catch(() => {});
|
|
884
|
+
}
|
|
885
|
+
} catch { /* best-effort */ }
|
|
886
|
+
continue;
|
|
892
887
|
}
|
|
893
|
-
|
|
894
|
-
});
|
|
895
|
-
for (const { prId, itemId, project, prNumber, url } of linksToPersist) {
|
|
896
|
-
addPrLink(prId, itemId, { project, prNumber, url });
|
|
888
|
+
if (result.created || result.linked) added++;
|
|
897
889
|
}
|
|
898
890
|
log('info', `Synced PR(s) from ${agentName}'s output to ${name === '_central' ? 'central' : name}/pull-requests.json`);
|
|
899
891
|
}
|
|
900
892
|
return added;
|
|
901
893
|
}
|
|
902
894
|
|
|
895
|
+
function isPrAttachmentRequired(type, item, meta = {}) {
|
|
896
|
+
if (!item?.id || item.skipPr) return false;
|
|
897
|
+
const explicit = item.requiresPr === true
|
|
898
|
+
|| item.prRequired === true
|
|
899
|
+
|| item.requiresPullRequest === true
|
|
900
|
+
|| item.itemType === 'pr';
|
|
901
|
+
if (meta.branchStrategy === 'shared-branch' && item.itemType !== 'pr' && !explicit) return false;
|
|
902
|
+
return explicit
|
|
903
|
+
|| type === WORK_TYPE.IMPLEMENT
|
|
904
|
+
|| type === WORK_TYPE.IMPLEMENT_LARGE
|
|
905
|
+
|| type === WORK_TYPE.FIX
|
|
906
|
+
|| type === WORK_TYPE.TEST;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function hasCanonicalPrAttachment(itemId, config) {
|
|
910
|
+
if (!itemId) return false;
|
|
911
|
+
if (Object.values(getPrLinks()).some(linkedIds => (linkedIds || []).includes(itemId))) return true;
|
|
912
|
+
const projects = shared.getProjects(config);
|
|
913
|
+
for (const p of projects) {
|
|
914
|
+
const prs = safeJson(shared.projectPrPath(p)) || [];
|
|
915
|
+
if (prs.some(pr => (pr.prdItems || []).includes(itemId))) return true;
|
|
916
|
+
}
|
|
917
|
+
const centralPrs = safeJson(path.join(MINIONS_DIR, 'pull-requests.json')) || [];
|
|
918
|
+
return centralPrs.some(pr => (pr.prdItems || []).includes(itemId));
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
async function findOpenPrForBranch(meta, config) {
|
|
922
|
+
if (!meta?.branch) return null;
|
|
923
|
+
const projectObj = shared.getProjects(config).find(p => p.name === meta?.project?.name);
|
|
924
|
+
if (!projectObj) return null;
|
|
925
|
+
const host = projectObj.repoHost || 'ado';
|
|
926
|
+
if (host === 'github') {
|
|
927
|
+
const ghSlug = projectObj.prUrlBase?.match(/github\.com\/([^/]+\/[^/]+)\/pull/)?.[1];
|
|
928
|
+
if (!ghSlug) return null;
|
|
929
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
930
|
+
if (attempt > 0) await new Promise(r => setTimeout(r, 3000));
|
|
931
|
+
let raw = '';
|
|
932
|
+
try {
|
|
933
|
+
raw = await execAsync(`gh pr list --head "${meta.branch}" --repo ${ghSlug} --json number,url,state --limit 1`, { timeout: 15000, windowsHide: true });
|
|
934
|
+
const parsed = JSON.parse(raw || '[]');
|
|
935
|
+
const hits = Array.isArray(parsed) ? parsed : [];
|
|
936
|
+
if (hits.length > 0 && hits[0].state === 'OPEN') {
|
|
937
|
+
return { project: projectObj, prNumber: hits[0].number, url: hits[0].url };
|
|
938
|
+
}
|
|
939
|
+
if (attempt === 2) {
|
|
940
|
+
log('warn', `Auto-link fallback: no open PR found on branch ${meta.branch} after 3 attempts (raw: ${(raw || '').slice(0, 200)})`);
|
|
941
|
+
}
|
|
942
|
+
} catch (err) {
|
|
943
|
+
if (attempt === 2) {
|
|
944
|
+
const rawSuffix = raw ? ` (raw: ${raw.slice(0, 200)})` : '';
|
|
945
|
+
log('warn', `Auto-link fallback: gh pr list lookup failed on branch ${meta.branch} after 3 attempts: ${err.message}${rawSuffix}`);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
return null;
|
|
950
|
+
}
|
|
951
|
+
if (host === 'ado') {
|
|
952
|
+
const found = await require('./ado').findOpenPrOnBranch(projectObj, meta.branch);
|
|
953
|
+
return found ? { project: projectObj, prNumber: found.prNumber, url: found.url } : null;
|
|
954
|
+
}
|
|
955
|
+
log('debug', `Skipping branch PR lookup for unsupported repo host "${host}" on ${projectObj.name}`);
|
|
956
|
+
return null;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function markMissingPrAttachment(meta, agentId, reason, resultSummary) {
|
|
960
|
+
const noPrWiPath = resolveWorkItemPath(meta);
|
|
961
|
+
if (noPrWiPath) {
|
|
962
|
+
mutateJsonFileLocked(noPrWiPath, data => {
|
|
963
|
+
if (!Array.isArray(data)) return data;
|
|
964
|
+
const w = data.find(i => i.id === meta.item.id);
|
|
965
|
+
if (!w) return data;
|
|
966
|
+
w.status = WI_STATUS.NEEDS_REVIEW;
|
|
967
|
+
w._missingPrAttachment = true;
|
|
968
|
+
w.failReason = reason;
|
|
969
|
+
w._lastReviewReason = reason;
|
|
970
|
+
delete w.completedAt;
|
|
971
|
+
delete w._noPr;
|
|
972
|
+
delete w._noPrReason;
|
|
973
|
+
return data;
|
|
974
|
+
}, { skipWriteIfUnchanged: true });
|
|
975
|
+
}
|
|
976
|
+
shared.writeToInbox('engine', `missing-pr-attachment-${meta.item.id}`,
|
|
977
|
+
`# PR attachment missing for ${meta.item.id}\n\n` +
|
|
978
|
+
`**Agent:** ${agentId}\n` +
|
|
979
|
+
`**Work item:** \`${meta.item.id}\` — ${meta.item.title || ''}\n` +
|
|
980
|
+
`**Type:** ${meta.item.type || 'unknown'}\n` +
|
|
981
|
+
`**Branch:** ${meta.branch || '(none)'}\n\n` +
|
|
982
|
+
`${reason}\n` +
|
|
983
|
+
(resultSummary ? `\n## Agent summary\n${resultSummary}\n` : ''),
|
|
984
|
+
null,
|
|
985
|
+
{ sourceItem: meta.item.id, reason: 'missing-pr-attachment' });
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
async function enforcePrAttachmentContract(type, meta, agentId, config, resultSummary) {
|
|
989
|
+
if (!isPrAttachmentRequired(type, meta?.item, meta)) return null;
|
|
990
|
+
if (hasCanonicalPrAttachment(meta.item.id, config)) return null;
|
|
991
|
+
|
|
992
|
+
const found = await findOpenPrForBranch(meta, config);
|
|
993
|
+
if (found) {
|
|
994
|
+
const entry = {
|
|
995
|
+
id: shared.getCanonicalPrId(found.project, found.prNumber, found.url),
|
|
996
|
+
prNumber: found.prNumber,
|
|
997
|
+
title: meta.item?.title || `PR #${found.prNumber}`,
|
|
998
|
+
agent: agentId,
|
|
999
|
+
branch: meta.branch || '',
|
|
1000
|
+
reviewStatus: 'pending',
|
|
1001
|
+
status: PR_STATUS.ACTIVE,
|
|
1002
|
+
created: ts(),
|
|
1003
|
+
url: found.url,
|
|
1004
|
+
prdItems: [meta.item.id],
|
|
1005
|
+
sourcePlan: meta.item?.sourcePlan || '',
|
|
1006
|
+
itemType: meta.item?.itemType || '',
|
|
1007
|
+
};
|
|
1008
|
+
shared.upsertPullRequestRecord(shared.projectPrPath(found.project), entry, {
|
|
1009
|
+
project: found.project,
|
|
1010
|
+
itemId: meta.item.id,
|
|
1011
|
+
});
|
|
1012
|
+
log('info', `Auto-linked existing PR ${entry.id} on branch ${meta.branch} for ${meta.item.id}`);
|
|
1013
|
+
if (hasCanonicalPrAttachment(meta.item.id, config)) return null;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const reason = `PR-producing work item ${meta.item.id} completed without a canonically attached PR record. Successful completion requires PR.prdItems/pr-links.json to include the work item; branch names, note URLs, and _context.workItemId metadata are not sufficient.`;
|
|
1017
|
+
markMissingPrAttachment(meta, agentId, reason, resultSummary);
|
|
1018
|
+
log('warn', reason);
|
|
1019
|
+
return { reason, itemId: meta.item.id };
|
|
1020
|
+
}
|
|
1021
|
+
|
|
903
1022
|
// ─── Post-Completion Hooks ──────────────────────────────────────────────────
|
|
904
1023
|
|
|
905
1024
|
/**
|
|
@@ -1688,8 +1807,8 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
1688
1807
|
log('info', `Structured completion reports PR (${structuredCompletion.pr}) but regex sync found none — PR may already be tracked`);
|
|
1689
1808
|
}
|
|
1690
1809
|
|
|
1691
|
-
// Auto-recover: if a failed implement/fix agent created PRs, it likely succeeded before the failure surfaced.
|
|
1692
|
-
const prCreatingType = type === WORK_TYPE.IMPLEMENT || type === WORK_TYPE.IMPLEMENT_LARGE || type === WORK_TYPE.FIX;
|
|
1810
|
+
// Auto-recover: if a failed implement/fix/test agent created PRs, it likely succeeded before the failure surfaced.
|
|
1811
|
+
const prCreatingType = type === WORK_TYPE.IMPLEMENT || type === WORK_TYPE.IMPLEMENT_LARGE || type === WORK_TYPE.FIX || type === WORK_TYPE.TEST;
|
|
1693
1812
|
const autoRecovered = !isSuccess && prsCreatedCount > 0 && prCreatingType && !!meta?.item?.id;
|
|
1694
1813
|
if (autoRecovered) {
|
|
1695
1814
|
log('info', `Auto-recovery: agent failed but created ${prsCreatedCount} PR(s) — upgrading ${meta.item.id} to done`);
|
|
@@ -1799,6 +1918,12 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
1799
1918
|
}
|
|
1800
1919
|
}
|
|
1801
1920
|
|
|
1921
|
+
let completionContractFailure = null;
|
|
1922
|
+
if (effectiveSuccess && meta?.item?.id && !skipDoneStatus) {
|
|
1923
|
+
completionContractFailure = await enforcePrAttachmentContract(type, meta, agentId, config, resultSummary);
|
|
1924
|
+
if (completionContractFailure) skipDoneStatus = true;
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1802
1927
|
if (effectiveSuccess && meta?.item?.id && !skipDoneStatus) {
|
|
1803
1928
|
meta._agentId = agentId;
|
|
1804
1929
|
updateWorkItemStatus(meta, WI_STATUS.DONE, '');
|
|
@@ -1898,131 +2023,6 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
1898
2023
|
}
|
|
1899
2024
|
}
|
|
1900
2025
|
|
|
1901
|
-
// Detect implement tasks that completed without creating a PR
|
|
1902
|
-
if (effectiveSuccess && (type === WORK_TYPE.IMPLEMENT || type === WORK_TYPE.IMPLEMENT_LARGE || type === WORK_TYPE.FIX) && prsCreatedCount === 0 && meta?.item?.id && !meta?.item?.skipPr && meta?.project?.localPath) {
|
|
1903
|
-
// Check if a PR already exists linked to this work item (from a previous attempt)
|
|
1904
|
-
let existingPrFound = Object.values(getPrLinks()).some(linkedIds => (linkedIds || []).includes(meta.item.id));
|
|
1905
|
-
// Also check pull-requests.json for PRs with matching prdItems or branch
|
|
1906
|
-
if (!existingPrFound) {
|
|
1907
|
-
const allProjects = shared.getProjects(config);
|
|
1908
|
-
for (const p of allProjects) {
|
|
1909
|
-
const prs = safeJson(shared.projectPrPath(p)) || [];
|
|
1910
|
-
if (prs.some(pr => (pr.prdItems || []).includes(meta.item.id) || (pr.branch && pr.branch.includes(meta.item.id)))) {
|
|
1911
|
-
existingPrFound = true;
|
|
1912
|
-
break;
|
|
1913
|
-
}
|
|
1914
|
-
}
|
|
1915
|
-
}
|
|
1916
|
-
// Last resort: query the platform directly for an open PR on this branch.
|
|
1917
|
-
// Handles the case where a prior orphaned dispatch created a PR but the engine
|
|
1918
|
-
// never processed its output — so the PR exists on the platform but not in pull-requests.json.
|
|
1919
|
-
if (!existingPrFound && meta?.branch) {
|
|
1920
|
-
const projectObj = shared.getProjects(config).find(p => p.name === meta?.project?.name);
|
|
1921
|
-
if (projectObj) {
|
|
1922
|
-
try {
|
|
1923
|
-
let found = null;
|
|
1924
|
-
const host = projectObj.repoHost || 'ado';
|
|
1925
|
-
if (host === 'github') {
|
|
1926
|
-
const ghSlug = projectObj.prUrlBase?.match(/github\.com\/([^/]+\/[^/]+)\/pull/)?.[1];
|
|
1927
|
-
if (ghSlug) {
|
|
1928
|
-
// Retry up to 3 times — newly created PRs can take a few seconds to appear in the API
|
|
1929
|
-
for (let attempt = 0; attempt < 3 && !found; attempt++) {
|
|
1930
|
-
if (attempt > 0) await new Promise(r => setTimeout(r, 3000));
|
|
1931
|
-
let raw = '';
|
|
1932
|
-
try {
|
|
1933
|
-
raw = await execAsync(`gh pr list --head "${meta.branch}" --repo ${ghSlug} --json number,url,state --limit 1`, { timeout: 15000, windowsHide: true });
|
|
1934
|
-
const parsed = JSON.parse(raw || '[]');
|
|
1935
|
-
const hits = Array.isArray(parsed) ? parsed : [];
|
|
1936
|
-
if (hits.length > 0 && hits[0].state === 'OPEN') {
|
|
1937
|
-
found = { prNumber: hits[0].number, url: hits[0].url };
|
|
1938
|
-
} else if (attempt === 2) {
|
|
1939
|
-
log('warn', `Auto-link fallback: no open PR found on branch ${meta.branch} after 3 attempts (raw: ${(raw || '').slice(0, 200)})`);
|
|
1940
|
-
}
|
|
1941
|
-
} catch (err) {
|
|
1942
|
-
if (attempt === 2) {
|
|
1943
|
-
const rawSuffix = raw ? ` (raw: ${raw.slice(0, 200)})` : '';
|
|
1944
|
-
log('warn', `Auto-link fallback: gh pr list lookup failed on branch ${meta.branch} after 3 attempts: ${err.message}${rawSuffix}`);
|
|
1945
|
-
}
|
|
1946
|
-
}
|
|
1947
|
-
}
|
|
1948
|
-
}
|
|
1949
|
-
} else if (host === 'ado') {
|
|
1950
|
-
found = await require('./ado').findOpenPrOnBranch(projectObj, meta.branch);
|
|
1951
|
-
} else {
|
|
1952
|
-
log('debug', `Skipping branch PR lookup for unsupported repo host "${host}" on ${projectObj.name}`);
|
|
1953
|
-
}
|
|
1954
|
-
if (found) {
|
|
1955
|
-
const fullId = shared.getCanonicalPrId(projectObj, found.prNumber, found.url);
|
|
1956
|
-
const prPath = shared.projectPrPath(projectObj);
|
|
1957
|
-
mutateJsonFileLocked(prPath, prs => {
|
|
1958
|
-
if (!Array.isArray(prs)) prs = [];
|
|
1959
|
-
const existingPr = prs.find(p => p.id === fullId);
|
|
1960
|
-
if (existingPr) {
|
|
1961
|
-
if (meta.item?.id) {
|
|
1962
|
-
if (!Array.isArray(existingPr.prdItems)) existingPr.prdItems = [];
|
|
1963
|
-
if (!existingPr.prdItems.includes(meta.item.id)) existingPr.prdItems.push(meta.item.id);
|
|
1964
|
-
}
|
|
1965
|
-
return prs;
|
|
1966
|
-
}
|
|
1967
|
-
prs.push({
|
|
1968
|
-
id: fullId, prNumber: found.prNumber, title: meta.item?.title || '',
|
|
1969
|
-
agent: agentId, branch: meta.branch, reviewStatus: 'pending',
|
|
1970
|
-
status: PR_STATUS.ACTIVE, created: ts(), url: found.url,
|
|
1971
|
-
prdItems: meta.item?.id ? [meta.item.id] : [],
|
|
1972
|
-
sourcePlan: meta.item?.sourcePlan || '', itemType: meta.item?.itemType || '',
|
|
1973
|
-
});
|
|
1974
|
-
return prs;
|
|
1975
|
-
});
|
|
1976
|
-
log('info', `Auto-linked existing PR ${fullId} on branch ${meta.branch} for ${meta.item?.id}`);
|
|
1977
|
-
existingPrFound = true;
|
|
1978
|
-
}
|
|
1979
|
-
} catch (e) { log('warn', `PR lookup for branch ${meta.branch}: ${e.message}`); }
|
|
1980
|
-
}
|
|
1981
|
-
}
|
|
1982
|
-
if (!existingPrFound) {
|
|
1983
|
-
const noPrWiPath = resolveWorkItemPath(meta);
|
|
1984
|
-
if (noPrWiPath) {
|
|
1985
|
-
const hasOutput = stdout && stdout.length > 500;
|
|
1986
|
-
let action = null;
|
|
1987
|
-
mutateJsonFileLocked(noPrWiPath, data => {
|
|
1988
|
-
if (!Array.isArray(data)) return data;
|
|
1989
|
-
const w = data.find(i => i.id === meta.item.id);
|
|
1990
|
-
if (!w) return data;
|
|
1991
|
-
const retries = w._retryCount || 0;
|
|
1992
|
-
if (!hasOutput && retries < ENGINE_DEFAULTS.maxRetries) {
|
|
1993
|
-
w.status = WI_STATUS.PENDING;
|
|
1994
|
-
w._retryCount = retries + 1;
|
|
1995
|
-
delete w.dispatched_at;
|
|
1996
|
-
delete w.dispatched_to;
|
|
1997
|
-
delete w.failReason;
|
|
1998
|
-
delete w.noPr;
|
|
1999
|
-
action = { type: 'retry', retries: retries + 1 };
|
|
2000
|
-
} else if (hasOutput) {
|
|
2001
|
-
w.status = WI_STATUS.DONE;
|
|
2002
|
-
w.completedAt = ts();
|
|
2003
|
-
w._noPr = true;
|
|
2004
|
-
w._noPrReason = 'Agent completed without creating a PR (changes may already exist or not be needed)';
|
|
2005
|
-
delete w.failReason;
|
|
2006
|
-
action = { type: 'done' };
|
|
2007
|
-
} else {
|
|
2008
|
-
w.status = WI_STATUS.NEEDS_REVIEW;
|
|
2009
|
-
w._noPr = true;
|
|
2010
|
-
w.failReason = 'Completed without output or PR after ' + ENGINE_DEFAULTS.maxRetries + ' attempts';
|
|
2011
|
-
action = { type: 'needs-review' };
|
|
2012
|
-
}
|
|
2013
|
-
return data;
|
|
2014
|
-
}, { skipWriteIfUnchanged: true });
|
|
2015
|
-
if (action?.type === 'retry') {
|
|
2016
|
-
log('info', `Auto-retry ${action.retries}/${ENGINE_DEFAULTS.maxRetries} for ${meta.item.id} (no output, no PR)`);
|
|
2017
|
-
} else if (action?.type === 'done') {
|
|
2018
|
-
log('info', `${meta.item.id} completed without PR — marking done (agent produced output)`);
|
|
2019
|
-
} else if (action?.type === 'needs-review') {
|
|
2020
|
-
log('warn', `${meta.item.id} needs review — no output after ${ENGINE_DEFAULTS.maxRetries} retries`);
|
|
2021
|
-
}
|
|
2022
|
-
}
|
|
2023
|
-
}
|
|
2024
|
-
}
|
|
2025
|
-
|
|
2026
2026
|
// Old plan-to-prd PRD check removed — moved before updateWorkItemStatus(DONE) to fix #893
|
|
2027
2027
|
// (retryCount was being deleted by done-marking before the check could read it)
|
|
2028
2028
|
// Review verdict check similarly moved before updateWorkItemStatus(DONE) — same root cause.
|
|
@@ -2046,7 +2046,8 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
2046
2046
|
}
|
|
2047
2047
|
}
|
|
2048
2048
|
checkForLearnings(agentId, config.agents[agentId], dispatchItem.task);
|
|
2049
|
-
|
|
2049
|
+
const finalResult = completionContractFailure ? DISPATCH_RESULT.ERROR : (effectiveSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR);
|
|
2050
|
+
if (finalResult === DISPATCH_RESULT.SUCCESS) {
|
|
2050
2051
|
extractSkillsFromOutput(stdout, agentId, dispatchItem, config);
|
|
2051
2052
|
// Also scan inbox notes for skill blocks — agents often write skills to inbox, not stdout
|
|
2052
2053
|
try {
|
|
@@ -2061,7 +2062,6 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
2061
2062
|
}
|
|
2062
2063
|
} catch {}
|
|
2063
2064
|
}
|
|
2064
|
-
const finalResult = effectiveSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR;
|
|
2065
2065
|
updateAgentHistory(agentId, dispatchItem, finalResult);
|
|
2066
2066
|
// Don't count auto-retries as errors in metrics — only count final outcomes
|
|
2067
2067
|
const isAutoRetry = !effectiveSuccess && meta?.item?.id && (meta.item._retryCount || 0) < ENGINE_DEFAULTS.maxRetries;
|
|
@@ -2074,7 +2074,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
2074
2074
|
teams.teamsNotifyCompletion(dispatchItem, finalResult, agentId).catch(() => {});
|
|
2075
2075
|
} catch {}
|
|
2076
2076
|
|
|
2077
|
-
return { resultSummary, taskUsage, autoRecovered, structuredCompletion };
|
|
2077
|
+
return { resultSummary, taskUsage, autoRecovered, structuredCompletion, completionContractFailure };
|
|
2078
2078
|
}
|
|
2079
2079
|
|
|
2080
2080
|
// ─── PR → PRD Status Sync ─────────────────────────────────────────────────────
|
package/engine/shared.js
CHANGED
|
@@ -1896,6 +1896,80 @@ function addPrLink(prId, itemId, { project = null, url = '', prNumber = null } =
|
|
|
1896
1896
|
});
|
|
1897
1897
|
}
|
|
1898
1898
|
|
|
1899
|
+
/**
|
|
1900
|
+
* Canonical PR-producing work contract helper.
|
|
1901
|
+
*
|
|
1902
|
+
* Dashboard rendering derives work-item PR columns from PR.prdItems (with
|
|
1903
|
+
* engine/pr-links.json as a compatibility fallback). Any path that discovers or
|
|
1904
|
+
* manually records a PR for a work item must use this helper so the PR record
|
|
1905
|
+
* and the canonical work-item attachment are created together and idempotently.
|
|
1906
|
+
*/
|
|
1907
|
+
function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null, itemIds = null, beforeInsert = null } = {}) {
|
|
1908
|
+
if (!prPath) throw new Error('prPath required');
|
|
1909
|
+
if (!entry || typeof entry !== 'object') throw new Error('entry required');
|
|
1910
|
+
|
|
1911
|
+
const linkedItemIds = normalizePrLinkItems([
|
|
1912
|
+
...(Array.isArray(entry.prdItems) ? entry.prdItems : []),
|
|
1913
|
+
...(Array.isArray(itemIds) ? itemIds : [itemId]),
|
|
1914
|
+
]);
|
|
1915
|
+
const prNumber = getPrNumber(entry.prNumber ?? entry.id ?? entry.url);
|
|
1916
|
+
const canonicalId = getCanonicalPrId(project, entry.prNumber ?? entry.id ?? entry.url ?? prNumber, entry.url || '');
|
|
1917
|
+
if (!canonicalId) throw new Error('PR id required');
|
|
1918
|
+
const normalizedEntry = {
|
|
1919
|
+
...entry,
|
|
1920
|
+
id: canonicalId,
|
|
1921
|
+
prNumber: prNumber ?? entry.prNumber ?? null,
|
|
1922
|
+
prdItems: linkedItemIds,
|
|
1923
|
+
};
|
|
1924
|
+
|
|
1925
|
+
let created = false;
|
|
1926
|
+
let linked = false;
|
|
1927
|
+
let skipped = false;
|
|
1928
|
+
let record = null;
|
|
1929
|
+
|
|
1930
|
+
mutatePullRequests(prPath, (prs) => {
|
|
1931
|
+
normalizePrRecords(prs, project);
|
|
1932
|
+
let target = findPrRecord(prs, normalizedEntry, project);
|
|
1933
|
+
if (!target && typeof beforeInsert === 'function' && beforeInsert(prs, normalizedEntry) === false) {
|
|
1934
|
+
skipped = true;
|
|
1935
|
+
return prs;
|
|
1936
|
+
}
|
|
1937
|
+
if (!target) {
|
|
1938
|
+
target = normalizedEntry;
|
|
1939
|
+
prs.push(target);
|
|
1940
|
+
created = true;
|
|
1941
|
+
} else {
|
|
1942
|
+
target.id = canonicalId;
|
|
1943
|
+
if (prNumber != null) target.prNumber = prNumber;
|
|
1944
|
+
for (const key of ['url', 'title', 'description', 'agent', 'branch', 'reviewStatus', 'status', 'created', 'sourcePlan', 'itemType']) {
|
|
1945
|
+
if (normalizedEntry[key] != null && normalizedEntry[key] !== '' && (target[key] == null || target[key] === '')) {
|
|
1946
|
+
target[key] = normalizedEntry[key];
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
for (const key of ['_manual', '_contextOnly', '_autoObserve', '_context']) {
|
|
1950
|
+
if (normalizedEntry[key] != null) target[key] = normalizedEntry[key];
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
target.prdItems = normalizePrLinkItems(target.prdItems || []);
|
|
1954
|
+
for (const linkedItemId of linkedItemIds) {
|
|
1955
|
+
if (!target.prdItems.includes(linkedItemId)) {
|
|
1956
|
+
target.prdItems.push(linkedItemId);
|
|
1957
|
+
linked = true;
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
record = { ...target, prdItems: [...target.prdItems] };
|
|
1961
|
+
return prs;
|
|
1962
|
+
});
|
|
1963
|
+
|
|
1964
|
+
if (!skipped) {
|
|
1965
|
+
for (const linkedItemId of linkedItemIds) {
|
|
1966
|
+
addPrLink(canonicalId, linkedItemId, { project, prNumber, url: normalizedEntry.url || '' });
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
return { id: canonicalId, prNumber, created, linked, skipped, record };
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1899
1973
|
// ─── Cross-Platform Process Kill Helpers ─────────────────────────────────────
|
|
1900
1974
|
|
|
1901
1975
|
function killGracefully(proc, graceMs = 5000) {
|
|
@@ -2203,6 +2277,7 @@ module.exports = {
|
|
|
2203
2277
|
findPrRecord,
|
|
2204
2278
|
normalizePrRecord,
|
|
2205
2279
|
normalizePrRecords,
|
|
2280
|
+
upsertPullRequestRecord,
|
|
2206
2281
|
nextWorkItemId,
|
|
2207
2282
|
getAdoOrgBase,
|
|
2208
2283
|
sanitizePath,
|
package/engine.js
CHANGED
|
@@ -1268,15 +1268,19 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
1268
1268
|
}
|
|
1269
1269
|
|
|
1270
1270
|
// Parse output and run all post-completion hooks
|
|
1271
|
-
const { resultSummary, autoRecovered } = await runPostCompletionHooks(dispatchItem, agentId, code, stdout, config);
|
|
1271
|
+
const { resultSummary, autoRecovered, completionContractFailure } = await runPostCompletionHooks(dispatchItem, agentId, code, stdout, config);
|
|
1272
1272
|
|
|
1273
1273
|
// Move from active to completed in dispatch (single source of truth for agent status)
|
|
1274
1274
|
// autoRecovered: agent failed after creating PRs — treat as success
|
|
1275
|
-
const effectiveResult = (code === 0 || autoRecovered) ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR;
|
|
1276
|
-
const completeOpts =
|
|
1275
|
+
const effectiveResult = completionContractFailure ? DISPATCH_RESULT.ERROR : ((code === 0 || autoRecovered) ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR);
|
|
1276
|
+
const completeOpts = completionContractFailure
|
|
1277
|
+
? { processWorkItemFailure: false }
|
|
1278
|
+
: (effectiveResult === DISPATCH_RESULT.ERROR && failureClass ? { failureClass } : {});
|
|
1277
1279
|
// Extract last 5 non-empty stderr lines as error context when exit code is non-zero
|
|
1278
1280
|
let errorReason = '';
|
|
1279
|
-
if (
|
|
1281
|
+
if (completionContractFailure) {
|
|
1282
|
+
errorReason = completionContractFailure.reason || 'PR attachment contract failed';
|
|
1283
|
+
} else if (effectiveResult === DISPATCH_RESULT.ERROR) {
|
|
1280
1284
|
errorReason = stderr.split('\n').filter(l => l.trim()).slice(-5).join(' | ').trim().slice(0, 300);
|
|
1281
1285
|
// W-mo3zul9pirjb — when claude CLI exits in <3s with code 1 and no output (the
|
|
1282
1286
|
// "silent crash" pattern seen during scheduled tasks when the box went to sleep
|
|
@@ -1982,56 +1986,99 @@ function clearPendingHumanFeedbackFlag(projectMeta, prId) {
|
|
|
1982
1986
|
|
|
1983
1987
|
const PR_PENDING_MISSING_BRANCH = 'missing_pr_branch';
|
|
1984
1988
|
|
|
1985
|
-
function
|
|
1986
|
-
|
|
1989
|
+
function normalizePrBranch(value) {
|
|
1990
|
+
const raw = value == null ? '' : String(value).trim();
|
|
1991
|
+
if (!raw) return '';
|
|
1992
|
+
return raw.replace(/^refs\/heads\//i, '');
|
|
1987
1993
|
}
|
|
1988
1994
|
|
|
1989
|
-
function
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1995
|
+
function resolvePrBranch(pr) {
|
|
1996
|
+
if (!pr || typeof pr !== 'object') return '';
|
|
1997
|
+
const candidates = [
|
|
1998
|
+
pr.branch,
|
|
1999
|
+
pr.pr_branch,
|
|
2000
|
+
pr.prBranch,
|
|
2001
|
+
pr.sourceRefName,
|
|
2002
|
+
pr.sourceBranch,
|
|
2003
|
+
pr.sourceRef,
|
|
2004
|
+
pr.headRefName,
|
|
2005
|
+
pr.head?.ref,
|
|
2006
|
+
];
|
|
2007
|
+
for (const candidate of candidates) {
|
|
2008
|
+
const branch = normalizePrBranch(candidate);
|
|
2009
|
+
if (branch) return branch;
|
|
2010
|
+
}
|
|
2011
|
+
return '';
|
|
1999
2012
|
}
|
|
2000
2013
|
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2014
|
+
// Unified branch-resolution state writer: when branch is found, persist it and
|
|
2015
|
+
// clear BOTH the structured _branchResolutionError (red-badge UI) and the
|
|
2016
|
+
// _pendingReason marker (dashboard pending-reason vocabulary). When branch is
|
|
2017
|
+
// missing, set BOTH fields so the dashboard can surface the gate via either
|
|
2018
|
+
// rendering path.
|
|
2019
|
+
function updatePrBranchResolutionState(project, pr, { branch = '', reason = '' } = {}) {
|
|
2020
|
+
let changed = false;
|
|
2021
|
+
try {
|
|
2022
|
+
mutatePullRequests(projectPrPath(project), prs => {
|
|
2023
|
+
const target = shared.findPrRecord(prs, pr, project);
|
|
2024
|
+
if (!target) return;
|
|
2025
|
+
if (branch) {
|
|
2026
|
+
if (target.branch !== branch) {
|
|
2027
|
+
target.branch = branch;
|
|
2028
|
+
changed = true;
|
|
2029
|
+
}
|
|
2030
|
+
if (target._branchResolutionError) {
|
|
2031
|
+
delete target._branchResolutionError;
|
|
2032
|
+
changed = true;
|
|
2033
|
+
}
|
|
2034
|
+
if (target._pendingReason === PR_PENDING_MISSING_BRANCH) {
|
|
2035
|
+
delete target._pendingReason;
|
|
2036
|
+
changed = true;
|
|
2037
|
+
}
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
if (reason) {
|
|
2041
|
+
const currentReason = target._branchResolutionError?.reason || '';
|
|
2042
|
+
if (currentReason !== reason) {
|
|
2043
|
+
target._branchResolutionError = { reason, at: ts() };
|
|
2044
|
+
changed = true;
|
|
2045
|
+
}
|
|
2046
|
+
if (target._pendingReason !== PR_PENDING_MISSING_BRANCH) {
|
|
2047
|
+
target._pendingReason = PR_PENDING_MISSING_BRANCH;
|
|
2048
|
+
changed = true;
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
});
|
|
2052
|
+
} catch (e) {
|
|
2053
|
+
log('warn', `mark PR branch resolution state for ${pr?.id || 'unknown PR'}: ${e.message}`);
|
|
2054
|
+
}
|
|
2055
|
+
if (branch) {
|
|
2056
|
+
pr.branch = branch;
|
|
2057
|
+
if (pr._branchResolutionError) delete pr._branchResolutionError;
|
|
2058
|
+
if (pr._pendingReason === PR_PENDING_MISSING_BRANCH) delete pr._pendingReason;
|
|
2059
|
+
} else if (reason && changed) {
|
|
2060
|
+
pr._branchResolutionError = { reason, at: ts() };
|
|
2009
2061
|
pr._pendingReason = PR_PENDING_MISSING_BRANCH;
|
|
2010
|
-
log('warn', `PR ${pr.id}: cannot dispatch ${action} — missing pr_branch; waiting for PR metadata enrichment`);
|
|
2011
2062
|
}
|
|
2012
2063
|
return changed;
|
|
2013
2064
|
}
|
|
2014
2065
|
|
|
2015
|
-
function
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
if (
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
}
|
|
2025
|
-
|
|
2026
|
-
function canDispatchPrBranch(project, pr, action) {
|
|
2027
|
-
if (getPrDispatchBranch(pr)) {
|
|
2028
|
-
clearPrMissingBranch(project, pr);
|
|
2029
|
-
return true;
|
|
2066
|
+
function ensurePrBranchForDispatch(project, pr, automationType) {
|
|
2067
|
+
const branch = resolvePrBranch(pr);
|
|
2068
|
+
if (branch) {
|
|
2069
|
+
if (pr.branch !== branch || pr._branchResolutionError || pr._pendingReason === PR_PENDING_MISSING_BRANCH) {
|
|
2070
|
+
updatePrBranchResolutionState(project, pr, { branch });
|
|
2071
|
+
}
|
|
2072
|
+
return branch;
|
|
2073
|
+
}
|
|
2074
|
+
const reason = `Cannot dispatch ${automationType} for ${shared.getPrDisplayId(pr)}: missing pr_branch/source branch metadata. Link or refresh the PR so the source branch is known.`;
|
|
2075
|
+
if (updatePrBranchResolutionState(project, pr, { reason })) {
|
|
2076
|
+
log('warn', `PR ${pr.id}: ${reason}`);
|
|
2030
2077
|
}
|
|
2031
|
-
|
|
2032
|
-
return false;
|
|
2078
|
+
return '';
|
|
2033
2079
|
}
|
|
2034
2080
|
|
|
2081
|
+
|
|
2035
2082
|
/**
|
|
2036
2083
|
* Scan pull-requests.json for PRs needing review or fixes
|
|
2037
2084
|
*/
|
|
@@ -2073,10 +2120,10 @@ async function discoverFromPrs(config, project) {
|
|
|
2073
2120
|
const prDisplayId = shared.getPrDisplayId(pr);
|
|
2074
2121
|
const prCanonicalId = shared.getCanonicalPrId(project, pr, pr.url || '');
|
|
2075
2122
|
if (activePrIds.has(prCanonicalId)) continue; // Skip PRs with active dispatch (prevent race)
|
|
2076
|
-
|
|
2123
|
+
const prBranchForMutex = resolvePrBranch(pr);
|
|
2077
2124
|
// Branch mutex: skip if PR branch is locked by any active dispatch (cross-type collision)
|
|
2078
|
-
if (
|
|
2079
|
-
log('info', `Branch mutex: skipping PR ${pr.id} dispatch — branch ${
|
|
2125
|
+
if (prBranchForMutex && isBranchActive(prBranchForMutex)) {
|
|
2126
|
+
log('info', `Branch mutex: skipping PR ${pr.id} dispatch — branch ${prBranchForMutex} locked by another agent`);
|
|
2080
2127
|
continue;
|
|
2081
2128
|
}
|
|
2082
2129
|
// Skip human-authored PRs not linked to any work item — only auto-manage agent PRs
|
|
@@ -2114,7 +2161,6 @@ async function discoverFromPrs(config, project) {
|
|
|
2114
2161
|
if (needsReview) {
|
|
2115
2162
|
const key = `review-${project?.name || 'default'}-${prDisplayId}`;
|
|
2116
2163
|
if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
|
|
2117
|
-
if (!canDispatchPrBranch(project, pr, 'review')) continue;
|
|
2118
2164
|
|
|
2119
2165
|
// Pre-dispatch live vote check — cached reviewStatus may be stale (poll lag ~6 min)
|
|
2120
2166
|
try {
|
|
@@ -2139,11 +2185,13 @@ async function discoverFromPrs(config, project) {
|
|
|
2139
2185
|
|
|
2140
2186
|
const agentId = resolveAgent('review', config);
|
|
2141
2187
|
if (!agentId) continue;
|
|
2188
|
+
const prBranch = ensurePrBranchForDispatch(project, pr, 'review');
|
|
2189
|
+
if (!prBranch) continue;
|
|
2142
2190
|
|
|
2143
2191
|
const item = buildPrDispatch(agentId, config, project, pr, 'review', {
|
|
2144
|
-
pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch:
|
|
2192
|
+
pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: prBranch,
|
|
2145
2193
|
pr_author: pr.agent || '', pr_url: pr.url || '',
|
|
2146
|
-
}, `Review ${pr.id}: ${pr.title}`, { dispatchKey: key, source: 'pr', pr, branch:
|
|
2194
|
+
}, `Review ${pr.id}: ${pr.title}`, { dispatchKey: key, source: 'pr', pr, branch: prBranch, project: projMeta });
|
|
2147
2195
|
if (item) { newWork.push(item); }
|
|
2148
2196
|
}
|
|
2149
2197
|
|
|
@@ -2158,7 +2206,6 @@ async function discoverFromPrs(config, project) {
|
|
|
2158
2206
|
// Skip isAlreadyDispatched — fixedAfterReview/lastReviewedAt already dedupe; the 1hr
|
|
2159
2207
|
// completed-dispatch window would block legitimate re-reviews within the hour after a fix
|
|
2160
2208
|
if (isOnCooldown(key, cooldownMs)) continue;
|
|
2161
|
-
if (!canDispatchPrBranch(project, pr, 're-review')) continue;
|
|
2162
2209
|
|
|
2163
2210
|
// Pre-dispatch live vote check — cached 'waiting' may be stale if reviewer already acted
|
|
2164
2211
|
try {
|
|
@@ -2181,11 +2228,13 @@ async function discoverFromPrs(config, project) {
|
|
|
2181
2228
|
|
|
2182
2229
|
const agentId = resolveAgent('review', config);
|
|
2183
2230
|
if (!agentId) continue;
|
|
2231
|
+
const prBranch = ensurePrBranchForDispatch(project, pr, 're-review');
|
|
2232
|
+
if (!prBranch) continue;
|
|
2184
2233
|
|
|
2185
2234
|
const item = buildPrDispatch(agentId, config, project, pr, 'review', {
|
|
2186
|
-
pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch:
|
|
2235
|
+
pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: prBranch,
|
|
2187
2236
|
pr_author: pr.agent || '', pr_url: pr.url || '',
|
|
2188
|
-
}, `Review ${pr.id}: ${pr.title}`, { dispatchKey: key, source: 'pr', pr, branch:
|
|
2237
|
+
}, `Review ${pr.id}: ${pr.title}`, { dispatchKey: key, source: 'pr', pr, branch: prBranch, project: projMeta });
|
|
2189
2238
|
if (item) { newWork.push(item); }
|
|
2190
2239
|
}
|
|
2191
2240
|
|
|
@@ -2195,14 +2244,15 @@ async function discoverFromPrs(config, project) {
|
|
|
2195
2244
|
if (evalLoopEnabled && reviewStatus === 'changes-requested' && !awaitingReReview && !evalEscalated) {
|
|
2196
2245
|
const key = `fix-${project?.name || 'default'}-${prDisplayId}`;
|
|
2197
2246
|
if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
|
|
2198
|
-
if (!canDispatchPrBranch(project, pr, 'fix')) continue;
|
|
2199
2247
|
const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
|
|
2200
2248
|
if (!agentId) continue;
|
|
2249
|
+
const prBranch = ensurePrBranchForDispatch(project, pr, 'fix');
|
|
2250
|
+
if (!prBranch) continue;
|
|
2201
2251
|
|
|
2202
2252
|
const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
|
|
2203
|
-
pr_id: pr.id, pr_branch:
|
|
2253
|
+
pr_id: pr.id, pr_branch: prBranch,
|
|
2204
2254
|
review_note: pr.minionsReview?.note || pr.reviewNote || 'See PR thread comments',
|
|
2205
|
-
}, `Fix ${pr.id}: ${pr.title || ''} — review feedback`, { dispatchKey: key, source: 'pr', pr, branch:
|
|
2255
|
+
}, `Fix ${pr.id}: ${pr.title || ''} — review feedback`, { dispatchKey: key, source: 'pr', pr, branch: prBranch, project: projMeta });
|
|
2206
2256
|
if (item) {
|
|
2207
2257
|
newWork.push(item); setCooldown(key); fixDispatched = true;
|
|
2208
2258
|
// Increment review→fix cycle counter
|
|
@@ -2235,9 +2285,10 @@ async function discoverFromPrs(config, project) {
|
|
|
2235
2285
|
}
|
|
2236
2286
|
continue;
|
|
2237
2287
|
}
|
|
2238
|
-
if (!canDispatchPrBranch(project, pr, 'human-feedback fix')) continue;
|
|
2239
2288
|
const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
|
|
2240
2289
|
if (!agentId) continue;
|
|
2290
|
+
const prBranch = ensurePrBranchForDispatch(project, pr, 'human-feedback fix');
|
|
2291
|
+
if (!prBranch) continue;
|
|
2241
2292
|
|
|
2242
2293
|
const coalesced = [...staleCoalesced, ...getCoalescedContexts(key)];
|
|
2243
2294
|
let reviewNote = pr.humanFeedback.feedbackContent || 'See PR thread comments';
|
|
@@ -2247,10 +2298,10 @@ async function discoverFromPrs(config, project) {
|
|
|
2247
2298
|
}
|
|
2248
2299
|
|
|
2249
2300
|
const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
|
|
2250
|
-
pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch:
|
|
2301
|
+
pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: prBranch,
|
|
2251
2302
|
reviewer: 'Human Reviewer',
|
|
2252
2303
|
review_note: reviewNote,
|
|
2253
|
-
}, `Fix ${pr.id}: ${pr.title || ''} — human feedback`, { dispatchKey: key, source: 'pr-human-feedback', pr, branch:
|
|
2304
|
+
}, `Fix ${pr.id}: ${pr.title || ''} — human feedback`, { dispatchKey: key, source: 'pr-human-feedback', pr, branch: prBranch, project: projMeta });
|
|
2254
2305
|
if (item) { newWork.push(item); fixDispatched = true; }
|
|
2255
2306
|
}
|
|
2256
2307
|
|
|
@@ -2283,7 +2334,6 @@ async function discoverFromPrs(config, project) {
|
|
|
2283
2334
|
|
|
2284
2335
|
const key = `build-fix-${project?.name || 'default'}-${prDisplayId}`;
|
|
2285
2336
|
if (fixThrottled || isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
|
|
2286
|
-
if (!canDispatchPrBranch(project, pr, 'build fix')) continue;
|
|
2287
2337
|
|
|
2288
2338
|
// Pre-dispatch live build check — cached buildStatus may be stale: ADO can
|
|
2289
2339
|
// recompute the merge commit when master moves and pollPrStatus deliberately
|
|
@@ -2319,6 +2369,8 @@ async function discoverFromPrs(config, project) {
|
|
|
2319
2369
|
|
|
2320
2370
|
const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
|
|
2321
2371
|
if (!agentId) continue;
|
|
2372
|
+
const prBranch = ensurePrBranchForDispatch(project, pr, 'build-fix');
|
|
2373
|
+
if (!prBranch) continue;
|
|
2322
2374
|
|
|
2323
2375
|
let reviewNote = `Build is failing: ${pr.buildFailReason || 'Check CI pipeline for details'}. Fix the build errors and push.`;
|
|
2324
2376
|
if (pr.buildErrorLog) {
|
|
@@ -2326,9 +2378,9 @@ async function discoverFromPrs(config, project) {
|
|
|
2326
2378
|
}
|
|
2327
2379
|
|
|
2328
2380
|
const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
|
|
2329
|
-
pr_id: pr.id, pr_branch:
|
|
2381
|
+
pr_id: pr.id, pr_branch: prBranch,
|
|
2330
2382
|
review_note: reviewNote,
|
|
2331
|
-
}, `Fix build failure on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, source: 'pr', pr, branch:
|
|
2383
|
+
}, `Fix build failure on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, source: 'pr', pr, branch: prBranch, project: projMeta });
|
|
2332
2384
|
if (item) {
|
|
2333
2385
|
newWork.push(item); setCooldown(key); fixDispatched = true;
|
|
2334
2386
|
// Increment build fix attempts counter
|
|
@@ -2368,7 +2420,6 @@ async function discoverFromPrs(config, project) {
|
|
|
2368
2420
|
const conflictFixedAt = pr._conflictFixedAt;
|
|
2369
2421
|
const withinLag = conflictFixedAt && Date.now() - new Date(conflictFixedAt).getTime() < 10 * 60 * 1000;
|
|
2370
2422
|
if (!withinLag && !fixThrottled && !isAlreadyDispatched(key) && !isOnCooldown(key, cooldownMs)) {
|
|
2371
|
-
if (!canDispatchPrBranch(project, pr, 'conflict fix')) continue;
|
|
2372
2423
|
// Pre-dispatch live conflict check — cached `_mergeConflict` may be
|
|
2373
2424
|
// stale: ADO/GitHub recompute mergeStatus asynchronously (1–5 min lag),
|
|
2374
2425
|
// so a successful upstream merge can leave the flag set even after the
|
|
@@ -2395,10 +2446,12 @@ async function discoverFromPrs(config, project) {
|
|
|
2395
2446
|
if (!liveSkip) {
|
|
2396
2447
|
const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
|
|
2397
2448
|
if (agentId) {
|
|
2449
|
+
const prBranch = ensurePrBranchForDispatch(project, pr, 'conflict-fix');
|
|
2450
|
+
if (!prBranch) continue;
|
|
2398
2451
|
const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
|
|
2399
|
-
pr_id: pr.id, pr_branch:
|
|
2452
|
+
pr_id: pr.id, pr_branch: prBranch,
|
|
2400
2453
|
review_note: `This PR has merge conflicts with the target branch. Resolve the conflicts:\n\n1. Pull latest from main/master\n2. Resolve all conflicts (prefer PR branch changes unless main has critical fixes)\n3. Build and test after resolving\n4. Push the resolved branch`,
|
|
2401
|
-
}, `Fix merge conflicts on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, source: 'pr', pr, branch:
|
|
2454
|
+
}, `Fix merge conflicts on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, source: 'pr', pr, branch: prBranch, project: projMeta });
|
|
2402
2455
|
if (item) {
|
|
2403
2456
|
newWork.push(item);
|
|
2404
2457
|
setCooldown(key);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1641",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|