@yemi33/minions 0.1.1666 → 0.1.1668
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/engine/copilot-models.json +1 -1
- package/engine/lifecycle.js +235 -20
- package/engine.js +70 -60
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
package/engine/lifecycle.js
CHANGED
|
@@ -943,7 +943,8 @@ function isPrAttachmentRequired(type, item, meta = {}) {
|
|
|
943
943
|
|| item.prRequired === true
|
|
944
944
|
|| item.requiresPullRequest === true
|
|
945
945
|
|| item.itemType === 'pr';
|
|
946
|
-
|
|
946
|
+
const branchStrategy = meta.branchStrategy || item.branchStrategy;
|
|
947
|
+
if (branchStrategy === 'shared-branch' && item.itemType !== 'pr' && !explicit) return false;
|
|
947
948
|
|
|
948
949
|
// Fix/test work items dispatched against an existing PR don't produce a new
|
|
949
950
|
// PR — the agent updates meta.pr in place. Only require fresh PR attachment
|
|
@@ -960,21 +961,144 @@ function isPrAttachmentRequired(type, item, meta = {}) {
|
|
|
960
961
|
|| type === WORK_TYPE.TEST;
|
|
961
962
|
}
|
|
962
963
|
|
|
964
|
+
function readOptionalJsonStrict(filePath, label, validate) {
|
|
965
|
+
if (!filePath) return null;
|
|
966
|
+
let raw;
|
|
967
|
+
try {
|
|
968
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
969
|
+
} catch (err) {
|
|
970
|
+
if (err.code === 'ENOENT') return null;
|
|
971
|
+
throw new Error(`Cannot read ${label} JSON at ${filePath}: ${err.message}`);
|
|
972
|
+
}
|
|
973
|
+
let parsed;
|
|
974
|
+
try {
|
|
975
|
+
parsed = JSON.parse(raw);
|
|
976
|
+
} catch (err) {
|
|
977
|
+
throw new Error(`Corrupt ${label} JSON at ${filePath}: ${err.message}`);
|
|
978
|
+
}
|
|
979
|
+
if (validate && !validate(parsed)) {
|
|
980
|
+
throw new Error(`Invalid ${label} JSON shape at ${filePath}`);
|
|
981
|
+
}
|
|
982
|
+
return parsed;
|
|
983
|
+
}
|
|
984
|
+
|
|
963
985
|
function hasCanonicalPrAttachment(itemId, config) {
|
|
964
986
|
if (!itemId) return false;
|
|
987
|
+
// Cheapest probe first — getPrLinks() is in-process cached and merges canonical IDs.
|
|
965
988
|
if (Object.values(getPrLinks()).some(linkedIds => (linkedIds || []).includes(itemId))) return true;
|
|
966
989
|
const projects = shared.getProjects(config);
|
|
967
990
|
for (const p of projects) {
|
|
968
|
-
const prs =
|
|
991
|
+
const prs = readOptionalJsonStrict(shared.projectPrPath(p), 'project pull-requests', Array.isArray) || [];
|
|
969
992
|
if (prs.some(pr => (pr.prdItems || []).includes(itemId))) return true;
|
|
970
993
|
}
|
|
971
|
-
const centralPrs =
|
|
994
|
+
const centralPrs = readOptionalJsonStrict(path.join(MINIONS_DIR, 'pull-requests.json'), 'central pull-requests', Array.isArray) || [];
|
|
972
995
|
return centralPrs.some(pr => (pr.prdItems || []).includes(itemId));
|
|
973
996
|
}
|
|
974
997
|
|
|
998
|
+
function resolvePrFallbackProject(meta, config) {
|
|
999
|
+
const projects = shared.getProjects(config);
|
|
1000
|
+
if (meta?.project?.name) {
|
|
1001
|
+
const match = projects.find(p => p.name === meta.project.name);
|
|
1002
|
+
if (match) return match;
|
|
1003
|
+
}
|
|
1004
|
+
if (meta?.project?.localPath) {
|
|
1005
|
+
const metaPath = path.resolve(meta.project.localPath);
|
|
1006
|
+
const match = projects.find(p => p.localPath && path.resolve(p.localPath) === metaPath);
|
|
1007
|
+
if (match) return match;
|
|
1008
|
+
}
|
|
1009
|
+
if (meta?.item?.project) {
|
|
1010
|
+
const match = projects.find(p => p.name === meta.item.project);
|
|
1011
|
+
if (match) return match;
|
|
1012
|
+
}
|
|
1013
|
+
return projects.length === 1 ? projects[0] : null;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function runFileCapture(file, args, opts = {}) {
|
|
1017
|
+
const { timeout = 30000, ...spawnOpts } = opts;
|
|
1018
|
+
const MAX_BUFFER = 4 * 1024 * 1024; // 4MB — generous for gh/git output
|
|
1019
|
+
return new Promise((resolve, reject) => {
|
|
1020
|
+
let child;
|
|
1021
|
+
let settled = false;
|
|
1022
|
+
let timedOut = false;
|
|
1023
|
+
let killedForBuffer = false;
|
|
1024
|
+
let stdout = '';
|
|
1025
|
+
let stderr = '';
|
|
1026
|
+
let hardKillTimer = null;
|
|
1027
|
+
const finish = (fn, value) => {
|
|
1028
|
+
if (settled) return;
|
|
1029
|
+
settled = true;
|
|
1030
|
+
if (timer) clearTimeout(timer);
|
|
1031
|
+
if (hardKillTimer) clearTimeout(hardKillTimer);
|
|
1032
|
+
fn(value);
|
|
1033
|
+
};
|
|
1034
|
+
const timer = timeout ? setTimeout(() => {
|
|
1035
|
+
timedOut = true;
|
|
1036
|
+
if (!child) return;
|
|
1037
|
+
shared.killGracefully(child, 1000);
|
|
1038
|
+
// Hard-kill fallback if the child ignores SIGTERM. Cleared on close.
|
|
1039
|
+
hardKillTimer = setTimeout(() => {
|
|
1040
|
+
try { shared.killImmediate(child); } catch {}
|
|
1041
|
+
}, 2500);
|
|
1042
|
+
}, timeout) : null;
|
|
1043
|
+
try {
|
|
1044
|
+
child = shared.runFile(file, args, { ...spawnOpts, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1045
|
+
} catch (err) {
|
|
1046
|
+
finish(reject, err);
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
child.stdout?.setEncoding('utf8');
|
|
1050
|
+
child.stderr?.setEncoding('utf8');
|
|
1051
|
+
child.stdout?.on('data', chunk => {
|
|
1052
|
+
if (stdout.length + chunk.length > MAX_BUFFER) {
|
|
1053
|
+
if (!killedForBuffer) { killedForBuffer = true; try { shared.killImmediate(child); } catch {} }
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
stdout += chunk;
|
|
1057
|
+
});
|
|
1058
|
+
child.stderr?.on('data', chunk => {
|
|
1059
|
+
if (stderr.length + chunk.length > MAX_BUFFER) {
|
|
1060
|
+
if (!killedForBuffer) { killedForBuffer = true; try { shared.killImmediate(child); } catch {} }
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
stderr += chunk;
|
|
1064
|
+
});
|
|
1065
|
+
child.on('error', err => {
|
|
1066
|
+
err.stdout = stdout;
|
|
1067
|
+
err.stderr = stderr;
|
|
1068
|
+
finish(reject, err);
|
|
1069
|
+
});
|
|
1070
|
+
child.once('close', () => {
|
|
1071
|
+
if (hardKillTimer) { clearTimeout(hardKillTimer); hardKillTimer = null; }
|
|
1072
|
+
});
|
|
1073
|
+
child.on('close', code => {
|
|
1074
|
+
if (code === 0 && !timedOut && !killedForBuffer) {
|
|
1075
|
+
finish(resolve, stdout);
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
let message;
|
|
1079
|
+
let errCode;
|
|
1080
|
+
if (timedOut) {
|
|
1081
|
+
message = `${file} timed out after ${timeout}ms`;
|
|
1082
|
+
errCode = 'ETIMEDOUT';
|
|
1083
|
+
} else if (killedForBuffer) {
|
|
1084
|
+
message = `${file} exceeded max buffer of ${MAX_BUFFER} bytes`;
|
|
1085
|
+
errCode = 'ERR_OUT_OF_RANGE';
|
|
1086
|
+
} else {
|
|
1087
|
+
message = `${file} exited with code ${code}`;
|
|
1088
|
+
errCode = code;
|
|
1089
|
+
}
|
|
1090
|
+
const err = new Error(message);
|
|
1091
|
+
err.code = errCode;
|
|
1092
|
+
err.stdout = stdout;
|
|
1093
|
+
err.stderr = stderr;
|
|
1094
|
+
finish(reject, err);
|
|
1095
|
+
});
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
|
|
975
1099
|
async function findOpenPrForBranch(meta, config) {
|
|
976
1100
|
if (!meta?.branch) return null;
|
|
977
|
-
const projectObj =
|
|
1101
|
+
const projectObj = resolvePrFallbackProject(meta, config);
|
|
978
1102
|
if (!projectObj) return null;
|
|
979
1103
|
const host = projectObj.repoHost || 'ado';
|
|
980
1104
|
if (host === 'github') {
|
|
@@ -984,7 +1108,7 @@ async function findOpenPrForBranch(meta, config) {
|
|
|
984
1108
|
if (attempt > 0) await new Promise(r => setTimeout(r, 3000));
|
|
985
1109
|
let raw = '';
|
|
986
1110
|
try {
|
|
987
|
-
raw = await
|
|
1111
|
+
raw = await runFileCapture('gh', ['pr', 'list', '--head', String(meta.branch), '--repo', ghSlug, '--json', 'number,url,state', '--limit', '1'], { timeout: 15000 });
|
|
988
1112
|
const parsed = JSON.parse(raw || '[]');
|
|
989
1113
|
const hits = Array.isArray(parsed) ? parsed : [];
|
|
990
1114
|
if (hits.length > 0 && hits[0].state === 'OPEN') {
|
|
@@ -1024,6 +1148,7 @@ function _outputContainsPrUrl(output) {
|
|
|
1024
1148
|
function markMissingPrAttachment(meta, agentId, reason, resultSummary, severity) {
|
|
1025
1149
|
const noPrWiPath = resolveWorkItemPath(meta);
|
|
1026
1150
|
const isHard = severity !== 'soft';
|
|
1151
|
+
let syncNeedsReviewToPrd = false;
|
|
1027
1152
|
if (noPrWiPath) {
|
|
1028
1153
|
mutateJsonFileLocked(noPrWiPath, data => {
|
|
1029
1154
|
if (!Array.isArray(data)) return data;
|
|
@@ -1034,6 +1159,7 @@ function markMissingPrAttachment(meta, agentId, reason, resultSummary, severity)
|
|
|
1034
1159
|
w._missingPrAttachment = true;
|
|
1035
1160
|
w.failReason = reason;
|
|
1036
1161
|
w._lastReviewReason = reason;
|
|
1162
|
+
syncNeedsReviewToPrd = !!meta.item?.sourcePlan;
|
|
1037
1163
|
delete w.completedAt;
|
|
1038
1164
|
delete w._noPr;
|
|
1039
1165
|
delete w._noPrReason;
|
|
@@ -1047,6 +1173,9 @@ function markMissingPrAttachment(meta, agentId, reason, resultSummary, severity)
|
|
|
1047
1173
|
return data;
|
|
1048
1174
|
}, { skipWriteIfUnchanged: true });
|
|
1049
1175
|
}
|
|
1176
|
+
if (isHard && syncNeedsReviewToPrd) {
|
|
1177
|
+
syncPrdItemStatus(meta.item.id, WI_STATUS.NEEDS_REVIEW, meta.item.sourcePlan);
|
|
1178
|
+
}
|
|
1050
1179
|
if (isHard) {
|
|
1051
1180
|
shared.writeToInbox('engine', `missing-pr-attachment-${meta.item.id}`,
|
|
1052
1181
|
`# PR attachment missing for ${meta.item.id}\n\n` +
|
|
@@ -1075,9 +1204,50 @@ function markMissingPrAttachment(meta, agentId, reason, resultSummary, severity)
|
|
|
1075
1204
|
}
|
|
1076
1205
|
}
|
|
1077
1206
|
|
|
1207
|
+
function markPrAttachmentVerificationError(meta, agentId, reason, resultSummary) {
|
|
1208
|
+
const wiPath = resolveWorkItemPath(meta);
|
|
1209
|
+
let syncNeedsReviewToPrd = false;
|
|
1210
|
+
if (wiPath) {
|
|
1211
|
+
mutateJsonFileLocked(wiPath, data => {
|
|
1212
|
+
if (!Array.isArray(data)) return data;
|
|
1213
|
+
const w = data.find(i => i.id === meta.item.id);
|
|
1214
|
+
if (!w) return data;
|
|
1215
|
+
w.status = WI_STATUS.NEEDS_REVIEW;
|
|
1216
|
+
w._prAttachmentStateError = true;
|
|
1217
|
+
w.failReason = reason;
|
|
1218
|
+
w._lastReviewReason = reason;
|
|
1219
|
+
syncNeedsReviewToPrd = !!meta.item?.sourcePlan;
|
|
1220
|
+
delete w.completedAt;
|
|
1221
|
+
delete w._missingPrAttachment;
|
|
1222
|
+
delete w._unverifiedPrAttachment;
|
|
1223
|
+
return data;
|
|
1224
|
+
}, { skipWriteIfUnchanged: true });
|
|
1225
|
+
}
|
|
1226
|
+
if (syncNeedsReviewToPrd) {
|
|
1227
|
+
syncPrdItemStatus(meta.item.id, WI_STATUS.NEEDS_REVIEW, meta.item.sourcePlan);
|
|
1228
|
+
}
|
|
1229
|
+
shared.writeToInbox('engine', `pr-attachment-state-error-${meta.item.id}`,
|
|
1230
|
+
`# PR attachment verification blocked for ${meta.item.id}\n\n` +
|
|
1231
|
+
`**Agent:** ${agentId}\n` +
|
|
1232
|
+
`**Work item:** \`${meta.item.id}\` — ${meta.item.title || ''}\n` +
|
|
1233
|
+
`**Type:** ${meta.item.type || 'unknown'}\n` +
|
|
1234
|
+
`**Branch:** ${meta.branch || '(none)'}\n\n` +
|
|
1235
|
+
`${reason}\n` +
|
|
1236
|
+
(resultSummary ? `\n## Agent summary\n${resultSummary}\n` : ''),
|
|
1237
|
+
null,
|
|
1238
|
+
{ sourceItem: meta.item.id, reason: 'pr-attachment-state-error' });
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1078
1241
|
async function enforcePrAttachmentContract(type, meta, agentId, config, resultSummary, output) {
|
|
1079
1242
|
if (!isPrAttachmentRequired(type, meta?.item, meta)) return null;
|
|
1080
|
-
|
|
1243
|
+
try {
|
|
1244
|
+
if (hasCanonicalPrAttachment(meta.item.id, config)) return null;
|
|
1245
|
+
} catch (err) {
|
|
1246
|
+
const reason = `${meta.item.id} completed but PR attachment verification could not read PR tracking state: ${err.message}`;
|
|
1247
|
+
markPrAttachmentVerificationError(meta, agentId, reason, resultSummary);
|
|
1248
|
+
log('warn', reason);
|
|
1249
|
+
return { reason, itemId: meta.item.id, severity: 'hard', stateError: true };
|
|
1250
|
+
}
|
|
1081
1251
|
|
|
1082
1252
|
const found = await findOpenPrForBranch(meta, config);
|
|
1083
1253
|
if (found) {
|
|
@@ -1100,7 +1270,14 @@ async function enforcePrAttachmentContract(type, meta, agentId, config, resultSu
|
|
|
1100
1270
|
itemId: meta.item.id,
|
|
1101
1271
|
});
|
|
1102
1272
|
log('info', `Auto-linked existing PR ${entry.id} on branch ${meta.branch} for ${meta.item.id}`);
|
|
1103
|
-
|
|
1273
|
+
try {
|
|
1274
|
+
if (hasCanonicalPrAttachment(meta.item.id, config)) return null;
|
|
1275
|
+
} catch (err) {
|
|
1276
|
+
const reason = `${meta.item.id} auto-linked a PR but PR attachment verification could not read PR tracking state: ${err.message}`;
|
|
1277
|
+
markPrAttachmentVerificationError(meta, agentId, reason, resultSummary);
|
|
1278
|
+
log('warn', reason);
|
|
1279
|
+
return { reason, itemId: meta.item.id, severity: 'hard', stateError: true };
|
|
1280
|
+
}
|
|
1104
1281
|
}
|
|
1105
1282
|
|
|
1106
1283
|
// Distinguish "agent never claimed a PR" (hard — silent failure the contract
|
|
@@ -1737,13 +1914,7 @@ function parseStructuredCompletion(stdout, runtimeName) {
|
|
|
1737
1914
|
if (!stdout || typeof stdout !== 'string') return null;
|
|
1738
1915
|
|
|
1739
1916
|
// Extract text from stream-json output if needed
|
|
1740
|
-
|
|
1741
|
-
if (stdout.includes('"type":')) {
|
|
1742
|
-
try {
|
|
1743
|
-
const parsed = shared.parseStreamJsonOutput(stdout, runtimeName);
|
|
1744
|
-
if (parsed.text) text = parsed.text;
|
|
1745
|
-
} catch {}
|
|
1746
|
-
}
|
|
1917
|
+
const text = extractCompletionText(stdout, runtimeName);
|
|
1747
1918
|
|
|
1748
1919
|
// Find all ```completion blocks, take the last one
|
|
1749
1920
|
const blockPattern = /```completion\s*\n([\s\S]*?)```/g;
|
|
@@ -1760,6 +1931,22 @@ function parseStructuredCompletion(stdout, runtimeName) {
|
|
|
1760
1931
|
return parseCompletionKeyValues(lastMatch);
|
|
1761
1932
|
}
|
|
1762
1933
|
|
|
1934
|
+
function extractCompletionText(stdout, runtimeName) {
|
|
1935
|
+
let text = stdout;
|
|
1936
|
+
if (typeof stdout === 'string' && stdout.includes('"type":')) {
|
|
1937
|
+
try {
|
|
1938
|
+
const parsed = shared.parseStreamJsonOutput(stdout, runtimeName);
|
|
1939
|
+
if (parsed.text) text = parsed.text;
|
|
1940
|
+
} catch {}
|
|
1941
|
+
}
|
|
1942
|
+
return text;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
function hasCompletionFence(stdout, runtimeName) {
|
|
1946
|
+
const text = extractCompletionText(stdout, runtimeName);
|
|
1947
|
+
return /```completion\s*\n[\s\S]*?```/.test(text);
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1763
1950
|
function extractTaskCompleteSummary(stdout) {
|
|
1764
1951
|
if (!stdout || typeof stdout !== 'string') return '';
|
|
1765
1952
|
let summary = '';
|
|
@@ -1817,6 +2004,29 @@ function parseCompletionKeyValues(text) {
|
|
|
1817
2004
|
return result;
|
|
1818
2005
|
}
|
|
1819
2006
|
|
|
2007
|
+
function parseCompletionFieldSummary(text) {
|
|
2008
|
+
if (!text || typeof text !== 'string') return null;
|
|
2009
|
+
|
|
2010
|
+
const allowedFields = new Set(shared.COMPLETION_FIELDS || []);
|
|
2011
|
+
const result = {};
|
|
2012
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
2013
|
+
const line = rawLine.trim().replace(/^[-*]\s+/, '');
|
|
2014
|
+
const colonIdx = line.indexOf(':');
|
|
2015
|
+
if (colonIdx < 1) continue;
|
|
2016
|
+
const key = line.slice(0, colonIdx).trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
2017
|
+
if (!allowedFields.has(key)) continue;
|
|
2018
|
+
const value = line.slice(colonIdx + 1).trim().replace(/^["'`]+|["'`]+$/g, '');
|
|
2019
|
+
if (value) result[key] = value;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
if (!result.status) return null;
|
|
2023
|
+
const fieldCount = Object.keys(result).length;
|
|
2024
|
+
const status = normalizeCompletionStatus(result.status);
|
|
2025
|
+
const explicitlyFailed = status.startsWith('fail') || status === 'error';
|
|
2026
|
+
if (fieldCount < 2 && !explicitlyFailed) return null;
|
|
2027
|
+
return result;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
1820
2030
|
function parseCompletionReportFile(dispatchItem, opts = {}) {
|
|
1821
2031
|
const reportPath = dispatchItem?.meta?.completionReportPath || shared.dispatchCompletionReportPath(dispatchItem?.id);
|
|
1822
2032
|
if (!reportPath || !fs.existsSync(reportPath)) {
|
|
@@ -1875,10 +2085,11 @@ function normalizeCompletionStatus(status) {
|
|
|
1875
2085
|
// and burned 3-9 minutes of agent time per false-positive retry.
|
|
1876
2086
|
//
|
|
1877
2087
|
// Both structured signals (the JSON completion report at MINIONS_COMPLETION_REPORT
|
|
1878
|
-
// and the fenced ```completion block in stdout) carry a `status` field.
|
|
1879
|
-
//
|
|
1880
|
-
//
|
|
1881
|
-
//
|
|
2088
|
+
// and the fenced ```completion block in stdout) carry a `status` field. A plain
|
|
2089
|
+
// task_complete summary made only of completion fields is accepted as a narrow
|
|
2090
|
+
// compatibility fallback. If the agent explicitly says they're not done, honor
|
|
2091
|
+
// it; otherwise accept the dispatch. The PR attachment contract still catches
|
|
2092
|
+
// silent-failure cases for PR-producing work.
|
|
1882
2093
|
const NON_TERMINAL_COMPLETION_STATUSES = new Set([
|
|
1883
2094
|
'partial', 'partially-complete', 'in-progress', 'pending', 'deferred',
|
|
1884
2095
|
'blocked', 'incomplete', 'to-be-continued',
|
|
@@ -2110,8 +2321,11 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
2110
2321
|
|
|
2111
2322
|
// Prefer the sidecar completion report; keep fenced output as a compatibility fallback.
|
|
2112
2323
|
const reportCompletion = parseCompletionReportFile(dispatchItem, { warnIfMissing: true });
|
|
2113
|
-
const
|
|
2114
|
-
const
|
|
2324
|
+
const fencedCompletion = reportCompletion ? null : parseStructuredCompletion(stdout, runtimeName);
|
|
2325
|
+
const summaryCompletion = reportCompletion || fencedCompletion ? null : parseCompletionFieldSummary(resultSummary);
|
|
2326
|
+
const fallbackCompletion = fencedCompletion || summaryCompletion;
|
|
2327
|
+
const fallbackSource = fencedCompletion && hasCompletionFence(stdout, runtimeName) ? 'fenced-completion' : 'summary-completion';
|
|
2328
|
+
const structuredCompletion = reportCompletion || persistCompletionReport(dispatchItem, fallbackCompletion, fallbackSource);
|
|
2115
2329
|
if (structuredCompletion) {
|
|
2116
2330
|
if (structuredCompletion.summary) resultSummary = String(structuredCompletion.summary);
|
|
2117
2331
|
log('info', `Structured completion from ${agentId}: status=${structuredCompletion.status}, pr=${structuredCompletion.pr || 'N/A'}${structuredCompletion._source ? ` (${structuredCompletion._source})` : ''}`);
|
|
@@ -2625,6 +2839,7 @@ module.exports = {
|
|
|
2625
2839
|
parseReviewVerdict,
|
|
2626
2840
|
isReviewBailout,
|
|
2627
2841
|
parseStructuredCompletion,
|
|
2842
|
+
parseCompletionFieldSummary,
|
|
2628
2843
|
detectNonTerminalResultSummary,
|
|
2629
2844
|
parseCompletionReportFile,
|
|
2630
2845
|
persistCompletionReport,
|
package/engine.js
CHANGED
|
@@ -32,7 +32,7 @@ const queries = require('./engine/queries');
|
|
|
32
32
|
|
|
33
33
|
// ─── Paths ──────────────────────────────────────────────────────────────────
|
|
34
34
|
|
|
35
|
-
const MINIONS_DIR =
|
|
35
|
+
const MINIONS_DIR = shared.MINIONS_DIR;
|
|
36
36
|
const ROUTING_PATH = path.join(MINIONS_DIR, 'routing.md');
|
|
37
37
|
const PLAYBOOKS_DIR = path.join(MINIONS_DIR, 'playbooks');
|
|
38
38
|
const ARCHIVE_DIR = path.join(MINIONS_DIR, 'notes', 'archive');
|
|
@@ -1365,13 +1365,15 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
1365
1365
|
let errorReason = '';
|
|
1366
1366
|
if (hardContractFail) {
|
|
1367
1367
|
errorReason = completionContractFailure.reason || 'PR attachment contract failed';
|
|
1368
|
-
} else if (agentReportedFailure
|
|
1369
|
-
errorReason =
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1368
|
+
} else if (agentReportedFailure) {
|
|
1369
|
+
errorReason = structuredCompletion
|
|
1370
|
+
? String(
|
|
1371
|
+
structuredCompletion.summary
|
|
1372
|
+
|| structuredCompletion.pending
|
|
1373
|
+
|| structuredCompletion.failure_class
|
|
1374
|
+
|| `Agent reported ${structuredCompletion.status || 'failure'}`
|
|
1375
|
+
).slice(0, 300)
|
|
1376
|
+
: 'Agent reported failure';
|
|
1375
1377
|
} else if (effectiveResult === DISPATCH_RESULT.ERROR) {
|
|
1376
1378
|
errorReason = stderr.split('\n').filter(l => l.trim()).slice(-5).join(' | ').trim().slice(0, 300);
|
|
1377
1379
|
// W-mo3zul9pirjb — when claude CLI exits in <3s with code 1 and no output (the
|
|
@@ -2743,9 +2745,9 @@ function discoverFromWorkItems(config, project) {
|
|
|
2743
2745
|
}
|
|
2744
2746
|
const agentHints = routing.extractAgentHints(item);
|
|
2745
2747
|
const hasAgentHints = Array.isArray(agentHints) && agentHints.length > 0;
|
|
2746
|
-
const
|
|
2747
|
-
|
|
2748
|
-
|
|
2748
|
+
const hardPinnedAgent = routing.getHardPinnedAgent(item, config.agents || {});
|
|
2749
|
+
const hardPinRequested = !!hardPinnedAgent;
|
|
2750
|
+
let agentId = hardPinnedAgent || resolveAgent(workType, config, { agentHints });
|
|
2749
2751
|
let reservedAgentId = agentId;
|
|
2750
2752
|
const cfgAgents = config.agents || {};
|
|
2751
2753
|
const budgetBlocked = Object.keys(cfgAgents).some(id => {
|
|
@@ -2863,6 +2865,17 @@ function discoverFromWorkItems(config, project) {
|
|
|
2863
2865
|
return newWork;
|
|
2864
2866
|
}
|
|
2865
2867
|
|
|
2868
|
+
|
|
2869
|
+
function getSyntheticCentralProject() {
|
|
2870
|
+
const base = path.basename(MINIONS_DIR);
|
|
2871
|
+
const root = base === '.minions' ? path.dirname(MINIONS_DIR) : MINIONS_DIR;
|
|
2872
|
+
return { name: 'root', localPath: root, repoHost: 'github', repoName: path.basename(root) || 'root', mainBranch: 'main', _synthetic: true };
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
function getCentralDispatchProjects(projects) {
|
|
2876
|
+
return projects.length > 0 ? projects : [getSyntheticCentralProject()];
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2866
2879
|
/**
|
|
2867
2880
|
* Build the multi-project context section for central work items.
|
|
2868
2881
|
* Inserted into the playbook via {{scope_section}}.
|
|
@@ -3129,6 +3142,7 @@ function discoverCentralWorkItems(config) {
|
|
|
3129
3142
|
const centralPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
3130
3143
|
const items = safeJson(centralPath) || [];
|
|
3131
3144
|
const projects = getProjects(config);
|
|
3145
|
+
const dispatchProjects = getCentralDispatchProjects(projects);
|
|
3132
3146
|
const newWork = [];
|
|
3133
3147
|
// Collect mutations to apply atomically inside lock callback (avoids TOCTOU)
|
|
3134
3148
|
const mutations = new Map(); // item.id → { field: value, ... }
|
|
@@ -3188,15 +3202,14 @@ function discoverCentralWorkItems(config) {
|
|
|
3188
3202
|
|
|
3189
3203
|
const assignments = idleAgents.map((agent, i) => ({
|
|
3190
3204
|
agent,
|
|
3191
|
-
assignedProject:
|
|
3205
|
+
assignedProject: dispatchProjects[i % dispatchProjects.length]
|
|
3192
3206
|
}));
|
|
3193
3207
|
|
|
3194
3208
|
for (const { agent, assignedProject } of assignments) {
|
|
3195
3209
|
const fanKey = `${key}-${agent.id}`;
|
|
3196
3210
|
if (isAlreadyDispatched(fanKey)) continue;
|
|
3197
3211
|
|
|
3198
|
-
const ap = assignedProject ||
|
|
3199
|
-
if (!ap) { log('warn', `Fan-out: skipping ${fanKey} — no projects configured`); continue; }
|
|
3212
|
+
const ap = assignedProject || dispatchProjects[0];
|
|
3200
3213
|
const fanBranch = `fan/${item.id}/${agent.id}`;
|
|
3201
3214
|
const vars = {
|
|
3202
3215
|
...buildBaseVars(agent.id, config, ap),
|
|
@@ -3206,7 +3219,7 @@ function discoverCentralWorkItems(config) {
|
|
|
3206
3219
|
item_description: item.description || '',
|
|
3207
3220
|
work_type: workType,
|
|
3208
3221
|
additional_context: item.prompt ? `## Additional Context\n\n${item.prompt}` : '',
|
|
3209
|
-
scope_section: buildProjectContext(
|
|
3222
|
+
scope_section: buildProjectContext(dispatchProjects, assignedProject, true, agent.name, agent.role),
|
|
3210
3223
|
project_path: ap?.localPath || '',
|
|
3211
3224
|
branch_name: fanBranch,
|
|
3212
3225
|
};
|
|
@@ -3250,16 +3263,16 @@ function discoverCentralWorkItems(config) {
|
|
|
3250
3263
|
} else {
|
|
3251
3264
|
// ─── Normal: single agent dispatch ──────────────────────────────
|
|
3252
3265
|
const agentHints = routing.extractAgentHints(item);
|
|
3253
|
-
const
|
|
3254
|
-
const
|
|
3255
|
-
|
|
3266
|
+
const hardPinnedAgent = routing.getHardPinnedAgent(item, config.agents || {});
|
|
3267
|
+
const hardPinRequested = !!hardPinnedAgent;
|
|
3268
|
+
const agentId = hardPinnedAgent
|
|
3269
|
+
|| resolveAgent(workType, config, { agentHints })
|
|
3256
3270
|
|| (!hardPinRequested && workType !== WORK_TYPE.FIX ? resolveAgentReservation(workType, config, { agentHints }) : null);
|
|
3257
3271
|
if (!agentId) continue;
|
|
3258
3272
|
|
|
3259
3273
|
const agentName = config.agents[agentId]?.name || agentId;
|
|
3260
3274
|
const agentRole = config.agents[agentId]?.role || 'Agent';
|
|
3261
|
-
const firstProject =
|
|
3262
|
-
if (!firstProject) { log('warn', `Dispatch: skipping ${item.id} — no projects configured`); continue; }
|
|
3275
|
+
const firstProject = dispatchProjects[0];
|
|
3263
3276
|
|
|
3264
3277
|
// Branch mutex: skip if target branch is locked by an active dispatch
|
|
3265
3278
|
const centralBranch = item.branch || item.featureBranch || `work/${item.id}`;
|
|
@@ -3280,7 +3293,7 @@ function discoverCentralWorkItems(config) {
|
|
|
3280
3293
|
task_id: item.id,
|
|
3281
3294
|
work_type: workType,
|
|
3282
3295
|
additional_context: item.prompt ? `## Additional Context\n\n${item.prompt}` : '',
|
|
3283
|
-
scope_section: buildProjectContext(
|
|
3296
|
+
scope_section: buildProjectContext(dispatchProjects, null, false, agentName, agentRole),
|
|
3284
3297
|
project_path: firstProject?.localPath || '',
|
|
3285
3298
|
branch_name: centralBranch,
|
|
3286
3299
|
};
|
|
@@ -3382,7 +3395,7 @@ function discoverCentralWorkItems(config) {
|
|
|
3382
3395
|
agentRole,
|
|
3383
3396
|
task: item.title || item.description?.slice(0, 80) || item.id,
|
|
3384
3397
|
prompt,
|
|
3385
|
-
meta: { dispatchKey: key, source: 'central-work-item', item: { ...item, ...mutations.get(item.id) }, planFileName: item.planFile || mutations.get(item.id)?._planFileName || null, branch: item.branch || item.featureBranch || `work/${item.id}
|
|
3398
|
+
meta: { dispatchKey: key, source: 'central-work-item', item: { ...item, ...mutations.get(item.id) }, planFileName: item.planFile || mutations.get(item.id)?._planFileName || null, branch: item.branch || item.featureBranch || `work/${item.id}`, project: { name: firstProject.name, localPath: firstProject.localPath } }
|
|
3386
3399
|
});
|
|
3387
3400
|
|
|
3388
3401
|
setCooldown(key);
|
|
@@ -4024,7 +4037,19 @@ async function tickInner() {
|
|
|
4024
4037
|
persistPendingDispatchAgent(item);
|
|
4025
4038
|
} catch (e) { log('warn', `Persist agent resolution for ${item.id} failed: ${e.message}`); }
|
|
4026
4039
|
}
|
|
4027
|
-
//
|
|
4040
|
+
// Unknown configured agent: string ID that is neither configured nor a known temp agent
|
|
4041
|
+
const isUnknownAssignedAgent = typeof item.agent === 'string' && !item.agent.startsWith('temp-') && !config.agents?.[item.agent] && !tempAgents.has(item.agent);
|
|
4042
|
+
if (isUnknownAssignedAgent) {
|
|
4043
|
+
const fallback = resolvePendingDispatchAgent(item, config);
|
|
4044
|
+
if (!fallback) {
|
|
4045
|
+
log('warn', `Pending dispatch ${item.id} has unknown agent ${item.agent} and no fallback available — skipping`);
|
|
4046
|
+
continue;
|
|
4047
|
+
}
|
|
4048
|
+
log('info', `Pending dispatch ${item.id} unknown agent ${item.agent}; routed → ${fallback}`);
|
|
4049
|
+
assignPendingDispatchAgent(item, fallback, config);
|
|
4050
|
+
persistPendingDispatchAgent(item);
|
|
4051
|
+
}
|
|
4052
|
+
// #1204: Pre-assigned unspawned temp agents never unblock naturally.
|
|
4028
4053
|
// When a batch discovery saturates maxConcurrent, resolveAgent hands out temp
|
|
4029
4054
|
// IDs that get stamped onto pending items. Because those temp IDs are never
|
|
4030
4055
|
// in busyAgents (they were never spawned), the agent-busy reassignment path
|
|
@@ -4042,43 +4067,28 @@ async function tickInner() {
|
|
|
4042
4067
|
}
|
|
4043
4068
|
}
|
|
4044
4069
|
if (busyAgents.has(item.agent)) {
|
|
4045
|
-
// Agent busy reassignment:
|
|
4046
|
-
//
|
|
4047
|
-
const
|
|
4048
|
-
const
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
if (Date.now() - busySinceMs > reassignMs) {
|
|
4066
|
-
const originalAgent = item.agent;
|
|
4067
|
-
const altAgent = resolvePendingDispatchAgent(item, config);
|
|
4068
|
-
if (altAgent && altAgent !== originalAgent && !busyAgents.has(altAgent)) {
|
|
4069
|
-
log('info', `Reassigning ${item.id} from ${originalAgent} to ${altAgent} — agent busy > ${reassignMs}ms`);
|
|
4070
|
-
assignPendingDispatchAgent(item, altAgent, config);
|
|
4071
|
-
// Persist reassignment to dispatch.json
|
|
4072
|
-
persistPendingDispatchAgent(item);
|
|
4073
|
-
// Fall through to branch mutex / concurrency checks below
|
|
4074
|
-
} else {
|
|
4075
|
-
continue; // No alternative agent available — keep waiting
|
|
4076
|
-
}
|
|
4077
|
-
} else {
|
|
4078
|
-
continue; // Below threshold — keep waiting
|
|
4079
|
-
}
|
|
4070
|
+
// Agent busy reassignment: compute hard-pin status; if hard-pinned keep waiting.
|
|
4071
|
+
// For all non-hard-pinned items, reroute immediately (no threshold wait).
|
|
4072
|
+
const originalAgent = item.agent;
|
|
4073
|
+
const hardPinnedAgent = routing.getHardPinnedAgent(item.meta?.item, config.agents || {});
|
|
4074
|
+
const isHardPinned = !!hardPinnedAgent && hardPinnedAgent === originalAgent;
|
|
4075
|
+
if (isHardPinned) {
|
|
4076
|
+
continue; // Valid hard pin — keep waiting for pinned agent
|
|
4077
|
+
}
|
|
4078
|
+
// agent busy and idle alternative available — reroute immediately (no threshold)
|
|
4079
|
+
const altAgent = resolvePendingDispatchAgent(item, config);
|
|
4080
|
+
if (altAgent && altAgent !== originalAgent && !busyAgents.has(altAgent)) {
|
|
4081
|
+
log('info', `Reassigning ${item.id} from ${originalAgent} to ${altAgent} — agent busy and idle alternative available`);
|
|
4082
|
+
assignPendingDispatchAgent(item, altAgent, config);
|
|
4083
|
+
persistPendingDispatchAgent(item);
|
|
4084
|
+
// Fall through to branch mutex / concurrency checks below
|
|
4085
|
+
} else if (isSoftFixDispatch(item)) {
|
|
4086
|
+
log('info', `Clearing busy soft fix agent on ${item.id} (${originalAgent}) — waiting for any available agent`);
|
|
4087
|
+
clearPendingDispatchAgent(item);
|
|
4088
|
+
persistPendingDispatchAgent(item);
|
|
4089
|
+
continue;
|
|
4080
4090
|
} else {
|
|
4081
|
-
continue; // No
|
|
4091
|
+
continue; // No alternative agent available — keep waiting
|
|
4082
4092
|
}
|
|
4083
4093
|
}
|
|
4084
4094
|
// Branch mutex: skip items targeting a branch already locked by an active or newly-dispatched task
|
|
@@ -4215,7 +4225,7 @@ module.exports = {
|
|
|
4215
4225
|
spawnAgent, resolveAgent,
|
|
4216
4226
|
|
|
4217
4227
|
// Discovery
|
|
4218
|
-
discoverWork, discoverFromPrs, discoverFromWorkItems,
|
|
4228
|
+
discoverWork, discoverFromPrs, discoverFromWorkItems, discoverCentralWorkItems,
|
|
4219
4229
|
materializePlansAsWorkItems,
|
|
4220
4230
|
|
|
4221
4231
|
// Shared helpers (used by lifecycle.js and tests)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1668",
|
|
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"
|