@yemi33/minions 0.1.533 → 0.1.535
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 +5 -1
- package/engine/pipeline.js +1 -2
- package/engine.js +257 -223
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.535 (2026-04-07)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
- Convert engine.js safeWrite calls to mutateWorkItems/mutatePullRequests (#416)
|
|
4
7
|
|
|
5
8
|
### Fixes
|
|
9
|
+
- update pipeline work item status on dispatch (#443)
|
|
6
10
|
- convert blocking spawnSync/execSync to async execAsync (#447)
|
|
7
11
|
|
|
8
12
|
## 0.1.532 (2026-04-07)
|
package/engine/pipeline.js
CHANGED
|
@@ -184,7 +184,6 @@ function executeTaskStage(stage, stageState, run, config) {
|
|
|
184
184
|
createdIds.push(id);
|
|
185
185
|
}
|
|
186
186
|
});
|
|
187
|
-
|
|
188
187
|
return { status: PIPELINE_STATUS.RUNNING, artifacts: { workItems: createdIds } };
|
|
189
188
|
}
|
|
190
189
|
|
|
@@ -278,7 +277,7 @@ async function executePlanStage(stage, stageState, run, config) {
|
|
|
278
277
|
|
|
279
278
|
safeWrite(filePath, content);
|
|
280
279
|
|
|
281
|
-
// Create plan-to-prd work item
|
|
280
|
+
// Create plan-to-prd work item — atomic write to prevent race with dispatch status updates
|
|
282
281
|
const wiPath = path.join(__dirname, '..', 'work-items.json');
|
|
283
282
|
const wiId = `PL-${run.runId.slice(4, 12)}-${stage.id}-prd`;
|
|
284
283
|
mutateWorkItems(wiPath, workItems => {
|
package/engine.js
CHANGED
|
@@ -92,6 +92,8 @@ const safeJson = shared.safeJson;
|
|
|
92
92
|
const safeRead = shared.safeRead;
|
|
93
93
|
const safeWrite = shared.safeWrite;
|
|
94
94
|
const mutateJsonFileLocked = shared.mutateJsonFileLocked;
|
|
95
|
+
const mutateWorkItems = shared.mutateWorkItems;
|
|
96
|
+
const mutatePullRequests = shared.mutatePullRequests;
|
|
95
97
|
const withFileLock = shared.withFileLock;
|
|
96
98
|
|
|
97
99
|
// ─── Dispatch Management (extracted to engine/dispatch.js) ───────────────────
|
|
@@ -1005,15 +1007,15 @@ function autoCleanPrdWorkItems(prdFile, config) {
|
|
|
1005
1007
|
const deletedIds = [];
|
|
1006
1008
|
for (const wiPath of wiPaths) {
|
|
1007
1009
|
try {
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1010
|
+
mutateWorkItems(wiPath, items => {
|
|
1011
|
+
const filtered = items.filter(w => {
|
|
1012
|
+
if (w.sourcePlan === prdFile && (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.FAILED)) {
|
|
1013
|
+
deletedIds.push(w.id); return false;
|
|
1014
|
+
}
|
|
1015
|
+
return true;
|
|
1016
|
+
});
|
|
1017
|
+
if (filtered.length < items.length) return filtered;
|
|
1015
1018
|
});
|
|
1016
|
-
if (filtered.length < items.length) safeWrite(wiPath, filtered);
|
|
1017
1019
|
} catch (e) { log('warn', 'auto-clean PRD work items: ' + e.message); }
|
|
1018
1020
|
}
|
|
1019
1021
|
if (deletedIds.length > 0) {
|
|
@@ -1237,71 +1239,71 @@ function materializePlansAsWorkItems(config) {
|
|
|
1237
1239
|
let totalCreated = 0;
|
|
1238
1240
|
for (const [projName, { project, items: projItems }] of itemsByProject) {
|
|
1239
1241
|
const wiPath = project ? projectWorkItemsPath(project) : path.join(MINIONS_DIR, 'work-items.json');
|
|
1240
|
-
const existingItems = safeJson(wiPath) || [];
|
|
1241
1242
|
let created = 0;
|
|
1242
1243
|
const newlyCreatedIds = new Set(); // tracks IDs created in this pass for reconciliation scoping
|
|
1243
1244
|
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1245
|
+
mutateWorkItems(wiPath, existingItems => {
|
|
1246
|
+
for (const item of projItems) {
|
|
1247
|
+
// Skip if already materialized — work item ID = PRD item ID, check all projects
|
|
1248
|
+
let alreadyExists = existingItems.some(w => w.id === item.id);
|
|
1249
|
+
if (!alreadyExists) {
|
|
1250
|
+
for (const p of allProjects) {
|
|
1251
|
+
if (p.name === projName) continue;
|
|
1252
|
+
const otherItems = safeJson(projectWorkItemsPath(p)) || [];
|
|
1253
|
+
if (otherItems.some(w => w.id === item.id)) { alreadyExists = true; break; }
|
|
1254
|
+
}
|
|
1252
1255
|
}
|
|
1256
|
+
if (alreadyExists) continue;
|
|
1257
|
+
// Skip items involved in dependency cycles
|
|
1258
|
+
if (cycleSet.has(item.id)) continue;
|
|
1259
|
+
|
|
1260
|
+
const id = item.id; // Work item ID = PRD item ID — no indirection
|
|
1261
|
+
const complexity = item.estimated_complexity || 'medium';
|
|
1262
|
+
const criteria = (item.acceptance_criteria || []).map(c => `- ${c}`).join('\n');
|
|
1263
|
+
|
|
1264
|
+
const newItem = {
|
|
1265
|
+
id,
|
|
1266
|
+
title: `Implement: ${item.name}`,
|
|
1267
|
+
type: complexity === 'large' ? 'implement:large' : 'implement',
|
|
1268
|
+
priority: item.priority || 'medium',
|
|
1269
|
+
description: `${item.description || ''}\n\n**Plan:** ${file}\n**Plan Item:** ${item.id}\n**Complexity:** ${complexity}${criteria ? '\n\n**Acceptance Criteria:**\n' + criteria : ''}`,
|
|
1270
|
+
status: 'pending',
|
|
1271
|
+
created: ts(),
|
|
1272
|
+
createdBy: 'engine:plan-discovery',
|
|
1273
|
+
sourcePlan: file,
|
|
1274
|
+
depends_on: item.depends_on || [],
|
|
1275
|
+
branchStrategy: plan.branch_strategy || 'parallel',
|
|
1276
|
+
featureBranch: plan.feature_branch || null,
|
|
1277
|
+
project: item.project || plan.project || null,
|
|
1278
|
+
};
|
|
1279
|
+
existingItems.push(newItem);
|
|
1280
|
+
newlyCreatedIds.add(id);
|
|
1281
|
+
created++;
|
|
1253
1282
|
}
|
|
1254
|
-
if (alreadyExists) continue;
|
|
1255
|
-
// Skip items involved in dependency cycles
|
|
1256
|
-
if (cycleSet.has(item.id)) continue;
|
|
1257
|
-
|
|
1258
|
-
const id = item.id; // Work item ID = PRD item ID — no indirection
|
|
1259
|
-
const complexity = item.estimated_complexity || 'medium';
|
|
1260
|
-
const criteria = (item.acceptance_criteria || []).map(c => `- ${c}`).join('\n');
|
|
1261
|
-
|
|
1262
|
-
const newItem = {
|
|
1263
|
-
id,
|
|
1264
|
-
title: `Implement: ${item.name}`,
|
|
1265
|
-
type: complexity === 'large' ? 'implement:large' : 'implement',
|
|
1266
|
-
priority: item.priority || 'medium',
|
|
1267
|
-
description: `${item.description || ''}\n\n**Plan:** ${file}\n**Plan Item:** ${item.id}\n**Complexity:** ${complexity}${criteria ? '\n\n**Acceptance Criteria:**\n' + criteria : ''}`,
|
|
1268
|
-
status: 'pending',
|
|
1269
|
-
created: ts(),
|
|
1270
|
-
createdBy: 'engine:plan-discovery',
|
|
1271
|
-
sourcePlan: file,
|
|
1272
|
-
depends_on: item.depends_on || [],
|
|
1273
|
-
branchStrategy: plan.branch_strategy || 'parallel',
|
|
1274
|
-
featureBranch: plan.feature_branch || null,
|
|
1275
|
-
project: item.project || plan.project || null,
|
|
1276
|
-
};
|
|
1277
|
-
existingItems.push(newItem);
|
|
1278
|
-
newlyCreatedIds.add(id);
|
|
1279
|
-
created++;
|
|
1280
|
-
}
|
|
1281
1283
|
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1284
|
+
if (created > 0) {
|
|
1285
|
+
// Reconciliation: exact prdItems match only, scoped to newly created items
|
|
1286
|
+
const allPrsForReconcile = allProjects.flatMap(p => safeJson(projectPrPath(p)) || []);
|
|
1287
|
+
const reconciled = reconcileItemsWithPrs(existingItems, allPrsForReconcile, { onlyIds: newlyCreatedIds });
|
|
1288
|
+
if (reconciled > 0) log('info', `Plan reconciliation: marked ${reconciled} item(s) as done → ${projName}`);
|
|
1289
|
+
|
|
1290
|
+
// PRD removal sync: cancel pending work items whose PRD item was removed from the plan
|
|
1291
|
+
const currentPrdIds = new Set(plan.missing_features.map(f => f.id));
|
|
1292
|
+
let cancelled = 0;
|
|
1293
|
+
for (const wi of existingItems) {
|
|
1294
|
+
if (wi.status !== WI_STATUS.PENDING || wi.sourcePlan !== file) continue;
|
|
1295
|
+
if (!currentPrdIds.has(wi.id)) {
|
|
1296
|
+
wi.status = WI_STATUS.CANCELLED;
|
|
1297
|
+
wi.cancelledAt = ts();
|
|
1298
|
+
wi.cancelReason = `PRD item removed from ${file}`;
|
|
1299
|
+
cancelled++;
|
|
1300
|
+
}
|
|
1298
1301
|
}
|
|
1299
|
-
|
|
1300
|
-
if (cancelled > 0) log('info', `Plan sync: cancelled ${cancelled} item(s) removed from ${file} → ${projName}`);
|
|
1302
|
+
if (cancelled > 0) log('info', `Plan sync: cancelled ${cancelled} item(s) removed from ${file} → ${projName}`);
|
|
1301
1303
|
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
}
|
|
1304
|
+
log('info', `Plan discovery: created ${created} work item(s) from ${file} → ${projName}`);
|
|
1305
|
+
}
|
|
1306
|
+
});
|
|
1305
1307
|
totalCreated += created;
|
|
1306
1308
|
}
|
|
1307
1309
|
|
|
@@ -1336,11 +1338,11 @@ function clearPendingHumanFeedbackFlag(projectMeta, prId) {
|
|
|
1336
1338
|
if (!prId) return;
|
|
1337
1339
|
try {
|
|
1338
1340
|
const prsPath = projectPrPath(projectMeta);
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1341
|
+
mutatePullRequests(prsPath, prs => {
|
|
1342
|
+
const target = prs.find(p => p.id === prId);
|
|
1343
|
+
if (!target?.humanFeedback?.pendingFix) return;
|
|
1344
|
+
target.humanFeedback.pendingFix = false;
|
|
1345
|
+
});
|
|
1344
1346
|
} catch (e) { log('warn', 'clear pending human feedback flag: ' + e.message); }
|
|
1345
1347
|
}
|
|
1346
1348
|
|
|
@@ -1488,12 +1490,12 @@ function discoverFromPrs(config, project) {
|
|
|
1488
1490
|
// Mark notified to prevent duplicate alerts
|
|
1489
1491
|
try {
|
|
1490
1492
|
const prPath = projectPrPath(project);
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
}
|
|
1493
|
+
mutatePullRequests(prPath, prs => {
|
|
1494
|
+
const target = prs.find(p => p.id === pr.id);
|
|
1495
|
+
if (target) {
|
|
1496
|
+
target._buildFailNotified = true;
|
|
1497
|
+
}
|
|
1498
|
+
});
|
|
1497
1499
|
} catch (e) { log('warn', 'mark build fail notified: ' + e.message); }
|
|
1498
1500
|
}
|
|
1499
1501
|
}
|
|
@@ -1738,14 +1740,13 @@ function discoverFromWorkItems(config, project) {
|
|
|
1738
1740
|
}
|
|
1739
1741
|
|
|
1740
1742
|
// Write back updated statuses (always, since we mark items dispatched before newWork check)
|
|
1741
|
-
if (newWork.length > 0) {
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1743
|
+
if (newWork.length > 0 || needsWrite) {
|
|
1744
|
+
mutateWorkItems(projectWorkItemsPath(project), () => items);
|
|
1745
|
+
if (newWork.length > 0) {
|
|
1746
|
+
for (const s of prdSyncQueue) syncPrdItemStatus(s.id, 'dispatched', s.sourcePlan);
|
|
1747
|
+
}
|
|
1745
1748
|
}
|
|
1746
1749
|
|
|
1747
|
-
if (needsWrite) safeWrite(projectWorkItemsPath(project), items);
|
|
1748
|
-
|
|
1749
1750
|
const skipTotal = skipped.gated + skipped.noAgent;
|
|
1750
1751
|
if (skipTotal > 0) {
|
|
1751
1752
|
log('debug', `Work item discovery (${project?.name}): skipped ${skipTotal} items (${skipped.gated} gated, ${skipped.noAgent} no agent)`);
|
|
@@ -1839,53 +1840,50 @@ function materializeSpecsAsWorkItems(config, project) {
|
|
|
1839
1840
|
if (recentSpecs.length === 0) return;
|
|
1840
1841
|
|
|
1841
1842
|
const wiPath = projectWorkItemsPath(project);
|
|
1842
|
-
const existingItems = safeJson(wiPath) || [];
|
|
1843
1843
|
let created = 0;
|
|
1844
1844
|
|
|
1845
|
-
|
|
1846
|
-
const
|
|
1847
|
-
|
|
1848
|
-
const
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
if (matchedSpecs.length === 0) {
|
|
1854
|
-
tracker.processedPrs[pr.id] = { processedAt: ts(), matched: false };
|
|
1855
|
-
continue;
|
|
1856
|
-
}
|
|
1845
|
+
mutateWorkItems(wiPath, existingItems => {
|
|
1846
|
+
for (const pr of mergedPrs) {
|
|
1847
|
+
const prBranch = (pr.branch || '').toLowerCase();
|
|
1848
|
+
const matchedSpecs = recentSpecs.filter(doc => {
|
|
1849
|
+
const msg = doc.message.toLowerCase();
|
|
1850
|
+
// Match any doc whose commit message references this PR's branch
|
|
1851
|
+
return prBranch && msg.includes(prBranch.split('/').pop());
|
|
1852
|
+
});
|
|
1857
1853
|
|
|
1858
|
-
|
|
1859
|
-
|
|
1854
|
+
if (matchedSpecs.length === 0) {
|
|
1855
|
+
tracker.processedPrs[pr.id] = { processedAt: ts(), matched: false };
|
|
1856
|
+
continue;
|
|
1857
|
+
}
|
|
1860
1858
|
|
|
1861
|
-
const
|
|
1862
|
-
|
|
1859
|
+
for (const doc of matchedSpecs) {
|
|
1860
|
+
if (existingItems.some(i => i.sourceSpec === doc.file)) continue;
|
|
1863
1861
|
|
|
1864
|
-
|
|
1862
|
+
const info = extractSpecInfo(doc.file, root);
|
|
1863
|
+
if (!info) continue;
|
|
1865
1864
|
|
|
1866
|
-
|
|
1867
|
-
id: newId,
|
|
1868
|
-
type: 'implement',
|
|
1869
|
-
title: `Implement: ${info.title}`,
|
|
1870
|
-
description: `Implementation work from merged spec.\n\n**Spec:** \`${doc.file}\`\n**Source PR:** ${pr.id} — ${pr.title || ''}\n**PR URL:** ${pr.url || 'N/A'}\n\n## Summary\n\n${info.summary}\n\nRead the full spec at \`${doc.file}\` before starting.`,
|
|
1871
|
-
priority: info.priority,
|
|
1872
|
-
status: 'queued',
|
|
1873
|
-
created: ts(),
|
|
1874
|
-
createdBy: 'engine:spec-discovery',
|
|
1875
|
-
sourceSpec: doc.file,
|
|
1876
|
-
sourcePr: pr.id
|
|
1877
|
-
});
|
|
1878
|
-
created++;
|
|
1879
|
-
log('info', `Spec discovery: created ${newId} "${info.title}" from PR ${pr.id} in ${project.name}`);
|
|
1880
|
-
}
|
|
1865
|
+
const newId = 'SP-' + shared.uid();
|
|
1881
1866
|
|
|
1882
|
-
|
|
1883
|
-
|
|
1867
|
+
existingItems.push({
|
|
1868
|
+
id: newId,
|
|
1869
|
+
type: 'implement',
|
|
1870
|
+
title: `Implement: ${info.title}`,
|
|
1871
|
+
description: `Implementation work from merged spec.\n\n**Spec:** \`${doc.file}\`\n**Source PR:** ${pr.id} — ${pr.title || ''}\n**PR URL:** ${pr.url || 'N/A'}\n\n## Summary\n\n${info.summary}\n\nRead the full spec at \`${doc.file}\` before starting.`,
|
|
1872
|
+
priority: info.priority,
|
|
1873
|
+
status: 'queued',
|
|
1874
|
+
created: ts(),
|
|
1875
|
+
createdBy: 'engine:spec-discovery',
|
|
1876
|
+
sourceSpec: doc.file,
|
|
1877
|
+
sourcePr: pr.id
|
|
1878
|
+
});
|
|
1879
|
+
created++;
|
|
1880
|
+
log('info', `Spec discovery: created ${newId} "${info.title}" from PR ${pr.id} in ${project.name}`);
|
|
1881
|
+
}
|
|
1884
1882
|
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
}
|
|
1888
|
-
|
|
1883
|
+
tracker.processedPrs[pr.id] = { processedAt: ts(), matched: true, specs: matchedSpecs.map(d => d.file) };
|
|
1884
|
+
}
|
|
1885
|
+
});
|
|
1886
|
+
mutateJsonFileLocked(trackerPath, () => tracker, { defaultValue: {} });
|
|
1889
1887
|
}
|
|
1890
1888
|
|
|
1891
1889
|
/**
|
|
@@ -1939,13 +1937,26 @@ function discoverCentralWorkItems(config) {
|
|
|
1939
1937
|
const items = safeJson(centralPath) || [];
|
|
1940
1938
|
const projects = getProjects(config);
|
|
1941
1939
|
const newWork = [];
|
|
1940
|
+
// Collect mutations to apply atomically inside lock callback (avoids TOCTOU)
|
|
1941
|
+
const mutations = new Map(); // item.id → { field: value, ... }
|
|
1942
1942
|
|
|
1943
1943
|
for (const item of items) {
|
|
1944
1944
|
try {
|
|
1945
1945
|
if (item.status !== WI_STATUS.QUEUED && item.status !== WI_STATUS.PENDING) continue;
|
|
1946
1946
|
|
|
1947
1947
|
const key = `central-work-${item.id}`;
|
|
1948
|
-
if
|
|
1948
|
+
// Self-heal: if already dispatched but work item is still pending, fix the status
|
|
1949
|
+
if (isAlreadyDispatched(key)) {
|
|
1950
|
+
const m = {};
|
|
1951
|
+
if (item.status === WI_STATUS.PENDING) { m.status = WI_STATUS.DISPATCHED; }
|
|
1952
|
+
if (!item.dispatched_to) {
|
|
1953
|
+
const existing = getDispatch().active?.find(d => d.meta?.dispatchKey === key);
|
|
1954
|
+
if (existing?.agent) { m.dispatched_to = existing.agent; }
|
|
1955
|
+
}
|
|
1956
|
+
if (Object.keys(m).length > 0) mutations.set(item.id, m);
|
|
1957
|
+
continue;
|
|
1958
|
+
}
|
|
1959
|
+
if (isOnCooldown(key, 0)) continue;
|
|
1949
1960
|
|
|
1950
1961
|
const workType = item.type || 'implement';
|
|
1951
1962
|
const isFanOut = item.scope === 'fan-out';
|
|
@@ -2029,11 +2040,13 @@ function discoverCentralWorkItems(config) {
|
|
|
2029
2040
|
});
|
|
2030
2041
|
}
|
|
2031
2042
|
|
|
2032
|
-
item.
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2043
|
+
mutations.set(item.id, {
|
|
2044
|
+
status: WI_STATUS.DISPATCHED,
|
|
2045
|
+
dispatched_at: ts(),
|
|
2046
|
+
dispatched_to: idleAgents.map(a => a.id).join(', '),
|
|
2047
|
+
scope: 'fan-out',
|
|
2048
|
+
fanOutAgents: idleAgents.map(a => a.id),
|
|
2049
|
+
});
|
|
2037
2050
|
setCooldown(key);
|
|
2038
2051
|
log('info', `Fan-out: ${item.id} dispatched to ${idleAgents.length} agents: ${idleAgents.map(a => a.name).join(', ')}`);
|
|
2039
2052
|
|
|
@@ -2085,11 +2098,10 @@ function discoverCentralWorkItems(config) {
|
|
|
2085
2098
|
const cpCount = (item._checkpointCount || 0) + 1;
|
|
2086
2099
|
if (cpCount > 3) {
|
|
2087
2100
|
log('warn', `Work item ${item.id} exceeded 3 checkpoint-resumes — marking as needs-human-review`);
|
|
2088
|
-
item.status
|
|
2089
|
-
item._checkpointCount = cpCount;
|
|
2101
|
+
mutations.set(item.id, { status: WI_STATUS.NEEDS_REVIEW, _checkpointCount: cpCount });
|
|
2090
2102
|
continue;
|
|
2091
2103
|
}
|
|
2092
|
-
item._checkpointCount
|
|
2104
|
+
mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, { _checkpointCount: cpCount }));
|
|
2093
2105
|
const cpSummary = [
|
|
2094
2106
|
`## Checkpoint (Resume #${cpCount}/3)`,
|
|
2095
2107
|
'',
|
|
@@ -2117,7 +2129,7 @@ function discoverCentralWorkItems(config) {
|
|
|
2117
2129
|
vars.notes_content = '';
|
|
2118
2130
|
try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch { /* optional */ }
|
|
2119
2131
|
// Track expected plan filename in meta for chainPlanToPrd
|
|
2120
|
-
item._planFileName
|
|
2132
|
+
mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, { _planFileName: planFileName }));
|
|
2121
2133
|
}
|
|
2122
2134
|
|
|
2123
2135
|
// Inject plan-to-prd variables — read the plan file content for the playbook
|
|
@@ -2172,6 +2184,13 @@ function discoverCentralWorkItems(config) {
|
|
|
2172
2184
|
continue;
|
|
2173
2185
|
}
|
|
2174
2186
|
|
|
2187
|
+
const dispatchMutation = {
|
|
2188
|
+
status: WI_STATUS.DISPATCHED,
|
|
2189
|
+
dispatched_at: ts(),
|
|
2190
|
+
dispatched_to: agentId,
|
|
2191
|
+
};
|
|
2192
|
+
mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, dispatchMutation));
|
|
2193
|
+
|
|
2175
2194
|
newWork.push({
|
|
2176
2195
|
type: workType,
|
|
2177
2196
|
agent: agentId,
|
|
@@ -2179,18 +2198,25 @@ function discoverCentralWorkItems(config) {
|
|
|
2179
2198
|
agentRole,
|
|
2180
2199
|
task: item.title || item.description?.slice(0, 80) || item.id,
|
|
2181
2200
|
prompt,
|
|
2182
|
-
meta: { dispatchKey: key, source: 'central-work-item', item, planFileName: item.planFile || item._planFileName || null, branch: item.branch || item.featureBranch || `work/${item.id}` }
|
|
2201
|
+
meta: { dispatchKey: key, source: 'central-work-item', item, planFileName: item.planFile || mutations.get(item.id)?._planFileName || null, branch: item.branch || item.featureBranch || `work/${item.id}` }
|
|
2183
2202
|
});
|
|
2184
2203
|
|
|
2185
|
-
item.status = WI_STATUS.DISPATCHED;
|
|
2186
|
-
item.dispatched_at = ts();
|
|
2187
|
-
item.dispatched_to = agentId;
|
|
2188
2204
|
setCooldown(key);
|
|
2189
2205
|
}
|
|
2190
2206
|
} catch (err) { log('warn', `discoverCentralWorkItems: skipping ${item.id}: ${err.message}`); }
|
|
2191
2207
|
}
|
|
2192
2208
|
|
|
2193
|
-
if (
|
|
2209
|
+
if (mutations.size > 0) {
|
|
2210
|
+
// True atomic read-modify-write — applies mutations to fresh locked data
|
|
2211
|
+
mutateJsonFileLocked(centralPath, (freshItems) => {
|
|
2212
|
+
if (!Array.isArray(freshItems)) freshItems = [];
|
|
2213
|
+
for (const fi of freshItems) {
|
|
2214
|
+
const m = mutations.get(fi.id);
|
|
2215
|
+
if (m) Object.assign(fi, m);
|
|
2216
|
+
}
|
|
2217
|
+
return freshItems;
|
|
2218
|
+
}, { defaultValue: [] });
|
|
2219
|
+
}
|
|
2194
2220
|
return newWork;
|
|
2195
2221
|
}
|
|
2196
2222
|
|
|
@@ -2238,24 +2264,33 @@ function discoverWork(config) {
|
|
|
2238
2264
|
if (scheduledWork.length > 0) {
|
|
2239
2265
|
const { createMeeting, getMeetings } = require('./engine/meeting');
|
|
2240
2266
|
const centralPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
2241
|
-
|
|
2242
|
-
|
|
2267
|
+
// Separate meetings (no work-items write) from task items
|
|
2268
|
+
const taskItems = [];
|
|
2243
2269
|
for (const item of scheduledWork) {
|
|
2244
2270
|
if (item.type === WORK_TYPE.MEETING) {
|
|
2245
|
-
// Create a real multi-agent meeting instead of a single-agent work item
|
|
2246
2271
|
const sched = (config.schedules || []).find(s => s.id === item._scheduleId);
|
|
2247
2272
|
const participants = (sched && sched.participants) || [];
|
|
2248
2273
|
const meeting = createMeeting({ title: item.title, agenda: item.description, participants });
|
|
2249
2274
|
log('info', `Scheduled meeting created: ${item._scheduleId} → ${meeting.id} (${participants.length} participants)`);
|
|
2250
2275
|
} else {
|
|
2251
|
-
|
|
2252
|
-
items.push(item);
|
|
2253
|
-
added++;
|
|
2254
|
-
log('info', `Scheduled task fired: ${item._scheduleId} → ${item.title}`);
|
|
2255
|
-
}
|
|
2276
|
+
taskItems.push(item);
|
|
2256
2277
|
}
|
|
2257
2278
|
}
|
|
2258
|
-
if (
|
|
2279
|
+
if (taskItems.length > 0) {
|
|
2280
|
+
// Atomic write — prevents race with dispatch status updates on central work-items.json
|
|
2281
|
+
mutateJsonFileLocked(centralPath, (items) => {
|
|
2282
|
+
if (!Array.isArray(items)) items = [];
|
|
2283
|
+
let added = 0;
|
|
2284
|
+
for (const item of taskItems) {
|
|
2285
|
+
if (!items.some(i => i._scheduleId === item._scheduleId && i.status !== WI_STATUS.DONE && i.status !== WI_STATUS.FAILED)) {
|
|
2286
|
+
items.push(item);
|
|
2287
|
+
added++;
|
|
2288
|
+
log('info', `Scheduled task fired: ${item._scheduleId} → ${item.title}`);
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
return items;
|
|
2292
|
+
}, { defaultValue: [] });
|
|
2293
|
+
}
|
|
2259
2294
|
}
|
|
2260
2295
|
} catch (e) { log('warn', 'discover scheduled work: ' + e.message); }
|
|
2261
2296
|
|
|
@@ -2435,78 +2470,77 @@ async function tickInner() {
|
|
|
2435
2470
|
for (const project of projects) {
|
|
2436
2471
|
try {
|
|
2437
2472
|
const wiPath = projectWorkItemsPath(project);
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
mutateDispatch((dp) => {
|
|
2466
|
-
dp.completed = dp.completed.filter(d => d.meta?.dispatchKey !== key);
|
|
2467
|
-
return dp;
|
|
2468
|
-
});
|
|
2469
|
-
} catch (e) { log('warn', 'stall recovery clear dispatch: ' + e.message); }
|
|
2470
|
-
|
|
2471
|
-
// Clear cooldown so item isn't blocked by exponential backoff
|
|
2472
|
-
try {
|
|
2473
|
-
const key = `work-${project.name}-${item.id}`;
|
|
2474
|
-
if (dispatchCooldowns.has(key)) {
|
|
2475
|
-
dispatchCooldowns.delete(key);
|
|
2476
|
-
saveCooldowns();
|
|
2477
|
-
}
|
|
2478
|
-
} catch (e) { log('warn', 'stall recovery clear cooldown: ' + e.message); }
|
|
2479
|
-
}
|
|
2480
|
-
}
|
|
2481
|
-
|
|
2482
|
-
// Un-fail dependent items that were cascade-failed
|
|
2483
|
-
if (changed) {
|
|
2484
|
-
const retriedIds = new Set(items.filter(w => w.status === WI_STATUS.PENDING && w._retryCount === 0).map(w => w.id));
|
|
2485
|
-
for (const dep of items) {
|
|
2486
|
-
if (dep.status === WI_STATUS.FAILED && !isItemCompleted(dep) && dep.failReason === 'Dependency failed — cannot proceed') {
|
|
2487
|
-
const blockers = (dep.depends_on || []).filter(d => retriedIds.has(d));
|
|
2488
|
-
if (blockers.length > 0) {
|
|
2489
|
-
log('info', `Stall recovery: un-failing ${dep.id} (blocker ${blockers.join(',')} retried)`);
|
|
2490
|
-
dep.status = WI_STATUS.PENDING;
|
|
2491
|
-
dep._retryCount = 0;
|
|
2492
|
-
delete dep.failReason;
|
|
2493
|
-
delete dep.failedAt;
|
|
2494
|
-
delete dep.dispatched_at;
|
|
2495
|
-
delete dep.dispatched_to;
|
|
2496
|
-
// Clear dispatch entries for this dependent too
|
|
2497
|
-
try {
|
|
2498
|
-
const key = `work-${project.name}-${dep.id}`;
|
|
2473
|
+
mutateWorkItems(wiPath, items => {
|
|
2474
|
+
let changed = false;
|
|
2475
|
+
const failedIds = new Set(items.filter(w => w.status === WI_STATUS.FAILED).map(w => w.id));
|
|
2476
|
+
const pendingWithBlockedDeps = items.filter(w =>
|
|
2477
|
+
w.status === WI_STATUS.PENDING && (w.depends_on || []).some(d => failedIds.has(d))
|
|
2478
|
+
);
|
|
2479
|
+
|
|
2480
|
+
if (pendingWithBlockedDeps.length > 0) {
|
|
2481
|
+
// Auto-retry failed items that are blocking others (transient errors)
|
|
2482
|
+
for (const item of items) {
|
|
2483
|
+
if (item.status !== WI_STATUS.FAILED || isItemCompleted(item)) continue;
|
|
2484
|
+
// Only retry if something depends on this item
|
|
2485
|
+
const isBlocking = items.some(w => w.status === WI_STATUS.PENDING && (w.depends_on || []).includes(item.id));
|
|
2486
|
+
if (!isBlocking) continue;
|
|
2487
|
+
|
|
2488
|
+
log('info', `Stall recovery: auto-retrying ${item.id} (blocking ${pendingWithBlockedDeps.filter(w => (w.depends_on || []).includes(item.id)).length} items)`);
|
|
2489
|
+
item.status = WI_STATUS.PENDING;
|
|
2490
|
+
item._retryCount = 0;
|
|
2491
|
+
delete item.failReason;
|
|
2492
|
+
delete item.failedAt;
|
|
2493
|
+
delete item.dispatched_at;
|
|
2494
|
+
delete item.dispatched_to;
|
|
2495
|
+
changed = true;
|
|
2496
|
+
|
|
2497
|
+
// Clear completed dispatch entries so isAlreadyDispatched doesn't block re-dispatch
|
|
2498
|
+
try {
|
|
2499
|
+
const key = `work-${project.name}-${item.id}`;
|
|
2499
2500
|
mutateDispatch((dp) => {
|
|
2500
2501
|
dp.completed = dp.completed.filter(d => d.meta?.dispatchKey !== key);
|
|
2501
2502
|
return dp;
|
|
2502
2503
|
});
|
|
2503
|
-
} catch (e) { log('warn', 'stall recovery clear
|
|
2504
|
-
|
|
2504
|
+
} catch (e) { log('warn', 'stall recovery clear dispatch: ' + e.message); }
|
|
2505
|
+
|
|
2506
|
+
// Clear cooldown so item isn't blocked by exponential backoff
|
|
2507
|
+
try {
|
|
2508
|
+
const key = `work-${project.name}-${item.id}`;
|
|
2509
|
+
if (dispatchCooldowns.has(key)) {
|
|
2510
|
+
dispatchCooldowns.delete(key);
|
|
2511
|
+
saveCooldowns();
|
|
2512
|
+
}
|
|
2513
|
+
} catch (e) { log('warn', 'stall recovery clear cooldown: ' + e.message); }
|
|
2505
2514
|
}
|
|
2506
2515
|
}
|
|
2507
|
-
}
|
|
2508
2516
|
|
|
2509
|
-
|
|
2517
|
+
// Un-fail dependent items that were cascade-failed
|
|
2518
|
+
if (changed) {
|
|
2519
|
+
const retriedIds = new Set(items.filter(w => w.status === WI_STATUS.PENDING && w._retryCount === 0).map(w => w.id));
|
|
2520
|
+
for (const dep of items) {
|
|
2521
|
+
if (dep.status === WI_STATUS.FAILED && !isItemCompleted(dep) && dep.failReason === 'Dependency failed — cannot proceed') {
|
|
2522
|
+
const blockers = (dep.depends_on || []).filter(d => retriedIds.has(d));
|
|
2523
|
+
if (blockers.length > 0) {
|
|
2524
|
+
log('info', `Stall recovery: un-failing ${dep.id} (blocker ${blockers.join(',')} retried)`);
|
|
2525
|
+
dep.status = WI_STATUS.PENDING;
|
|
2526
|
+
dep._retryCount = 0;
|
|
2527
|
+
delete dep.failReason;
|
|
2528
|
+
delete dep.failedAt;
|
|
2529
|
+
delete dep.dispatched_at;
|
|
2530
|
+
delete dep.dispatched_to;
|
|
2531
|
+
// Clear dispatch entries for this dependent too
|
|
2532
|
+
try {
|
|
2533
|
+
const key = `work-${project.name}-${dep.id}`;
|
|
2534
|
+
mutateDispatch((dp) => {
|
|
2535
|
+
dp.completed = dp.completed.filter(d => d.meta?.dispatchKey !== key);
|
|
2536
|
+
return dp;
|
|
2537
|
+
});
|
|
2538
|
+
} catch (e) { log('warn', 'stall recovery clear dependent dispatch: ' + e.message); }
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
});
|
|
2510
2544
|
} catch (e) { log('warn', 'stall recovery process project: ' + e.message); }
|
|
2511
2545
|
}
|
|
2512
2546
|
}
|
|
@@ -2591,19 +2625,19 @@ async function tickInner() {
|
|
|
2591
2625
|
? path.join(ENGINE_DIR, '..', 'work-items.json')
|
|
2592
2626
|
: item.meta.project?.name ? projectWorkItemsPath({ name: item.meta.project.name, localPath: item.meta.project.localPath }) : null;
|
|
2593
2627
|
if (wiPath) {
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
}
|
|
2628
|
+
mutateWorkItems(wiPath, items => {
|
|
2629
|
+
const wi = items.find(i => i.id === item.meta.item.id);
|
|
2630
|
+
if (wi && wi.status === WI_STATUS.DISPATCHED) {
|
|
2631
|
+
// completeDispatch didn't update the work item — re-queue manually
|
|
2632
|
+
wi.status = WI_STATUS.PENDING;
|
|
2633
|
+
wi._retryCount = (wi._retryCount || 0) + 1;
|
|
2634
|
+
wi._lastRetryReason = 'spawnAgent returned null';
|
|
2635
|
+
wi._lastRetryAt = ts();
|
|
2636
|
+
delete wi.dispatched_at;
|
|
2637
|
+
delete wi.dispatched_to;
|
|
2638
|
+
log('info', `Re-queued ${item.meta.item.id} as pending (retry ${wi._retryCount})`);
|
|
2639
|
+
}
|
|
2640
|
+
});
|
|
2607
2641
|
}
|
|
2608
2642
|
} catch (e) { log('warn', `Failed to re-queue work item after spawn failure: ${e.message}`); }
|
|
2609
2643
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.535",
|
|
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"
|