@yemi33/minions 0.1.1640 → 0.1.1642
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 +10 -0
- package/dashboard.js +53 -56
- package/engine/ado.js +17 -5
- package/engine/copilot-models.json +1 -1
- package/engine/github.js +17 -5
- package/engine/lifecycle.js +170 -170
- package/engine/shared.js +75 -0
- package/engine.js +53 -47
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
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 () => {
|
|
@@ -6426,6 +6422,7 @@ module.exports = {
|
|
|
6426
6422
|
_parseDocChatResultText,
|
|
6427
6423
|
_messageRequestsOrchestration,
|
|
6428
6424
|
_formatDocChatContext,
|
|
6425
|
+
_linkPullRequestForTracking: linkPullRequestForTracking,
|
|
6429
6426
|
_resolveSkillReadPath,
|
|
6430
6427
|
DOC_CHAT_DOCUMENT_DELIMITER,
|
|
6431
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
|
|
|
@@ -803,7 +803,18 @@ async function reconcilePrs(config) {
|
|
|
803
803
|
}
|
|
804
804
|
// PR already tracked — write link to pr-links.json if we can extract an ID
|
|
805
805
|
if (confirmedItemId) {
|
|
806
|
-
|
|
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 });
|
|
807
818
|
if (existing && !(existing.prdItems || []).includes(confirmedItemId)) {
|
|
808
819
|
existing.prdItems = Array.isArray(existing.prdItems) ? existing.prdItems : [];
|
|
809
820
|
existing.prdItems.push(confirmedItemId);
|
|
@@ -818,7 +829,7 @@ async function reconcilePrs(config) {
|
|
|
818
829
|
// are human-authored and should not be auto-tracked or auto-reviewed.
|
|
819
830
|
if (!confirmedItemId) continue;
|
|
820
831
|
|
|
821
|
-
|
|
832
|
+
const entry = {
|
|
822
833
|
id: prId,
|
|
823
834
|
prNumber: adoPr.pullRequestId,
|
|
824
835
|
title: (adoPr.title || `PR #${adoPr.pullRequestId}`).slice(0, 120),
|
|
@@ -829,8 +840,9 @@ async function reconcilePrs(config) {
|
|
|
829
840
|
created: adoPr.creationDate || ts(),
|
|
830
841
|
url: prUrl,
|
|
831
842
|
prdItems: [confirmedItemId],
|
|
832
|
-
}
|
|
833
|
-
|
|
843
|
+
};
|
|
844
|
+
const upserted = shared.upsertPullRequestRecord(prPath, entry, { project, itemId: confirmedItemId });
|
|
845
|
+
existingPrs.push(upserted.record || entry);
|
|
834
846
|
existingIds.add(prId);
|
|
835
847
|
projectAdded++;
|
|
836
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
|
|
|
@@ -717,7 +717,18 @@ async function reconcilePrs(config) {
|
|
|
717
717
|
metadataUpdated++;
|
|
718
718
|
}
|
|
719
719
|
if (confirmedItemId) {
|
|
720
|
-
|
|
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 });
|
|
721
732
|
if (existing && !(existing.prdItems || []).includes(confirmedItemId)) {
|
|
722
733
|
existing.prdItems = Array.isArray(existing.prdItems) ? existing.prdItems : [];
|
|
723
734
|
existing.prdItems.push(confirmedItemId);
|
|
@@ -732,7 +743,7 @@ async function reconcilePrs(config) {
|
|
|
732
743
|
// Only auto-track PRs linked to a minions work item — skip human-authored PRs
|
|
733
744
|
if (!confirmedItemId && !isE2eBranch) continue;
|
|
734
745
|
|
|
735
|
-
|
|
746
|
+
const entry = {
|
|
736
747
|
id: prId,
|
|
737
748
|
prNumber: ghPr.number,
|
|
738
749
|
title: (ghPr.title || `PR #${ghPr.number}`).slice(0, 120),
|
|
@@ -743,8 +754,9 @@ async function reconcilePrs(config) {
|
|
|
743
754
|
created: ghPr.created_at || ts(),
|
|
744
755
|
url: prUrl,
|
|
745
756
|
prdItems: [confirmedItemId],
|
|
746
|
-
}
|
|
747
|
-
|
|
757
|
+
};
|
|
758
|
+
const upserted = shared.upsertPullRequestRecord(prPath, entry, { project, itemId: confirmedItemId });
|
|
759
|
+
currentPrs.push(upserted.record || entry);
|
|
748
760
|
existingIds.add(prId);
|
|
749
761
|
projectAdded++;
|
|
750
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
|
|
@@ -2191,12 +2195,55 @@ async function discoverFromPrs(config, project) {
|
|
|
2191
2195
|
if (item) { newWork.push(item); }
|
|
2192
2196
|
}
|
|
2193
2197
|
|
|
2198
|
+
let fixDispatched = false;
|
|
2199
|
+
|
|
2200
|
+
// Fresh reviewer comments are actionable fixes, even while the PR is otherwise
|
|
2201
|
+
// awaiting a stale-vote re-review or has build-fix retries escalated.
|
|
2202
|
+
const humanFixKey = `human-fix-${project?.name || 'default'}-${prDisplayId}`;
|
|
2203
|
+
const hasCoalescedFeedback = (dispatchCooldowns.get(humanFixKey)?.pendingContexts || []).length > 0;
|
|
2204
|
+
if ((pr.humanFeedback?.pendingFix || hasCoalescedFeedback) && !fixDispatched) {
|
|
2205
|
+
const key = humanFixKey;
|
|
2206
|
+
let staleCoalesced = [];
|
|
2207
|
+
const alreadyDispatched = isAlreadyDispatched(key);
|
|
2208
|
+
const blockedByCooldown = isOnCooldown(key, cooldownMs);
|
|
2209
|
+
if (blockedByCooldown && !alreadyDispatched) {
|
|
2210
|
+
staleCoalesced = getCoalescedContexts(key);
|
|
2211
|
+
clearCooldown(key);
|
|
2212
|
+
log('info', `Cleared stale cooldown for ${key} — no matching dispatch history`);
|
|
2213
|
+
}
|
|
2214
|
+
if (alreadyDispatched || isOnCooldown(key, cooldownMs)) {
|
|
2215
|
+
// Coalesce: save feedback for next dispatch
|
|
2216
|
+
if (pr.humanFeedback?.feedbackContent) {
|
|
2217
|
+
setCooldownWithContext(key, { feedbackContent: pr.humanFeedback.feedbackContent, timestamp: ts() });
|
|
2218
|
+
}
|
|
2219
|
+
continue;
|
|
2220
|
+
}
|
|
2221
|
+
const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
|
|
2222
|
+
if (!agentId) continue;
|
|
2223
|
+
const prBranch = ensurePrBranchForDispatch(project, pr, 'human-feedback fix');
|
|
2224
|
+
if (!prBranch) continue;
|
|
2225
|
+
|
|
2226
|
+
const coalesced = [...staleCoalesced, ...getCoalescedContexts(key)];
|
|
2227
|
+
let reviewNote = pr.humanFeedback.feedbackContent || 'See PR thread comments';
|
|
2228
|
+
if (coalesced.length > 0) {
|
|
2229
|
+
const earlier = coalesced.map(c => c.feedbackContent).filter(Boolean).join('\n\n---\n\n');
|
|
2230
|
+
if (earlier) reviewNote = earlier + '\n\n---\n\n' + reviewNote;
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
|
|
2234
|
+
pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: prBranch,
|
|
2235
|
+
reviewer: 'Human Reviewer',
|
|
2236
|
+
review_note: reviewNote,
|
|
2237
|
+
}, `Fix ${pr.id}: ${pr.title || ''} — human feedback`, { dispatchKey: key, source: 'pr-human-feedback', pr, branch: prBranch, project: projMeta });
|
|
2238
|
+
if (item) { newWork.push(item); fixDispatched = true; }
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2194
2241
|
// Re-review after fix: trigger when a fix was pushed after the last minions review,
|
|
2195
2242
|
// or when no minions review has completed yet (e.g. human-feedback-only fix path).
|
|
2196
2243
|
const fixedAfterReview = !!(pr.minionsReview?.fixedAt &&
|
|
2197
2244
|
(!pr.lastReviewedAt || pr.minionsReview.fixedAt > pr.lastReviewedAt));
|
|
2198
2245
|
const needsReReview = reviewEnabled && reviewStatus === 'waiting' &&
|
|
2199
|
-
fixedAfterReview && !evalEscalated;
|
|
2246
|
+
fixedAfterReview && !evalEscalated && !fixDispatched;
|
|
2200
2247
|
if (needsReReview) {
|
|
2201
2248
|
const key = `rereview-${project?.name || 'default'}-${prDisplayId}`;
|
|
2202
2249
|
// Skip isAlreadyDispatched — fixedAfterReview/lastReviewedAt already dedupe; the 1hr
|
|
@@ -2236,8 +2283,7 @@ async function discoverFromPrs(config, project) {
|
|
|
2236
2283
|
|
|
2237
2284
|
// PRs with changes requested → route back to author for fix
|
|
2238
2285
|
// Gate on evalLoopEnabled — the review→fix cycle is the eval loop
|
|
2239
|
-
|
|
2240
|
-
if (evalLoopEnabled && reviewStatus === 'changes-requested' && !awaitingReReview && !evalEscalated) {
|
|
2286
|
+
if (evalLoopEnabled && reviewStatus === 'changes-requested' && !awaitingReReview && !evalEscalated && !fixDispatched) {
|
|
2241
2287
|
const key = `fix-${project?.name || 'default'}-${prDisplayId}`;
|
|
2242
2288
|
if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
|
|
2243
2289
|
const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
|
|
@@ -2261,46 +2307,6 @@ async function discoverFromPrs(config, project) {
|
|
|
2261
2307
|
}
|
|
2262
2308
|
}
|
|
2263
2309
|
|
|
2264
|
-
// PRs with pending human feedback (skip if review-fix already dispatched above)
|
|
2265
|
-
const humanFixKey = `human-fix-${project?.name || 'default'}-${prDisplayId}`;
|
|
2266
|
-
const hasCoalescedFeedback = (dispatchCooldowns.get(humanFixKey)?.pendingContexts || []).length > 0;
|
|
2267
|
-
if ((pr.humanFeedback?.pendingFix || hasCoalescedFeedback) && !awaitingReReview && !fixDispatched) {
|
|
2268
|
-
const key = humanFixKey;
|
|
2269
|
-
let staleCoalesced = [];
|
|
2270
|
-
const alreadyDispatched = isAlreadyDispatched(key);
|
|
2271
|
-
const blockedByCooldown = isOnCooldown(key, cooldownMs);
|
|
2272
|
-
if (blockedByCooldown && !alreadyDispatched) {
|
|
2273
|
-
staleCoalesced = getCoalescedContexts(key);
|
|
2274
|
-
clearCooldown(key);
|
|
2275
|
-
log('info', `Cleared stale cooldown for ${key} — no matching dispatch history`);
|
|
2276
|
-
}
|
|
2277
|
-
if (alreadyDispatched || isOnCooldown(key, cooldownMs)) {
|
|
2278
|
-
// Coalesce: save feedback for next dispatch
|
|
2279
|
-
if (pr.humanFeedback?.feedbackContent) {
|
|
2280
|
-
setCooldownWithContext(key, { feedbackContent: pr.humanFeedback.feedbackContent, timestamp: ts() });
|
|
2281
|
-
}
|
|
2282
|
-
continue;
|
|
2283
|
-
}
|
|
2284
|
-
const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
|
|
2285
|
-
if (!agentId) continue;
|
|
2286
|
-
const prBranch = ensurePrBranchForDispatch(project, pr, 'human-feedback fix');
|
|
2287
|
-
if (!prBranch) continue;
|
|
2288
|
-
|
|
2289
|
-
const coalesced = [...staleCoalesced, ...getCoalescedContexts(key)];
|
|
2290
|
-
let reviewNote = pr.humanFeedback.feedbackContent || 'See PR thread comments';
|
|
2291
|
-
if (coalesced.length > 0) {
|
|
2292
|
-
const earlier = coalesced.map(c => c.feedbackContent).filter(Boolean).join('\n\n---\n\n');
|
|
2293
|
-
if (earlier) reviewNote = earlier + '\n\n---\n\n' + reviewNote;
|
|
2294
|
-
}
|
|
2295
|
-
|
|
2296
|
-
const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
|
|
2297
|
-
pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: prBranch,
|
|
2298
|
-
reviewer: 'Human Reviewer',
|
|
2299
|
-
review_note: reviewNote,
|
|
2300
|
-
}, `Fix ${pr.id}: ${pr.title || ''} — human feedback`, { dispatchKey: key, source: 'pr-human-feedback', pr, branch: prBranch, project: projMeta });
|
|
2301
|
-
if (item) { newWork.push(item); fixDispatched = true; }
|
|
2302
|
-
}
|
|
2303
|
-
|
|
2304
2310
|
// PRs with build failures — route to author (has session context from implementing)
|
|
2305
2311
|
// Grace period: after a build fix push, wait for CI to run before re-dispatching
|
|
2306
2312
|
// Skip if build hasn't transitioned since last fix (still showing the old failure)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1642",
|
|
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"
|