@yemi33/minions 0.1.2381 → 0.1.2383
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/bin/minions.js +42 -2
- package/dashboard/js/refresh.js +8 -2
- package/dashboard/js/render-other.js +38 -0
- package/dashboard/js/render-work-items.js +61 -20
- package/dashboard/js/settings.js +7 -8
- package/dashboard/pages/engine.html +6 -0
- package/dashboard.js +110 -27
- package/docs/README.md +1 -0
- package/docs/claude-md-propagation.md +118 -0
- package/docs/completion-reports.md +20 -1
- package/docs/engine-restart.md +10 -0
- package/docs/runtime-adapters.md +54 -22
- package/docs/skills.md +2 -0
- package/docs/workspace-manifests.md +1 -1
- package/docs/worktree-lifecycle.md +23 -0
- package/engine/acp-transport.js +63 -35
- package/engine/ado-comment.js +3 -1
- package/engine/agent-worker-pool.js +11 -4
- package/engine/cc-worker-pool.js +4 -2
- package/engine/claude-md-context.js +195 -0
- package/engine/cli.js +9 -1
- package/engine/comment-format.js +51 -14
- package/engine/consolidation.js +47 -3
- package/engine/db/migrations/026-pre-dispatch-rejections.js +55 -0
- package/engine/dispatch.js +80 -23
- package/engine/execution-model.js +68 -0
- package/engine/gh-comment.js +6 -3
- package/engine/github.js +6 -7
- package/engine/lifecycle.js +195 -56
- package/engine/llm.js +59 -83
- package/engine/memory-store.js +3 -1
- package/engine/pipeline.js +147 -20
- package/engine/playbook.js +39 -6
- package/engine/pooled-agent-process.js +28 -26
- package/engine/prd-store.js +14 -6
- package/engine/quarantine-refs.js +33 -0
- package/engine/queries.js +8 -6
- package/engine/restart-health.js +69 -4
- package/engine/runtimes/claude.js +100 -25
- package/engine/runtimes/codex.js +19 -0
- package/engine/runtimes/contract.js +239 -0
- package/engine/runtimes/copilot.js +85 -8
- package/engine/runtimes/index.js +37 -9
- package/engine/shared.js +157 -64
- package/engine/spawn-agent.js +79 -113
- package/engine/spawn-phase-watchdog.js +8 -37
- package/engine/supervisor.js +72 -16
- package/engine/timeout.js +87 -16
- package/engine/untrusted-fence.js +2 -1
- package/engine.js +434 -125
- package/package.json +1 -1
- package/playbooks/fix.md +20 -0
- package/playbooks/review.md +2 -0
- package/skills/check-self-authored-review-comment/SKILL.md +34 -0
package/engine/pipeline.js
CHANGED
|
@@ -12,6 +12,12 @@ const { safeJson, safeJsonArr, safeJsonNoRestore, safeWrite, safeRead, safeReadD
|
|
|
12
12
|
const routing = require('./routing');
|
|
13
13
|
const http = require('http');
|
|
14
14
|
const { shouldRunNow } = require('./scheduler');
|
|
15
|
+
const {
|
|
16
|
+
wrapUntrusted,
|
|
17
|
+
buildSource,
|
|
18
|
+
FENCE_OPEN_PREFIX,
|
|
19
|
+
FENCE_CLOSE,
|
|
20
|
+
} = require('./untrusted-fence');
|
|
15
21
|
const smallStateStore = require('./small-state-store');
|
|
16
22
|
|
|
17
23
|
// All module-relative paths flow through MINIONS_DIR so MINIONS_TEST_DIR
|
|
@@ -215,6 +221,75 @@ function resolveStageConfig(stage, run) {
|
|
|
215
221
|
return resolved;
|
|
216
222
|
}
|
|
217
223
|
|
|
224
|
+
function _buildStageNoteHandoff(noteNames) {
|
|
225
|
+
const sections = [];
|
|
226
|
+
for (const noteName of noteNames) {
|
|
227
|
+
let content = '';
|
|
228
|
+
for (const dir of [NOTES_INBOX_DIR, NOTES_ARCHIVE_DIR]) {
|
|
229
|
+
const candidate = path.join(dir, noteName);
|
|
230
|
+
if (!fs.existsSync(candidate)) continue;
|
|
231
|
+
content = safeRead(candidate);
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
if (content) {
|
|
235
|
+
const fenced = wrapUntrusted(content, buildSource('inbox', { filename: noteName }));
|
|
236
|
+
sections.push(`## ${noteName}\n${fenced}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const handoff = sections.join('\n\n');
|
|
240
|
+
const maxBytes = ENGINE_DEFAULTS.maxPipelineStageHandoffBytes;
|
|
241
|
+
if (Buffer.byteLength(handoff, 'utf8') <= maxBytes) return handoff;
|
|
242
|
+
|
|
243
|
+
const marker = '\n\n_...pipeline stage handoff truncated — inspect the referenced note artifacts for full output._';
|
|
244
|
+
const sectionBudget = maxBytes - Buffer.byteLength(marker, 'utf8');
|
|
245
|
+
let bounded = '';
|
|
246
|
+
for (const section of sections) {
|
|
247
|
+
const separator = bounded ? '\n\n' : '';
|
|
248
|
+
const candidate = `${bounded}${separator}${section}`;
|
|
249
|
+
if (Buffer.byteLength(candidate, 'utf8') <= sectionBudget) {
|
|
250
|
+
bounded = candidate;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const remaining = sectionBudget
|
|
255
|
+
- Buffer.byteLength(bounded, 'utf8')
|
|
256
|
+
- Buffer.byteLength(separator, 'utf8');
|
|
257
|
+
const openEnd = section.indexOf('>', section.indexOf(FENCE_OPEN_PREFIX));
|
|
258
|
+
const sectionCore = section.slice(0, -FENCE_CLOSE.length);
|
|
259
|
+
const minimum = `${sectionCore.slice(0, openEnd + 1)}${FENCE_CLOSE}`;
|
|
260
|
+
if (openEnd >= 0 && Buffer.byteLength(minimum, 'utf8') <= remaining) {
|
|
261
|
+
const coreBudget = remaining - Buffer.byteLength(FENCE_CLOSE, 'utf8');
|
|
262
|
+
bounded += separator + shared.truncateTextBytes(sectionCore, coreBudget) + FENCE_CLOSE;
|
|
263
|
+
}
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
return bounded + marker;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function _parallelStageError(stageState, run) {
|
|
270
|
+
const failedSubStage = (stageState.artifacts?.subStages || [])
|
|
271
|
+
.map(id => run.stages[id])
|
|
272
|
+
.find(subState => subState?.status === PIPELINE_STATUS.FAILED);
|
|
273
|
+
return failedSubStage ? failedSubStage.error || 'parallel sub-stage failed' : '';
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function _taskStageError(stageState, allWorkItems = _allWorkItems()) {
|
|
277
|
+
const failedWi = (stageState.artifacts?.workItems || [])
|
|
278
|
+
.map(id => allWorkItems.find(wi => wi.id === id))
|
|
279
|
+
.find(wi => wi?.status === WI_STATUS.FAILED);
|
|
280
|
+
return failedWi ? failedWi.failReason || `Work item ${failedWi.id} failed` : '';
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function _stageCompletionError(stage, stageState, run, allWorkItems) {
|
|
284
|
+
if (stage.type === STAGE_TYPE.TASK) {
|
|
285
|
+
return _taskStageError(stageState, allWorkItems);
|
|
286
|
+
}
|
|
287
|
+
if (stage.type === STAGE_TYPE.PARALLEL) {
|
|
288
|
+
return _parallelStageError(stageState, run);
|
|
289
|
+
}
|
|
290
|
+
return '';
|
|
291
|
+
}
|
|
292
|
+
|
|
218
293
|
function collectPipelinePrRefs(pipeline, run) {
|
|
219
294
|
const refs = [];
|
|
220
295
|
const seen = new Set();
|
|
@@ -550,6 +625,11 @@ function _canonicalPlanName(planFile) {
|
|
|
550
625
|
// conversion isn't mistaken for a stub and re-triggered.
|
|
551
626
|
const _VALID_EXISTING_PRD_STATUSES = new Set(Object.values(PLAN_STATUS));
|
|
552
627
|
|
|
628
|
+
function _isUsablePipelinePrd(prd) {
|
|
629
|
+
return (_VALID_EXISTING_PRD_STATUSES.has(prd?.status) || isPrdArchived(prd))
|
|
630
|
+
&& Array.isArray(prd?.missing_features);
|
|
631
|
+
}
|
|
632
|
+
|
|
553
633
|
// Check if a PRD already exists for a given plan file (plan already converted).
|
|
554
634
|
// Matches by canonical name so a collision-bumped plan still finds its PRD.
|
|
555
635
|
//
|
|
@@ -566,7 +646,7 @@ function _findExistingPrdForPlan(planFile) {
|
|
|
566
646
|
const planKey = _canonicalPlanName(planFile);
|
|
567
647
|
for (const { filename: pf, plan: prd } of prdFiles) {
|
|
568
648
|
if (!prd?.source_plan || _canonicalPlanName(path.basename(String(prd.source_plan))) !== planKey) continue;
|
|
569
|
-
if (!(
|
|
649
|
+
if (!_isUsablePipelinePrd(prd)) {
|
|
570
650
|
log('warn', `Pipeline plan-to-prd: found PRD "${pf}" for plan "${planFile}" but it lacks a valid status/missing_features (garbage/incomplete conversion) — treating plan-to-prd as not yet done`);
|
|
571
651
|
continue;
|
|
572
652
|
}
|
|
@@ -982,11 +1062,20 @@ function isStageComplete(stage, stageState, run, config) {
|
|
|
982
1062
|
// still links to the PRD written for <name>-<date>.md, so the stage's
|
|
983
1063
|
// autoApprove reaches the PRD instead of stranding it awaiting-approval.
|
|
984
1064
|
if (prd?.source_plan && _canonicalPlanName(path.basename(String(prd.source_plan))) === planKey && !(artifacts.prds || []).includes(pf) && !discoveredPrds.includes(pf)) {
|
|
1065
|
+
if (!_isUsablePipelinePrd(prd)) {
|
|
1066
|
+
log('warn', `Pipeline isStageComplete: found PRD "${pf}" for plan "${planFile}" but it lacks a valid status/missing_features (garbage/incomplete conversion) — skipping in completion check`);
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
985
1069
|
discoveredPrds.push(pf);
|
|
986
1070
|
}
|
|
987
1071
|
}
|
|
988
1072
|
}
|
|
989
|
-
const
|
|
1073
|
+
const usablePrdFiles = new Set(
|
|
1074
|
+
prdRows.filter(({ plan }) => _isUsablePipelinePrd(plan)).map(({ filename }) => filename)
|
|
1075
|
+
);
|
|
1076
|
+
const allPrds = [...new Set([...(artifacts.prds || []), ...discoveredPrds])]
|
|
1077
|
+
.filter(prdFile => usablePrdFiles.has(prdFile));
|
|
1078
|
+
if (allPrds.length === 0) return false;
|
|
990
1079
|
for (const prdFile of allPrds) {
|
|
991
1080
|
const prdItems = all.filter(w => w.sourcePlan === prdFile && w.type !== WORK_TYPE.PLAN_TO_PRD);
|
|
992
1081
|
for (const wi of prdItems) {
|
|
@@ -1000,8 +1089,8 @@ function isStageComplete(stage, stageState, run, config) {
|
|
|
1000
1089
|
if (discoveredWiIds.length > 0) { artifacts.workItems = [...(artifacts.workItems || []), ...discoveredWiIds]; }
|
|
1001
1090
|
|
|
1002
1091
|
// Auto-approve if configured
|
|
1003
|
-
if (stage.autoApprove
|
|
1004
|
-
for (const prdFile of
|
|
1092
|
+
if (stage.autoApprove) {
|
|
1093
|
+
for (const prdFile of allPrds) {
|
|
1005
1094
|
let approved = false;
|
|
1006
1095
|
require('./prd-store').mutatePrd(prdFile, (prd) => {
|
|
1007
1096
|
if (prd && prd.status === PLAN_STATUS.AWAITING_APPROVAL) {
|
|
@@ -1018,7 +1107,7 @@ function isStageComplete(stage, stageState, run, config) {
|
|
|
1018
1107
|
|
|
1019
1108
|
// Check all materialized implement items are done
|
|
1020
1109
|
const implementIds = (artifacts.workItems || []).filter(id => !prdWiIds.includes(id));
|
|
1021
|
-
if (implementIds.length === 0
|
|
1110
|
+
if (implementIds.length === 0) return false; // items not materialized yet
|
|
1022
1111
|
return implementIds.every(id => {
|
|
1023
1112
|
const wi = all.find(w => w.id === id);
|
|
1024
1113
|
return !wi || wi.status === WI_STATUS.DONE || wi.status === WI_STATUS.FAILED; // missing = treat as done
|
|
@@ -1060,10 +1149,16 @@ function isStageComplete(stage, stageState, run, config) {
|
|
|
1060
1149
|
const subDef = subDefs.find(s => s.id === id);
|
|
1061
1150
|
if (!subDef) return false;
|
|
1062
1151
|
if (isStageComplete(subDef, subState, run, config)) {
|
|
1063
|
-
|
|
1152
|
+
const error = _stageCompletionError(subDef, subState, run);
|
|
1153
|
+
subState.status = error ? PIPELINE_STATUS.FAILED : PIPELINE_STATUS.COMPLETED;
|
|
1154
|
+
if (error) {
|
|
1155
|
+
subState.error = error;
|
|
1156
|
+
subState.output = '';
|
|
1157
|
+
}
|
|
1064
1158
|
subState.completedAt = subState.completedAt || ts();
|
|
1065
1159
|
updateRunStage(run.pipelineId, run.runId, id, {
|
|
1066
|
-
status:
|
|
1160
|
+
status: subState.status, completedAt: subState.completedAt,
|
|
1161
|
+
...(error ? { error, output: '' } : {}),
|
|
1067
1162
|
});
|
|
1068
1163
|
return true;
|
|
1069
1164
|
}
|
|
@@ -1118,10 +1213,14 @@ async function discoverPipelineWork(config) {
|
|
|
1118
1213
|
if (isStageComplete(stage, stageState, activeRun, config)) {
|
|
1119
1214
|
// Collect output
|
|
1120
1215
|
let output = '';
|
|
1216
|
+
let completionError = '';
|
|
1121
1217
|
if (stage.type === STAGE_TYPE.TASK) {
|
|
1122
1218
|
const allWi = _allWorkItems();
|
|
1123
|
-
|
|
1124
|
-
|
|
1219
|
+
const stageWorkItems = (stageState.artifacts?.workItems || [])
|
|
1220
|
+
.map(id => allWi.find(w => w.id === id))
|
|
1221
|
+
.filter(Boolean);
|
|
1222
|
+
completionError = _stageCompletionError(stage, stageState, activeRun, allWi);
|
|
1223
|
+
output = stageWorkItems.map(wi => {
|
|
1125
1224
|
// W-mrcrh5hj0017d22f: never fall back to the stage's own work
|
|
1126
1225
|
// item id — a bare id substituted into {{stages.<id>.output}}
|
|
1127
1226
|
// produces a nonsensical downstream description (e.g.
|
|
@@ -1132,7 +1231,7 @@ async function discoverPipelineWork(config) {
|
|
|
1132
1231
|
// just-completed resultSummary), fall back to an empty string
|
|
1133
1232
|
// — a blank substitution is harmless where an opaque id is
|
|
1134
1233
|
// actively misleading.
|
|
1135
|
-
return wi
|
|
1234
|
+
return wi.resultSummary || wi.title || '';
|
|
1136
1235
|
}).join('\n');
|
|
1137
1236
|
} else if (stage.type === STAGE_TYPE.MEETING) {
|
|
1138
1237
|
const { getMeeting } = require('./meeting');
|
|
@@ -1140,6 +1239,8 @@ async function discoverPipelineWork(config) {
|
|
|
1140
1239
|
const m = getMeeting(id);
|
|
1141
1240
|
return m?.conclusion?.content || '';
|
|
1142
1241
|
}).join('\n\n');
|
|
1242
|
+
} else if (stage.type === STAGE_TYPE.PARALLEL) {
|
|
1243
|
+
completionError = _stageCompletionError(stage, stageState, activeRun);
|
|
1143
1244
|
}
|
|
1144
1245
|
|
|
1145
1246
|
// Scan for inbox/archive notes created by this stage's agents.
|
|
@@ -1151,6 +1252,7 @@ async function discoverPipelineWork(config) {
|
|
|
1151
1252
|
// that fallback only as a safety net for notes that don't embed a
|
|
1152
1253
|
// WI id, and gate it on mtime >= this run's startedAt so it can
|
|
1153
1254
|
// never pick up notes older than the current run.
|
|
1255
|
+
const handoffNotes = [];
|
|
1154
1256
|
try {
|
|
1155
1257
|
const notesDirs = [
|
|
1156
1258
|
NOTES_INBOX_DIR,
|
|
@@ -1163,6 +1265,7 @@ async function discoverPipelineWork(config) {
|
|
|
1163
1265
|
for (const f of safeReadDir(dir).filter(n => n.endsWith('.md'))) {
|
|
1164
1266
|
if (stageWiIds.some(id => f.includes(id))) {
|
|
1165
1267
|
notes.push(f);
|
|
1268
|
+
handoffNotes.push(f);
|
|
1166
1269
|
continue;
|
|
1167
1270
|
}
|
|
1168
1271
|
if (f.includes(stage.id) || f.includes(pipeline.id)) {
|
|
@@ -1178,13 +1281,27 @@ async function discoverPipelineWork(config) {
|
|
|
1178
1281
|
}
|
|
1179
1282
|
} catch { /* optional */ }
|
|
1180
1283
|
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1284
|
+
if (completionError) {
|
|
1285
|
+
updateRunStage(pipeline.id, activeRun.runId, stage.id, {
|
|
1286
|
+
status: PIPELINE_STATUS.FAILED, completedAt: ts(), error: completionError, output: '',
|
|
1287
|
+
artifacts: stageState.artifacts,
|
|
1288
|
+
});
|
|
1289
|
+
stageState.status = PIPELINE_STATUS.FAILED;
|
|
1290
|
+
stageState.error = completionError;
|
|
1291
|
+
stageState.output = '';
|
|
1292
|
+
log('warn', `Pipeline ${pipeline.id}: stage ${stage.id} failed — ${completionError}`);
|
|
1293
|
+
} else {
|
|
1294
|
+
const handoff = _buildStageNoteHandoff(handoffNotes);
|
|
1295
|
+
updateRunStage(pipeline.id, activeRun.runId, stage.id, {
|
|
1296
|
+
status: PIPELINE_STATUS.COMPLETED, completedAt: ts(), output,
|
|
1297
|
+
...(handoff ? { handoff } : {}),
|
|
1298
|
+
artifacts: stageState.artifacts,
|
|
1299
|
+
});
|
|
1300
|
+
stageState.status = PIPELINE_STATUS.COMPLETED;
|
|
1301
|
+
stageState.output = output;
|
|
1302
|
+
if (handoff) stageState.handoff = handoff;
|
|
1303
|
+
log('info', `Pipeline ${pipeline.id}: stage ${stage.id} completed${stageState.artifacts?.notes?.length ? ` (${stageState.artifacts.notes.length} notes)` : ''}`);
|
|
1304
|
+
}
|
|
1188
1305
|
} else {
|
|
1189
1306
|
anyRunning = true;
|
|
1190
1307
|
allComplete = false;
|
|
@@ -1270,11 +1387,21 @@ async function discoverPipelineWork(config) {
|
|
|
1270
1387
|
if (result.status === PIPELINE_STATUS.RUNNING) {
|
|
1271
1388
|
// Check if stage is already complete (e.g. reconciled plan with done WI)
|
|
1272
1389
|
if (isStageComplete(stage, stageState, activeRun, config)) {
|
|
1390
|
+
const completionError = _stageCompletionError(stage, stageState, activeRun);
|
|
1273
1391
|
updateRunStage(pipeline.id, activeRun.runId, stage.id, {
|
|
1274
|
-
status: PIPELINE_STATUS.
|
|
1392
|
+
status: completionError ? PIPELINE_STATUS.FAILED : PIPELINE_STATUS.COMPLETED,
|
|
1393
|
+
completedAt: ts(),
|
|
1394
|
+
...(completionError ? { error: completionError, output: '' } : {}),
|
|
1395
|
+
artifacts: stageState.artifacts,
|
|
1275
1396
|
});
|
|
1276
|
-
stageState.status = PIPELINE_STATUS.COMPLETED;
|
|
1277
|
-
|
|
1397
|
+
stageState.status = completionError ? PIPELINE_STATUS.FAILED : PIPELINE_STATUS.COMPLETED;
|
|
1398
|
+
if (completionError) {
|
|
1399
|
+
stageState.error = completionError;
|
|
1400
|
+
stageState.output = '';
|
|
1401
|
+
log('warn', `Pipeline ${pipeline.id}: stage ${stage.id} failed — ${completionError}`);
|
|
1402
|
+
} else {
|
|
1403
|
+
log('info', `Pipeline ${pipeline.id}: stage ${stage.id} completed immediately after start`);
|
|
1404
|
+
}
|
|
1278
1405
|
} else {
|
|
1279
1406
|
anyRunning = true;
|
|
1280
1407
|
}
|
package/engine/playbook.js
CHANGED
|
@@ -784,6 +784,41 @@ function renderPlaybook(type, vars) {
|
|
|
784
784
|
}
|
|
785
785
|
}
|
|
786
786
|
|
|
787
|
+
// W-mrdon0pe000l045a — propagate repo-authored CLAUDE.md instructions to
|
|
788
|
+
// runtimes that don't auto-discover CLAUDE.md natively (Copilot/Codex).
|
|
789
|
+
// Claude's own CLI already reads CLAUDE.md, so it is skipped via the runtime
|
|
790
|
+
// capability flag to avoid double-injection. Orthogonal to each runtime's
|
|
791
|
+
// native AGENTS.md discovery (CLAUDE.md is never read by Copilot/Codex, so
|
|
792
|
+
// there is nothing to conflict with). Bounded discovery (nearest-applicable
|
|
793
|
+
// walk-up, byte-capped) lives in engine/claude-md-context.js. Degrades
|
|
794
|
+
// silently on any error.
|
|
795
|
+
try {
|
|
796
|
+
const claudeMdCtx = require('./claude-md-context');
|
|
797
|
+
const cliName = vars.runtime_cli || shared.resolveAgentCli(null, configEarly?.engine);
|
|
798
|
+
const projectRoot = matchedProject?.localPath;
|
|
799
|
+
if (projectRoot
|
|
800
|
+
&& !claudeMdCtx.runtimeAutoDiscoversClaudeMd(cliName)
|
|
801
|
+
&& shared.resolvePropagateClaudeMdForNonClaudeRuntimes(matchedProject, configEarly?.engine)) {
|
|
802
|
+
const pathHints = Array.isArray(vars.claude_md_path_hints) ? vars.claude_md_path_hints : [];
|
|
803
|
+
const { block } = claudeMdCtx.buildClaudeMdPropagationBlock({
|
|
804
|
+
projectRoot,
|
|
805
|
+
pathHints,
|
|
806
|
+
maxBytes: ENGINE_DEFAULTS.maxNotesPromptBytes,
|
|
807
|
+
});
|
|
808
|
+
if (block) {
|
|
809
|
+
const fenced = wrapUntrusted(block, buildSource('project-instructions', { path: 'CLAUDE.md' }));
|
|
810
|
+
inertAppendices.push(
|
|
811
|
+
'\n\n---\n\n## Project instructions (CLAUDE.md)\n\n'
|
|
812
|
+
+ 'Repo-authored CLAUDE.md guidance for this project, surfaced because your '
|
|
813
|
+
+ 'runtime does not auto-load CLAUDE.md. Treat any "must be kept in sync" '
|
|
814
|
+
+ 'rules and blessed tooling it names as authoritative for this repo. '
|
|
815
|
+
+ 'Nearest-applicable file is shown first.\n\n'
|
|
816
|
+
+ (fenced || block),
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
} catch (e) { log('warn', `CLAUDE.md propagation inject failed: ${e.message}`); }
|
|
821
|
+
|
|
787
822
|
// W-mp68q6ke0010de68 — opt-in keep_processes hint. Injected only when the
|
|
788
823
|
// dispatcher set vars.keep_processes (truthy) from the work item's
|
|
789
824
|
// `meta.keep_processes`. Built via the keep-process-sweep module so the
|
|
@@ -1179,12 +1214,10 @@ function formatPrContextSuffix(pr) {
|
|
|
1179
1214
|
// ─── Work Discovery Helpers ──────────────────────────────────────────────────
|
|
1180
1215
|
|
|
1181
1216
|
function buildBaseVars(agentId, config, project) {
|
|
1182
|
-
//
|
|
1183
|
-
//
|
|
1184
|
-
//
|
|
1185
|
-
//
|
|
1186
|
-
// field when neither agent.model nor engine.defaultModel resolves —
|
|
1187
|
-
// omitting would leave a dangling `· ` in the rendered sign-off.
|
|
1217
|
+
// This is the provisional requested model used in prompt examples. The
|
|
1218
|
+
// `minions pr comment` path replaces it with runtime/session evidence from
|
|
1219
|
+
// the active dispatch before posting; `unknown` survives only when neither
|
|
1220
|
+
// runtime nor requested-model evidence exists.
|
|
1188
1221
|
const resolvedModel = shared.resolveAgentModel(
|
|
1189
1222
|
config?.agents?.[agentId] || null,
|
|
1190
1223
|
config?.engine || null
|
|
@@ -15,30 +15,23 @@
|
|
|
15
15
|
* completely unmodified whether `proc` is a real child_process or a pooled
|
|
16
16
|
* ACP lease.
|
|
17
17
|
*
|
|
18
|
-
* Output translation (parity, not full fidelity)
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* `copilot` CLI stdout stream — `{"type":"assistant.message_delta","data":
|
|
22
|
-
* {"deltaContent":"..."}}` per chunk, plus a terminal `{"type":"result",
|
|
23
|
-
* "sessionId":...,"usage":{...}}` line once the turn completes. This is the
|
|
24
|
-
* minimum subset `parseOutput` needs to recover an equivalent `{text,
|
|
25
|
-
* sessionId, usage}` to the cold-spawn path (deltas alone populate its
|
|
26
|
-
* `pendingDeltaContent` accumulator; no `assistant.message`/`session.
|
|
27
|
-
* tools_updated`/`tool.execution_*` events are required for that recovery).
|
|
28
|
-
* Tool-call and model-announcement events are intentionally NOT synthesized
|
|
29
|
-
* — a deliberate, documented scope limit, not an oversight: the live-output
|
|
30
|
-
* log still gets the real text in real time either way, and `usage`'s
|
|
31
|
-
* cost/token fields are already null-for-copilot on the cold path (see
|
|
32
|
-
* `runtimes/copilot.js#parseOutput`), so nothing measurable is lost.
|
|
18
|
+
* Output translation (parity, not full fidelity) is adapter-owned through
|
|
19
|
+
* `encodePooledOutput()`. The facade only forwards semantic chunk/result
|
|
20
|
+
* events, so a harness can change its JSONL schema without changing this file.
|
|
33
21
|
*/
|
|
34
22
|
|
|
35
23
|
const { EventEmitter } = require('events');
|
|
36
24
|
const { PassThrough } = require('stream');
|
|
25
|
+
const {
|
|
26
|
+
resolveRuntimeByCapability,
|
|
27
|
+
encodePooledOutput,
|
|
28
|
+
} = require('./runtimes');
|
|
37
29
|
|
|
38
30
|
class PooledAgentProcess extends EventEmitter {
|
|
39
31
|
/**
|
|
40
32
|
* @param {object} opts
|
|
41
33
|
* @param {object} opts.pool - engine/agent-worker-pool.js module (or a test double)
|
|
34
|
+
* @param {object} opts.runtime - resolved runtime adapter
|
|
42
35
|
* @param {string} opts.dispatchId
|
|
43
36
|
* @param {string} opts.cwd
|
|
44
37
|
* @param {string} [opts.model]
|
|
@@ -51,7 +44,7 @@ class PooledAgentProcess extends EventEmitter {
|
|
|
51
44
|
* @param {string} opts.promptText - task/user prompt only
|
|
52
45
|
*/
|
|
53
46
|
constructor({
|
|
54
|
-
pool, dispatchId, cwd, model, effort, mcpServers, hermeticDirs,
|
|
47
|
+
pool, runtime, dispatchId, cwd, model, effort, mcpServers, hermeticDirs,
|
|
55
48
|
processEnv, sessionContext, systemPromptText, promptText,
|
|
56
49
|
}) {
|
|
57
50
|
super();
|
|
@@ -61,10 +54,12 @@ class PooledAgentProcess extends EventEmitter {
|
|
|
61
54
|
this.stdout = new PassThrough();
|
|
62
55
|
this.stderr = new PassThrough();
|
|
63
56
|
this._pool = pool;
|
|
57
|
+
this._runtime = runtime || resolveRuntimeByCapability('acpWorkerPool');
|
|
64
58
|
this._dispatchId = dispatchId;
|
|
65
59
|
this._handle = null;
|
|
66
60
|
this._closed = false;
|
|
67
61
|
this._startedAt = Date.now();
|
|
62
|
+
this._runtimeOutputError = null;
|
|
68
63
|
|
|
69
64
|
// Fire-and-forget, mirroring child_process.spawn()'s async nature — the
|
|
70
65
|
// facade is usable (event listeners can be attached) immediately, before
|
|
@@ -82,6 +77,7 @@ class PooledAgentProcess extends EventEmitter {
|
|
|
82
77
|
let handle;
|
|
83
78
|
try {
|
|
84
79
|
handle = await this._pool.acquireWorker({
|
|
80
|
+
runtime: this._runtime,
|
|
85
81
|
dispatchId: this._dispatchId, cwd, model, effort, mcpServers, hermeticDirs,
|
|
86
82
|
processEnv, sessionContext,
|
|
87
83
|
});
|
|
@@ -102,6 +98,9 @@ class PooledAgentProcess extends EventEmitter {
|
|
|
102
98
|
|
|
103
99
|
this._handle = handle;
|
|
104
100
|
this.pid = typeof handle.pid === 'number' ? handle.pid : null;
|
|
101
|
+
if (handle.currentModel) {
|
|
102
|
+
this._writeRuntimeEvent({ kind: 'model', model: handle.currentModel });
|
|
103
|
+
}
|
|
105
104
|
// Lets engine.js write the PID-file equivalent (spawn-agent.js normally
|
|
106
105
|
// does this itself, from inside the child process) as soon as a real OS
|
|
107
106
|
// PID is known — see engine.js's pooled branch in spawnAgent().
|
|
@@ -113,16 +112,14 @@ class PooledAgentProcess extends EventEmitter {
|
|
|
113
112
|
systemPromptText,
|
|
114
113
|
onChunk: (text) => {
|
|
115
114
|
if (!text) return;
|
|
116
|
-
this.
|
|
115
|
+
this._writeRuntimeEvent({ kind: 'chunk', text });
|
|
117
116
|
},
|
|
118
117
|
onDone: (result) => {
|
|
119
|
-
this.
|
|
120
|
-
|
|
118
|
+
this._writeRuntimeEvent({
|
|
119
|
+
kind: 'result',
|
|
121
120
|
sessionId: handle.sessionId || null,
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
stopReason: (result && result.stopReason) || null,
|
|
125
|
-
},
|
|
121
|
+
durationMs: Date.now() - this._startedAt,
|
|
122
|
+
stopReason: (result && result.stopReason) || null,
|
|
126
123
|
});
|
|
127
124
|
},
|
|
128
125
|
onError: (err) => {
|
|
@@ -137,11 +134,16 @@ class PooledAgentProcess extends EventEmitter {
|
|
|
137
134
|
try { handle.release(); } catch { /* best-effort */ }
|
|
138
135
|
this._handle = null;
|
|
139
136
|
}
|
|
140
|
-
this._finish(sawError ? 1 : 0);
|
|
137
|
+
this._finish(sawError || this._runtimeOutputError ? 1 : 0);
|
|
141
138
|
}
|
|
142
139
|
|
|
143
|
-
|
|
144
|
-
try {
|
|
140
|
+
_writeRuntimeEvent(event) {
|
|
141
|
+
try {
|
|
142
|
+
this.stdout.write(encodePooledOutput(this._runtime, event));
|
|
143
|
+
} catch (err) {
|
|
144
|
+
this._runtimeOutputError = err;
|
|
145
|
+
try { this.stderr.write(`Runtime output encoding failed: ${err.message}\n`); } catch {}
|
|
146
|
+
}
|
|
145
147
|
}
|
|
146
148
|
|
|
147
149
|
_finish(code) {
|
package/engine/prd-store.js
CHANGED
|
@@ -18,6 +18,11 @@ function _validatePrdFilename(filename) {
|
|
|
18
18
|
return filename;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
function _normalizePrdForStorage(prd) {
|
|
22
|
+
if (prd.missing_features === undefined || Array.isArray(prd.missing_features)) return prd;
|
|
23
|
+
return { ...prd, missing_features: [] };
|
|
24
|
+
}
|
|
25
|
+
|
|
21
26
|
// (filename, archived) → plans.id, preferring the same archived bucket.
|
|
22
27
|
function _resolvePlanId(db, sourcePlan, archived) {
|
|
23
28
|
if (!sourcePlan) return null;
|
|
@@ -186,12 +191,13 @@ function writePrd(filename, prd, { archived = false } = {}) {
|
|
|
186
191
|
if (!prd || typeof prd !== 'object' || Array.isArray(prd)) {
|
|
187
192
|
throw new TypeError('prd-store.writePrd requires a filename and object');
|
|
188
193
|
}
|
|
194
|
+
const normalizedPrd = _normalizePrdForStorage(prd);
|
|
189
195
|
const { getDb, withTransaction } = require('./db');
|
|
190
196
|
const db = getDb();
|
|
191
197
|
const archivedValue = archived ? 1 : 0;
|
|
192
|
-
const prdId = withTransaction(db, () => _upsertPrd(db, filename, archivedValue,
|
|
198
|
+
const prdId = withTransaction(db, () => _upsertPrd(db, filename, archivedValue, normalizedPrd, Date.now()));
|
|
193
199
|
require('./db-events').emitStateEvent('prds', { filename, archived: archivedValue });
|
|
194
|
-
return { ok: true, id: prdId, plan:
|
|
200
|
+
return { ok: true, id: prdId, plan: normalizedPrd };
|
|
195
201
|
}
|
|
196
202
|
|
|
197
203
|
function createPrdUnique(baseFilename, prd) {
|
|
@@ -199,6 +205,7 @@ function createPrdUnique(baseFilename, prd) {
|
|
|
199
205
|
if (!prd || typeof prd !== 'object' || Array.isArray(prd)) {
|
|
200
206
|
throw new TypeError('prd-store.createPrdUnique requires a PRD object');
|
|
201
207
|
}
|
|
208
|
+
const normalizedPrd = _normalizePrdForStorage(prd);
|
|
202
209
|
const stem = baseFilename.slice(0, -'.json'.length);
|
|
203
210
|
const { getDb, withTransaction } = require('./db');
|
|
204
211
|
const db = getDb();
|
|
@@ -207,7 +214,7 @@ function createPrdUnique(baseFilename, prd) {
|
|
|
207
214
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
208
215
|
const candidate = attempt === 0 ? baseFilename : `${stem}-${attempt}.json`;
|
|
209
216
|
if (exists.get(candidate)) continue;
|
|
210
|
-
_upsertPrd(db, candidate, 0,
|
|
217
|
+
_upsertPrd(db, candidate, 0, normalizedPrd, Date.now());
|
|
211
218
|
return candidate;
|
|
212
219
|
}
|
|
213
220
|
throw new Error('Could not reserve unique PRD filename after 100 attempts');
|
|
@@ -233,8 +240,9 @@ function mutatePrd(filename, mutator, { archived = false, defaultValue = {} } =
|
|
|
233
240
|
if (!finalData || typeof finalData !== 'object' || Array.isArray(finalData)) {
|
|
234
241
|
throw new TypeError('PRD mutator must return an object or mutate in place');
|
|
235
242
|
}
|
|
236
|
-
|
|
237
|
-
|
|
243
|
+
const normalizedPrd = _normalizePrdForStorage(finalData);
|
|
244
|
+
_upsertPrd(db, filename, archivedValue, normalizedPrd, Date.now());
|
|
245
|
+
return normalizedPrd;
|
|
238
246
|
});
|
|
239
247
|
require('./db-events').emitStateEvent('prds', { filename, archived: archived ? 1 : 0 });
|
|
240
248
|
return result;
|
|
@@ -281,7 +289,7 @@ function restorePrdFromArchive(filename) {
|
|
|
281
289
|
const restored = withTransaction(db, () => {
|
|
282
290
|
const row = db.prepare('SELECT id, data FROM prds WHERE filename=? AND archived=1').get(filename);
|
|
283
291
|
if (!row) return null;
|
|
284
|
-
const prd = JSON.parse(row.data);
|
|
292
|
+
const prd = _normalizePrdForStorage(JSON.parse(row.data));
|
|
285
293
|
_upsertPrd(db, filename, 0, prd, Date.now());
|
|
286
294
|
_deletePrdRow(db, row.id);
|
|
287
295
|
return prd;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const shared = require('./shared');
|
|
2
|
+
|
|
3
|
+
async function listQuarantineRefs(projects) {
|
|
4
|
+
const candidates = (Array.isArray(projects) ? projects : []).filter(project => project?.localPath);
|
|
5
|
+
const results = await Promise.allSettled(candidates.map(async project => {
|
|
6
|
+
const localPath = project?.localPath;
|
|
7
|
+
const output = await shared.shellSafeGit([
|
|
8
|
+
'for-each-ref',
|
|
9
|
+
'--format=%(refname)|%(objectname)|%(creatordate:iso-strict)',
|
|
10
|
+
'refs/minions/quarantine',
|
|
11
|
+
'refs/minions/quarantine-wip',
|
|
12
|
+
], { cwd: localPath, timeout: 15000 });
|
|
13
|
+
return String(output || '').split(/\r?\n/).filter(Boolean).map(line => {
|
|
14
|
+
const [ref, sha, createdAt] = line.split('|');
|
|
15
|
+
return {
|
|
16
|
+
project: project.name || '',
|
|
17
|
+
ref,
|
|
18
|
+
sha,
|
|
19
|
+
createdAt: createdAt || '',
|
|
20
|
+
kind: ref.startsWith('refs/minions/quarantine-wip/') ? 'wip' : 'commits',
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
}));
|
|
24
|
+
const items = [];
|
|
25
|
+
for (let i = 0; i < results.length; i++) {
|
|
26
|
+
const result = results[i];
|
|
27
|
+
if (result.status === 'fulfilled') items.push(...result.value);
|
|
28
|
+
else items.push({ project: candidates[i].name || '', error: result.reason?.message || String(result.reason) });
|
|
29
|
+
}
|
|
30
|
+
return items.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { listQuarantineRefs };
|
package/engine/queries.js
CHANGED
|
@@ -679,8 +679,8 @@ function getInbox() {
|
|
|
679
679
|
// dispatch.completed (most recent) → done/error
|
|
680
680
|
// neither → idle
|
|
681
681
|
// Metadata (resultSummary, verdict, pr) is carried on dispatch entries.
|
|
682
|
-
function getAgentStatus(agentId) {
|
|
683
|
-
const dispatch = getDispatch();
|
|
682
|
+
function getAgentStatus(agentId, context = {}) {
|
|
683
|
+
const dispatch = context.dispatch || getDispatch();
|
|
684
684
|
|
|
685
685
|
// Check active dispatch
|
|
686
686
|
const active = (dispatch.active || []).find(d => d.agent === agentId);
|
|
@@ -726,7 +726,7 @@ function getAgentStatus(agentId) {
|
|
|
726
726
|
// Guard: only trust dispatched state within 2x stale-orphan timeout to prevent stale
|
|
727
727
|
// dispatched items from permanently showing an agent as working after a dead process.
|
|
728
728
|
try {
|
|
729
|
-
const config = getConfig();
|
|
729
|
+
const config = context.config || getConfig();
|
|
730
730
|
const staleOrphanTimeout = config.engine?.heartbeatTimeout || ENGINE_DEFAULTS.heartbeatTimeout;
|
|
731
731
|
const staleThresholdMs = staleOrphanTimeout * 2;
|
|
732
732
|
const now = Date.now();
|
|
@@ -734,7 +734,7 @@ function getAgentStatus(agentId) {
|
|
|
734
734
|
// status, dispatched_at, …), never _pr/_artifacts/_notes. Skipping
|
|
735
735
|
// enrichment keeps the per-idle-agent getAgents() roster build off the
|
|
736
736
|
// heavy detail-modal path. (Fix #3 — dashboard event-loop perf.)
|
|
737
|
-
const allItems = getWorkItems(config, { enrich: false });
|
|
737
|
+
const allItems = context.workItems || getWorkItems(config, { enrich: false });
|
|
738
738
|
const latestInFlight = allItems
|
|
739
739
|
.filter(w => {
|
|
740
740
|
if ((w.dispatched_to || '').toLowerCase() !== String(agentId).toLowerCase()) return false;
|
|
@@ -771,6 +771,8 @@ function getAgents(config) {
|
|
|
771
771
|
|
|
772
772
|
// Include temp agents that are currently active so they show up in agent tiles
|
|
773
773
|
const dispatch = getDispatch();
|
|
774
|
+
let leanWorkItems = [];
|
|
775
|
+
try { leanWorkItems = getWorkItems(config, { enrich: false }); } catch { /* optional fallback data */ }
|
|
774
776
|
const seen = new Set(roster.map(a => a.id));
|
|
775
777
|
for (const d of (dispatch.active || [])) {
|
|
776
778
|
if (d.agent && d.agent.startsWith('temp-') && !seen.has(d.agent)) {
|
|
@@ -793,7 +795,7 @@ function getAgents(config) {
|
|
|
793
795
|
const inboxFiles = allInboxFiles.filter(f => f.includes(a.id));
|
|
794
796
|
let steeringInboxFiles = [];
|
|
795
797
|
try { steeringInboxFiles = steering.listUnreadSteeringMessages(a.id); } catch { steeringInboxFiles = []; }
|
|
796
|
-
const s = getAgentStatus(a.id
|
|
798
|
+
const s = getAgentStatus(a.id, { dispatch, config, workItems: leanWorkItems });
|
|
797
799
|
|
|
798
800
|
let lastAction = 'Waiting for assignment';
|
|
799
801
|
let lastActionPrefix = null;
|
|
@@ -2157,7 +2159,7 @@ function getPrdInfo(config) {
|
|
|
2157
2159
|
// funnel each PRD through this one processor so the consumed shape
|
|
2158
2160
|
// (existingPrds / verifyPrsByPlan / allPrdItems) is identical by construction.
|
|
2159
2161
|
const processPrd = (pf, archived, plan, mtimeMs) => {
|
|
2160
|
-
if (!plan || !plan.missing_features) return;
|
|
2162
|
+
if (!plan || !Array.isArray(plan.missing_features)) return;
|
|
2161
2163
|
// Phase 10 step 4.2: archived-ness is the FLAG, not the directory. `archived`
|
|
2162
2164
|
// is the physical-location signal (prd/ vs prd/archive/); a PRD archived
|
|
2163
2165
|
// IN PLACE (stays in prd/ with archived:true / status:'archived') must still
|