@yemi33/minions 0.1.534 → 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.
Files changed (3) hide show
  1. package/CHANGELOG.md +4 -1
  2. package/engine.js +196 -199
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,6 +1,9 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.534 (2026-04-07)
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
6
9
  - 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) {
@@ -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
- 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; }
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
- 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++;
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
- safeWrite(wiPath, existingItems);
1303
- log('info', `Plan discovery: created ${created} work item(s) from ${file} → ${projName}`);
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
- 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);
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
- 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
- }
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
- const workItemsPath = projectWorkItemsPath(project);
1743
- safeWrite(workItemsPath, items);
1744
- for (const s of prdSyncQueue) syncPrdItemStatus(s.id, 'dispatched', s.sourcePlan);
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
- 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
- }
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
- for (const doc of matchedSpecs) {
1859
- if (existingItems.some(i => i.sourceSpec === doc.file)) continue;
1854
+ if (matchedSpecs.length === 0) {
1855
+ tracker.processedPrs[pr.id] = { processedAt: ts(), matched: false };
1856
+ continue;
1857
+ }
1860
1858
 
1861
- const info = extractSpecInfo(doc.file, root);
1862
- if (!info) continue;
1859
+ for (const doc of matchedSpecs) {
1860
+ if (existingItems.some(i => i.sourceSpec === doc.file)) continue;
1863
1861
 
1864
- const newId = 'SP-' + shared.uid();
1862
+ const info = extractSpecInfo(doc.file, root);
1863
+ if (!info) continue;
1865
1864
 
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
- }
1865
+ const newId = 'SP-' + shared.uid();
1881
1866
 
1882
- tracker.processedPrs[pr.id] = { processedAt: ts(), matched: true, specs: matchedSpecs.map(d => d.file) };
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
- if (created > 0) {
1886
- safeWrite(wiPath, existingItems);
1887
- }
1888
- safeWrite(trackerPath, tracker);
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
  /**
@@ -2472,78 +2470,77 @@ async function tickInner() {
2472
2470
  for (const project of projects) {
2473
2471
  try {
2474
2472
  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}`;
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}`;
2536
2500
  mutateDispatch((dp) => {
2537
2501
  dp.completed = dp.completed.filter(d => d.meta?.dispatchKey !== key);
2538
2502
  return dp;
2539
2503
  });
2540
- } catch (e) { log('warn', 'stall recovery clear dependent dispatch: ' + e.message); }
2541
- }
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); }
2542
2514
  }
2543
2515
  }
2544
- }
2545
2516
 
2546
- if (changed) safeWrite(wiPath, items);
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
+ });
2547
2544
  } catch (e) { log('warn', 'stall recovery process project: ' + e.message); }
2548
2545
  }
2549
2546
  }
@@ -2628,19 +2625,19 @@ async function tickInner() {
2628
2625
  ? path.join(ENGINE_DIR, '..', 'work-items.json')
2629
2626
  : item.meta.project?.name ? projectWorkItemsPath({ name: item.meta.project.name, localPath: item.meta.project.localPath }) : null;
2630
2627
  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
- }
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
+ });
2644
2641
  }
2645
2642
  } catch (e) { log('warn', `Failed to re-queue work item after spawn failure: ${e.message}`); }
2646
2643
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.534",
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"