@yemi33/minions 0.1.1653 → 0.1.1655
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/settings.js +8 -0
- package/dashboard.js +59 -10
- package/engine/ado.js +45 -8
- package/engine/cleanup.js +57 -2
- package/engine/cooldown.js +10 -5
- package/engine/copilot-models.json +1 -1
- package/engine/dispatch.js +14 -0
- package/engine/lifecycle.js +64 -14
- package/engine/queries.js +114 -5
- package/engine/shared.js +11 -2
- package/engine.js +39 -20
- package/package.json +1 -1
- package/playbooks/shared-rules.md +2 -2
package/CHANGELOG.md
CHANGED
package/dashboard/js/settings.js
CHANGED
|
@@ -98,6 +98,10 @@ async function openSettings() {
|
|
|
98
98
|
'<div style="margin-left:20px;padding-left:10px;border-left:2px solid var(--border);display:flex;flex-direction:column;gap:4px">' +
|
|
99
99
|
settingsToggle('Auto-fix Builds', 'set-autoFixBuilds', e.autoFixBuilds !== false, 'Dispatch gate: auto-fix agent when build fails (downstream of ADO Polling)') +
|
|
100
100
|
settingsToggle('Auto-fix Conflicts', 'set-autoFixConflicts', e.autoFixConflicts !== false, 'Dispatch gate: auto-fix agent when merge conflict detected (downstream of ADO Polling)') +
|
|
101
|
+
settingsToggle('Auto-review PRs', 'set-autoReviewPrs', e.autoReviewPrs !== false, 'Dispatch gate: review agent for newly opened agent PRs (throttle-aware)') +
|
|
102
|
+
settingsToggle('Auto-re-review PRs', 'set-autoReReviewPrs', e.autoReReviewPrs !== false, 'Dispatch gate: review agent after a fix push is awaiting re-review (throttle-aware)') +
|
|
103
|
+
settingsToggle('Auto-fix Review Feedback', 'set-autoFixReviewFeedback', e.autoFixReviewFeedback !== false, 'Dispatch gate: fix agent for minions changes-requested verdicts (throttle-aware)') +
|
|
104
|
+
settingsToggle('Auto-fix Human Comments', 'set-autoFixHumanComments', e.autoFixHumanComments !== false, 'Dispatch gate: fix agent for actionable human PR comments (throttle-aware)') +
|
|
101
105
|
'</div>' +
|
|
102
106
|
'</div>' +
|
|
103
107
|
settingsToggle('GitHub Polling', 'set-ghPollEnabled', e.ghPollEnabled !== false, 'Keeps GitHub PR build results, votes, and comments fresh each tick (reconciliation always runs regardless)') +
|
|
@@ -547,6 +551,10 @@ async function saveSettings() {
|
|
|
547
551
|
autoArchive: document.getElementById('set-autoArchive').checked,
|
|
548
552
|
autoFixBuilds: document.getElementById('set-autoFixBuilds').checked,
|
|
549
553
|
autoFixConflicts: document.getElementById('set-autoFixConflicts').checked,
|
|
554
|
+
autoReviewPrs: document.getElementById('set-autoReviewPrs').checked,
|
|
555
|
+
autoReReviewPrs: document.getElementById('set-autoReReviewPrs').checked,
|
|
556
|
+
autoFixReviewFeedback: document.getElementById('set-autoFixReviewFeedback').checked,
|
|
557
|
+
autoFixHumanComments: document.getElementById('set-autoFixHumanComments').checked,
|
|
550
558
|
autoCompletePrs: document.getElementById('set-autoCompletePrs').checked,
|
|
551
559
|
adoPollEnabled: document.getElementById('set-adoPollEnabled').checked,
|
|
552
560
|
ghPollEnabled: document.getElementById('set-ghPollEnabled').checked,
|
package/dashboard.js
CHANGED
|
@@ -75,7 +75,42 @@ function getWorkItemIdFromPrLinkContext(context, workItemId) {
|
|
|
75
75
|
return null;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
function
|
|
78
|
+
function decodeUrlSegment(segment) {
|
|
79
|
+
try { return decodeURIComponent(segment); } catch { return segment; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseAdoPrMetadataTarget(url) {
|
|
83
|
+
const raw = String(url || '');
|
|
84
|
+
const devAzure = raw.match(/https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)\/pullrequest\/(\d+)/i);
|
|
85
|
+
if (devAzure) {
|
|
86
|
+
return {
|
|
87
|
+
adoOrg: decodeUrlSegment(devAzure[1]),
|
|
88
|
+
adoProj: decodeUrlSegment(devAzure[2]),
|
|
89
|
+
adoRepo: decodeUrlSegment(devAzure[3]),
|
|
90
|
+
prNum: devAzure[4],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const visualStudio = raw.match(/https?:\/\/([^/.]+)\.visualstudio\.com\/(?:DefaultCollection\/)?([^/]+)\/_git\/([^/]+)\/pullrequest\/(\d+)/i);
|
|
94
|
+
if (!visualStudio) return null;
|
|
95
|
+
return {
|
|
96
|
+
adoOrg: `${decodeUrlSegment(visualStudio[1])}.visualstudio.com`,
|
|
97
|
+
adoProj: decodeUrlSegment(visualStudio[2]),
|
|
98
|
+
adoRepo: decodeUrlSegment(visualStudio[3]),
|
|
99
|
+
prNum: visualStudio[4],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizePrMetadata(metadata) {
|
|
104
|
+
if (!metadata || typeof metadata !== 'object') return null;
|
|
105
|
+
return {
|
|
106
|
+
title: typeof metadata.title === 'string' ? metadata.title.trim() : '',
|
|
107
|
+
description: typeof metadata.description === 'string' ? metadata.description : '',
|
|
108
|
+
branch: typeof metadata.branch === 'string' ? metadata.branch.trim().replace(/^refs\/heads\//i, '') : '',
|
|
109
|
+
author: typeof metadata.author === 'string' ? metadata.author.trim() : '',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function linkPullRequestForTracking({ url, title, project: projectName, autoObserve, context, workItemId }, config = CONFIG, options = {}) {
|
|
79
114
|
if (!url) {
|
|
80
115
|
const err = new Error('url required');
|
|
81
116
|
err.statusCode = 400;
|
|
@@ -90,13 +125,14 @@ function linkPullRequestForTracking({ url, title, project: projectName, autoObse
|
|
|
90
125
|
const prId = shared.getCanonicalPrId(targetProject, prNum, url);
|
|
91
126
|
const linkedWorkItemId = getWorkItemIdFromPrLinkContext(context, workItemId);
|
|
92
127
|
const contextText = typeof context === 'string' ? context : (context == null ? '' : JSON.stringify(context));
|
|
128
|
+
const metadata = normalizePrMetadata(options.metadata);
|
|
93
129
|
const result = shared.upsertPullRequestRecord(prPath, {
|
|
94
130
|
id: prId,
|
|
95
131
|
prNumber: parseInt(prNum, 10) || null,
|
|
96
|
-
title: (title || 'PR #' + prNum + ' (polling...)').slice(0, 120),
|
|
97
|
-
description: '',
|
|
98
|
-
agent: 'human',
|
|
99
|
-
branch: '',
|
|
132
|
+
title: (metadata?.title || title || 'PR #' + prNum + ' (polling...)').slice(0, 120),
|
|
133
|
+
description: metadata?.description ? metadata.description.slice(0, 500) : '',
|
|
134
|
+
agent: metadata?.author || 'human',
|
|
135
|
+
branch: metadata?.branch || '',
|
|
100
136
|
reviewStatus: 'pending',
|
|
101
137
|
status: 'active',
|
|
102
138
|
created: new Date().toISOString(),
|
|
@@ -5994,7 +6030,16 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
5994
6030
|
if (!url) return jsonReply(res, 400, { error: 'url required' });
|
|
5995
6031
|
|
|
5996
6032
|
reloadConfig();
|
|
5997
|
-
const
|
|
6033
|
+
const adoTarget = parseAdoPrMetadataTarget(url);
|
|
6034
|
+
let initialPrData = null;
|
|
6035
|
+
if (adoTarget) {
|
|
6036
|
+
try {
|
|
6037
|
+
initialPrData = await ado.fetchAdoPrMetadata(adoTarget.prNum, adoTarget.adoOrg, adoTarget.adoProj, adoTarget.adoRepo);
|
|
6038
|
+
} catch (e) {
|
|
6039
|
+
shared.log('warn', `ADO PR link metadata fetch failed for ${url}: ${e.message}`);
|
|
6040
|
+
}
|
|
6041
|
+
}
|
|
6042
|
+
const { id: prId, prPath, prNum, created, linked } = linkPullRequestForTracking(body, CONFIG, { metadata: initialPrData });
|
|
5998
6043
|
invalidateStatusCache();
|
|
5999
6044
|
jsonReply(res, 200, { ok: true, id: prId, created, linked });
|
|
6000
6045
|
|
|
@@ -6003,16 +6048,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6003
6048
|
try {
|
|
6004
6049
|
let prData = null;
|
|
6005
6050
|
const ghMatch = url.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/);
|
|
6006
|
-
const adoMatch = url.match(/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)\/pullrequest\/(\d+)/);
|
|
6007
6051
|
if (ghMatch) {
|
|
6008
6052
|
const slug = ghMatch[1];
|
|
6009
6053
|
const result = await shared.execAsync(`gh api "repos/${slug}/pulls/${prNum}"`, { timeout: 15000, encoding: 'utf-8' });
|
|
6010
6054
|
const d = JSON.parse(result);
|
|
6011
6055
|
prData = { title: d.title, description: d.body, branch: d.head?.ref, author: d.user?.login };
|
|
6012
|
-
} else if (
|
|
6013
|
-
const [, adoOrg, adoProj, adoRepo] = adoMatch;
|
|
6056
|
+
} else if (adoTarget && !initialPrData) {
|
|
6014
6057
|
try {
|
|
6015
|
-
prData = await ado.fetchAdoPrMetadata(prNum, adoOrg, adoProj, adoRepo);
|
|
6058
|
+
prData = await ado.fetchAdoPrMetadata(adoTarget.prNum, adoTarget.adoOrg, adoTarget.adoProj, adoTarget.adoRepo);
|
|
6016
6059
|
} catch { /* ADO token may not be available */ }
|
|
6017
6060
|
}
|
|
6018
6061
|
if (!prData) return;
|
|
@@ -6134,6 +6177,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6134
6177
|
{ method: 'GET', path: /^\/api\/agent\/([\w-]+)\/live(?:\?.*)?$/, desc: 'Tail live output for a working agent', params: 'tail? (bytes, default 8192)', handler: handleAgentLive },
|
|
6135
6178
|
{ method: 'GET', path: /^\/api\/agent\/([\w-]+)\/output(?:\?.*)?$/, desc: 'Fetch final output.log for an agent', handler: handleAgentOutput },
|
|
6136
6179
|
{ method: 'GET', path: /^\/api\/agent\/([\w-]+)$/, desc: 'Get detailed agent info', handler: handleAgentDetail },
|
|
6180
|
+
{ method: 'GET', path: /^\/api\/dispatch\/([\w.-]+)\/completion-report$/, desc: 'Read structured completion report for a dispatch', handler: (req, res, match) => {
|
|
6181
|
+
const id = match && match[1];
|
|
6182
|
+
const payload = queries.getDispatchCompletionReport(id);
|
|
6183
|
+
if (!payload) return jsonReply(res, 404, { error: 'completion report not found' }, req);
|
|
6184
|
+
return jsonReply(res, 200, payload, req);
|
|
6185
|
+
} },
|
|
6137
6186
|
{ method: 'GET', path: '/api/agent-output', desc: 'Read agent output log file', params: 'file', handler: async (req, res) => {
|
|
6138
6187
|
const file = new URL(req.url, 'http://localhost').searchParams.get('file');
|
|
6139
6188
|
if (!file || file.includes('..') || file.includes('\0') || !file.startsWith('agents/')) return jsonReply(res, 400, { error: 'invalid file' });
|
package/engine/ado.js
CHANGED
|
@@ -138,6 +138,48 @@ function clearBuildStatusStale(pr) {
|
|
|
138
138
|
if (pr._buildStatusDetail) delete pr._buildStatusDetail;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
function isPlaceholderPrTitle(title, prNum) {
|
|
142
|
+
const text = String(title || '').trim();
|
|
143
|
+
if (!text) return true;
|
|
144
|
+
if (/\bpolling\.\.\./i.test(text)) return true;
|
|
145
|
+
if (String(prNum || '') && (text === `PR #${prNum}` || text === `PR-${prNum}`)) return true;
|
|
146
|
+
if (/[{}"\[\]]/.test(text)) return true;
|
|
147
|
+
return /^[0-9a-f-]{8,}$/i.test(text);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function applyAdoPrMetadata(pr, prData, prNum) {
|
|
151
|
+
if (!pr || !prData) return false;
|
|
152
|
+
let updated = false;
|
|
153
|
+
|
|
154
|
+
const sourceBranch = stripRefsHeads(prData.sourceRefName);
|
|
155
|
+
if (sourceBranch && (pr.branch !== sourceBranch || pr._branchResolutionError || pr._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH)) {
|
|
156
|
+
pr.branch = sourceBranch;
|
|
157
|
+
if (pr._branchResolutionError) delete pr._branchResolutionError;
|
|
158
|
+
if (pr._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete pr._pendingReason;
|
|
159
|
+
updated = true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const title = String(prData.title || '').trim();
|
|
163
|
+
if (title && isPlaceholderPrTitle(pr.title, prNum) && pr.title !== title.slice(0, 120)) {
|
|
164
|
+
pr.title = title.slice(0, 120);
|
|
165
|
+
updated = true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const description = typeof prData.description === 'string' ? prData.description : '';
|
|
169
|
+
if (description && (pr.description == null || pr.description === '')) {
|
|
170
|
+
pr.description = description.slice(0, 500);
|
|
171
|
+
updated = true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const author = String(prData.createdBy?.displayName || '').trim();
|
|
175
|
+
if (author && pr.agent === 'human') {
|
|
176
|
+
pr.agent = author;
|
|
177
|
+
updated = true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return updated;
|
|
181
|
+
}
|
|
182
|
+
|
|
141
183
|
// ── Build/Review Status Helpers ───────────────────────────────────────────────
|
|
142
184
|
|
|
143
185
|
/** Classify an array of ADO build records into a single status string. */
|
|
@@ -454,13 +496,7 @@ async function pollPrStatus(config) {
|
|
|
454
496
|
|
|
455
497
|
const prData = await adoFetch(`${repoBase}?api-version=7.1`, token);
|
|
456
498
|
|
|
457
|
-
|
|
458
|
-
if (sourceBranch && pr.branch !== sourceBranch) {
|
|
459
|
-
pr.branch = sourceBranch;
|
|
460
|
-
if (pr._branchResolutionError) delete pr._branchResolutionError;
|
|
461
|
-
if (pr._pendingReason === 'missing_pr_branch') delete pr._pendingReason;
|
|
462
|
-
updated = true;
|
|
463
|
-
}
|
|
499
|
+
if (applyAdoPrMetadata(pr, prData, prNum)) updated = true;
|
|
464
500
|
|
|
465
501
|
let newStatus = pr.status;
|
|
466
502
|
if (prData.status === 'completed') newStatus = PR_STATUS.MERGED;
|
|
@@ -1121,7 +1157,8 @@ async function checkLiveBuildAndConflict(pr, project) {
|
|
|
1121
1157
|
async function fetchAdoPrMetadata(prNum, adoOrg, adoProj, adoRepo) {
|
|
1122
1158
|
const token = await getAdoToken();
|
|
1123
1159
|
if (!token) return null;
|
|
1124
|
-
const
|
|
1160
|
+
const orgBase = getAdoOrgBase({ adoOrg });
|
|
1161
|
+
const url = `${orgBase}/${encodeURIComponent(adoProj)}/_apis/git/repositories/${encodeURIComponent(adoRepo)}/pullrequests/${encodeURIComponent(String(prNum))}?api-version=7.1`;
|
|
1125
1162
|
const pr = await adoFetch(url, token);
|
|
1126
1163
|
if (!pr) return null;
|
|
1127
1164
|
return {
|
package/engine/cleanup.js
CHANGED
|
@@ -724,8 +724,63 @@ function runCleanup(config, verbose = false) {
|
|
|
724
724
|
// 14. Scrub stale temp agent keys from metrics.json
|
|
725
725
|
try { scrubStaleMetrics(); } catch { /* best-effort cleanup */ }
|
|
726
726
|
|
|
727
|
-
|
|
728
|
-
|
|
727
|
+
// 15. Evict old completion reports — keep reports durable beyond the capped
|
|
728
|
+
// dispatch history, but bound disk growth by age/count.
|
|
729
|
+
cleaned.completionReports = 0;
|
|
730
|
+
try {
|
|
731
|
+
const dispatch = getDispatch();
|
|
732
|
+
const protectedReportFiles = new Set();
|
|
733
|
+
for (const queue of ['pending', 'active', 'completed']) {
|
|
734
|
+
for (const entry of dispatch[queue] || []) {
|
|
735
|
+
if (!entry?.id) continue;
|
|
736
|
+
const reportPath = shared.dispatchCompletionReportPath(entry.id);
|
|
737
|
+
if (reportPath) protectedReportFiles.add(path.basename(reportPath));
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
const configuredRetentionDays = Number(config?.engine?.completionReportRetentionDays ?? ENGINE_DEFAULTS.completionReportRetentionDays);
|
|
741
|
+
const configuredMaxReports = Number(config?.engine?.completionReportMaxFiles ?? ENGINE_DEFAULTS.completionReportMaxFiles);
|
|
742
|
+
const retentionDays = Number.isFinite(configuredRetentionDays) ? configuredRetentionDays : ENGINE_DEFAULTS.completionReportRetentionDays;
|
|
743
|
+
const maxReports = Number.isFinite(configuredMaxReports) ? configuredMaxReports : ENGINE_DEFAULTS.completionReportMaxFiles;
|
|
744
|
+
const retentionMs = retentionDays > 0 ? retentionDays * 24 * 60 * 60 * 1000 : 0;
|
|
745
|
+
const cutoffMs = retentionMs > 0 ? Date.now() - retentionMs : 0;
|
|
746
|
+
const completionsDir = path.join(ENGINE_DIR, 'completions');
|
|
747
|
+
if (fs.existsSync(completionsDir)) {
|
|
748
|
+
const reports = fs.readdirSync(completionsDir)
|
|
749
|
+
.filter(f => f.endsWith('.json'))
|
|
750
|
+
.map(f => {
|
|
751
|
+
const fp = path.join(completionsDir, f);
|
|
752
|
+
let mtimeMs = 0;
|
|
753
|
+
try { mtimeMs = fs.statSync(fp).mtimeMs; } catch {}
|
|
754
|
+
return { file: f, path: fp, mtimeMs, protected: protectedReportFiles.has(f) };
|
|
755
|
+
})
|
|
756
|
+
.filter(r => r.mtimeMs > 0)
|
|
757
|
+
.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
758
|
+
const removeReport = (report) => {
|
|
759
|
+
try { fs.unlinkSync(report.path); cleaned.completionReports++; return true; } catch { return false; }
|
|
760
|
+
};
|
|
761
|
+
for (const report of reports) {
|
|
762
|
+
if (report.protected) continue;
|
|
763
|
+
if (cutoffMs > 0 && report.mtimeMs < cutoffMs) {
|
|
764
|
+
removeReport(report);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
if (maxReports > 0) {
|
|
768
|
+
const remaining = reports.filter(r => fs.existsSync(r.path));
|
|
769
|
+
let overflow = remaining.length - maxReports;
|
|
770
|
+
for (const report of remaining) {
|
|
771
|
+
if (overflow <= 0) break;
|
|
772
|
+
if (report.protected) continue;
|
|
773
|
+
if (removeReport(report)) overflow--;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
if (cleaned.completionReports > 0) {
|
|
777
|
+
log('info', `Cleanup: removed ${cleaned.completionReports} old completion report(s)`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
} catch (e) { log('warn', `cleanupCompletionReports: ${e.message}`); }
|
|
781
|
+
|
|
782
|
+
if (cleaned.ccSessions + cleaned.docSessions + cleaned.cooldowns + cleaned.pidFiles + cleaned.pendingContextsTrimmed + cleaned.notesArchive + cleaned.completionReports > 0) {
|
|
783
|
+
log('info', `Cleanup (resources): ${cleaned.ccSessions} cc-sessions, ${cleaned.docSessions} doc-sessions, ${cleaned.cooldowns} cooldowns, ${cleaned.pendingContextsTrimmed} pendingCtx trimmed, ${cleaned.notesArchive} archived notes, ${cleaned.pidFiles} PID files, ${cleaned.completionReports} completion reports`);
|
|
729
784
|
}
|
|
730
785
|
|
|
731
786
|
return cleaned;
|
package/engine/cooldown.js
CHANGED
|
@@ -117,12 +117,17 @@ function setCooldownWithContext(key, context) {
|
|
|
117
117
|
saveCooldowns();
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
// Drain pending coalesced contexts for a key, clearing the entry's
|
|
121
|
+
// pendingContexts and persisting the change. Returns [] for unknown / empty
|
|
122
|
+
// keys without side effects (no save, no phantom entry creation).
|
|
123
|
+
function drainCoalescedContexts(key) {
|
|
121
124
|
const entry = dispatchCooldowns.get(key);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
entry.pendingContexts = []; // Clear after retrieval
|
|
125
|
+
if (!entry || !Array.isArray(entry.pendingContexts) || entry.pendingContexts.length === 0) {
|
|
126
|
+
return [];
|
|
125
127
|
}
|
|
128
|
+
const contexts = entry.pendingContexts;
|
|
129
|
+
entry.pendingContexts = [];
|
|
130
|
+
saveCooldowns();
|
|
126
131
|
return contexts;
|
|
127
132
|
}
|
|
128
133
|
|
|
@@ -181,7 +186,7 @@ module.exports = {
|
|
|
181
186
|
isOnCooldown,
|
|
182
187
|
setCooldown,
|
|
183
188
|
setCooldownWithContext,
|
|
184
|
-
|
|
189
|
+
drainCoalescedContexts,
|
|
185
190
|
setCooldownFailure,
|
|
186
191
|
clearCooldown,
|
|
187
192
|
isAlreadyDispatched,
|
package/engine/dispatch.js
CHANGED
|
@@ -62,6 +62,10 @@ function mutateDispatch(mutator) {
|
|
|
62
62
|
function addToDispatch(item) {
|
|
63
63
|
item.id = item.id || `${item.agent}-${item.type}-${shared.uid()}`;
|
|
64
64
|
item.created_at = ts();
|
|
65
|
+
item.meta = item.meta && typeof item.meta === 'object' ? item.meta : {};
|
|
66
|
+
if (!item.meta.completionReportPath) {
|
|
67
|
+
item.meta.completionReportPath = shared.dispatchCompletionReportPath(item.id);
|
|
68
|
+
}
|
|
65
69
|
let added = false;
|
|
66
70
|
mutateDispatch((dispatch) => {
|
|
67
71
|
// Dedup: skip if same work item ID is already pending or active
|
|
@@ -262,6 +266,16 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
|
|
|
262
266
|
if (reason) item.reason = reason;
|
|
263
267
|
if (resultSummary) item.resultSummary = resultSummary;
|
|
264
268
|
if (failureClass && result === DISPATCH_RESULT.ERROR) item.failureClass = failureClass;
|
|
269
|
+
item.meta = item.meta && typeof item.meta === 'object' ? item.meta : {};
|
|
270
|
+
if (opts.completionReportPath && !item.meta.completionReportPath) {
|
|
271
|
+
item.meta.completionReportPath = opts.completionReportPath;
|
|
272
|
+
}
|
|
273
|
+
if (opts.structuredCompletion && typeof opts.structuredCompletion === 'object') {
|
|
274
|
+
item.structuredCompletion = opts.structuredCompletion;
|
|
275
|
+
if (opts.structuredCompletion._path && !item.meta.completionReportPath) {
|
|
276
|
+
item.meta.completionReportPath = opts.structuredCompletion._path;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
265
279
|
// Drop prompt (and sidecar file, if any) — completed entries don't need
|
|
266
280
|
// replayable content and it would accumulate forever (#1167).
|
|
267
281
|
try { deleteDispatchPromptSidecar(item); } catch { /* best-effort */ }
|
package/engine/lifecycle.js
CHANGED
|
@@ -706,8 +706,9 @@ function reconcilePrdStatuses(config) {
|
|
|
706
706
|
|
|
707
707
|
// ─── PR Sync from Output ─────────────────────────────────────────────────────
|
|
708
708
|
|
|
709
|
-
function syncPrsFromOutput(output, agentId, meta, config) {
|
|
710
|
-
|
|
709
|
+
function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
|
|
710
|
+
const { structuredCompletion = null } = opts;
|
|
711
|
+
const outputText = String(output || '');
|
|
711
712
|
const prEvidence = new Map();
|
|
712
713
|
const trustedPrCreateToolIds = new Set();
|
|
713
714
|
const prUrlPattern = /(https?:\/\/github\.com\/[^\s"'\\)\]]+\/[^\s"'\\)\]]+\/pull\/(\d+)(?:[^\s"'\\)\]]*)?|https?:\/\/(?:dev\.azure\.com|[^/\s"'\\)\]]+\.visualstudio\.com)[^\s"'\\)\]]*?pullrequest\/(\d+)(?:[^\s"'\\)\]]*)?)/gi;
|
|
@@ -735,6 +736,21 @@ function syncPrsFromOutput(output, agentId, meta, config) {
|
|
|
735
736
|
}
|
|
736
737
|
}
|
|
737
738
|
|
|
739
|
+
function addStructuredPrEvidence(completion) {
|
|
740
|
+
const raw = completion?.pr ?? completion?.pull_request ?? completion?.pullRequest;
|
|
741
|
+
if (raw == null) return;
|
|
742
|
+
const values = Array.isArray(raw) ? raw : [raw];
|
|
743
|
+
for (const value of values) {
|
|
744
|
+
const text = typeof value === 'object' ? JSON.stringify(value) : String(value || '');
|
|
745
|
+
if (!text || /^(?:n\/a|na|none|null|no pr|-)\s*$/i.test(text.trim())) continue;
|
|
746
|
+
const before = prEvidence.size;
|
|
747
|
+
addPrUrlEvidence(text);
|
|
748
|
+
if (prEvidence.size > before) continue;
|
|
749
|
+
const idMatch = text.match(/\b(?:PR|pull\s*request)?\s*#?\s*(\d{1,10})\b/i);
|
|
750
|
+
if (idMatch && !prEvidence.has(idMatch[1])) prEvidence.set(idMatch[1], '');
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
738
754
|
function isTrustedPrCreateToolUse(block) {
|
|
739
755
|
const name = String(block?.name || '');
|
|
740
756
|
if (/(?:create|open|submit)[_-]?(?:pull[_-]?request|pr)|(?:pull[_-]?request|pr)[_-]?(?:create|open|submit)/i.test(name)) {
|
|
@@ -749,7 +765,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
|
|
|
749
765
|
}
|
|
750
766
|
|
|
751
767
|
try {
|
|
752
|
-
const lines =
|
|
768
|
+
const lines = outputText.split('\n');
|
|
753
769
|
for (const line of lines) {
|
|
754
770
|
try {
|
|
755
771
|
if (!line.includes('"type":"assistant"') && !line.includes('"type":"result"') && !line.includes('"type":"user"')) continue;
|
|
@@ -779,13 +795,15 @@ function syncPrsFromOutput(output, agentId, meta, config) {
|
|
|
779
795
|
}
|
|
780
796
|
} catch {}
|
|
781
797
|
|
|
798
|
+
addStructuredPrEvidence(structuredCompletion);
|
|
799
|
+
|
|
782
800
|
// Accept inbox fallback ONLY when the agent's stdout is empty (rotated/lost).
|
|
783
801
|
// The inbox note is the durable artifact for the "gh pr create ran in a sibling
|
|
784
802
|
// dispatch whose stdout was rotated" case. When stdout has actual content (even
|
|
785
803
|
// without PR evidence — e.g. the agent ran gh issue view but didn't create a PR),
|
|
786
804
|
// we must NOT pull in PR URLs from leftover inbox files of prior dispatches —
|
|
787
805
|
// those would falsely attribute unrelated PRs to this run.
|
|
788
|
-
if (!
|
|
806
|
+
if (!outputText.trim()) {
|
|
789
807
|
const today = dateStamp();
|
|
790
808
|
const inboxFiles = getInboxFiles().filter(f => f.includes(agentId) && f.includes(today));
|
|
791
809
|
const currentItemId = meta?.item?.id ? String(meta.item.id) : '';
|
|
@@ -813,13 +831,15 @@ function syncPrsFromOutput(output, agentId, meta, config) {
|
|
|
813
831
|
|
|
814
832
|
// Match each PR to its correct project by finding which repo URL appears near the PR number in output
|
|
815
833
|
function resolveProjectForPr(prId) {
|
|
834
|
+
const evidenceUrl = prEvidence.get(prId) || '';
|
|
835
|
+
const evidenceText = `${outputText}\n${evidenceUrl}`;
|
|
816
836
|
for (const p of projects) {
|
|
817
837
|
if (!p.prUrlBase) continue;
|
|
818
838
|
const urlFragment = p.prUrlBase.replace(/pullrequest\/$/, '');
|
|
819
|
-
if (
|
|
839
|
+
if (evidenceText.includes(urlFragment + 'pullrequest/' + prId) || evidenceText.includes(urlFragment + prId)) return p;
|
|
820
840
|
}
|
|
821
841
|
for (const p of projects) {
|
|
822
|
-
if (p.repoName &&
|
|
842
|
+
if (p.repoName && evidenceText.includes(`_git/${p.repoName}/pullrequest/${prId}`)) return p;
|
|
823
843
|
}
|
|
824
844
|
return defaultProject;
|
|
825
845
|
}
|
|
@@ -847,7 +867,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
|
|
|
847
867
|
const fullId = shared.getCanonicalPrId(targetProject, prId, prUrl);
|
|
848
868
|
|
|
849
869
|
let title = meta?.item?.title || '';
|
|
850
|
-
const titleMatch =
|
|
870
|
+
const titleMatch = outputText.match(new RegExp(`${prId}[^\\n]*?[—–-]\\s*([^\\n]+)`, 'i'));
|
|
851
871
|
if (titleMatch) title = titleMatch[1].trim();
|
|
852
872
|
if (title.includes('session_id') || title.includes('is_error') || title.includes('uuid') || title.length > 120 || /[{}"\[\]]/.test(title) || /^[0-9a-f-]{8,}$/i.test(title)) {
|
|
853
873
|
title = meta?.item?.title || '';
|
|
@@ -902,7 +922,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
|
|
|
902
922
|
log('warn', `Duplicate PR detected: ${fullId} on branch ${entry.branch || entryBranch} — already tracked as ${duplicateOnBranch.id}. Skipping.`);
|
|
903
923
|
// Best-effort close the duplicate on GitHub (non-blocking, fire-and-forget)
|
|
904
924
|
try {
|
|
905
|
-
const ghSlug =
|
|
925
|
+
const ghSlug = outputText.match(/github\.com\/([^/]+\/[^/]+)/)?.[1];
|
|
906
926
|
if (ghSlug) {
|
|
907
927
|
execAsync(`gh pr close ${prId} --repo ${ghSlug} --comment "Closing duplicate — ${duplicateOnBranch.id} already tracks this branch."`, { timeout: 15000 })
|
|
908
928
|
.catch(() => {});
|
|
@@ -1750,11 +1770,16 @@ function parseStructuredCompletion(stdout, runtimeName) {
|
|
|
1750
1770
|
return result;
|
|
1751
1771
|
}
|
|
1752
1772
|
|
|
1753
|
-
function parseCompletionReportFile(dispatchItem) {
|
|
1773
|
+
function parseCompletionReportFile(dispatchItem, opts = {}) {
|
|
1754
1774
|
const reportPath = dispatchItem?.meta?.completionReportPath || shared.dispatchCompletionReportPath(dispatchItem?.id);
|
|
1755
|
-
if (!reportPath || !fs.existsSync(reportPath))
|
|
1775
|
+
if (!reportPath || !fs.existsSync(reportPath)) {
|
|
1776
|
+
if (opts.warnIfMissing && dispatchItem?.id) {
|
|
1777
|
+
log('warn', `Completion report missing for ${dispatchItem.id}: ${reportPath || '(no path)'}`);
|
|
1778
|
+
}
|
|
1779
|
+
return null;
|
|
1780
|
+
}
|
|
1756
1781
|
const report = safeJson(reportPath);
|
|
1757
|
-
if (!
|
|
1782
|
+
if (!shared.isPlainObject(report)) {
|
|
1758
1783
|
log('warn', `Ignoring malformed completion report for ${dispatchItem?.id || 'unknown'}: ${reportPath}`);
|
|
1759
1784
|
return null;
|
|
1760
1785
|
}
|
|
@@ -1768,6 +1793,29 @@ function parseCompletionReportFile(dispatchItem) {
|
|
|
1768
1793
|
return report;
|
|
1769
1794
|
}
|
|
1770
1795
|
|
|
1796
|
+
function persistCompletionReport(dispatchItem, completion, source = 'fallback') {
|
|
1797
|
+
if (!dispatchItem?.id || !completion || typeof completion !== 'object') return completion;
|
|
1798
|
+
const reportPath = dispatchItem?.meta?.completionReportPath || shared.dispatchCompletionReportPath(dispatchItem.id);
|
|
1799
|
+
if (!reportPath) return completion;
|
|
1800
|
+
const report = {
|
|
1801
|
+
...completion,
|
|
1802
|
+
status: completion.status || completion.outcome || 'unknown',
|
|
1803
|
+
_source: source,
|
|
1804
|
+
_path: reportPath,
|
|
1805
|
+
dispatchId: dispatchItem.id,
|
|
1806
|
+
agent: dispatchItem.agent || null,
|
|
1807
|
+
type: dispatchItem.type || null,
|
|
1808
|
+
completedAt: ts(),
|
|
1809
|
+
};
|
|
1810
|
+
try {
|
|
1811
|
+
safeWrite(reportPath, report);
|
|
1812
|
+
log('info', `Persisted ${source} completion report for ${dispatchItem.id}: ${reportPath}`);
|
|
1813
|
+
} catch (err) {
|
|
1814
|
+
log('warn', `Persist fallback completion report for ${dispatchItem.id}: ${err.message}`);
|
|
1815
|
+
}
|
|
1816
|
+
return report;
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1771
1819
|
function normalizeCompletionStatus(status) {
|
|
1772
1820
|
return String(status || '').trim().toLowerCase().replace(/[\s_]+/g, '-');
|
|
1773
1821
|
}
|
|
@@ -2045,8 +2093,9 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
2045
2093
|
let { resultSummary, taskUsage, sessionId, model } = parseAgentOutput(stdout, runtimeName);
|
|
2046
2094
|
|
|
2047
2095
|
// Prefer the sidecar completion report; keep fenced output as a compatibility fallback.
|
|
2048
|
-
const reportCompletion = parseCompletionReportFile(dispatchItem);
|
|
2049
|
-
const
|
|
2096
|
+
const reportCompletion = parseCompletionReportFile(dispatchItem, { warnIfMissing: true });
|
|
2097
|
+
const fallbackCompletion = reportCompletion ? null : parseStructuredCompletion(stdout, runtimeName);
|
|
2098
|
+
const structuredCompletion = reportCompletion || persistCompletionReport(dispatchItem, fallbackCompletion, 'fenced-completion');
|
|
2050
2099
|
if (structuredCompletion) {
|
|
2051
2100
|
if (structuredCompletion.summary) resultSummary = String(structuredCompletion.summary);
|
|
2052
2101
|
log('info', `Structured completion from ${agentId}: status=${structuredCompletion.status}, pr=${structuredCompletion.pr || 'N/A'}${structuredCompletion._source ? ` (${structuredCompletion._source})` : ''}`);
|
|
@@ -2073,7 +2122,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
2073
2122
|
// Always attempt PR sync — even failed/timed-out agents may have created PRs before dying
|
|
2074
2123
|
let prsCreatedCount = 0;
|
|
2075
2124
|
try {
|
|
2076
|
-
prsCreatedCount = syncPrsFromOutput(stdout, agentId, meta, config) || 0;
|
|
2125
|
+
prsCreatedCount = syncPrsFromOutput(stdout, agentId, meta, config, { structuredCompletion }) || 0;
|
|
2077
2126
|
} catch (err) { log('warn', `PR sync from output: ${err.message}`); }
|
|
2078
2127
|
|
|
2079
2128
|
// Structured completion may report PR even when regex didn't find it
|
|
@@ -2559,6 +2608,7 @@ module.exports = {
|
|
|
2559
2608
|
parseStructuredCompletion,
|
|
2560
2609
|
detectNonTerminalResultSummary,
|
|
2561
2610
|
parseCompletionReportFile,
|
|
2611
|
+
persistCompletionReport,
|
|
2562
2612
|
runPostCompletionHooks,
|
|
2563
2613
|
syncPrdFromPrs,
|
|
2564
2614
|
resolveWorkItemPath,
|
package/engine/queries.js
CHANGED
|
@@ -168,14 +168,121 @@ function getDispatch() {
|
|
|
168
168
|
}
|
|
169
169
|
function invalidateDispatchCache() { _dispatchCache = null; _dispatchCacheAt = 0; }
|
|
170
170
|
|
|
171
|
+
function _relativeStatePath(filePath) {
|
|
172
|
+
if (!filePath) return '';
|
|
173
|
+
try { return path.relative(MINIONS_DIR, filePath).replace(/\\/g, '/'); } catch { return filePath; }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function _findDispatchEntry(dispatch, id) {
|
|
177
|
+
for (const listName of ['pending', 'active', 'completed']) {
|
|
178
|
+
const entry = (dispatch[listName] || []).find(d => d.id === id);
|
|
179
|
+
if (entry) return entry;
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function _completionReportPathForEntry(entryOrId) {
|
|
185
|
+
const id = typeof entryOrId === 'string' ? entryOrId : entryOrId?.id;
|
|
186
|
+
return (typeof entryOrId === 'object' && entryOrId?.meta?.completionReportPath)
|
|
187
|
+
|| shared.dispatchCompletionReportPath(id);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// In-memory cache for completion report summaries, keyed by dispatch id.
|
|
191
|
+
// /api/state polls at ~1Hz and re-reads ~40 completion-report files per call;
|
|
192
|
+
// this cache turns those into stat-only checks unless mtime changed.
|
|
193
|
+
const _completionReportCache = new Map();
|
|
194
|
+
|
|
195
|
+
function _getCachedCompletionSummary(id) {
|
|
196
|
+
if (!id) return null;
|
|
197
|
+
const reportPath = shared.dispatchCompletionReportPath(id);
|
|
198
|
+
let stat;
|
|
199
|
+
try { stat = fs.statSync(reportPath); }
|
|
200
|
+
catch { _completionReportCache.delete(id); return null; }
|
|
201
|
+
const cached = _completionReportCache.get(id);
|
|
202
|
+
if (cached && cached.mtime === stat.mtimeMs) return cached.summary;
|
|
203
|
+
const report = safeJson(reportPath);
|
|
204
|
+
if (!shared.isPlainObject(report)) {
|
|
205
|
+
_completionReportCache.delete(id);
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
const summary = {
|
|
209
|
+
available: true,
|
|
210
|
+
path: _relativeStatePath(reportPath),
|
|
211
|
+
status: report.status || report.outcome || '',
|
|
212
|
+
summary: String(report.summary || '').slice(0, 500),
|
|
213
|
+
verdict: report.verdict || report.review_verdict || report.reviewVerdict || '',
|
|
214
|
+
pr: report.pr || report.pull_request || report.pullRequest || '',
|
|
215
|
+
source: report._source || '',
|
|
216
|
+
};
|
|
217
|
+
if (Array.isArray(report.artifacts)) summary.artifacts = report.artifacts.slice(0, 20);
|
|
218
|
+
_completionReportCache.set(id, { mtime: stat.mtimeMs, summary });
|
|
219
|
+
return summary;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function _pruneCompletionReportCache(activeIds) {
|
|
223
|
+
if (_completionReportCache.size < 200) return;
|
|
224
|
+
for (const id of _completionReportCache.keys()) {
|
|
225
|
+
if (!activeIds.has(id)) _completionReportCache.delete(id);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function getDispatchCompletionReport(id) {
|
|
230
|
+
if (!id) return null;
|
|
231
|
+
const dispatch = getDispatch();
|
|
232
|
+
const entry = _findDispatchEntry(dispatch, id);
|
|
233
|
+
const reportPath = _completionReportPathForEntry(entry || id);
|
|
234
|
+
// TOCTOU-safe: skip existsSync gate; treat null/non-object safeJson result as not-found.
|
|
235
|
+
const report = safeJson(reportPath);
|
|
236
|
+
if (!shared.isPlainObject(report)) return null;
|
|
237
|
+
return {
|
|
238
|
+
id,
|
|
239
|
+
path: _relativeStatePath(reportPath),
|
|
240
|
+
report,
|
|
241
|
+
dispatch: entry ? {
|
|
242
|
+
id: entry.id,
|
|
243
|
+
agent: entry.agent || '',
|
|
244
|
+
type: entry.type || '',
|
|
245
|
+
task: entry.task || '',
|
|
246
|
+
result: entry.result || '',
|
|
247
|
+
completed_at: entry.completed_at || '',
|
|
248
|
+
} : null,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function _completionReportSummary(entry) {
|
|
253
|
+
if (!entry?.id) return null;
|
|
254
|
+
const cached = _getCachedCompletionSummary(entry.id);
|
|
255
|
+
if (cached) return cached;
|
|
256
|
+
// Cache miss / file absent — return a "not available" stub keyed off the canonical path.
|
|
257
|
+
const reportPath = _completionReportPathForEntry(entry);
|
|
258
|
+
return { available: false, path: _relativeStatePath(reportPath) };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function _withCompletionReportSummary(entry) {
|
|
262
|
+
if (!entry || typeof entry !== 'object') return entry;
|
|
263
|
+
return { ...entry, completionReport: _completionReportSummary(entry) };
|
|
264
|
+
}
|
|
265
|
+
|
|
171
266
|
function getDispatchQueue() {
|
|
172
267
|
const d = getDispatch();
|
|
173
268
|
const allCompleted = d.completed || [];
|
|
174
269
|
// Lifetime total from metrics (dispatch.completed is capped at 100)
|
|
175
270
|
const metrics = readJsonNoRestore(path.join(ENGINE_DIR, 'metrics.json')) || {};
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
271
|
+
// Periodically prune cache entries for dispatches that have rotated out of the queue.
|
|
272
|
+
if (_completionReportCache.size >= 200) {
|
|
273
|
+
const activeIds = new Set();
|
|
274
|
+
for (const list of [d.pending, d.active, allCompleted]) {
|
|
275
|
+
for (const entry of list || []) { if (entry?.id) activeIds.add(entry.id); }
|
|
276
|
+
}
|
|
277
|
+
_pruneCompletionReportCache(activeIds);
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
...d,
|
|
281
|
+
pending: (d.pending || []).map(_withCompletionReportSummary),
|
|
282
|
+
active: (d.active || []).map(_withCompletionReportSummary),
|
|
283
|
+
completed: allCompleted.slice(-20).map(_withCompletionReportSummary),
|
|
284
|
+
completedTotal: Object.entries(metrics).filter(([k]) => !k.startsWith('_')).reduce((sum, [, m]) => sum + (m.tasksCompleted || 0) + (m.tasksErrored || 0), 0),
|
|
285
|
+
};
|
|
179
286
|
}
|
|
180
287
|
|
|
181
288
|
function getNotes() {
|
|
@@ -207,7 +314,7 @@ function getMetrics() {
|
|
|
207
314
|
if (agentId.startsWith('_')) continue;
|
|
208
315
|
metrics[agentId] = {
|
|
209
316
|
...DEFAULT_AGENT_METRICS,
|
|
210
|
-
...(
|
|
317
|
+
...(shared.isPlainObject(m) ? m : {}),
|
|
211
318
|
};
|
|
212
319
|
}
|
|
213
320
|
|
|
@@ -352,6 +459,7 @@ function getAgentStatus(agentId) {
|
|
|
352
459
|
started_at: latest.started_at || null,
|
|
353
460
|
completed_at: latest.completed_at,
|
|
354
461
|
resultSummary: latest.resultSummary || latest.reason || '',
|
|
462
|
+
completionReport: _completionReportSummary(latest),
|
|
355
463
|
};
|
|
356
464
|
}
|
|
357
465
|
}
|
|
@@ -484,6 +592,7 @@ function getAgentDetail(id) {
|
|
|
484
592
|
id: d.id, task: d.task || '', type: d.type || '',
|
|
485
593
|
result: d.result || '', reason: d.reason || '',
|
|
486
594
|
started_at: d.started_at || '', completed_at: d.completed_at || '',
|
|
595
|
+
completionReport: _completionReportSummary(d),
|
|
487
596
|
}));
|
|
488
597
|
} catch { /* optional */ }
|
|
489
598
|
|
|
@@ -1234,7 +1343,7 @@ module.exports = {
|
|
|
1234
1343
|
invalidateKnowledgeBaseCache,
|
|
1235
1344
|
|
|
1236
1345
|
// Core state
|
|
1237
|
-
getConfig, getControl, getDispatch, getDispatchQueue, invalidateDispatchCache,
|
|
1346
|
+
getConfig, getControl, getDispatch, getDispatchQueue, getDispatchCompletionReport, invalidateDispatchCache,
|
|
1238
1347
|
getNotes, getNotesWithMeta, getEngineLog, getMetrics,
|
|
1239
1348
|
|
|
1240
1349
|
// Inbox
|
package/engine/shared.js
CHANGED
|
@@ -720,6 +720,12 @@ const ENGINE_DEFAULTS = {
|
|
|
720
720
|
autoArchive: false, // opt-in: auto-archive plans after verify completes (false = mark ready, user archives manually)
|
|
721
721
|
autoFixConflicts: true, // auto-dispatch fix agents when a PR has merge conflicts
|
|
722
722
|
autoFixBuilds: true, // auto-dispatch fix agents when a PR build fails
|
|
723
|
+
autoReviewPrs: true, // auto-dispatch review agents for newly opened agent PRs
|
|
724
|
+
autoReReviewPrs: true, // auto-dispatch review agents after a PR fix is pushed
|
|
725
|
+
autoFixReviewFeedback: true, // auto-dispatch fix agents for minions review changes-requested verdicts
|
|
726
|
+
autoFixHumanComments: true, // auto-dispatch fix agents for actionable human PR comments
|
|
727
|
+
completionReportRetentionDays: 90, // retain completion report sidecars beyond capped dispatch history
|
|
728
|
+
completionReportMaxFiles: 5000, // hard cap for completion report sidecars during cleanup
|
|
723
729
|
meetingRoundTimeout: 900000, // 15min per meeting round before auto-advance
|
|
724
730
|
evalLoop: true, // enable review→fix loop after implementation completes
|
|
725
731
|
evalMaxIterations: 3, // legacy UI/config field; engine discovery no longer enforces review→fix cycle caps
|
|
@@ -1177,7 +1183,7 @@ const ESCALATION_POLICY = {
|
|
|
1177
1183
|
};
|
|
1178
1184
|
|
|
1179
1185
|
// Structured completion protocol — fields agents must produce in ```completion blocks
|
|
1180
|
-
const COMPLETION_FIELDS = ['status', 'summary', 'files_changed', 'tests', 'pr', 'pending', 'failure_class', 'retryable', 'needs_rerun', 'verdict'];
|
|
1186
|
+
const COMPLETION_FIELDS = ['status', 'summary', 'files_changed', 'tests', 'pr', 'pending', 'failure_class', 'retryable', 'needs_rerun', 'verdict', 'artifacts'];
|
|
1181
1187
|
|
|
1182
1188
|
const DEFAULT_AGENT_METRICS = {
|
|
1183
1189
|
tasksCompleted: 0, tasksErrored: 0,
|
|
@@ -1793,9 +1799,11 @@ function _jsonEqual(a, b) {
|
|
|
1793
1799
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
1794
1800
|
}
|
|
1795
1801
|
|
|
1796
|
-
function
|
|
1802
|
+
function isPlainObject(value) {
|
|
1797
1803
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
1798
1804
|
}
|
|
1805
|
+
// Backwards-compat alias for legacy in-file callers.
|
|
1806
|
+
const _isPlainObject = isPlainObject;
|
|
1799
1807
|
|
|
1800
1808
|
function applyPrFieldDelta(target, before, after) {
|
|
1801
1809
|
if (!target || typeof target !== 'object' || !after || typeof after !== 'object') return target;
|
|
@@ -2322,6 +2330,7 @@ module.exports = {
|
|
|
2322
2330
|
mutatePullRequests,
|
|
2323
2331
|
uid,
|
|
2324
2332
|
uniquePath,
|
|
2333
|
+
isPlainObject,
|
|
2325
2334
|
truncateTextBytes,
|
|
2326
2335
|
tailTextBytes,
|
|
2327
2336
|
appendTextTail,
|
package/engine.js
CHANGED
|
@@ -486,7 +486,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
486
486
|
'',
|
|
487
487
|
`Before exiting, write a JSON completion report to: \`${completionReportPath}\``,
|
|
488
488
|
'',
|
|
489
|
-
'Use this shape: {"status":"success|partial|failed","summary":"...","verdict":"approved|changes-requested|null","pr":"PR URL or id if relevant","failure_class":"...","retryable":true|false,"needs_rerun":true|false}.',
|
|
489
|
+
'Use this shape: {"status":"success|partial|failed","summary":"...","verdict":"approved|changes-requested|null","pr":"PR URL or id if relevant","failure_class":"...","retryable":true|false,"needs_rerun":true|false,"artifacts":[{"type":"note|plan|prd|pr|file","path":"relative/path/or/url","title":"short label"}]}.',
|
|
490
490
|
'This report is the primary completion signal; fenced completion blocks are only a fallback.',
|
|
491
491
|
'',
|
|
492
492
|
].join('\n') : '';
|
|
@@ -1336,13 +1336,19 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
1336
1336
|
const hardContractFail = completionContractFailure?.severity === 'hard'
|
|
1337
1337
|
|| completionContractFailure?.nonTerminal === true;
|
|
1338
1338
|
const effectiveResult = hardContractFail ? DISPATCH_RESULT.ERROR : (((code === 0 && !agentReportedFailure) || autoRecovered) ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR);
|
|
1339
|
+
const completionReportPath = structuredCompletion?._path || dispatchItem.meta?.completionReportPath || shared.dispatchCompletionReportPath(id);
|
|
1340
|
+
const completionOpts = {
|
|
1341
|
+
...(completionReportPath ? { completionReportPath } : {}),
|
|
1342
|
+
...(structuredCompletion ? { structuredCompletion } : {}),
|
|
1343
|
+
};
|
|
1339
1344
|
const completeOpts = hardContractFail
|
|
1340
|
-
? { processWorkItemFailure: false }
|
|
1345
|
+
? { ...completionOpts, processWorkItemFailure: false }
|
|
1341
1346
|
: (effectiveResult === DISPATCH_RESULT.ERROR ? {
|
|
1347
|
+
...completionOpts,
|
|
1342
1348
|
...(failureClass ? { failureClass } : {}),
|
|
1343
1349
|
...(typeof retryableDecision === 'boolean' ? { agentRetryable: retryableDecision } : {}),
|
|
1344
1350
|
...(structuredCompletion?.failure_class ? { failureClass: structuredCompletion.failure_class } : {}),
|
|
1345
|
-
} :
|
|
1351
|
+
} : completionOpts);
|
|
1346
1352
|
// Extract last 5 non-empty stderr lines as error context when exit code is non-zero
|
|
1347
1353
|
let errorReason = '';
|
|
1348
1354
|
if (hardContractFail) {
|
|
@@ -1679,7 +1685,7 @@ function updateSnapshot(config) {
|
|
|
1679
1685
|
// ─── Cooldowns (extracted to engine/cooldown.js) ─────────────────────────────
|
|
1680
1686
|
|
|
1681
1687
|
const { COOLDOWN_PATH, dispatchCooldowns, loadCooldowns, saveCooldowns,
|
|
1682
|
-
isOnCooldown, setCooldown, setCooldownWithContext,
|
|
1688
|
+
isOnCooldown, setCooldown, setCooldownWithContext, drainCoalescedContexts,
|
|
1683
1689
|
setCooldownFailure, clearCooldown, isAlreadyDispatched, isBranchActive } = require('./engine/cooldown');
|
|
1684
1690
|
|
|
1685
1691
|
|
|
@@ -2174,6 +2180,11 @@ async function discoverFromPrs(config, project) {
|
|
|
2174
2180
|
? (config.engine?.adoPollEnabled ?? ENGINE_DEFAULTS.adoPollEnabled)
|
|
2175
2181
|
: (config.engine?.ghPollEnabled ?? ENGINE_DEFAULTS.ghPollEnabled);
|
|
2176
2182
|
const evalLoopEnabled = config.engine?.evalLoop !== false;
|
|
2183
|
+
const fixThrottled = isAdoProject ? isAdoThrottled() : isGhThrottled();
|
|
2184
|
+
const autoReviewPrs = config.engine?.autoReviewPrs ?? ENGINE_DEFAULTS.autoReviewPrs;
|
|
2185
|
+
const autoReReviewPrs = config.engine?.autoReReviewPrs ?? ENGINE_DEFAULTS.autoReReviewPrs;
|
|
2186
|
+
const autoFixReviewFeedback = config.engine?.autoFixReviewFeedback ?? ENGINE_DEFAULTS.autoFixReviewFeedback;
|
|
2187
|
+
const autoFixHumanComments = config.engine?.autoFixHumanComments ?? ENGINE_DEFAULTS.autoFixHumanComments;
|
|
2177
2188
|
|
|
2178
2189
|
// Collect active PR dispatches to prevent simultaneous review+fix on same PR
|
|
2179
2190
|
const dispatch = getDispatch();
|
|
@@ -2217,12 +2228,13 @@ async function discoverFromPrs(config, project) {
|
|
|
2217
2228
|
const awaitingReReview = reviewStatus === 'waiting' && !!pr.minionsReview?.fixedAt;
|
|
2218
2229
|
|
|
2219
2230
|
// PRs needing review: evalLoop gates the entire review+fix cycle; pollEnabled ensures reviewStatus is fresh
|
|
2220
|
-
const reviewEnabled = evalLoopEnabled && pollEnabled;
|
|
2231
|
+
const reviewEnabled = evalLoopEnabled && pollEnabled && autoReviewPrs;
|
|
2232
|
+
const reReviewEnabled = evalLoopEnabled && pollEnabled && autoReReviewPrs;
|
|
2221
2233
|
const alreadyReviewed = pr.lastReviewedAt && (!pr.lastPushedAt || pr.lastPushedAt <= pr.lastReviewedAt);
|
|
2222
2234
|
const needsReview = reviewEnabled && reviewStatus === 'pending' && !alreadyReviewed;
|
|
2223
2235
|
if (needsReview) {
|
|
2224
2236
|
const key = `review-${project?.name || 'default'}-${prDisplayId}`;
|
|
2225
|
-
if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
|
|
2237
|
+
if (fixThrottled || isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
|
|
2226
2238
|
|
|
2227
2239
|
// Pre-dispatch live vote check — cached reviewStatus may be stale (poll lag ~6 min)
|
|
2228
2240
|
try {
|
|
@@ -2263,21 +2275,29 @@ async function discoverFromPrs(config, project) {
|
|
|
2263
2275
|
// awaiting a stale-vote re-review or has build-fix retries escalated.
|
|
2264
2276
|
const humanFixKey = `human-fix-${project?.name || 'default'}-${prDisplayId}`;
|
|
2265
2277
|
const hasCoalescedFeedback = (dispatchCooldowns.get(humanFixKey)?.pendingContexts || []).length > 0;
|
|
2266
|
-
if ((pr.humanFeedback?.pendingFix || hasCoalescedFeedback) && !fixDispatched) {
|
|
2278
|
+
if (autoFixHumanComments && (pr.humanFeedback?.pendingFix || hasCoalescedFeedback) && !fixDispatched) {
|
|
2267
2279
|
const key = humanFixKey;
|
|
2268
2280
|
let staleCoalesced = [];
|
|
2269
2281
|
const alreadyDispatched = isAlreadyDispatched(key);
|
|
2270
2282
|
const blockedByCooldown = isOnCooldown(key, cooldownMs);
|
|
2283
|
+
const currentFeedback = pr.humanFeedback?.pendingFix ? pr.humanFeedback.feedbackContent : '';
|
|
2284
|
+
const coalesceCurrentHumanFeedback = () => {
|
|
2285
|
+
if (!currentFeedback) return;
|
|
2286
|
+
setCooldownWithContext(key, { feedbackContent: currentFeedback, timestamp: ts() });
|
|
2287
|
+
clearPendingHumanFeedbackFlag(projMeta, pr.id);
|
|
2288
|
+
};
|
|
2289
|
+
if (fixThrottled) {
|
|
2290
|
+
coalesceCurrentHumanFeedback();
|
|
2291
|
+
continue;
|
|
2292
|
+
}
|
|
2271
2293
|
if (blockedByCooldown && !alreadyDispatched) {
|
|
2272
|
-
staleCoalesced =
|
|
2294
|
+
staleCoalesced = drainCoalescedContexts(key);
|
|
2273
2295
|
clearCooldown(key);
|
|
2274
2296
|
log('info', `Cleared stale cooldown for ${key} — no matching dispatch history`);
|
|
2275
2297
|
}
|
|
2276
2298
|
if (alreadyDispatched || isOnCooldown(key, cooldownMs)) {
|
|
2277
2299
|
// Coalesce: save feedback for next dispatch
|
|
2278
|
-
|
|
2279
|
-
setCooldownWithContext(key, { feedbackContent: pr.humanFeedback.feedbackContent, timestamp: ts() });
|
|
2280
|
-
}
|
|
2300
|
+
coalesceCurrentHumanFeedback();
|
|
2281
2301
|
continue;
|
|
2282
2302
|
}
|
|
2283
2303
|
const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
|
|
@@ -2285,11 +2305,11 @@ async function discoverFromPrs(config, project) {
|
|
|
2285
2305
|
const prBranch = ensurePrBranchForDispatch(project, pr, 'human-feedback fix');
|
|
2286
2306
|
if (!prBranch) continue;
|
|
2287
2307
|
|
|
2288
|
-
const coalesced = [...staleCoalesced, ...
|
|
2289
|
-
let reviewNote =
|
|
2308
|
+
const coalesced = [...staleCoalesced, ...drainCoalescedContexts(key)];
|
|
2309
|
+
let reviewNote = currentFeedback || 'See PR thread comments';
|
|
2290
2310
|
if (coalesced.length > 0) {
|
|
2291
2311
|
const earlier = coalesced.map(c => c.feedbackContent).filter(Boolean).join('\n\n---\n\n');
|
|
2292
|
-
if (earlier) reviewNote = earlier + '\n\n---\n\n' +
|
|
2312
|
+
if (earlier) reviewNote = currentFeedback ? earlier + '\n\n---\n\n' + currentFeedback : earlier;
|
|
2293
2313
|
}
|
|
2294
2314
|
reviewNote = `New PR comments were observed. Read the full PR thread, decide whether the comments require code/documentation/test changes, make only necessary changes, and push if action is needed.\n\n${reviewNote}`;
|
|
2295
2315
|
|
|
@@ -2305,13 +2325,13 @@ async function discoverFromPrs(config, project) {
|
|
|
2305
2325
|
// or when no minions review has completed yet (e.g. human-feedback-only fix path).
|
|
2306
2326
|
const fixedAfterReview = !!(pr.minionsReview?.fixedAt &&
|
|
2307
2327
|
(!pr.lastReviewedAt || pr.minionsReview.fixedAt > pr.lastReviewedAt));
|
|
2308
|
-
const needsReReview =
|
|
2328
|
+
const needsReReview = reReviewEnabled && reviewStatus === 'waiting' &&
|
|
2309
2329
|
fixedAfterReview && !fixDispatched;
|
|
2310
2330
|
if (needsReReview) {
|
|
2311
2331
|
const key = `rereview-${project?.name || 'default'}-${prDisplayId}`;
|
|
2312
2332
|
// Skip isAlreadyDispatched — fixedAfterReview/lastReviewedAt already dedupe; the 1hr
|
|
2313
2333
|
// completed-dispatch window would block legitimate re-reviews within the hour after a fix
|
|
2314
|
-
if (isOnCooldown(key, cooldownMs)) continue;
|
|
2334
|
+
if (fixThrottled || isOnCooldown(key, cooldownMs)) continue;
|
|
2315
2335
|
|
|
2316
2336
|
// Pre-dispatch live vote check — cached 'waiting' may be stale if reviewer already acted
|
|
2317
2337
|
try {
|
|
@@ -2346,9 +2366,9 @@ async function discoverFromPrs(config, project) {
|
|
|
2346
2366
|
|
|
2347
2367
|
// PRs with changes requested → route back to author for fix
|
|
2348
2368
|
// Gate on evalLoopEnabled — the review→fix cycle is the eval loop
|
|
2349
|
-
if (evalLoopEnabled && reviewStatus === 'changes-requested' && !awaitingReReview && !fixDispatched) {
|
|
2369
|
+
if (evalLoopEnabled && autoFixReviewFeedback && reviewStatus === 'changes-requested' && !awaitingReReview && !fixDispatched) {
|
|
2350
2370
|
const key = `fix-${project?.name || 'default'}-${prDisplayId}`;
|
|
2351
|
-
if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
|
|
2371
|
+
if (fixThrottled || isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
|
|
2352
2372
|
const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
|
|
2353
2373
|
if (!agentId) continue;
|
|
2354
2374
|
const prBranch = ensurePrBranchForDispatch(project, pr, 'fix');
|
|
@@ -2371,7 +2391,6 @@ async function discoverFromPrs(config, project) {
|
|
|
2371
2391
|
if (Date.now() - new Date(pr._buildFixPushedAt).getTime() < gracePeriodMs) continue;
|
|
2372
2392
|
}
|
|
2373
2393
|
const autoFixBuilds = config.engine?.autoFixBuilds ?? ENGINE_DEFAULTS.autoFixBuilds;
|
|
2374
|
-
const fixThrottled = isAdoProject ? isAdoThrottled() : isGhThrottled();
|
|
2375
2394
|
if (autoFixBuilds && pr.status === PR_STATUS.ACTIVE && pr.buildStatus === 'failing') {
|
|
2376
2395
|
const key = `build-fix-${project?.name || 'default'}-${prDisplayId}`;
|
|
2377
2396
|
if (fixThrottled || isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
|
|
@@ -4082,7 +4101,7 @@ module.exports = {
|
|
|
4082
4101
|
updateWorkItemStatus, handlePostMerge,
|
|
4083
4102
|
|
|
4084
4103
|
// Cooldowns
|
|
4085
|
-
loadCooldowns, setCooldownWithContext,
|
|
4104
|
+
loadCooldowns, setCooldownWithContext, drainCoalescedContexts,
|
|
4086
4105
|
|
|
4087
4106
|
// Budget
|
|
4088
4107
|
getMonthlySpend,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1655",
|
|
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"
|
|
@@ -55,10 +55,10 @@ Treat a Minions assignment like the user typed the same task directly into a cap
|
|
|
55
55
|
The engine provides a completion report path in the prompt and in `MINIONS_COMPLETION_REPORT`. Before exiting, write JSON there with the actual outcome:
|
|
56
56
|
|
|
57
57
|
```json
|
|
58
|
-
{"status":"success","summary":"what changed and how it was validated","verdict":null,"pr":"PR id/url or N/A","failure_class":"N/A","retryable":false,"needs_rerun":false}
|
|
58
|
+
{"status":"success","summary":"what changed and how it was validated","verdict":null,"pr":"PR id/url or N/A","failure_class":"N/A","retryable":false,"needs_rerun":false,"artifacts":[{"type":"note|plan|prd|pr|file","path":"relative/path/or/url","title":"short label"}]}
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
Use `status: "failed"` plus an accurate `failure_class`, `retryable`, and `needs_rerun` when the task could not be completed. For PR reviews, set `verdict` to `approved` or `changes-requested`. Fenced `completion` blocks are still accepted as a fallback, but the JSON report is the primary signal.
|
|
61
|
+
Use `status: "failed"` plus an accurate `failure_class`, `retryable`, and `needs_rerun` when the task could not be completed. For PR reviews, set `verdict` to `approved` or `changes-requested`. Include every durable artifact you created or updated in `artifacts` (PRs, notes, plans, PRDs, important files) so the dashboard can display them. Fenced `completion` blocks are still accepted as a fallback, but the JSON report is the primary signal.
|
|
62
62
|
|
|
63
63
|
## Long-Running Commands
|
|
64
64
|
|