@yemi33/minions 0.1.726 → 0.1.728
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 +15 -2
- package/dashboard/js/render-plans.js +12 -4
- package/dashboard/js/render-work-items.js +1 -1
- package/dashboard/js/settings.js +2 -0
- package/dashboard-build.js +1 -1
- package/dashboard.js +19 -17
- package/engine/dispatch.js +40 -6
- package/engine/lifecycle.js +120 -2
- package/engine/playbook.js +54 -0
- package/engine/recovery.js +112 -0
- package/engine/shared.js +30 -1
- package/engine/timeout.js +4 -1
- package/engine.js +46 -9
- package/package.json +1 -1
- package/playbooks/build-and-test.md +2 -2
- package/playbooks/decompose.md +1 -1
- package/playbooks/fix.md +15 -0
- package/playbooks/implement-shared.md +15 -0
- package/playbooks/shared-rules.md +4 -0
- package/playbooks/test.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.728 (2026-04-09)
|
|
4
4
|
|
|
5
|
-
###
|
|
5
|
+
### Features
|
|
6
|
+
- replace raw status strings with WI_STATUS constants in dashboard.js
|
|
7
|
+
- Soften playbook CAPS emphasis and add context-awareness guidance
|
|
8
|
+
- Add structured completion protocol to PR-producing playbooks
|
|
9
|
+
- Create engine/recovery.js with recovery recipes
|
|
10
|
+
- Add FAILURE_CLASS enum and classifyFailure() function
|
|
11
|
+
- Add AGENT_STATUS enum and worker-state tracking
|
|
12
|
+
- Validate required template variables before dispatch
|
|
13
|
+
- make post-verify plan archiving opt-in via autoArchive config
|
|
14
|
+
|
|
15
|
+
### Fixes
|
|
16
|
+
- repair 3 pre-existing test failures in source-pattern tests
|
|
17
|
+
- merge master, sync dashboard-build jsFiles, bump HTML size limit
|
|
18
|
+
- resolve 4 pre-existing test failures breaking CI
|
|
6
19
|
- reject JSON/UUID fragments as PR titles, self-heal from platform
|
|
7
20
|
|
|
8
21
|
## 0.1.725 (2026-04-09)
|
|
@@ -183,7 +183,11 @@ function renderPlans(plans) {
|
|
|
183
183
|
let prdJsonStatus = p.status || 'active';
|
|
184
184
|
if (prdFile && p.format !== 'prd') {
|
|
185
185
|
const linkedPrd = plans.find(pp => pp.file === prdFile && pp.format === 'prd');
|
|
186
|
-
if (linkedPrd)
|
|
186
|
+
if (linkedPrd) {
|
|
187
|
+
prdJsonStatus = linkedPrd.status || prdJsonStatus;
|
|
188
|
+
// Propagate archiveReady from PRD to source .md plan card
|
|
189
|
+
if (linkedPrd.archiveReady) p.archiveReady = true;
|
|
190
|
+
}
|
|
187
191
|
else if (!linkedPrd) {
|
|
188
192
|
const archivedPrd = archivedPlans.find(pp => pp.file === prdFile && pp.format === 'prd');
|
|
189
193
|
if (archivedPrd) prdJsonStatus = 'completed';
|
|
@@ -245,8 +249,12 @@ function renderPlans(plans) {
|
|
|
245
249
|
'onclick="event.stopPropagation();triggerVerify(\'' + escHtml(prdFile) + '\',this)">Verify</button>' : '';
|
|
246
250
|
const showArchive = !isArchived;
|
|
247
251
|
const archiveFile = prdFile || p.file;
|
|
248
|
-
const
|
|
249
|
-
|
|
252
|
+
const archiveReady = p.archiveReady && !isArchived;
|
|
253
|
+
const archiveBtn = showArchive ? '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px' +
|
|
254
|
+
(archiveReady ? ';color:var(--green);font-weight:600;border:1px solid var(--green)' : '') + '" ' +
|
|
255
|
+
'onclick="event.stopPropagation();planArchive(\'' + escHtml(archiveFile) + '\',this)">' +
|
|
256
|
+
(archiveReady ? '✓ Archive' : 'Archive') + '</button>' : '';
|
|
257
|
+
const archiveReadyBadge = archiveReady ? '<span style="font-size:9px;font-weight:600;padding:1px 6px;border-radius:3px;background:rgba(63,185,80,0.15);color:var(--green);vertical-align:middle" title="Verification passed — ready to archive">Ready to archive</span>' : '';
|
|
250
258
|
const deleteBtn = !isArchived ? '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--red)" ' +
|
|
251
259
|
'onclick="event.stopPropagation();planDelete(\'' + escHtml(p.file) + '\')">Delete</button>' : '';
|
|
252
260
|
|
|
@@ -263,7 +271,7 @@ function renderPlans(plans) {
|
|
|
263
271
|
(p.updatedAt ? '<span title="Last updated: ' + p.updatedAt + '">Updated ' + timeAgo(p.updatedAt) + '</span>' : '') +
|
|
264
272
|
(p.completedAt ? '<span>' + p.completedAt.slice(0, 10) + '</span>' : '') +
|
|
265
273
|
(p.generatedBy ? '<span>by ' + escHtml(p.generatedBy) + '</span>' : '') +
|
|
266
|
-
executeBtn + pauseBtn + resumeBtn + verifyBtn + (hasVerifyWi ? _renderVerifyBadge(verifyWi) : '') + archiveBtn + deleteBtn +
|
|
274
|
+
executeBtn + pauseBtn + resumeBtn + verifyBtn + (hasVerifyWi ? _renderVerifyBadge(verifyWi) : '') + archiveReadyBadge + archiveBtn + deleteBtn +
|
|
267
275
|
'</div>' +
|
|
268
276
|
'</div>' +
|
|
269
277
|
'</div>' +
|
|
@@ -40,7 +40,7 @@ function wiRow(item) {
|
|
|
40
40
|
: (item.branchStrategy === 'shared-branch' && item.status === 'done')
|
|
41
41
|
? '<span style="font-size:9px;color:var(--muted)" title="Part of shared branch — aggregate PR created at verify stage">shared branch</span>'
|
|
42
42
|
: '<span style="color:var(--muted)">—</span>';
|
|
43
|
-
return '<tr style="cursor:pointer" onclick="openWorkItemDetail(\'' + escHtml(item.id) + '\')">' +
|
|
43
|
+
return '<tr style="cursor:pointer" onclick="if(window.getSelection().toString().length>0)return;openWorkItemDetail(\'' + escHtml(item.id) + '\')">' +
|
|
44
44
|
'<td><span class="pr-id">' + escHtml(item.id || '') + '</span></td>' +
|
|
45
45
|
'<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escHtml((item.title || '').slice(0, 200)) + '">' + escHtml(item.title || '') + '</td>' +
|
|
46
46
|
'<td><span style="font-size:10px;color:var(--muted)">' + escHtml(item._source || '') + '</span>' +
|
package/dashboard/js/settings.js
CHANGED
|
@@ -47,6 +47,7 @@ async function openSettings() {
|
|
|
47
47
|
settingsToggle('Eval Loop', 'set-evalLoop', e.evalLoop !== false, 'Auto-review implementations and iterate fix cycles until pass') +
|
|
48
48
|
settingsToggle('Auto-decompose', 'set-autoDecompose', e.autoDecompose !== false, 'Large implement items are auto-split into sub-tasks') +
|
|
49
49
|
settingsToggle('Allow Temp Agents', 'set-allowTempAgents', !!e.allowTempAgents, 'Spawn ephemeral agents when all permanent agents are busy') +
|
|
50
|
+
settingsToggle('Auto-archive Plans', 'set-autoArchive', !!e.autoArchive, 'Automatically archive plans after verify completes (off = manual archive via dashboard)') +
|
|
50
51
|
'</div>' +
|
|
51
52
|
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px">' +
|
|
52
53
|
settingsField('Eval Max Iterations', 'set-evalMaxIterations', e.evalMaxIterations || 3, '', 'Max review→fix cycles before escalating (1-10)') +
|
|
@@ -205,6 +206,7 @@ async function saveSettings() {
|
|
|
205
206
|
evalLoop: document.getElementById('set-evalLoop').checked,
|
|
206
207
|
autoDecompose: document.getElementById('set-autoDecompose').checked,
|
|
207
208
|
allowTempAgents: document.getElementById('set-allowTempAgents').checked,
|
|
209
|
+
autoArchive: document.getElementById('set-autoArchive').checked,
|
|
208
210
|
evalMaxIterations: document.getElementById('set-evalMaxIterations').value,
|
|
209
211
|
evalMaxCost: document.getElementById('set-evalMaxCost').value || null,
|
|
210
212
|
maxBuildFixAttempts: document.getElementById('set-maxBuildFixAttempts').value,
|
package/dashboard-build.js
CHANGED
|
@@ -32,7 +32,7 @@ function buildDashboardHtml() {
|
|
|
32
32
|
'utils', 'state', 'detail-panel', 'live-stream',
|
|
33
33
|
'render-agents', 'render-dispatch', 'render-work-items', 'render-prd',
|
|
34
34
|
'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
|
|
35
|
-
'render-other', 'render-schedules', 'render-meetings', 'render-pinned',
|
|
35
|
+
'render-other', 'render-schedules', 'render-pipelines', 'render-meetings', 'render-pinned',
|
|
36
36
|
'command-parser', 'command-input', 'command-center', 'command-history',
|
|
37
37
|
'modal', 'modal-qa', 'settings', 'refresh'
|
|
38
38
|
];
|
package/dashboard.js
CHANGED
|
@@ -1055,12 +1055,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
1055
1055
|
const item = items.find(i => i.id === id);
|
|
1056
1056
|
if (!item) return items;
|
|
1057
1057
|
// Don't reset completed items unless explicitly forced
|
|
1058
|
-
if ((item.status
|
|
1058
|
+
if ((DONE_STATUSES.has(item.status) || item.completedAt) && !body.force) {
|
|
1059
1059
|
found = 'already_done';
|
|
1060
1060
|
return items;
|
|
1061
1061
|
}
|
|
1062
1062
|
found = true;
|
|
1063
|
-
item.status =
|
|
1063
|
+
item.status = WI_STATUS.PENDING;
|
|
1064
1064
|
item._retryCount = 0; // Reset retry counter on manual retry
|
|
1065
1065
|
delete item.dispatched_at;
|
|
1066
1066
|
delete item.dispatched_to;
|
|
@@ -1224,7 +1224,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1224
1224
|
let wiPath;
|
|
1225
1225
|
if (body.project) {
|
|
1226
1226
|
// Write to project-specific queue
|
|
1227
|
-
const targetProject = PROJECTS.find(p => p.name === body.project) || PROJECTS[0];
|
|
1227
|
+
const targetProject = PROJECTS.find(p => p.name === body.project) || (PROJECTS.length > 0 ? PROJECTS[0] : null);
|
|
1228
1228
|
if (!targetProject) return jsonReply(res, 400, { error: 'No projects configured' });
|
|
1229
1229
|
wiPath = shared.projectWorkItemsPath(targetProject);
|
|
1230
1230
|
} else {
|
|
@@ -1324,7 +1324,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1324
1324
|
const item = {
|
|
1325
1325
|
id, title: body.title, type: 'plan',
|
|
1326
1326
|
priority: body.priority || 'high', description: body.description || '',
|
|
1327
|
-
status:
|
|
1327
|
+
status: WI_STATUS.PENDING, created: new Date().toISOString(), createdBy: 'dashboard',
|
|
1328
1328
|
branchStrategy: body.branch_strategy || 'parallel',
|
|
1329
1329
|
};
|
|
1330
1330
|
if (body.project) item.project = body.project;
|
|
@@ -1402,7 +1402,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1402
1402
|
try {
|
|
1403
1403
|
mutateWorkItems(wiPath, items => {
|
|
1404
1404
|
const wi = items.find(w => w.sourcePlan === body.source && w.id === body.itemId);
|
|
1405
|
-
if (wi && wi.status ===
|
|
1405
|
+
if (wi && wi.status === WI_STATUS.PENDING) {
|
|
1406
1406
|
if (body.name !== undefined) wi.title = 'Implement: ' + body.name;
|
|
1407
1407
|
if (body.description !== undefined) wi.description = body.description;
|
|
1408
1408
|
if (body.priority !== undefined) wi.priority = body.priority;
|
|
@@ -1910,7 +1910,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
|
|
|
1910
1910
|
// Load work items to check for completed plan-to-prd conversions
|
|
1911
1911
|
const centralWi = safeJsonArr(path.join(MINIONS_DIR, 'work-items.json'));
|
|
1912
1912
|
const completedPrdFiles = new Set(
|
|
1913
|
-
centralWi.filter(w => w.type === 'plan-to-prd' && w.status
|
|
1913
|
+
centralWi.filter(w => w.type === 'plan-to-prd' && DONE_STATUSES.has(w.status) && w.planFile)
|
|
1914
1914
|
.map(w => w.planFile)
|
|
1915
1915
|
);
|
|
1916
1916
|
const plans = [];
|
|
@@ -1941,6 +1941,8 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
|
|
|
1941
1941
|
requiresApproval: plan.requires_approval || false,
|
|
1942
1942
|
revisionFeedback: plan.revision_feedback || null,
|
|
1943
1943
|
sourcePlan: plan.source_plan || null,
|
|
1944
|
+
archiveReady: plan._archiveReady || false,
|
|
1945
|
+
archiveReadyAt: plan._archiveReadyAt || null,
|
|
1944
1946
|
});
|
|
1945
1947
|
} catch { /* JSON parse fallback */ }
|
|
1946
1948
|
} else {
|
|
@@ -2237,14 +2239,14 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
|
|
|
2237
2239
|
mutateWorkItems(wiPath, items => {
|
|
2238
2240
|
// Dedup: check if already queued
|
|
2239
2241
|
const alreadyQueued = items.find(w =>
|
|
2240
|
-
w.type === 'plan-to-prd' && w.planFile === plan.source_plan && (w.status ===
|
|
2242
|
+
w.type === 'plan-to-prd' && w.planFile === plan.source_plan && (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.DISPATCHED)
|
|
2241
2243
|
);
|
|
2242
2244
|
if (alreadyQueued) { alreadyQueuedId = alreadyQueued.id; return; }
|
|
2243
2245
|
items.push({
|
|
2244
2246
|
id, title: `Regenerate PRD: ${plan.plan_summary || plan.source_plan}`,
|
|
2245
2247
|
type: 'plan-to-prd', priority: 'high',
|
|
2246
2248
|
description: `Plan file: plans/${plan.source_plan}\nTarget PRD filename: ${body.file}\nRegeneration requested by user after plan revision.${completedContext}`,
|
|
2247
|
-
status:
|
|
2249
|
+
status: WI_STATUS.PENDING, created: new Date().toISOString(), createdBy: 'dashboard:regenerate',
|
|
2248
2250
|
project: plan.project || '', planFile: plan.source_plan,
|
|
2249
2251
|
_targetPrdFile: body.file,
|
|
2250
2252
|
});
|
|
@@ -2271,13 +2273,13 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
|
|
|
2271
2273
|
mutateJsonFileLocked(centralPath, (items) => {
|
|
2272
2274
|
if (!Array.isArray(items)) items = [];
|
|
2273
2275
|
// Only block if actively pending/dispatched — allow re-execute after completion
|
|
2274
|
-
const existing = items.find(w => w.type === 'plan-to-prd' && w.planFile === body.file && (w.status ===
|
|
2276
|
+
const existing = items.find(w => w.type === 'plan-to-prd' && w.planFile === body.file && (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.DISPATCHED));
|
|
2275
2277
|
if (existing) { existingId = existing.id; return items; }
|
|
2276
2278
|
items.push({
|
|
2277
2279
|
id, title: 'Convert plan to PRD: ' + body.file.replace('.md', ''),
|
|
2278
2280
|
type: 'plan-to-prd', priority: 'high',
|
|
2279
2281
|
description: 'Plan file: plans/' + body.file,
|
|
2280
|
-
status:
|
|
2282
|
+
status: WI_STATUS.PENDING, created: new Date().toISOString(),
|
|
2281
2283
|
createdBy: 'dashboard:execute', project: body.project || '',
|
|
2282
2284
|
planFile: body.file,
|
|
2283
2285
|
});
|
|
@@ -2332,7 +2334,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
|
|
|
2332
2334
|
for (const w of items) {
|
|
2333
2335
|
if (w.sourcePlan === body.source) {
|
|
2334
2336
|
materializedPlanItemIds.add(w.id);
|
|
2335
|
-
if (w.status ===
|
|
2337
|
+
if (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.FAILED) {
|
|
2336
2338
|
// Delete — will re-materialize on next tick with updated plan data
|
|
2337
2339
|
reset++;
|
|
2338
2340
|
deletedItemIds.push(w.id);
|
|
@@ -2417,8 +2419,8 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
|
|
|
2417
2419
|
const centralPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
2418
2420
|
mutateWorkItems(centralPath, items => {
|
|
2419
2421
|
for (const w of items) {
|
|
2420
|
-
if (w.type === 'plan-to-prd' && w.status
|
|
2421
|
-
w.status =
|
|
2422
|
+
if (w.type === 'plan-to-prd' && DONE_STATUSES.has(w.status) && w.planFile === prdSourcePlan) {
|
|
2423
|
+
w.status = WI_STATUS.CANCELLED;
|
|
2422
2424
|
w._cancelledBy = 'prd-deleted';
|
|
2423
2425
|
}
|
|
2424
2426
|
}
|
|
@@ -2530,7 +2532,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
|
|
|
2530
2532
|
id, title: 'Revise plan: ' + (plan.plan_summary || body.file),
|
|
2531
2533
|
type: 'plan-to-prd', priority: 'high',
|
|
2532
2534
|
description: 'Revision requested on plan file: ' + (body.file.endsWith('.json') ? 'prd/' : 'plans/') + body.file + '\n\nFeedback:\n' + body.feedback + '\n\nRevise the plan to address this feedback. Read the existing plan, apply the feedback, and overwrite the file with the updated version. Set status back to "awaiting-approval".',
|
|
2533
|
-
status:
|
|
2535
|
+
status: WI_STATUS.PENDING, created: new Date().toISOString(), createdBy: 'dashboard:revision',
|
|
2534
2536
|
project: plan.project || '',
|
|
2535
2537
|
planFile: body.file,
|
|
2536
2538
|
});
|
|
@@ -2629,7 +2631,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
|
|
|
2629
2631
|
const filtered = [];
|
|
2630
2632
|
for (const w of items) {
|
|
2631
2633
|
if (w.sourcePlan === body.source) {
|
|
2632
|
-
if (w.status ===
|
|
2634
|
+
if (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.FAILED) {
|
|
2633
2635
|
reset++;
|
|
2634
2636
|
deletedItemIds.push(w.id);
|
|
2635
2637
|
} else {
|
|
@@ -2660,7 +2662,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
|
|
|
2660
2662
|
type: 'plan-to-prd',
|
|
2661
2663
|
priority: 'high',
|
|
2662
2664
|
description: `The source plan \`${sourcePlanFile}\` has been revised. Convert it into a fresh PRD JSON.\n\nRevision instruction: ${body.instruction}\n\nRead the revised plan, generate updated PRD items (missing_features), and write to \`prd/${body.source}\`. Set status to "approved". Include \`"source_plan": "${sourcePlanFile}"\` in the JSON root.\n\nPreserve items that are already done (status "implemented" or "complete"). Reset or replace items that were pending/failed.`,
|
|
2663
|
-
status:
|
|
2665
|
+
status: WI_STATUS.PENDING,
|
|
2664
2666
|
created: new Date().toISOString(),
|
|
2665
2667
|
createdBy: 'dashboard:revise-and-regenerate',
|
|
2666
2668
|
project: prd.project || '',
|
|
@@ -3555,7 +3557,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
3555
3557
|
config.engine.maxTurnsByType = mbt;
|
|
3556
3558
|
}
|
|
3557
3559
|
// Boolean fields
|
|
3558
|
-
for (const key of ['autoApprovePlans', 'evalLoop', 'autoDecompose', 'allowTempAgents']) {
|
|
3560
|
+
for (const key of ['autoApprovePlans', 'evalLoop', 'autoDecompose', 'allowTempAgents', 'autoArchive']) {
|
|
3559
3561
|
if (e[key] !== undefined) config.engine[key] = !!e[key];
|
|
3560
3562
|
}
|
|
3561
3563
|
// Eval loop settings
|
package/engine/dispatch.js
CHANGED
|
@@ -11,7 +11,7 @@ const { setCooldownFailure } = require('./cooldown');
|
|
|
11
11
|
|
|
12
12
|
const { safeJson, safeWrite, safeReadDir, mutateJsonFileLocked, mutateWorkItems,
|
|
13
13
|
mutatePullRequests, getProjects, projectWorkItemsPath, projectPrPath, log, ts, dateStamp,
|
|
14
|
-
WI_STATUS, DISPATCH_RESULT, ENGINE_DEFAULTS } = shared;
|
|
14
|
+
WI_STATUS, DISPATCH_RESULT, ENGINE_DEFAULTS, AGENT_STATUS, FAILURE_CLASS } = shared;
|
|
15
15
|
const { getConfig, getDispatch, DISPATCH_PATH, INBOX_DIR } = queries;
|
|
16
16
|
|
|
17
17
|
const MINIONS_DIR = shared.MINIONS_DIR;
|
|
@@ -19,6 +19,8 @@ const MINIONS_DIR = shared.MINIONS_DIR;
|
|
|
19
19
|
// Lazy require to break circular dependency with engine.js
|
|
20
20
|
let _lifecycle = null;
|
|
21
21
|
function lifecycle() { if (!_lifecycle) _lifecycle = require('./lifecycle'); return _lifecycle; }
|
|
22
|
+
let _recovery = null;
|
|
23
|
+
function recovery() { if (!_recovery) _recovery = require('./recovery'); return _recovery; }
|
|
22
24
|
|
|
23
25
|
// ─── Dispatch Mutation ───────────────────────────────────────────────────────
|
|
24
26
|
|
|
@@ -69,7 +71,12 @@ function addToDispatch(item) {
|
|
|
69
71
|
|
|
70
72
|
// ─── Retryable Failure Classification ────────────────────────────────────────
|
|
71
73
|
|
|
72
|
-
function isRetryableFailureReason(reason = '') {
|
|
74
|
+
function isRetryableFailureReason(reason = '', failureClass = '') {
|
|
75
|
+
// FAILURE_CLASS-based classification takes precedence when available
|
|
76
|
+
if (failureClass) {
|
|
77
|
+
const neverRetry = new Set([FAILURE_CLASS.CONFIG_ERROR, FAILURE_CLASS.PERMISSION_BLOCKED]);
|
|
78
|
+
if (neverRetry.has(failureClass)) return false;
|
|
79
|
+
}
|
|
73
80
|
const r = String(reason || '').toLowerCase();
|
|
74
81
|
if (!r) return true; // unknown error from tool exit — keep retryable
|
|
75
82
|
const nonRetryable = [
|
|
@@ -89,7 +96,7 @@ function isRetryableFailureReason(reason = '') {
|
|
|
89
96
|
// ─── Complete Dispatch ───────────────────────────────────────────────────────
|
|
90
97
|
|
|
91
98
|
function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', resultSummary = '', opts = {}) {
|
|
92
|
-
const { processWorkItemFailure = true } = opts;
|
|
99
|
+
const { processWorkItemFailure = true, failureClass } = opts;
|
|
93
100
|
let item = null;
|
|
94
101
|
|
|
95
102
|
mutateDispatch((dispatch) => {
|
|
@@ -108,6 +115,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
|
|
|
108
115
|
item.result = result;
|
|
109
116
|
if (reason) item.reason = reason;
|
|
110
117
|
if (resultSummary) item.resultSummary = resultSummary;
|
|
118
|
+
if (failureClass && result === DISPATCH_RESULT.ERROR) item.failureClass = failureClass;
|
|
111
119
|
delete item.prompt;
|
|
112
120
|
if (dispatch.completed.length >= 100) {
|
|
113
121
|
dispatch.completed = dispatch.completed.slice(-100);
|
|
@@ -120,7 +128,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
|
|
|
120
128
|
log('info', `Completed dispatch: ${id} (${result}${reason ? ': ' + reason : ''})`);
|
|
121
129
|
|
|
122
130
|
// Update source work item status on failure + auto-retry with backoff
|
|
123
|
-
const retryableFailure = isRetryableFailureReason(reason);
|
|
131
|
+
const retryableFailure = isRetryableFailureReason(reason, failureClass);
|
|
124
132
|
if (result === DISPATCH_RESULT.ERROR && item.meta?.dispatchKey && retryableFailure) setCooldownFailure(item.meta.dispatchKey);
|
|
125
133
|
|
|
126
134
|
if (processWorkItemFailure && result === DISPATCH_RESULT.ERROR && item.meta?.item?.id) {
|
|
@@ -130,8 +138,10 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
|
|
|
130
138
|
if (wi) retries = wi._retryCount || 0;
|
|
131
139
|
} catch (e) { log('warn', 'read retry count: ' + e.message); }
|
|
132
140
|
const maxRetries = ENGINE_DEFAULTS.maxRetries;
|
|
133
|
-
|
|
134
|
-
|
|
141
|
+
// Use per-class retry limits from recovery.js when failureClass is available
|
|
142
|
+
const classAllowsRetry = failureClass ? recovery().shouldRetry(failureClass, retries) : (retries < maxRetries);
|
|
143
|
+
if (retryableFailure && classAllowsRetry) {
|
|
144
|
+
log('info', `Dispatch error for ${item.meta.item.id} — auto-retry ${retries + 1}/${maxRetries}${failureClass ? ' [' + failureClass + ']' : ''}`);
|
|
135
145
|
lifecycle().updateWorkItemStatus(item.meta, WI_STATUS.PENDING, '');
|
|
136
146
|
// Remove this dispatch key from completed so dedupe doesn't block immediate redispatch.
|
|
137
147
|
if (item.meta?.dispatchKey) {
|
|
@@ -227,6 +237,29 @@ function writeInboxAlert(slug, content) {
|
|
|
227
237
|
} catch (e) { log('warn', 'write inbox alert: ' + e.message); }
|
|
228
238
|
}
|
|
229
239
|
|
|
240
|
+
// ─── Agent Worker Status ────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Update the worker-state fields on an active dispatch entry.
|
|
244
|
+
* Uses mutateDispatch() for atomic read-modify-write.
|
|
245
|
+
* @param {string} dispatchId — dispatch entry ID
|
|
246
|
+
* @param {string} status — one of AGENT_STATUS values
|
|
247
|
+
* @param {string} [detail] — optional human-readable detail string
|
|
248
|
+
*/
|
|
249
|
+
function updateAgentStatus(dispatchId, status, detail) {
|
|
250
|
+
if (!dispatchId || !status) return;
|
|
251
|
+
mutateDispatch((dispatch) => {
|
|
252
|
+
const entry = (dispatch.active || []).find(d => d.id === dispatchId)
|
|
253
|
+
|| (dispatch.pending || []).find(d => d.id === dispatchId);
|
|
254
|
+
if (entry) {
|
|
255
|
+
entry.workerState = status;
|
|
256
|
+
entry.workerStateAt = ts();
|
|
257
|
+
if (detail !== undefined) entry.workerStateDetail = detail;
|
|
258
|
+
}
|
|
259
|
+
return dispatch;
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
230
263
|
// ─── Exports ─────────────────────────────────────────────────────────────────
|
|
231
264
|
|
|
232
265
|
module.exports = {
|
|
@@ -235,4 +268,5 @@ module.exports = {
|
|
|
235
268
|
isRetryableFailureReason,
|
|
236
269
|
completeDispatch,
|
|
237
270
|
writeInboxAlert,
|
|
271
|
+
updateAgentStatus,
|
|
238
272
|
};
|
package/engine/lifecycle.js
CHANGED
|
@@ -9,7 +9,7 @@ const os = require('os');
|
|
|
9
9
|
const shared = require('./shared');
|
|
10
10
|
const { safeRead, safeJson, safeWrite, mutateJsonFileLocked, mutateWorkItems, execSilent, projectPrPath, getPrLinks, addPrLink,
|
|
11
11
|
log, ts, dateStamp, WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT,
|
|
12
|
-
ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS } = shared;
|
|
12
|
+
ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS, FAILURE_CLASS } = shared;
|
|
13
13
|
const { trackEngineUsage } = require('./llm');
|
|
14
14
|
const queries = require('./queries');
|
|
15
15
|
const { getConfig, getInboxFiles, getNotes, getPrs, getDispatch,
|
|
@@ -1101,6 +1101,49 @@ function parseAgentOutput(stdout) {
|
|
|
1101
1101
|
return { resultSummary: text, taskUsage: usage, sessionId, model };
|
|
1102
1102
|
}
|
|
1103
1103
|
|
|
1104
|
+
/**
|
|
1105
|
+
* Parse structured completion block from agent output.
|
|
1106
|
+
* Agents produce a ```completion fenced block with key: value pairs.
|
|
1107
|
+
* Returns parsed object or null if not found / malformed.
|
|
1108
|
+
* If multiple blocks exist, the last one wins (agent may retry).
|
|
1109
|
+
*/
|
|
1110
|
+
function parseStructuredCompletion(stdout) {
|
|
1111
|
+
if (!stdout || typeof stdout !== 'string') return null;
|
|
1112
|
+
|
|
1113
|
+
// Extract text from stream-json output if needed
|
|
1114
|
+
let text = stdout;
|
|
1115
|
+
if (stdout.includes('"type":')) {
|
|
1116
|
+
try {
|
|
1117
|
+
const parsed = shared.parseStreamJsonOutput(stdout);
|
|
1118
|
+
if (parsed.text) text = parsed.text;
|
|
1119
|
+
} catch {}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Find all ```completion blocks, take the last one
|
|
1123
|
+
const blockPattern = /```completion\s*\n([\s\S]*?)```/g;
|
|
1124
|
+
let lastMatch = null;
|
|
1125
|
+
let m;
|
|
1126
|
+
while ((m = blockPattern.exec(text)) !== null) {
|
|
1127
|
+
lastMatch = m[1];
|
|
1128
|
+
}
|
|
1129
|
+
if (!lastMatch) return null;
|
|
1130
|
+
|
|
1131
|
+
// Parse key: value pairs
|
|
1132
|
+
const result = {};
|
|
1133
|
+
const lines = lastMatch.trim().split('\n');
|
|
1134
|
+
for (const line of lines) {
|
|
1135
|
+
const colonIdx = line.indexOf(':');
|
|
1136
|
+
if (colonIdx < 1) continue;
|
|
1137
|
+
const key = line.slice(0, colonIdx).trim().toLowerCase();
|
|
1138
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
1139
|
+
if (key && value) result[key] = value;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// Must have at least the status field to be valid
|
|
1143
|
+
if (!result.status) return null;
|
|
1144
|
+
return result;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1104
1147
|
/**
|
|
1105
1148
|
* Handle decomposition result — parse sub-items from agent output and create child work items.
|
|
1106
1149
|
* Called from runPostCompletionHooks when type === 'decompose'.
|
|
@@ -1191,6 +1234,12 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
1191
1234
|
const result = isSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR;
|
|
1192
1235
|
const { resultSummary, taskUsage, sessionId, model } = parseAgentOutput(stdout);
|
|
1193
1236
|
|
|
1237
|
+
// Try structured completion protocol first (```completion block from agent output)
|
|
1238
|
+
const structuredCompletion = parseStructuredCompletion(stdout);
|
|
1239
|
+
if (structuredCompletion) {
|
|
1240
|
+
log('info', `Structured completion from ${agentId}: status=${structuredCompletion.status}, pr=${structuredCompletion.pr || 'N/A'}`);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1194
1243
|
// Save session for potential resume on next dispatch
|
|
1195
1244
|
if (isSuccess && sessionId && agentId && !agentId.startsWith('temp-')) {
|
|
1196
1245
|
try {
|
|
@@ -1207,6 +1256,12 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
1207
1256
|
prsCreatedCount = syncPrsFromOutput(stdout, agentId, meta, config) || 0;
|
|
1208
1257
|
} catch (err) { log('warn', `PR sync from output: ${err.message}`); }
|
|
1209
1258
|
|
|
1259
|
+
// Structured completion may report PR even when regex didn't find it
|
|
1260
|
+
const scHasPr = structuredCompletion && structuredCompletion.pr && structuredCompletion.pr !== 'N/A';
|
|
1261
|
+
if (scHasPr && prsCreatedCount === 0) {
|
|
1262
|
+
log('info', `Structured completion reports PR (${structuredCompletion.pr}) but regex sync found none — PR may already be tracked`);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1210
1265
|
// Auto-recover: if a failed implement/fix agent created PRs, it likely succeeded before being killed (e.g. heartbeat timeout)
|
|
1211
1266
|
const prCreatingType = type === WORK_TYPE.IMPLEMENT || type === WORK_TYPE.IMPLEMENT_LARGE || type === WORK_TYPE.FIX;
|
|
1212
1267
|
const autoRecovered = !isSuccess && prsCreatedCount > 0 && prCreatingType && !!meta?.item?.id;
|
|
@@ -1414,7 +1469,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
1414
1469
|
const metricsResult = isAutoRetry ? 'retry' : finalResult;
|
|
1415
1470
|
updateMetrics(agentId, dispatchItem, metricsResult, taskUsage, prsCreatedCount, model);
|
|
1416
1471
|
|
|
1417
|
-
return { resultSummary, taskUsage, autoRecovered };
|
|
1472
|
+
return { resultSummary, taskUsage, autoRecovered, structuredCompletion };
|
|
1418
1473
|
}
|
|
1419
1474
|
|
|
1420
1475
|
// ─── PR → PRD Status Sync ─────────────────────────────────────────────────────
|
|
@@ -1460,6 +1515,67 @@ function syncPrdFromPrs(config) {
|
|
|
1460
1515
|
}
|
|
1461
1516
|
}
|
|
1462
1517
|
|
|
1518
|
+
// ─── Failure Classification ─────────────────────────────────────────────────
|
|
1519
|
+
|
|
1520
|
+
/**
|
|
1521
|
+
* Classify an agent failure into a FAILURE_CLASS value based on exit code and output.
|
|
1522
|
+
* @param {number} code — process exit code
|
|
1523
|
+
* @param {string} stdout — agent stdout
|
|
1524
|
+
* @param {string} stderr — agent stderr
|
|
1525
|
+
* @returns {string} — one of FAILURE_CLASS values
|
|
1526
|
+
*/
|
|
1527
|
+
function classifyFailure(code, stdout = '', stderr = '') {
|
|
1528
|
+
const out = String(stdout || '').toLowerCase();
|
|
1529
|
+
const err = String(stderr || '').toLowerCase();
|
|
1530
|
+
const combined = out + '\n' + err;
|
|
1531
|
+
|
|
1532
|
+
// Exit code 78 — configuration error (Claude CLI not found, bad setup)
|
|
1533
|
+
if (code === 78) return FAILURE_CLASS.CONFIG_ERROR;
|
|
1534
|
+
|
|
1535
|
+
// Permission / trust / auth failures
|
|
1536
|
+
if (/permission denied|access denied|unauthorized|403 forbidden|trust.*blocked|auth.*fail/i.test(combined)) {
|
|
1537
|
+
return FAILURE_CLASS.PERMISSION_BLOCKED;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// Merge conflicts
|
|
1541
|
+
if (/merge conflict|conflict.*merge|automatic merge failed|fix conflicts/i.test(combined)) {
|
|
1542
|
+
return FAILURE_CLASS.MERGE_CONFLICT;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// Context window / max turns exhausted
|
|
1546
|
+
if (/context window|max.*turns.*reached|token limit|conversation.*too long|context.*length.*exceeded/i.test(combined)) {
|
|
1547
|
+
return FAILURE_CLASS.OUT_OF_CONTEXT;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// Network / API errors
|
|
1551
|
+
if (/rate limit|429|econnrefused|enotfound|etimedout|dns.*resolution|api.*error.*5\d\d|overloaded/i.test(combined)) {
|
|
1552
|
+
return FAILURE_CLASS.NETWORK_ERROR;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// Build / test / lint failures
|
|
1556
|
+
if (/build failed|compilation error|test.*fail|lint.*error|type.*error|error ts\d+|syntax error|npm err/i.test(combined)) {
|
|
1557
|
+
return FAILURE_CLASS.BUILD_FAILURE;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// Spawn errors — process crashed immediately or couldn't start
|
|
1561
|
+
if (/spawn.*error|enoent|cannot find module|cannot find.*binary/i.test(combined)) {
|
|
1562
|
+
return FAILURE_CLASS.SPAWN_ERROR;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// Empty output — agent produced nothing useful
|
|
1566
|
+
if (!stdout || stdout.trim().length < 50) {
|
|
1567
|
+
return FAILURE_CLASS.EMPTY_OUTPUT;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// Timeout is classified by the caller (timeout.js), not by output pattern
|
|
1571
|
+
// but check for timeout markers in output as fallback
|
|
1572
|
+
if (/timed.?out|timeout|heartbeat.*expired/i.test(combined)) {
|
|
1573
|
+
return FAILURE_CLASS.TIMEOUT;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
return FAILURE_CLASS.UNKNOWN;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1463
1579
|
module.exports = {
|
|
1464
1580
|
checkPlanCompletion,
|
|
1465
1581
|
archivePlan,
|
|
@@ -1476,9 +1592,11 @@ module.exports = {
|
|
|
1476
1592
|
createReviewFeedbackForAuthor,
|
|
1477
1593
|
updateMetrics,
|
|
1478
1594
|
parseAgentOutput,
|
|
1595
|
+
parseStructuredCompletion,
|
|
1479
1596
|
runPostCompletionHooks,
|
|
1480
1597
|
syncPrdFromPrs,
|
|
1481
1598
|
resolveWorkItemPath,
|
|
1482
1599
|
isItemCompleted,
|
|
1600
|
+
classifyFailure,
|
|
1483
1601
|
};
|
|
1484
1602
|
|
package/engine/playbook.js
CHANGED
|
@@ -206,6 +206,51 @@ function resolveTaskContext(item, config) {
|
|
|
206
206
|
return resolved;
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
// ─── Required Template Variables ────────────────────────────────────────────
|
|
210
|
+
// Defines which caller-provided variables are mandatory per playbook type.
|
|
211
|
+
// Base vars (from buildBaseVars) and project vars (injected by renderPlaybook)
|
|
212
|
+
// are always present and excluded. Variables in conditional blocks ({{#key}})
|
|
213
|
+
// are optional by design. Only variables that make the playbook non-functional
|
|
214
|
+
// when absent are listed here.
|
|
215
|
+
|
|
216
|
+
const PLAYBOOK_REQUIRED_VARS = {
|
|
217
|
+
'implement': ['item_id', 'item_name', 'branch_name', 'project_path'],
|
|
218
|
+
'implement-shared': ['item_id', 'item_name', 'branch_name', 'worktree_path'],
|
|
219
|
+
'fix': ['pr_id', 'pr_branch'],
|
|
220
|
+
'review': ['pr_id', 'pr_branch'],
|
|
221
|
+
'build-and-test': ['pr_id', 'pr_branch', 'project_path'],
|
|
222
|
+
'explore': ['task_description'],
|
|
223
|
+
'ask': ['question'],
|
|
224
|
+
'plan': ['task_description', 'project_path'],
|
|
225
|
+
'plan-to-prd': ['plan_content', 'plan_file', 'prd_filename', 'project_path'],
|
|
226
|
+
'decompose': ['item_id', 'item_description', 'project_path'],
|
|
227
|
+
'verify': ['task_description'],
|
|
228
|
+
'test': ['item_name'],
|
|
229
|
+
'work-item': ['item_id', 'item_name'],
|
|
230
|
+
'meeting-investigate': ['meeting_title', 'agenda'],
|
|
231
|
+
'meeting-debate': ['meeting_title', 'agenda'],
|
|
232
|
+
'meeting-conclude': ['meeting_title', 'agenda'],
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Validate that all required template variables for a playbook type are present
|
|
237
|
+
* and non-empty in the provided vars object.
|
|
238
|
+
* @param {string} playbookName - The playbook type name
|
|
239
|
+
* @param {object} vars - The template variables object
|
|
240
|
+
* @returns {{ valid: boolean, missing: string[] }}
|
|
241
|
+
*/
|
|
242
|
+
function validatePlaybookVars(playbookName, vars) {
|
|
243
|
+
const required = PLAYBOOK_REQUIRED_VARS[playbookName];
|
|
244
|
+
if (!required) return { valid: true, missing: [] };
|
|
245
|
+
|
|
246
|
+
const missing = required.filter(key => {
|
|
247
|
+
const val = vars[key];
|
|
248
|
+
return val === undefined || val === null || String(val).trim() === '';
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
return { valid: missing.length === 0, missing };
|
|
252
|
+
}
|
|
253
|
+
|
|
209
254
|
// ─── Playbook Renderer ──────────────────────────────────────────────────────
|
|
210
255
|
|
|
211
256
|
function renderPlaybook(type, vars) {
|
|
@@ -292,6 +337,13 @@ function renderPlaybook(type, vars) {
|
|
|
292
337
|
};
|
|
293
338
|
const allVars = { ...projectVars, ...vars };
|
|
294
339
|
|
|
340
|
+
// Validate required template variables before substitution
|
|
341
|
+
const validation = validatePlaybookVars(type, allVars);
|
|
342
|
+
if (!validation.valid) {
|
|
343
|
+
log('error', `Playbook "${type}": missing required template variables: ${validation.missing.join(', ')} — skipping render`);
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
295
347
|
// Process conditional blocks: {{#key}}...{{/key}} — include block only if key is truthy
|
|
296
348
|
content = content.replace(/\{\{#(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g, (_, key, block) => {
|
|
297
349
|
const val = allVars[key];
|
|
@@ -496,6 +548,8 @@ function buildPrDispatch(agentId, config, project, pr, type, extraVars, taskLabe
|
|
|
496
548
|
|
|
497
549
|
module.exports = {
|
|
498
550
|
renderPlaybook,
|
|
551
|
+
validatePlaybookVars,
|
|
552
|
+
PLAYBOOK_REQUIRED_VARS,
|
|
499
553
|
buildSystemPrompt,
|
|
500
554
|
buildAgentContext,
|
|
501
555
|
selectPlaybook,
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/recovery.js — Recovery recipes for classified agent failures.
|
|
3
|
+
* Maps FAILURE_CLASS values to per-class retry limits and escalation policies.
|
|
4
|
+
* Zero external dependencies — uses only Node.js built-ins and imports from shared.js.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { FAILURE_CLASS, ESCALATION_POLICY, ENGINE_DEFAULTS } = require('./shared');
|
|
8
|
+
|
|
9
|
+
// ─── Recovery Recipes ───────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Each recipe defines:
|
|
13
|
+
* maxAttempts — max retries for this failure class (0 = never retry)
|
|
14
|
+
* escalation — ESCALATION_POLICY value
|
|
15
|
+
* freshSession — whether to clear session.json before retry
|
|
16
|
+
* description — human-readable explanation for logs/dashboard
|
|
17
|
+
*/
|
|
18
|
+
const RECOVERY_RECIPES = new Map([
|
|
19
|
+
[FAILURE_CLASS.CONFIG_ERROR, {
|
|
20
|
+
maxAttempts: 0,
|
|
21
|
+
escalation: ESCALATION_POLICY.NO_RETRY,
|
|
22
|
+
freshSession: false,
|
|
23
|
+
description: 'Configuration error — fix config before retrying',
|
|
24
|
+
}],
|
|
25
|
+
[FAILURE_CLASS.PERMISSION_BLOCKED, {
|
|
26
|
+
maxAttempts: 0,
|
|
27
|
+
escalation: ESCALATION_POLICY.NO_RETRY,
|
|
28
|
+
freshSession: false,
|
|
29
|
+
description: 'Permission/trust gate blocked — requires human intervention',
|
|
30
|
+
}],
|
|
31
|
+
[FAILURE_CLASS.MERGE_CONFLICT, {
|
|
32
|
+
maxAttempts: 2,
|
|
33
|
+
escalation: ESCALATION_POLICY.RETRY_SAME,
|
|
34
|
+
freshSession: false,
|
|
35
|
+
description: 'Merge conflict — retry may succeed after dependency updates',
|
|
36
|
+
}],
|
|
37
|
+
[FAILURE_CLASS.BUILD_FAILURE, {
|
|
38
|
+
maxAttempts: 2,
|
|
39
|
+
escalation: ESCALATION_POLICY.RETRY_SAME,
|
|
40
|
+
freshSession: false,
|
|
41
|
+
description: 'Build/test failure — retry with same context for iterative fix',
|
|
42
|
+
}],
|
|
43
|
+
[FAILURE_CLASS.TIMEOUT, {
|
|
44
|
+
maxAttempts: 1,
|
|
45
|
+
escalation: ESCALATION_POLICY.RETRY_FRESH,
|
|
46
|
+
freshSession: true,
|
|
47
|
+
description: 'Timeout — retry with fresh session to avoid stuck state',
|
|
48
|
+
}],
|
|
49
|
+
[FAILURE_CLASS.EMPTY_OUTPUT, {
|
|
50
|
+
maxAttempts: 1,
|
|
51
|
+
escalation: ESCALATION_POLICY.HUMAN_REVIEW,
|
|
52
|
+
freshSession: true,
|
|
53
|
+
description: 'Empty output — agent produced nothing useful, flag for review',
|
|
54
|
+
}],
|
|
55
|
+
[FAILURE_CLASS.SPAWN_ERROR, {
|
|
56
|
+
maxAttempts: 2,
|
|
57
|
+
escalation: ESCALATION_POLICY.RETRY_FRESH,
|
|
58
|
+
freshSession: true,
|
|
59
|
+
description: 'Spawn error — retry with fresh session after transient failure',
|
|
60
|
+
}],
|
|
61
|
+
[FAILURE_CLASS.NETWORK_ERROR, {
|
|
62
|
+
maxAttempts: 3,
|
|
63
|
+
escalation: ESCALATION_POLICY.AUTO,
|
|
64
|
+
freshSession: false,
|
|
65
|
+
description: 'Network/API error — retry with exponential backoff',
|
|
66
|
+
}],
|
|
67
|
+
[FAILURE_CLASS.OUT_OF_CONTEXT, {
|
|
68
|
+
maxAttempts: 1,
|
|
69
|
+
escalation: ESCALATION_POLICY.HUMAN_REVIEW,
|
|
70
|
+
freshSession: true,
|
|
71
|
+
description: 'Context exhausted — retry with fresh session, flag if repeated',
|
|
72
|
+
}],
|
|
73
|
+
[FAILURE_CLASS.UNKNOWN, {
|
|
74
|
+
maxAttempts: null, // null = fall back to ENGINE_DEFAULTS.maxRetries
|
|
75
|
+
escalation: ESCALATION_POLICY.AUTO,
|
|
76
|
+
freshSession: false,
|
|
77
|
+
description: 'Unclassified failure — use default retry behavior',
|
|
78
|
+
}],
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get the recovery recipe for a failure class.
|
|
85
|
+
* @param {string} failureClass — one of FAILURE_CLASS values
|
|
86
|
+
* @returns {object} recipe with maxAttempts, escalation, freshSession, description
|
|
87
|
+
*/
|
|
88
|
+
function getRecoveryRecipe(failureClass) {
|
|
89
|
+
return RECOVERY_RECIPES.get(failureClass) || RECOVERY_RECIPES.get(FAILURE_CLASS.UNKNOWN);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Determine whether a failed dispatch should be retried based on its failure class
|
|
94
|
+
* and current attempt count.
|
|
95
|
+
* @param {string} failureClass — one of FAILURE_CLASS values (or empty for unclassified)
|
|
96
|
+
* @param {number} attemptCount — how many times this item has already been retried
|
|
97
|
+
* @returns {boolean} true if another retry is allowed
|
|
98
|
+
*/
|
|
99
|
+
function shouldRetry(failureClass, attemptCount = 0) {
|
|
100
|
+
const recipe = getRecoveryRecipe(failureClass || FAILURE_CLASS.UNKNOWN);
|
|
101
|
+
// null maxAttempts = fall back to global ENGINE_DEFAULTS.maxRetries
|
|
102
|
+
const limit = recipe.maxAttempts !== null ? recipe.maxAttempts : ENGINE_DEFAULTS.maxRetries;
|
|
103
|
+
return attemptCount < limit;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── Exports ────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
module.exports = {
|
|
109
|
+
RECOVERY_RECIPES,
|
|
110
|
+
getRecoveryRecipe,
|
|
111
|
+
shouldRetry,
|
|
112
|
+
};
|
package/engine/shared.js
CHANGED
|
@@ -530,6 +530,7 @@ const ENGINE_DEFAULTS = {
|
|
|
530
530
|
allowTempAgents: false, // opt-in: spawn ephemeral agents when all permanent agents are busy
|
|
531
531
|
autoDecompose: true, // auto-decompose implement:large items into sub-tasks
|
|
532
532
|
autoApprovePlans: false, // auto-approve PRDs without waiting for human approval
|
|
533
|
+
autoArchive: false, // opt-in: auto-archive plans after verify completes (false = mark ready, user archives manually)
|
|
533
534
|
autoReview: true, // auto-dispatch review agents for new PRs (disable for manual review workflow)
|
|
534
535
|
meetingRoundTimeout: 600000, // 10min per meeting round before auto-advance
|
|
535
536
|
evalLoop: true, // enable review→fix loop after implementation completes
|
|
@@ -585,6 +586,33 @@ const MEETING_STATUS = {
|
|
|
585
586
|
INVESTIGATING: 'investigating', DEBATING: 'debating', CONCLUDING: 'concluding',
|
|
586
587
|
COMPLETED: 'completed', ARCHIVED: 'archived',
|
|
587
588
|
};
|
|
589
|
+
const AGENT_STATUS = {
|
|
590
|
+
SPAWNING: 'spawning', WORKTREE_SETUP: 'worktree-setup', READY: 'ready',
|
|
591
|
+
RUNNING: 'running', FINISHED: 'finished', FAILED: 'failed',
|
|
592
|
+
TRUST_BLOCKED: 'trust-blocked', TIMED_OUT: 'timed-out',
|
|
593
|
+
};
|
|
594
|
+
const FAILURE_CLASS = {
|
|
595
|
+
CONFIG_ERROR: 'config-error', // Exit code 78, CLI not found, bad config
|
|
596
|
+
PERMISSION_BLOCKED: 'permission-blocked', // Trust gate, permission denied, auth failure
|
|
597
|
+
MERGE_CONFLICT: 'merge-conflict', // Git merge conflict in worktree or dependency
|
|
598
|
+
BUILD_FAILURE: 'build-failure', // Compilation, lint, or test failure
|
|
599
|
+
TIMEOUT: 'timeout', // Hard timeout or heartbeat timeout
|
|
600
|
+
EMPTY_OUTPUT: 'empty-output', // Agent produced no meaningful output
|
|
601
|
+
SPAWN_ERROR: 'spawn-error', // Process failed to start or crashed immediately
|
|
602
|
+
NETWORK_ERROR: 'network-error', // API rate limit, DNS, connectivity
|
|
603
|
+
OUT_OF_CONTEXT: 'out-of-context', // Context window exhausted, max turns reached
|
|
604
|
+
UNKNOWN: 'unknown', // Unclassified failure
|
|
605
|
+
};
|
|
606
|
+
const ESCALATION_POLICY = {
|
|
607
|
+
NO_RETRY: 'no-retry', // CONFIG_ERROR, PERMISSION_BLOCKED — never retry
|
|
608
|
+
RETRY_SAME: 'retry-same', // MERGE_CONFLICT, BUILD_FAILURE — retry same agent
|
|
609
|
+
RETRY_FRESH: 'retry-fresh', // TIMEOUT, SPAWN_ERROR — retry with fresh session
|
|
610
|
+
HUMAN_REVIEW: 'human-review', // EMPTY_OUTPUT, OUT_OF_CONTEXT — flag for human
|
|
611
|
+
AUTO: 'auto', // UNKNOWN, NETWORK_ERROR — use default retry logic
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
// Structured completion protocol — fields agents must produce in ```completion blocks
|
|
615
|
+
const COMPLETION_FIELDS = ['status', 'files_changed', 'tests', 'pr', 'pending', 'failure_class'];
|
|
588
616
|
|
|
589
617
|
const DEFAULT_AGENT_METRICS = {
|
|
590
618
|
tasksCompleted: 0, tasksErrored: 0,
|
|
@@ -911,7 +939,8 @@ module.exports = {
|
|
|
911
939
|
classifyInboxItem,
|
|
912
940
|
ENGINE_DEFAULTS,
|
|
913
941
|
WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT,
|
|
914
|
-
PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS,
|
|
942
|
+
PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
|
|
943
|
+
FAILURE_CLASS, ESCALATION_POLICY, COMPLETION_FIELDS,
|
|
915
944
|
DEFAULT_AGENT_METRICS,
|
|
916
945
|
DEFAULT_AGENTS,
|
|
917
946
|
DEFAULT_CLAUDE,
|
package/engine/timeout.js
CHANGED
|
@@ -9,7 +9,7 @@ const shared = require('./shared');
|
|
|
9
9
|
const queries = require('./queries');
|
|
10
10
|
|
|
11
11
|
const { safeRead, safeWrite, safeJson, mutateJsonFileLocked, getProjects, projectWorkItemsPath, log, ts,
|
|
12
|
-
ENGINE_DEFAULTS: DEFAULTS, WI_STATUS, DISPATCH_RESULT } = shared;
|
|
12
|
+
ENGINE_DEFAULTS: DEFAULTS, WI_STATUS, DISPATCH_RESULT, AGENT_STATUS } = shared;
|
|
13
13
|
const { getDispatch, getAgentStatus } = queries;
|
|
14
14
|
const AGENTS_DIR = queries.AGENTS_DIR;
|
|
15
15
|
const MINIONS_DIR = shared.MINIONS_DIR;
|
|
@@ -138,6 +138,7 @@ function checkTimeouts(config) {
|
|
|
138
138
|
const elapsed = Date.now() - new Date(info.startedAt).getTime();
|
|
139
139
|
if (elapsed > itemTimeout) {
|
|
140
140
|
log('warn', `Agent ${info.agentId} (${id}) hit hard timeout after ${Math.round(elapsed / 1000)}s — killing`);
|
|
141
|
+
dispatch().updateAgentStatus(id, AGENT_STATUS.TIMED_OUT, `Hard timeout after ${Math.round(elapsed / 1000)}s`);
|
|
141
142
|
shared.killGracefully(info.proc, 5000);
|
|
142
143
|
}
|
|
143
144
|
}
|
|
@@ -267,12 +268,14 @@ function checkTimeouts(config) {
|
|
|
267
268
|
if (!hasProcess && silentMs > effectiveTimeout && Date.now() > engineRestartGraceUntil) {
|
|
268
269
|
// No tracked process AND no recent output past effective timeout AND grace period expired → orphaned
|
|
269
270
|
log('warn', `Orphan detected: ${item.agent} (${item.id}) — no process tracked, silent for ${silentSec}s${isBlocking ? ' (blocking timeout exceeded)' : ''}`);
|
|
271
|
+
dispatch().updateAgentStatus(item.id, AGENT_STATUS.TIMED_OUT, `Orphaned — no process, silent for ${silentSec}s`);
|
|
270
272
|
// Clear session so retry starts fresh
|
|
271
273
|
try { shared.safeUnlink(path.join(AGENTS_DIR, item.agent, 'session.json')); } catch {}
|
|
272
274
|
deadItems.push({ item, reason: `Orphaned — no process, silent for ${silentSec}s` });
|
|
273
275
|
} else if (hasProcess && silentMs > effectiveTimeout) {
|
|
274
276
|
// Has process but no output past effective timeout → hung
|
|
275
277
|
log('warn', `Hung agent: ${item.agent} (${item.id}) — process exists but no output for ${silentSec}s${isBlocking ? ' (blocking timeout exceeded)' : ''}`);
|
|
278
|
+
dispatch().updateAgentStatus(item.id, AGENT_STATUS.TIMED_OUT, `Hung — no output for ${silentSec}s`);
|
|
276
279
|
const procInfo = activeProcesses.get(item.id);
|
|
277
280
|
if (procInfo) {
|
|
278
281
|
shared.killGracefully(procInfo.proc, 5000);
|
package/engine.js
CHANGED
|
@@ -25,7 +25,7 @@ const fs = require('fs');
|
|
|
25
25
|
const path = require('path');
|
|
26
26
|
const shared = require('./engine/shared');
|
|
27
27
|
const { exec, execAsync, execSilent, runFile, ts, ENGINE_DEFAULTS: DEFAULTS,
|
|
28
|
-
WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT } = shared;
|
|
28
|
+
WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT, AGENT_STATUS } = shared;
|
|
29
29
|
const queries = require('./engine/queries');
|
|
30
30
|
|
|
31
31
|
// ─── Paths ──────────────────────────────────────────────────────────────────
|
|
@@ -100,7 +100,7 @@ const withFileLock = shared.withFileLock;
|
|
|
100
100
|
// ─── Dispatch Management (extracted to engine/dispatch.js) ───────────────────
|
|
101
101
|
|
|
102
102
|
const { mutateDispatch, addToDispatch, isRetryableFailureReason, completeDispatch,
|
|
103
|
-
writeInboxAlert } = require('./engine/dispatch');
|
|
103
|
+
writeInboxAlert, updateAgentStatus } = require('./engine/dispatch');
|
|
104
104
|
|
|
105
105
|
// ─── Timeout / Steering / Idle (extracted to engine/timeout.js) ──────────────
|
|
106
106
|
|
|
@@ -125,7 +125,8 @@ const { getRouting, parseRoutingTable, getRoutingTableCached, getMonthlySpend,
|
|
|
125
125
|
|
|
126
126
|
// ─── Playbook, system prompt, agent context (extracted to engine/playbook.js) ─
|
|
127
127
|
|
|
128
|
-
const { renderPlaybook,
|
|
128
|
+
const { renderPlaybook, validatePlaybookVars, PLAYBOOK_REQUIRED_VARS,
|
|
129
|
+
buildSystemPrompt, buildAgentContext, selectPlaybook,
|
|
129
130
|
buildBaseVars, buildPrDispatch, resolveTaskContext,
|
|
130
131
|
getRepoHostLabel, getRepoHostToolRule } = require('./engine/playbook');
|
|
131
132
|
|
|
@@ -136,7 +137,7 @@ const { renderPlaybook, buildSystemPrompt, buildAgentContext, selectPlaybook,
|
|
|
136
137
|
const { runPostCompletionHooks, updateWorkItemStatus, syncPrdItemStatus, handlePostMerge, checkPlanCompletion,
|
|
137
138
|
syncPrsFromOutput, updatePrAfterReview, updatePrAfterFix, checkForLearnings, extractSkillsFromOutput,
|
|
138
139
|
updateAgentHistory, updateMetrics, createReviewFeedbackForAuthor, parseAgentOutput, syncPrdFromPrs,
|
|
139
|
-
isItemCompleted } = require('./engine/lifecycle');
|
|
140
|
+
isItemCompleted, classifyFailure } = require('./engine/lifecycle');
|
|
140
141
|
|
|
141
142
|
// ─── Agent Spawner ──────────────────────────────────────────────────────────
|
|
142
143
|
|
|
@@ -278,6 +279,8 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
278
279
|
const engineConfig = config.engine || {};
|
|
279
280
|
const startedAt = ts();
|
|
280
281
|
|
|
282
|
+
updateAgentStatus(id, AGENT_STATUS.SPAWNING, `Preparing ${type} task for ${agentId}`);
|
|
283
|
+
|
|
281
284
|
// Resolve project context for this dispatch
|
|
282
285
|
// meta.project has {name, localPath} — enrich with full config (mainBranch, repoHost, etc.)
|
|
283
286
|
const metaProject = meta?.project || {};
|
|
@@ -311,6 +314,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
311
314
|
const _cleanupPromptFiles = () => { safeUnlink(promptPath); safeUnlink(sysPromptPath); };
|
|
312
315
|
|
|
313
316
|
if (branchName) {
|
|
317
|
+
updateAgentStatus(id, AGENT_STATUS.WORKTREE_SETUP, `Setting up worktree for branch ${branchName}`);
|
|
314
318
|
const wtSuffix = id ? id.split('-').pop() : shared.uid();
|
|
315
319
|
const projectSlug = (project.name || 'default').replace(/[^a-zA-Z0-9_-]/g, '-');
|
|
316
320
|
const wtDirName = `${projectSlug}-${branchName}-${wtSuffix}`;
|
|
@@ -491,6 +495,8 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
491
495
|
}
|
|
492
496
|
}
|
|
493
497
|
|
|
498
|
+
updateAgentStatus(id, AGENT_STATUS.READY, 'Worktree ready, preparing to spawn process');
|
|
499
|
+
|
|
494
500
|
// Safety check: warn if a write-capable task is running in the main repo without a worktree
|
|
495
501
|
if (cwd === rootDir && ['implement', 'implement:large', 'fix', 'test', 'verify', 'plan-to-prd'].includes(type)) {
|
|
496
502
|
log('warn', `Agent ${agentId} running ${type} task in main repo (no worktree) for ${id} — changes may land on master directly`);
|
|
@@ -558,6 +564,8 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
558
564
|
let stderr = '';
|
|
559
565
|
let lastOutputAt = Date.now();
|
|
560
566
|
let heartbeatTimer = null;
|
|
567
|
+
let _trustCheckDone = false;
|
|
568
|
+
const _spawnTime = Date.now();
|
|
561
569
|
|
|
562
570
|
// Live output file — written as data arrives so dashboard can tail it
|
|
563
571
|
const liveOutputPath = path.join(AGENTS_DIR, agentId, 'live-output.log');
|
|
@@ -592,6 +600,25 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
592
600
|
if (stdout.length < MAX_OUTPUT) stdout += chunk.slice(0, MAX_OUTPUT - stdout.length);
|
|
593
601
|
try { fs.appendFileSync(liveOutputPath, chunk); } catch { /* optional */ }
|
|
594
602
|
|
|
603
|
+
// Trust gate detection: check first 30s of output for trust/permission prompts
|
|
604
|
+
if (!_trustCheckDone && (Date.now() - _spawnTime) <= 30000) {
|
|
605
|
+
const lower = chunk.toLowerCase();
|
|
606
|
+
if (/\b(trust this|do you trust|allow access|grant permission|approve tools?|permission prompt)\b/.test(lower)) {
|
|
607
|
+
_trustCheckDone = true;
|
|
608
|
+
updateAgentStatus(id, AGENT_STATUS.TRUST_BLOCKED, 'Agent appears to be waiting for trust approval');
|
|
609
|
+
log('warn', `Trust gate detected for ${agentId} (${id}) — agent may be blocked on a permission prompt`);
|
|
610
|
+
writeInboxAlert(`trust-blocked-${id}`,
|
|
611
|
+
`# Trust Gate Blocked — \`${id}\`\n\n` +
|
|
612
|
+
`**Agent:** ${agentId}\n` +
|
|
613
|
+
`**Task:** ${dispatchItem.task || type}\n\n` +
|
|
614
|
+
`The agent appears to be blocked on a trust/permission prompt within 30s of spawn.\n` +
|
|
615
|
+
`Check the agent's live output and approve the trust gate manually.\n`
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
} else if (!_trustCheckDone) {
|
|
619
|
+
_trustCheckDone = true; // past 30s window
|
|
620
|
+
}
|
|
621
|
+
|
|
595
622
|
// Capture sessionId early for mid-session steering
|
|
596
623
|
const procInfo = activeProcesses.get(id);
|
|
597
624
|
if (procInfo && !procInfo.sessionId && chunk.includes('session_id')) {
|
|
@@ -622,6 +649,10 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
622
649
|
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
|
623
650
|
log('info', `Agent ${agentId} (${id}) exited with code ${code}`);
|
|
624
651
|
|
|
652
|
+
// Emit worker-state transition: FINISHED or FAILED
|
|
653
|
+
updateAgentStatus(id, code === 0 ? AGENT_STATUS.FINISHED : AGENT_STATUS.FAILED,
|
|
654
|
+
code === 0 ? 'Agent completed successfully' : `Agent exited with code ${code}`);
|
|
655
|
+
|
|
625
656
|
// Clear stale session if resume failed — prevents burning all retries on the same bad session
|
|
626
657
|
if (code !== 0 && cachedSessionId && stderr.includes('No conversation found')) {
|
|
627
658
|
log('warn', `Stale session ${cachedSessionId} for ${agentId} — clearing session.json`);
|
|
@@ -762,11 +793,14 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
762
793
|
safeWrite(archivePath, outputContent);
|
|
763
794
|
safeWrite(latestPath, outputContent); // overwrite latest for dashboard compat
|
|
764
795
|
|
|
796
|
+
// Classify failure for non-zero exits
|
|
797
|
+
const failureClass = code !== 0 ? classifyFailure(code, stdout, stderr) : undefined;
|
|
798
|
+
|
|
765
799
|
// Detect configuration errors (e.g. Claude CLI not found) — fail immediately with clear message
|
|
766
800
|
if (code === 78) {
|
|
767
801
|
const errMsg = stderr.includes('claude-code') ? stderr.trim() : 'Configuration error — Claude Code CLI not found. Install with: npm install -g @anthropic-ai/claude-code';
|
|
768
|
-
log('error', `Agent ${agentId} (${id}) failed: ${errMsg}`);
|
|
769
|
-
completeDispatch(id, DISPATCH_RESULT.ERROR, errMsg, '');
|
|
802
|
+
log('error', `Agent ${agentId} (${id}) failed: ${errMsg} [${failureClass}]`);
|
|
803
|
+
completeDispatch(id, DISPATCH_RESULT.ERROR, errMsg, '', { failureClass });
|
|
770
804
|
try { fs.unlinkSync(sysPromptPath); } catch { /* cleanup */ }
|
|
771
805
|
try { fs.unlinkSync(promptPath); } catch { /* cleanup */ }
|
|
772
806
|
try { fs.unlinkSync(promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid')); } catch { /* cleanup */ }
|
|
@@ -779,7 +813,8 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
779
813
|
// Move from active to completed in dispatch (single source of truth for agent status)
|
|
780
814
|
// autoRecovered: agent failed (e.g. heartbeat timeout) but created PRs — treat as success
|
|
781
815
|
const effectiveResult = (code === 0 || autoRecovered) ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR;
|
|
782
|
-
|
|
816
|
+
const completeOpts = effectiveResult === DISPATCH_RESULT.ERROR && failureClass ? { failureClass } : {};
|
|
817
|
+
completeDispatch(id, effectiveResult, '', resultSummary, completeOpts);
|
|
783
818
|
|
|
784
819
|
// Cleanup temp files (including PID file now that dispatch is complete)
|
|
785
820
|
try { fs.unlinkSync(sysPromptPath); } catch { /* cleanup */ }
|
|
@@ -858,6 +893,8 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
858
893
|
// Track process — even if PID isn't available yet (async on Windows)
|
|
859
894
|
activeProcesses.set(id, { proc, agentId, startedAt, sessionId: cachedSessionId });
|
|
860
895
|
|
|
896
|
+
updateAgentStatus(id, AGENT_STATUS.RUNNING, `Process spawned for ${agentId}`);
|
|
897
|
+
|
|
861
898
|
// Log PID and persist to registry
|
|
862
899
|
if (proc.pid) {
|
|
863
900
|
log('info', `Agent process started: PID ${proc.pid}`);
|
|
@@ -2919,7 +2956,7 @@ module.exports = {
|
|
|
2919
2956
|
validateConfig,
|
|
2920
2957
|
|
|
2921
2958
|
// Dispatch management (re-exported from engine/dispatch.js)
|
|
2922
|
-
mutateDispatch, addToDispatch, isRetryableFailureReason, completeDispatch, writeInboxAlert,
|
|
2959
|
+
mutateDispatch, addToDispatch, isRetryableFailureReason, completeDispatch, writeInboxAlert, updateAgentStatus,
|
|
2923
2960
|
activeProcesses, get engineRestartGraceUntil() { return engineRestartGraceUntil; },
|
|
2924
2961
|
set engineRestartGraceUntil(v) { engineRestartGraceUntil = v; },
|
|
2925
2962
|
|
|
@@ -2934,7 +2971,7 @@ module.exports = {
|
|
|
2934
2971
|
reconcileItemsWithPrs, detectDependencyCycles,
|
|
2935
2972
|
|
|
2936
2973
|
// Playbooks
|
|
2937
|
-
renderPlaybook, buildWorkItemDispatchVars,
|
|
2974
|
+
renderPlaybook, validatePlaybookVars, PLAYBOOK_REQUIRED_VARS, buildWorkItemDispatchVars,
|
|
2938
2975
|
|
|
2939
2976
|
// Timeout / Steering / Idle (re-exported from engine/timeout.js)
|
|
2940
2977
|
checkTimeouts, checkSteering, checkIdleThreshold,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.728",
|
|
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"
|
|
@@ -143,9 +143,9 @@ Replace `<SHORT DESCRIPTION OF FAILURE>` and `<PASTE THE BUILD/TEST ERROR OUTPUT
|
|
|
143
143
|
- Use the worktree path, NOT the main project path, for all commands
|
|
144
144
|
- The worktree will persist after your process ends so the user can inspect it
|
|
145
145
|
|
|
146
|
-
##
|
|
146
|
+
## Do not clean up the worktree
|
|
147
147
|
|
|
148
|
-
Leave the worktree in place at `{{project_path}}/../worktrees/bt-{{pr_number}}` — the user needs it to review the running app. The engine will clean it up after the PR is merged or closed.
|
|
148
|
+
Leave the worktree in place at `{{project_path}}/../worktrees/bt-{{pr_number}}` — the user needs it to review the running app. The engine will clean it up automatically after the PR is merged or closed.
|
|
149
149
|
|
|
150
150
|
|
|
151
151
|
## When to Stop
|
package/playbooks/decompose.md
CHANGED
|
@@ -10,7 +10,7 @@ A work item has been flagged as too large for a single agent dispatch. Analyze t
|
|
|
10
10
|
## Work Item
|
|
11
11
|
|
|
12
12
|
- **ID:** {{item_id}}
|
|
13
|
-
- **Title:** {{
|
|
13
|
+
- **Title:** {{item_name}}
|
|
14
14
|
- **Description:** {{item_description}}
|
|
15
15
|
- **Complexity:** {{item_complexity}}
|
|
16
16
|
- **Project:** {{project_name}} (`{{project_path}}`)
|
package/playbooks/fix.md
CHANGED
|
@@ -66,3 +66,18 @@ Do NOT remove the worktree — the engine handles cleanup automatically.
|
|
|
66
66
|
|
|
67
67
|
Your task is complete once you have: (1) confirmed build and tests pass, (2) pushed the fix, and (3) commented on the PR. Do NOT continue exploring unrelated code or making additional improvements. Stop immediately.
|
|
68
68
|
|
|
69
|
+
## Completion
|
|
70
|
+
|
|
71
|
+
After finishing, output a structured completion block so the engine can parse your results:
|
|
72
|
+
|
|
73
|
+
```completion
|
|
74
|
+
status: done | partial | failed
|
|
75
|
+
files_changed: <comma-separated list of key files changed>
|
|
76
|
+
tests: pass | fail | skipped | N/A
|
|
77
|
+
pr: PR-<number> or N/A
|
|
78
|
+
failure_class: N/A
|
|
79
|
+
pending: <any remaining work, or none>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Replace the values with your actual results. This block MUST appear in your final output.
|
|
83
|
+
|
|
@@ -78,3 +78,18 @@ git push origin {{branch_name}}
|
|
|
78
78
|
## When to Stop
|
|
79
79
|
|
|
80
80
|
Your task is complete once you have: (1) confirmed build and tests pass, and (2) pushed to the shared branch. Do NOT create a PR — the engine creates one when all plan items are done. Stop after pushing.
|
|
81
|
+
|
|
82
|
+
## Completion
|
|
83
|
+
|
|
84
|
+
After finishing, output a structured completion block so the engine can parse your results:
|
|
85
|
+
|
|
86
|
+
```completion
|
|
87
|
+
status: done | partial | failed
|
|
88
|
+
files_changed: <comma-separated list of key files changed>
|
|
89
|
+
tests: pass | fail | skipped | N/A
|
|
90
|
+
pr: N/A
|
|
91
|
+
failure_class: N/A
|
|
92
|
+
pending: <any remaining work, or none>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Replace the values with your actual results. This block MUST appear in your final output.
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
## Context Window Awareness
|
|
2
|
+
|
|
3
|
+
Your context window may be compacted or summarized mid-task by Claude's automatic context management. This is normal and expected for long-running tasks. Do NOT interpret compacted or truncated context as a signal to stop early, wrap up prematurely, or skip remaining work. Continue working toward your stated objective regardless of context window state — re-read key files if needed to recover context.
|
|
4
|
+
|
|
1
5
|
## Engine Rules (apply to all tasks)
|
|
2
6
|
|
|
3
7
|
- Do NOT write to `agents/*/status.json` — the engine manages your status automatically.
|
package/playbooks/test.md
CHANGED
|
@@ -39,7 +39,7 @@ Use subagents only for genuinely parallel, independent tasks. For building, test
|
|
|
39
39
|
- If a build or test fails, report the error clearly — don't try to fix it unless asked
|
|
40
40
|
- If running a local server, report the URL (e.g., http://localhost:3000)
|
|
41
41
|
|
|
42
|
-
## Run Command
|
|
42
|
+
## Run Command
|
|
43
43
|
|
|
44
44
|
When the build succeeds and the task involves running a server or app, output a ready-to-paste run command using **absolute paths** so the user can launch it from any terminal. Format it exactly like this:
|
|
45
45
|
|