@yemi33/minions 0.1.534 → 0.1.536

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +9 -1
  2. package/engine.js +200 -210
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,6 +1,14 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.534 (2026-04-07)
3
+ ## 0.1.536 (2026-04-07)
4
+
5
+ ### Fixes
6
+ - plan regenerate/execute always queues fresh plan-to-prd (#449)
7
+
8
+ ## 0.1.535 (2026-04-07)
9
+
10
+ ### Features
11
+ - Convert engine.js safeWrite calls to mutateWorkItems/mutatePullRequests (#416)
4
12
 
5
13
  ### Fixes
6
14
  - update pipeline work item status on dispatch (#443)
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
- const items = safeJson(wiPath);
1009
- if (!items) continue;
1010
- const filtered = items.filter(w => {
1011
- if (w.sourcePlan === prdFile && (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.FAILED)) {
1012
- deletedIds.push(w.id); return false;
1013
- }
1014
- return true;
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) {
@@ -1096,18 +1098,11 @@ function materializePlansAsWorkItems(config) {
1096
1098
  plan.sourcePlanModifiedAt = new Date(sourceMtime).toISOString();
1097
1099
  plan.lastSyncedFromPlan = ts();
1098
1100
 
1099
- // Handle PRD based on current status
1101
+ // Handle PRD based on current status — auto-regenerate for any state
1100
1102
  const prdStatus = plan.status || (plan.requires_approval ? 'awaiting-approval' : null);
1101
1103
 
1102
- // Approved/executing PRDs: flag as stale but don't disrupt in-flight work
1103
- if (prdStatus === 'approved' || prdStatus === 'completed') {
1104
- plan.planStale = true;
1105
- log('info', `PRD ${file} flagged as stale (plan revised while ${prdStatus}) — user can regenerate from dashboard`);
1106
- }
1107
-
1108
- // Awaiting-approval PRDs: invalidate, carry over completed items, delete old PRD, queue regeneration
1109
- if (prdStatus === 'awaiting-approval') {
1110
- log('info', `PRD ${file} invalidated (was awaiting-approval) — queuing regeneration from revised plan`);
1104
+ if (prdStatus) {
1105
+ log('info', `PRD ${file} invalidated (was ${prdStatus}) queuing regeneration from revised plan`);
1111
1106
 
1112
1107
  // Collect completed items to carry over to new PRD
1113
1108
  const completedStatuses = new Set(['done', 'in-pr', 'implemented']); // in-pr kept for backward compat
@@ -1140,7 +1135,7 @@ function materializePlansAsWorkItems(config) {
1140
1135
  title: `Regenerate PRD from revised plan: ${plan.source_plan}`,
1141
1136
  type: 'plan-to-prd',
1142
1137
  priority: 'high',
1143
- description: `Plan file: plans/${plan.source_plan}\nSource plan was revised while PRD was awaiting approval — regenerating.${completedContext}`,
1138
+ description: `Plan file: plans/${plan.source_plan}\nSource plan was revised (PRD was ${prdStatus}) — regenerating.${completedContext}`,
1144
1139
  status: 'pending',
1145
1140
  created: ts(),
1146
1141
  createdBy: 'engine:plan-revision',
@@ -1237,71 +1232,71 @@ function materializePlansAsWorkItems(config) {
1237
1232
  let totalCreated = 0;
1238
1233
  for (const [projName, { project, items: projItems }] of itemsByProject) {
1239
1234
  const wiPath = project ? projectWorkItemsPath(project) : path.join(MINIONS_DIR, 'work-items.json');
1240
- const existingItems = safeJson(wiPath) || [];
1241
1235
  let created = 0;
1242
1236
  const newlyCreatedIds = new Set(); // tracks IDs created in this pass for reconciliation scoping
1243
1237
 
1244
- for (const item of projItems) {
1245
- // Skip if already materialized — work item ID = PRD item ID, check all projects
1246
- let alreadyExists = existingItems.some(w => w.id === item.id);
1247
- if (!alreadyExists) {
1248
- for (const p of allProjects) {
1249
- if (p.name === projName) continue;
1250
- const otherItems = safeJson(projectWorkItemsPath(p)) || [];
1251
- if (otherItems.some(w => w.id === item.id)) { alreadyExists = true; break; }
1238
+ mutateWorkItems(wiPath, existingItems => {
1239
+ for (const item of projItems) {
1240
+ // Skip if already materialized — work item ID = PRD item ID, check all projects
1241
+ let alreadyExists = existingItems.some(w => w.id === item.id);
1242
+ if (!alreadyExists) {
1243
+ for (const p of allProjects) {
1244
+ if (p.name === projName) continue;
1245
+ const otherItems = safeJson(projectWorkItemsPath(p)) || [];
1246
+ if (otherItems.some(w => w.id === item.id)) { alreadyExists = true; break; }
1247
+ }
1252
1248
  }
1249
+ if (alreadyExists) continue;
1250
+ // Skip items involved in dependency cycles
1251
+ if (cycleSet.has(item.id)) continue;
1252
+
1253
+ const id = item.id; // Work item ID = PRD item ID — no indirection
1254
+ const complexity = item.estimated_complexity || 'medium';
1255
+ const criteria = (item.acceptance_criteria || []).map(c => `- ${c}`).join('\n');
1256
+
1257
+ const newItem = {
1258
+ id,
1259
+ title: `Implement: ${item.name}`,
1260
+ type: complexity === 'large' ? 'implement:large' : 'implement',
1261
+ priority: item.priority || 'medium',
1262
+ description: `${item.description || ''}\n\n**Plan:** ${file}\n**Plan Item:** ${item.id}\n**Complexity:** ${complexity}${criteria ? '\n\n**Acceptance Criteria:**\n' + criteria : ''}`,
1263
+ status: 'pending',
1264
+ created: ts(),
1265
+ createdBy: 'engine:plan-discovery',
1266
+ sourcePlan: file,
1267
+ depends_on: item.depends_on || [],
1268
+ branchStrategy: plan.branch_strategy || 'parallel',
1269
+ featureBranch: plan.feature_branch || null,
1270
+ project: item.project || plan.project || null,
1271
+ };
1272
+ existingItems.push(newItem);
1273
+ newlyCreatedIds.add(id);
1274
+ created++;
1253
1275
  }
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
1276
 
1282
- if (created > 0) {
1283
- // Reconciliation: exact prdItems match only, scoped to newly created items
1284
- const allPrsForReconcile = allProjects.flatMap(p => safeJson(projectPrPath(p)) || []);
1285
- const reconciled = reconcileItemsWithPrs(existingItems, allPrsForReconcile, { onlyIds: newlyCreatedIds });
1286
- if (reconciled > 0) log('info', `Plan reconciliation: marked ${reconciled} item(s) as done → ${projName}`);
1287
-
1288
- // PRD removal sync: cancel pending work items whose PRD item was removed from the plan
1289
- const currentPrdIds = new Set(plan.missing_features.map(f => f.id));
1290
- let cancelled = 0;
1291
- for (const wi of existingItems) {
1292
- if (wi.status !== WI_STATUS.PENDING || wi.sourcePlan !== file) continue;
1293
- if (!currentPrdIds.has(wi.id)) {
1294
- wi.status = WI_STATUS.CANCELLED;
1295
- wi.cancelledAt = ts();
1296
- wi.cancelReason = `PRD item removed from ${file}`;
1297
- cancelled++;
1277
+ if (created > 0) {
1278
+ // Reconciliation: exact prdItems match only, scoped to newly created items
1279
+ const allPrsForReconcile = allProjects.flatMap(p => safeJson(projectPrPath(p)) || []);
1280
+ const reconciled = reconcileItemsWithPrs(existingItems, allPrsForReconcile, { onlyIds: newlyCreatedIds });
1281
+ if (reconciled > 0) log('info', `Plan reconciliation: marked ${reconciled} item(s) as done → ${projName}`);
1282
+
1283
+ // PRD removal sync: cancel pending work items whose PRD item was removed from the plan
1284
+ const currentPrdIds = new Set(plan.missing_features.map(f => f.id));
1285
+ let cancelled = 0;
1286
+ for (const wi of existingItems) {
1287
+ if (wi.status !== WI_STATUS.PENDING || wi.sourcePlan !== file) continue;
1288
+ if (!currentPrdIds.has(wi.id)) {
1289
+ wi.status = WI_STATUS.CANCELLED;
1290
+ wi.cancelledAt = ts();
1291
+ wi.cancelReason = `PRD item removed from ${file}`;
1292
+ cancelled++;
1293
+ }
1298
1294
  }
1299
- }
1300
- if (cancelled > 0) log('info', `Plan sync: cancelled ${cancelled} item(s) removed from ${file} → ${projName}`);
1295
+ if (cancelled > 0) log('info', `Plan sync: cancelled ${cancelled} item(s) removed from ${file} → ${projName}`);
1301
1296
 
1302
- safeWrite(wiPath, existingItems);
1303
- log('info', `Plan discovery: created ${created} work item(s) from ${file} → ${projName}`);
1304
- }
1297
+ log('info', `Plan discovery: created ${created} work item(s) from ${file} → ${projName}`);
1298
+ }
1299
+ });
1305
1300
  totalCreated += created;
1306
1301
  }
1307
1302
 
@@ -1336,11 +1331,11 @@ function clearPendingHumanFeedbackFlag(projectMeta, prId) {
1336
1331
  if (!prId) return;
1337
1332
  try {
1338
1333
  const prsPath = projectPrPath(projectMeta);
1339
- const prs = safeJson(prsPath) || [];
1340
- const target = prs.find(p => p.id === prId);
1341
- if (!target?.humanFeedback?.pendingFix) return;
1342
- target.humanFeedback.pendingFix = false;
1343
- safeWrite(prsPath, prs);
1334
+ mutatePullRequests(prsPath, prs => {
1335
+ const target = prs.find(p => p.id === prId);
1336
+ if (!target?.humanFeedback?.pendingFix) return;
1337
+ target.humanFeedback.pendingFix = false;
1338
+ });
1344
1339
  } catch (e) { log('warn', 'clear pending human feedback flag: ' + e.message); }
1345
1340
  }
1346
1341
 
@@ -1488,12 +1483,12 @@ function discoverFromPrs(config, project) {
1488
1483
  // Mark notified to prevent duplicate alerts
1489
1484
  try {
1490
1485
  const prPath = projectPrPath(project);
1491
- const prs = safeJson(prPath) || [];
1492
- const target = prs.find(p => p.id === pr.id);
1493
- if (target) {
1494
- target._buildFailNotified = true;
1495
- safeWrite(prPath, prs);
1496
- }
1486
+ mutatePullRequests(prPath, prs => {
1487
+ const target = prs.find(p => p.id === pr.id);
1488
+ if (target) {
1489
+ target._buildFailNotified = true;
1490
+ }
1491
+ });
1497
1492
  } catch (e) { log('warn', 'mark build fail notified: ' + e.message); }
1498
1493
  }
1499
1494
  }
@@ -1738,14 +1733,13 @@ function discoverFromWorkItems(config, project) {
1738
1733
  }
1739
1734
 
1740
1735
  // Write back updated statuses (always, since we mark items dispatched before newWork check)
1741
- if (newWork.length > 0) {
1742
- const workItemsPath = projectWorkItemsPath(project);
1743
- safeWrite(workItemsPath, items);
1744
- for (const s of prdSyncQueue) syncPrdItemStatus(s.id, 'dispatched', s.sourcePlan);
1736
+ if (newWork.length > 0 || needsWrite) {
1737
+ mutateWorkItems(projectWorkItemsPath(project), () => items);
1738
+ if (newWork.length > 0) {
1739
+ for (const s of prdSyncQueue) syncPrdItemStatus(s.id, 'dispatched', s.sourcePlan);
1740
+ }
1745
1741
  }
1746
1742
 
1747
- if (needsWrite) safeWrite(projectWorkItemsPath(project), items);
1748
-
1749
1743
  const skipTotal = skipped.gated + skipped.noAgent;
1750
1744
  if (skipTotal > 0) {
1751
1745
  log('debug', `Work item discovery (${project?.name}): skipped ${skipTotal} items (${skipped.gated} gated, ${skipped.noAgent} no agent)`);
@@ -1839,53 +1833,50 @@ function materializeSpecsAsWorkItems(config, project) {
1839
1833
  if (recentSpecs.length === 0) return;
1840
1834
 
1841
1835
  const wiPath = projectWorkItemsPath(project);
1842
- const existingItems = safeJson(wiPath) || [];
1843
1836
  let created = 0;
1844
1837
 
1845
- for (const pr of mergedPrs) {
1846
- const prBranch = (pr.branch || '').toLowerCase();
1847
- const matchedSpecs = recentSpecs.filter(doc => {
1848
- const msg = doc.message.toLowerCase();
1849
- // Match any doc whose commit message references this PR's branch
1850
- return prBranch && msg.includes(prBranch.split('/').pop());
1851
- });
1852
-
1853
- if (matchedSpecs.length === 0) {
1854
- tracker.processedPrs[pr.id] = { processedAt: ts(), matched: false };
1855
- continue;
1856
- }
1838
+ mutateWorkItems(wiPath, existingItems => {
1839
+ for (const pr of mergedPrs) {
1840
+ const prBranch = (pr.branch || '').toLowerCase();
1841
+ const matchedSpecs = recentSpecs.filter(doc => {
1842
+ const msg = doc.message.toLowerCase();
1843
+ // Match any doc whose commit message references this PR's branch
1844
+ return prBranch && msg.includes(prBranch.split('/').pop());
1845
+ });
1857
1846
 
1858
- for (const doc of matchedSpecs) {
1859
- if (existingItems.some(i => i.sourceSpec === doc.file)) continue;
1847
+ if (matchedSpecs.length === 0) {
1848
+ tracker.processedPrs[pr.id] = { processedAt: ts(), matched: false };
1849
+ continue;
1850
+ }
1860
1851
 
1861
- const info = extractSpecInfo(doc.file, root);
1862
- if (!info) continue;
1852
+ for (const doc of matchedSpecs) {
1853
+ if (existingItems.some(i => i.sourceSpec === doc.file)) continue;
1863
1854
 
1864
- const newId = 'SP-' + shared.uid();
1855
+ const info = extractSpecInfo(doc.file, root);
1856
+ if (!info) continue;
1865
1857
 
1866
- existingItems.push({
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
- }
1858
+ const newId = 'SP-' + shared.uid();
1881
1859
 
1882
- tracker.processedPrs[pr.id] = { processedAt: ts(), matched: true, specs: matchedSpecs.map(d => d.file) };
1883
- }
1860
+ existingItems.push({
1861
+ id: newId,
1862
+ type: 'implement',
1863
+ title: `Implement: ${info.title}`,
1864
+ 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.`,
1865
+ priority: info.priority,
1866
+ status: 'queued',
1867
+ created: ts(),
1868
+ createdBy: 'engine:spec-discovery',
1869
+ sourceSpec: doc.file,
1870
+ sourcePr: pr.id
1871
+ });
1872
+ created++;
1873
+ log('info', `Spec discovery: created ${newId} "${info.title}" from PR ${pr.id} in ${project.name}`);
1874
+ }
1884
1875
 
1885
- if (created > 0) {
1886
- safeWrite(wiPath, existingItems);
1887
- }
1888
- safeWrite(trackerPath, tracker);
1876
+ tracker.processedPrs[pr.id] = { processedAt: ts(), matched: true, specs: matchedSpecs.map(d => d.file) };
1877
+ }
1878
+ });
1879
+ mutateJsonFileLocked(trackerPath, () => tracker, { defaultValue: {} });
1889
1880
  }
1890
1881
 
1891
1882
  /**
@@ -2472,78 +2463,77 @@ async function tickInner() {
2472
2463
  for (const project of projects) {
2473
2464
  try {
2474
2465
  const wiPath = projectWorkItemsPath(project);
2475
- const items = safeJson(wiPath) || [];
2476
- let changed = false;
2477
- const failedIds = new Set(items.filter(w => w.status === WI_STATUS.FAILED).map(w => w.id));
2478
- const pendingWithBlockedDeps = items.filter(w =>
2479
- w.status === WI_STATUS.PENDING && (w.depends_on || []).some(d => failedIds.has(d))
2480
- );
2481
-
2482
- if (pendingWithBlockedDeps.length > 0) {
2483
- // Auto-retry failed items that are blocking others (transient errors)
2484
- for (const item of items) {
2485
- if (item.status !== WI_STATUS.FAILED || isItemCompleted(item)) continue;
2486
- // Only retry if something depends on this item
2487
- const isBlocking = items.some(w => w.status === WI_STATUS.PENDING && (w.depends_on || []).includes(item.id));
2488
- if (!isBlocking) continue;
2489
-
2490
- log('info', `Stall recovery: auto-retrying ${item.id} (blocking ${pendingWithBlockedDeps.filter(w => (w.depends_on || []).includes(item.id)).length} items)`);
2491
- item.status = WI_STATUS.PENDING;
2492
- item._retryCount = 0;
2493
- delete item.failReason;
2494
- delete item.failedAt;
2495
- delete item.dispatched_at;
2496
- delete item.dispatched_to;
2497
- changed = true;
2498
-
2499
- // Clear completed dispatch entries so isAlreadyDispatched doesn't block re-dispatch
2500
- try {
2501
- const key = `work-${project.name}-${item.id}`;
2502
- mutateDispatch((dp) => {
2503
- dp.completed = dp.completed.filter(d => d.meta?.dispatchKey !== key);
2504
- return dp;
2505
- });
2506
- } catch (e) { log('warn', 'stall recovery clear dispatch: ' + e.message); }
2507
-
2508
- // Clear cooldown so item isn't blocked by exponential backoff
2509
- try {
2510
- const key = `work-${project.name}-${item.id}`;
2511
- if (dispatchCooldowns.has(key)) {
2512
- dispatchCooldowns.delete(key);
2513
- saveCooldowns();
2514
- }
2515
- } catch (e) { log('warn', 'stall recovery clear cooldown: ' + e.message); }
2516
- }
2517
- }
2518
-
2519
- // Un-fail dependent items that were cascade-failed
2520
- if (changed) {
2521
- const retriedIds = new Set(items.filter(w => w.status === WI_STATUS.PENDING && w._retryCount === 0).map(w => w.id));
2522
- for (const dep of items) {
2523
- if (dep.status === WI_STATUS.FAILED && !isItemCompleted(dep) && dep.failReason === 'Dependency failed — cannot proceed') {
2524
- const blockers = (dep.depends_on || []).filter(d => retriedIds.has(d));
2525
- if (blockers.length > 0) {
2526
- log('info', `Stall recovery: un-failing ${dep.id} (blocker ${blockers.join(',')} retried)`);
2527
- dep.status = WI_STATUS.PENDING;
2528
- dep._retryCount = 0;
2529
- delete dep.failReason;
2530
- delete dep.failedAt;
2531
- delete dep.dispatched_at;
2532
- delete dep.dispatched_to;
2533
- // Clear dispatch entries for this dependent too
2534
- try {
2535
- const key = `work-${project.name}-${dep.id}`;
2466
+ mutateWorkItems(wiPath, items => {
2467
+ let changed = false;
2468
+ const failedIds = new Set(items.filter(w => w.status === WI_STATUS.FAILED).map(w => w.id));
2469
+ const pendingWithBlockedDeps = items.filter(w =>
2470
+ w.status === WI_STATUS.PENDING && (w.depends_on || []).some(d => failedIds.has(d))
2471
+ );
2472
+
2473
+ if (pendingWithBlockedDeps.length > 0) {
2474
+ // Auto-retry failed items that are blocking others (transient errors)
2475
+ for (const item of items) {
2476
+ if (item.status !== WI_STATUS.FAILED || isItemCompleted(item)) continue;
2477
+ // Only retry if something depends on this item
2478
+ const isBlocking = items.some(w => w.status === WI_STATUS.PENDING && (w.depends_on || []).includes(item.id));
2479
+ if (!isBlocking) continue;
2480
+
2481
+ log('info', `Stall recovery: auto-retrying ${item.id} (blocking ${pendingWithBlockedDeps.filter(w => (w.depends_on || []).includes(item.id)).length} items)`);
2482
+ item.status = WI_STATUS.PENDING;
2483
+ item._retryCount = 0;
2484
+ delete item.failReason;
2485
+ delete item.failedAt;
2486
+ delete item.dispatched_at;
2487
+ delete item.dispatched_to;
2488
+ changed = true;
2489
+
2490
+ // Clear completed dispatch entries so isAlreadyDispatched doesn't block re-dispatch
2491
+ try {
2492
+ const key = `work-${project.name}-${item.id}`;
2536
2493
  mutateDispatch((dp) => {
2537
2494
  dp.completed = dp.completed.filter(d => d.meta?.dispatchKey !== key);
2538
2495
  return dp;
2539
2496
  });
2540
- } catch (e) { log('warn', 'stall recovery clear dependent dispatch: ' + e.message); }
2541
- }
2497
+ } catch (e) { log('warn', 'stall recovery clear dispatch: ' + e.message); }
2498
+
2499
+ // Clear cooldown so item isn't blocked by exponential backoff
2500
+ try {
2501
+ const key = `work-${project.name}-${item.id}`;
2502
+ if (dispatchCooldowns.has(key)) {
2503
+ dispatchCooldowns.delete(key);
2504
+ saveCooldowns();
2505
+ }
2506
+ } catch (e) { log('warn', 'stall recovery clear cooldown: ' + e.message); }
2542
2507
  }
2543
2508
  }
2544
- }
2545
2509
 
2546
- if (changed) safeWrite(wiPath, items);
2510
+ // Un-fail dependent items that were cascade-failed
2511
+ if (changed) {
2512
+ const retriedIds = new Set(items.filter(w => w.status === WI_STATUS.PENDING && w._retryCount === 0).map(w => w.id));
2513
+ for (const dep of items) {
2514
+ if (dep.status === WI_STATUS.FAILED && !isItemCompleted(dep) && dep.failReason === 'Dependency failed — cannot proceed') {
2515
+ const blockers = (dep.depends_on || []).filter(d => retriedIds.has(d));
2516
+ if (blockers.length > 0) {
2517
+ log('info', `Stall recovery: un-failing ${dep.id} (blocker ${blockers.join(',')} retried)`);
2518
+ dep.status = WI_STATUS.PENDING;
2519
+ dep._retryCount = 0;
2520
+ delete dep.failReason;
2521
+ delete dep.failedAt;
2522
+ delete dep.dispatched_at;
2523
+ delete dep.dispatched_to;
2524
+ // Clear dispatch entries for this dependent too
2525
+ try {
2526
+ const key = `work-${project.name}-${dep.id}`;
2527
+ mutateDispatch((dp) => {
2528
+ dp.completed = dp.completed.filter(d => d.meta?.dispatchKey !== key);
2529
+ return dp;
2530
+ });
2531
+ } catch (e) { log('warn', 'stall recovery clear dependent dispatch: ' + e.message); }
2532
+ }
2533
+ }
2534
+ }
2535
+ }
2536
+ });
2547
2537
  } catch (e) { log('warn', 'stall recovery process project: ' + e.message); }
2548
2538
  }
2549
2539
  }
@@ -2628,19 +2618,19 @@ async function tickInner() {
2628
2618
  ? path.join(ENGINE_DIR, '..', 'work-items.json')
2629
2619
  : item.meta.project?.name ? projectWorkItemsPath({ name: item.meta.project.name, localPath: item.meta.project.localPath }) : null;
2630
2620
  if (wiPath) {
2631
- const items = safeJson(wiPath) || [];
2632
- const wi = items.find(i => i.id === item.meta.item.id);
2633
- if (wi && wi.status === WI_STATUS.DISPATCHED) {
2634
- // completeDispatch didn't update the work item — re-queue manually
2635
- wi.status = WI_STATUS.PENDING;
2636
- wi._retryCount = (wi._retryCount || 0) + 1;
2637
- wi._lastRetryReason = 'spawnAgent returned null';
2638
- wi._lastRetryAt = ts();
2639
- delete wi.dispatched_at;
2640
- delete wi.dispatched_to;
2641
- safeWrite(wiPath, items);
2642
- log('info', `Re-queued ${item.meta.item.id} as pending (retry ${wi._retryCount})`);
2643
- }
2621
+ mutateWorkItems(wiPath, items => {
2622
+ const wi = items.find(i => i.id === item.meta.item.id);
2623
+ if (wi && wi.status === WI_STATUS.DISPATCHED) {
2624
+ // completeDispatch didn't update the work item — re-queue manually
2625
+ wi.status = WI_STATUS.PENDING;
2626
+ wi._retryCount = (wi._retryCount || 0) + 1;
2627
+ wi._lastRetryReason = 'spawnAgent returned null';
2628
+ wi._lastRetryAt = ts();
2629
+ delete wi.dispatched_at;
2630
+ delete wi.dispatched_to;
2631
+ log('info', `Re-queued ${item.meta.item.id} as pending (retry ${wi._retryCount})`);
2632
+ }
2633
+ });
2644
2634
  }
2645
2635
  } catch (e) { log('warn', `Failed to re-queue work item after spawn failure: ${e.message}`); }
2646
2636
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.534",
3
+ "version": "0.1.536",
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"