@yemi33/minions 0.1.531 → 0.1.533

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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.533 (2026-04-07)
4
+
5
+ ### Fixes
6
+ - convert blocking spawnSync/execSync to async execAsync (#447)
7
+
8
+ ## 0.1.532 (2026-04-07)
9
+
10
+ ### Fixes
11
+ - meeting cards show doc-chat processing dots and notification badges
12
+
3
13
  ## 0.1.531 (2026-04-07)
4
14
 
5
15
  ### Fixes
@@ -48,7 +48,7 @@ function renderMeetings(meetings) {
48
48
  const dt = m.completedAt || m.createdAt;
49
49
  const timeStr = dt ? new Date(dt).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '';
50
50
 
51
- return '<div style="background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:8px;cursor:pointer" onclick="openMeetingDetail(\'' + escHtml(m.id) + '\')">' +
51
+ return '<div data-file="meetings/' + escHtml(m.id) + '.json" style="background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:8px;cursor:pointer;position:relative" onclick="openMeetingDetail(\'' + escHtml(m.id) + '\')">' +
52
52
  '<div style="display:flex;justify-content:space-between;align-items:center">' +
53
53
  '<strong style="font-size:13px">' + escHtml(m.title) + '</strong>' +
54
54
  '<div style="display:flex;align-items:center;gap:8px">' +
@@ -78,6 +78,7 @@ function renderMeetings(meetings) {
78
78
  el.innerHTML += '<div style="text-align:center;margin-top:8px"><button class="pr-pager-btn" style="font-size:10px" onclick="_toggleArchivedMeetings()">' +
79
79
  (_showArchived ? 'Hide' : 'Show') + ' ' + archived.length + ' archived</button></div>';
80
80
  }
81
+ restoreNotifBadges();
81
82
  }
82
83
 
83
84
  function _mtgPrev() { if (_mtgPage > 0) { _mtgPage--; refresh(); } }
package/dashboard.js CHANGED
@@ -22,7 +22,7 @@ const shared = require('./engine/shared');
22
22
  const queries = require('./engine/queries');
23
23
  const os = require('os');
24
24
 
25
- const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeUnlink, mutateJsonFileLocked, getProjects: _getProjects, DONE_STATUSES, WI_STATUS } = shared;
25
+ const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeUnlink, mutateJsonFileLocked, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS } = shared;
26
26
  const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
27
27
  getSkills, getInbox, getNotesWithMeta, getPullRequests,
28
28
  getEngineLog, getMetrics, getKnowledgeBaseEntries, timeSince,
@@ -1381,9 +1381,6 @@ const server = http.createServer(async (req, res) => {
1381
1381
  if (!body.title || !body.title.trim()) return jsonReply(res, 400, { error: 'title is required' });
1382
1382
  // Write as a work item with type 'plan' — user must explicitly execute plan-to-prd after reviewing
1383
1383
  const wiPath = path.join(MINIONS_DIR, 'work-items.json');
1384
- let items = [];
1385
- const existing = safeRead(wiPath);
1386
- if (existing) { try { items = JSON.parse(existing); } catch {} }
1387
1384
  const id = 'W-' + shared.uid();
1388
1385
  const item = {
1389
1386
  id, title: body.title, type: 'plan',
@@ -1393,8 +1390,7 @@ const server = http.createServer(async (req, res) => {
1393
1390
  };
1394
1391
  if (body.project) item.project = body.project;
1395
1392
  if (body.agent) item.agent = body.agent;
1396
- items.push(item);
1397
- safeWrite(wiPath, items);
1393
+ mutateWorkItems(wiPath, items => { items.push(item); });
1398
1394
  return jsonReply(res, 200, { ok: true, id, agent: body.agent || '' });
1399
1395
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1400
1396
  }
@@ -1465,18 +1461,18 @@ const server = http.createServer(async (req, res) => {
1465
1461
  }
1466
1462
  for (const wiPath of wiSyncPaths) {
1467
1463
  try {
1468
- const items = safeJson(wiPath);
1469
- const wi = items.find(w => w.sourcePlan === body.source && w.id === body.itemId);
1470
- if (wi && wi.status === 'pending') {
1471
- if (body.name !== undefined) wi.title = 'Implement: ' + body.name;
1472
- if (body.description !== undefined) wi.description = body.description;
1473
- if (body.priority !== undefined) wi.priority = body.priority;
1474
- if (body.estimated_complexity !== undefined) {
1475
- wi.type = body.estimated_complexity === 'large' ? 'implement:large' : 'implement';
1464
+ mutateWorkItems(wiPath, items => {
1465
+ const wi = items.find(w => w.sourcePlan === body.source && w.id === body.itemId);
1466
+ if (wi && wi.status === 'pending') {
1467
+ if (body.name !== undefined) wi.title = 'Implement: ' + body.name;
1468
+ if (body.description !== undefined) wi.description = body.description;
1469
+ if (body.priority !== undefined) wi.priority = body.priority;
1470
+ if (body.estimated_complexity !== undefined) {
1471
+ wi.type = body.estimated_complexity === 'large' ? 'implement:large' : 'implement';
1472
+ }
1473
+ workItemSynced = true;
1476
1474
  }
1477
- safeWrite(wiPath, items);
1478
- workItemSynced = true;
1479
- }
1475
+ });
1480
1476
  } catch (e) { console.error('work item sync:', e.message); }
1481
1477
  }
1482
1478
 
@@ -1500,26 +1496,21 @@ const server = http.createServer(async (req, res) => {
1500
1496
 
1501
1497
  // Also remove any materialized work item for this plan item
1502
1498
  let cancelled = false;
1499
+ const allWiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
1503
1500
  for (const proj of PROJECTS) {
1504
- const wiPath = shared.projectWorkItemsPath(proj);
1501
+ allWiPaths.push(shared.projectWorkItemsPath(proj));
1502
+ }
1503
+ for (const wiPath of allWiPaths) {
1505
1504
  try {
1506
- const items = safeJson(wiPath);
1507
- const before = items.length;
1508
- const filtered = items.filter(w => !(w.sourcePlan === body.source && w.id === body.itemId));
1509
- if (filtered.length < before) {
1510
- safeWrite(wiPath, filtered);
1511
- cancelled = true;
1512
- }
1505
+ mutateWorkItems(wiPath, items => {
1506
+ const filtered = items.filter(w => !(w.sourcePlan === body.source && w.id === body.itemId));
1507
+ if (filtered.length < items.length) {
1508
+ cancelled = true;
1509
+ return filtered;
1510
+ }
1511
+ });
1513
1512
  } catch (e) { console.error('work item cleanup:', e.message); }
1514
1513
  }
1515
- // Also check central work-items
1516
- const centralPath = path.join(MINIONS_DIR, 'work-items.json');
1517
- try {
1518
- const items = safeJson(centralPath);
1519
- const before = items.length;
1520
- const filtered = items.filter(w => !(w.sourcePlan === body.source && w.id === body.itemId));
1521
- if (filtered.length < before) { safeWrite(centralPath, filtered); cancelled = true; }
1522
- } catch (e) { console.error('central work item cleanup:', e.message); }
1523
1514
 
1524
1515
  // Clean dispatch entries for this item
1525
1516
  cleanDispatchEntries(d =>
@@ -2130,52 +2121,51 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2130
2121
  mutateJsonFileLocked(dispatchPath, (dispatch) => {
2131
2122
  for (const wiPath of wiPaths) {
2132
2123
  try {
2133
- const items = safeJson(wiPath);
2134
- if (!items) continue;
2135
- let changed = false;
2136
- for (const w of items) {
2137
- if (w.sourcePlan !== body.file) continue;
2138
- // Keep completed items as-is, reset everything else to pending.
2139
- if (w.completedAt || DONE_STATUSES.has(w.status)) continue;
2140
-
2141
- if (w.status === WI_STATUS.DISPATCHED) {
2142
- // Kill the agent working on this item, if any.
2143
- const activeEntry = (dispatch.active || []).find(d => d.meta?.item?.id === w.id || d.meta?.dispatchKey?.includes(w.id));
2144
- if (activeEntry) {
2145
- const statusPath = path.join(MINIONS_DIR, 'agents', activeEntry.agent, 'status.json');
2146
- try {
2147
- const agentStatus = JSON.parse(safeRead(statusPath) || '{}');
2148
- if (agentStatus.pid) {
2149
- try {
2150
- const safePid = shared.validatePid(agentStatus.pid);
2151
- if (process.platform === 'win32') {
2152
- require('child_process').execFileSync('taskkill', ['/PID', String(safePid), '/F', '/T'], { stdio: 'pipe', timeout: 5000, windowsHide: true });
2153
- } else {
2154
- process.kill(safePid, 'SIGTERM');
2155
- }
2156
- } catch { /* process may be dead or invalid PID */ }
2157
- }
2158
- agentStatus.status = 'idle';
2159
- delete agentStatus.currentTask;
2160
- delete agentStatus.dispatched;
2161
- safeWrite(statusPath, agentStatus);
2162
- } catch (e) { console.error('agent reset:', e.message); }
2163
- killedAgents.add(activeEntry.agent);
2124
+ mutateWorkItems(wiPath, items => {
2125
+ let changed = false;
2126
+ for (const w of items) {
2127
+ if (w.sourcePlan !== body.file) continue;
2128
+ // Keep completed items as-is, reset everything else to pending.
2129
+ if (w.completedAt || DONE_STATUSES.has(w.status)) continue;
2130
+
2131
+ if (w.status === WI_STATUS.DISPATCHED) {
2132
+ // Kill the agent working on this item, if any.
2133
+ const activeEntry = (dispatch.active || []).find(d => d.meta?.item?.id === w.id || d.meta?.dispatchKey?.includes(w.id));
2134
+ if (activeEntry) {
2135
+ const statusPath = path.join(MINIONS_DIR, 'agents', activeEntry.agent, 'status.json');
2136
+ try {
2137
+ const agentStatus = JSON.parse(safeRead(statusPath) || '{}');
2138
+ if (agentStatus.pid) {
2139
+ try {
2140
+ const safePid = shared.validatePid(agentStatus.pid);
2141
+ if (process.platform === 'win32') {
2142
+ require('child_process').execFileSync('taskkill', ['/PID', String(safePid), '/F', '/T'], { stdio: 'pipe', timeout: 5000, windowsHide: true });
2143
+ } else {
2144
+ process.kill(safePid, 'SIGTERM');
2145
+ }
2146
+ } catch { /* process may be dead or invalid PID */ }
2147
+ }
2148
+ agentStatus.status = 'idle';
2149
+ delete agentStatus.currentTask;
2150
+ delete agentStatus.dispatched;
2151
+ safeWrite(statusPath, agentStatus);
2152
+ } catch (e) { console.error('agent reset:', e.message); }
2153
+ killedAgents.add(activeEntry.agent);
2154
+ }
2164
2155
  }
2165
- }
2166
2156
 
2167
- if (w.status !== WI_STATUS.PAUSED) reset++;
2168
- w.status = WI_STATUS.PAUSED;
2169
- w._pausedBy = 'prd-pause';
2170
- delete w._resumedAt;
2171
- delete w.dispatched_at;
2172
- delete w.dispatched_to;
2173
- delete w.failReason;
2174
- delete w.failedAt;
2175
- changed = true;
2176
- if (w.id) resetItemIds.add(w.id);
2177
- }
2178
- if (changed) safeWrite(wiPath, items);
2157
+ if (w.status !== WI_STATUS.PAUSED) reset++;
2158
+ w.status = WI_STATUS.PAUSED;
2159
+ w._pausedBy = 'prd-pause';
2160
+ delete w._resumedAt;
2161
+ delete w.dispatched_at;
2162
+ delete w.dispatched_to;
2163
+ delete w.failReason;
2164
+ delete w.failedAt;
2165
+ changed = true;
2166
+ if (w.id) resetItemIds.add(w.id);
2167
+ }
2168
+ });
2179
2169
  } catch (e) { console.error('reset work items:', e.message); }
2180
2170
  }
2181
2171
 
@@ -2220,13 +2210,15 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2220
2210
  const config = queries.getConfig();
2221
2211
  for (const p of getProjects(config)) {
2222
2212
  const projWiPath = projectWorkItemsPath(p);
2223
- const projItems = safeJson(projWiPath);
2224
- if (!projItems) continue;
2225
- const filtered = projItems.filter(w => {
2226
- if (w.sourcePlan !== body.file) return true; // different plan, keep
2227
- return completedStatuses.has(w.status); // keep completed, remove pending/failed
2228
- });
2229
- if (filtered.length < projItems.length) safeWrite(projWiPath, filtered);
2213
+ try {
2214
+ mutateWorkItems(projWiPath, items => {
2215
+ const filtered = items.filter(w => {
2216
+ if (w.sourcePlan !== body.file) return true; // different plan, keep
2217
+ return completedStatuses.has(w.status); // keep completed, remove pending/failed
2218
+ });
2219
+ if (filtered.length < items.length) return filtered;
2220
+ });
2221
+ } catch { /* project may not have work items */ }
2230
2222
  }
2231
2223
 
2232
2224
  // Delete old PRD — agent will write replacement at same path
@@ -2234,30 +2226,29 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2234
2226
 
2235
2227
  // Queue plan-to-prd regeneration with instructions to preserve completed items
2236
2228
  const wiPath = path.join(MINIONS_DIR, 'work-items.json');
2237
- let items = [];
2238
- const existing = safeRead(wiPath);
2239
- if (existing) { try { items = JSON.parse(existing); } catch {} }
2240
-
2241
- // Dedup: check if already queued
2242
- const alreadyQueued = items.find(w =>
2243
- w.type === 'plan-to-prd' && w.planFile === plan.source_plan && (w.status === 'pending' || w.status === 'dispatched')
2244
- );
2245
- if (alreadyQueued) return jsonReply(res, 200, { id: alreadyQueued.id, alreadyQueued: true });
2246
2229
 
2247
2230
  const completedContext = completedItems.length > 0
2248
2231
  ? `\n\n**Previously completed items (preserve their status in the new PRD):**\n${completedItems.map(i => `- ${i.id}: ${i.name} [${i.status}]`).join('\n')}`
2249
2232
  : '';
2250
2233
 
2251
2234
  const id = 'W-' + shared.uid();
2252
- items.push({
2253
- id, title: `Regenerate PRD: ${plan.plan_summary || plan.source_plan}`,
2254
- type: 'plan-to-prd', priority: 'high',
2255
- description: `Plan file: plans/${plan.source_plan}\nTarget PRD filename: ${body.file}\nRegeneration requested by user after plan revision.${completedContext}`,
2256
- status: 'pending', created: new Date().toISOString(), createdBy: 'dashboard:regenerate',
2257
- project: plan.project || '', planFile: plan.source_plan,
2258
- _targetPrdFile: body.file,
2235
+ let alreadyQueuedId = null;
2236
+ mutateWorkItems(wiPath, items => {
2237
+ // Dedup: check if already queued
2238
+ const alreadyQueued = items.find(w =>
2239
+ w.type === 'plan-to-prd' && w.planFile === plan.source_plan && (w.status === 'pending' || w.status === 'dispatched')
2240
+ );
2241
+ if (alreadyQueued) { alreadyQueuedId = alreadyQueued.id; return; }
2242
+ items.push({
2243
+ id, title: `Regenerate PRD: ${plan.plan_summary || plan.source_plan}`,
2244
+ type: 'plan-to-prd', priority: 'high',
2245
+ description: `Plan file: plans/${plan.source_plan}\nTarget PRD filename: ${body.file}\nRegeneration requested by user after plan revision.${completedContext}`,
2246
+ status: 'pending', created: new Date().toISOString(), createdBy: 'dashboard:regenerate',
2247
+ project: plan.project || '', planFile: plan.source_plan,
2248
+ _targetPrdFile: body.file,
2249
+ });
2259
2250
  });
2260
- safeWrite(wiPath, items);
2251
+ if (alreadyQueuedId) return jsonReply(res, 200, { id: alreadyQueuedId, alreadyQueued: true });
2261
2252
  return jsonReply(res, 200, { id, file: plan.source_plan });
2262
2253
  } catch (e) { return jsonReply(res, 500, { error: e.message }); }
2263
2254
  }
@@ -2334,27 +2325,26 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2334
2325
 
2335
2326
  for (const wiInfo of wiPaths) {
2336
2327
  try {
2337
- const items = safeJson(wiInfo.path);
2338
- const filtered = [];
2339
- for (const w of items) {
2340
- if (w.sourcePlan === body.source) {
2341
- materializedPlanItemIds.add(w.id);
2342
- if (w.status === 'pending' || w.status === 'failed') {
2343
- // Delete — will re-materialize on next tick with updated plan data
2344
- reset++;
2345
- deletedItemIds.push(w.id);
2328
+ mutateWorkItems(wiInfo.path, items => {
2329
+ const filtered = [];
2330
+ for (const w of items) {
2331
+ if (w.sourcePlan === body.source) {
2332
+ materializedPlanItemIds.add(w.id);
2333
+ if (w.status === 'pending' || w.status === 'failed') {
2334
+ // Delete — will re-materialize on next tick with updated plan data
2335
+ reset++;
2336
+ deletedItemIds.push(w.id);
2337
+ } else {
2338
+ // dispatched or done — leave alone
2339
+ kept++;
2340
+ filtered.push(w);
2341
+ }
2346
2342
  } else {
2347
- // dispatched or done — leave alone
2348
- kept++;
2349
2343
  filtered.push(w);
2350
2344
  }
2351
- } else {
2352
- filtered.push(w);
2353
2345
  }
2354
- }
2355
- if (filtered.length < items.length) {
2356
- safeWrite(wiInfo.path, filtered);
2357
- }
2346
+ if (filtered.length < items.length) return filtered;
2347
+ });
2358
2348
  } catch (e) { console.error('work item sync:', e.message); }
2359
2349
  }
2360
2350
 
@@ -2396,13 +2386,13 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2396
2386
  }
2397
2387
  for (const wiPath of wiPaths) {
2398
2388
  try {
2399
- const items = safeJsonArr(wiPath);
2400
- if (!items) continue;
2401
- const filtered = items.filter(w => w.sourcePlan !== body.file);
2402
- if (filtered.length < items.length) {
2403
- cleaned += items.length - filtered.length;
2404
- safeWrite(wiPath, filtered);
2405
- }
2389
+ mutateWorkItems(wiPath, items => {
2390
+ const filtered = items.filter(w => w.sourcePlan !== body.file);
2391
+ if (filtered.length < items.length) {
2392
+ cleaned += items.length - filtered.length;
2393
+ return filtered;
2394
+ }
2395
+ });
2406
2396
  } catch (e) { console.error('plan cleanup:', e.message); }
2407
2397
  }
2408
2398
 
@@ -2417,16 +2407,14 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2417
2407
  if (prdSourcePlan) {
2418
2408
  try {
2419
2409
  const centralPath = path.join(MINIONS_DIR, 'work-items.json');
2420
- const centralItems = safeJson(centralPath) || [];
2421
- let changed = false;
2422
- for (const w of centralItems) {
2423
- if (w.type === 'plan-to-prd' && w.status === 'done' && w.planFile === prdSourcePlan) {
2424
- w.status = 'cancelled';
2425
- w._cancelledBy = 'prd-deleted';
2426
- changed = true;
2410
+ mutateWorkItems(centralPath, items => {
2411
+ for (const w of items) {
2412
+ if (w.type === 'plan-to-prd' && w.status === 'done' && w.planFile === prdSourcePlan) {
2413
+ w.status = 'cancelled';
2414
+ w._cancelledBy = 'prd-deleted';
2415
+ }
2427
2416
  }
2428
- }
2429
- if (changed) safeWrite(centralPath, centralItems);
2417
+ });
2430
2418
  } catch (e) { console.error('plan-to-prd cleanup:', e.message); }
2431
2419
  }
2432
2420
 
@@ -2521,19 +2509,17 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2521
2509
 
2522
2510
  // Create a work item to revise the plan
2523
2511
  const wiPath = path.join(MINIONS_DIR, 'work-items.json');
2524
- let items = [];
2525
- const existing = safeRead(wiPath);
2526
- if (existing) { try { items = JSON.parse(existing); } catch {} }
2527
2512
  const id = 'W-' + shared.uid();
2528
- items.push({
2529
- id, title: 'Revise plan: ' + (plan.plan_summary || body.file),
2530
- type: 'plan-to-prd', priority: 'high',
2531
- 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".',
2532
- status: 'pending', created: new Date().toISOString(), createdBy: 'dashboard:revision',
2533
- project: plan.project || '',
2534
- planFile: body.file,
2513
+ mutateWorkItems(wiPath, items => {
2514
+ items.push({
2515
+ id, title: 'Revise plan: ' + (plan.plan_summary || body.file),
2516
+ type: 'plan-to-prd', priority: 'high',
2517
+ 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".',
2518
+ status: 'pending', created: new Date().toISOString(), createdBy: 'dashboard:revision',
2519
+ project: plan.project || '',
2520
+ planFile: body.file,
2521
+ });
2535
2522
  });
2536
- safeWrite(wiPath, items);
2537
2523
  return jsonReply(res, 200, { ok: true, status: 'revision-requested', workItemId: id });
2538
2524
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
2539
2525
  }
@@ -2624,22 +2610,23 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2624
2610
  const deletedItemIds = [];
2625
2611
  for (const wiInfo of wiPaths) {
2626
2612
  try {
2627
- const items = safeJson(wiInfo.path);
2628
- const filtered = [];
2629
- for (const w of items) {
2630
- if (w.sourcePlan === body.source) {
2631
- if (w.status === 'pending' || w.status === 'failed') {
2632
- reset++;
2633
- deletedItemIds.push(w.id);
2613
+ mutateWorkItems(wiInfo.path, items => {
2614
+ const filtered = [];
2615
+ for (const w of items) {
2616
+ if (w.sourcePlan === body.source) {
2617
+ if (w.status === 'pending' || w.status === 'failed') {
2618
+ reset++;
2619
+ deletedItemIds.push(w.id);
2620
+ } else {
2621
+ kept++;
2622
+ filtered.push(w);
2623
+ }
2634
2624
  } else {
2635
- kept++;
2636
2625
  filtered.push(w);
2637
2626
  }
2638
- } else {
2639
- filtered.push(w);
2640
2627
  }
2641
- }
2642
- if (filtered.length < items.length) safeWrite(wiInfo.path, filtered);
2628
+ if (filtered.length < items.length) return filtered;
2629
+ });
2643
2630
  } catch (e) { console.error('work item deletion:', e.message); }
2644
2631
  }
2645
2632
  for (const itemId of deletedItemIds) {
@@ -2650,22 +2637,21 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2650
2637
 
2651
2638
  // Step 4: Dispatch plan-to-prd to regenerate PRD from revised plan
2652
2639
  const centralWiPath = path.join(MINIONS_DIR, 'work-items.json');
2653
- let centralItems = [];
2654
- try { centralItems = JSON.parse(safeRead(centralWiPath) || '[]'); } catch {}
2655
2640
  const wiId = 'W-' + shared.uid();
2656
- centralItems.push({
2657
- id: wiId,
2658
- title: 'Regenerate PRD from revised plan: ' + sourcePlanFile,
2659
- type: 'plan-to-prd',
2660
- priority: 'high',
2661
- 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.`,
2662
- status: 'pending',
2663
- created: new Date().toISOString(),
2664
- createdBy: 'dashboard:revise-and-regenerate',
2665
- project: prd.project || '',
2666
- planFile: sourcePlanFile,
2641
+ mutateWorkItems(centralWiPath, items => {
2642
+ items.push({
2643
+ id: wiId,
2644
+ title: 'Regenerate PRD from revised plan: ' + sourcePlanFile,
2645
+ type: 'plan-to-prd',
2646
+ priority: 'high',
2647
+ 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.`,
2648
+ status: 'pending',
2649
+ created: new Date().toISOString(),
2650
+ createdBy: 'dashboard:revise-and-regenerate',
2651
+ project: prd.project || '',
2652
+ planFile: sourcePlanFile,
2653
+ });
2667
2654
  });
2668
- safeWrite(centralWiPath, centralItems);
2669
2655
 
2670
2656
  return jsonReply(res, 200, {
2671
2657
  ok: true,
@@ -3728,24 +3714,27 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3728
3714
  const projects = shared.getProjects(CONFIG);
3729
3715
  const paths = [path.join(MINIONS_DIR, 'work-items.json')];
3730
3716
  for (const p of projects) paths.push(shared.projectWorkItemsPath(p));
3717
+ let found = null;
3731
3718
  for (const wiPath of paths) {
3732
- const items = JSON.parse(safeRead(wiPath) || '[]');
3733
- const item = items.find(i => i.id === id);
3734
- if (!item) continue;
3735
- item._humanFeedback = { rating, comment: comment || '', at: new Date().toISOString() };
3736
- safeWrite(wiPath, items);
3737
- const agent = item.dispatched_to || item.agent || 'unknown';
3738
- const feedbackNote = '# Human Feedback on ' + id + '\n\n' +
3739
- '**Rating:** ' + (rating === 'up' ? '👍 Good' : '👎 Needs improvement') + '\n' +
3740
- '**Item:** ' + (item.title || id) + '\n' +
3741
- '**Agent:** ' + agent + '\n' +
3742
- (comment ? '**Feedback:** ' + comment + '\n' : '');
3743
- const inboxPath = path.join(MINIONS_DIR, 'notes', 'inbox', agent + '-feedback-' + new Date().toISOString().slice(0, 10) + '-' + shared.uid().slice(0, 4) + '.md');
3744
- safeWrite(inboxPath, feedbackNote);
3745
- invalidateStatusCache();
3746
- return jsonReply(res, 200, { ok: true });
3719
+ mutateWorkItems(wiPath, items => {
3720
+ const item = items.find(i => i.id === id);
3721
+ if (item && !found) {
3722
+ item._humanFeedback = { rating, comment: comment || '', at: new Date().toISOString() };
3723
+ found = { agent: item.dispatched_to || item.agent || 'unknown', title: item.title || id };
3724
+ }
3725
+ });
3726
+ if (found) break;
3747
3727
  }
3748
- return jsonReply(res, 404, { error: 'Work item not found' });
3728
+ if (!found) return jsonReply(res, 404, { error: 'Work item not found' });
3729
+ const feedbackNote = '# Human Feedback on ' + id + '\n\n' +
3730
+ '**Rating:** ' + (rating === 'up' ? '👍 Good' : '👎 Needs improvement') + '\n' +
3731
+ '**Item:** ' + found.title + '\n' +
3732
+ '**Agent:** ' + found.agent + '\n' +
3733
+ (comment ? '**Feedback:** ' + comment + '\n' : '');
3734
+ const inboxPath = path.join(MINIONS_DIR, 'notes', 'inbox', found.agent + '-feedback-' + new Date().toISOString().slice(0, 10) + '-' + shared.uid().slice(0, 4) + '.md');
3735
+ safeWrite(inboxPath, feedbackNote);
3736
+ invalidateStatusCache();
3737
+ return jsonReply(res, 200, { ok: true });
3749
3738
  }},
3750
3739
 
3751
3740
  // Pinned notes
package/engine/ado.js CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  const path = require('path');
7
7
  const shared = require('./shared');
8
- const { exec, getAdoOrgBase, addPrLink, log, ts, dateStamp, PR_STATUS } = shared;
8
+ const { exec, execAsync, getAdoOrgBase, addPrLink, log, ts, dateStamp, PR_STATUS } = shared;
9
9
  const { getPrs } = require('./queries');
10
10
  const { mutateJsonFileLocked } = shared;
11
11
 
@@ -21,7 +21,7 @@ function engine() {
21
21
  let _adoTokenCache = { token: null, expiresAt: 0 };
22
22
  let _adoTokenFailedUntil = 0; // backoff: skip azureauth calls until this timestamp
23
23
 
24
- function getAdoToken() {
24
+ async function getAdoToken() {
25
25
  if (_adoTokenCache.token && Date.now() < _adoTokenCache.expiresAt) {
26
26
  return _adoTokenCache.token;
27
27
  }
@@ -30,8 +30,9 @@ function getAdoToken() {
30
30
  try {
31
31
  // azureauth supports multiple --mode flags as an ordered fallback chain:
32
32
  // tries IWA (Integrated Windows Auth) first, falls back to broker if unavailable.
33
- const token = exec('azureauth ado token --mode iwa --mode broker --output token --timeout 1', {
34
- timeout: 15000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }).trim();
33
+ // Uses execAsync to avoid blocking the event loop on Windows (spawnSync ETIMEDOUT).
34
+ const token = (await execAsync('azureauth ado token --mode iwa --mode broker --output token --timeout 1', {
35
+ timeout: 15000, encoding: 'utf-8', windowsHide: true })).trim();
35
36
  if (token && token.startsWith('eyJ')) {
36
37
  _adoTokenCache = { token, expiresAt: Date.now() + 30 * 60 * 1000 };
37
38
  _adoTokenFailedUntil = 0;
@@ -56,7 +57,7 @@ async function adoFetch(url, token, _retryCount = 0) {
56
57
  // Invalidate cached token — it's likely expired
57
58
  _adoTokenCache = { token: null, expiresAt: 0 };
58
59
  if (_retryCount < MAX_RETRIES) {
59
- const freshToken = getAdoToken();
60
+ const freshToken = await getAdoToken();
60
61
  if (freshToken) {
61
62
  log('info', 'ADO token expired mid-session — refreshed and retrying');
62
63
  return adoFetch(url, freshToken, _retryCount + 1);
@@ -132,7 +133,7 @@ async function forEachActivePr(config, token, callback) {
132
133
  // ─── PR Status Polling ───────────────────────────────────────────────────────
133
134
 
134
135
  async function pollPrStatus(config) {
135
- const token = getAdoToken();
136
+ const token = await getAdoToken();
136
137
  if (!token) {
137
138
  log('warn', 'Skipping PR status poll — no ADO token available');
138
139
  return;
@@ -287,7 +288,7 @@ async function pollPrStatus(config) {
287
288
  // ─── Poll Human Comments on PRs ──────────────────────────────────────────────
288
289
 
289
290
  async function pollPrHumanComments(config) {
290
- const token = getAdoToken();
291
+ const token = await getAdoToken();
291
292
  if (!token) return;
292
293
 
293
294
  const totalUpdated = await forEachActivePr(config, token, async (project, pr, prNum, orgBase) => {
@@ -361,7 +362,7 @@ async function pollPrHumanComments(config) {
361
362
  * in pull-requests.json, and add them. Matches PRs to work items by branch name.
362
363
  */
363
364
  async function reconcilePrs(config) {
364
- const token = getAdoToken();
365
+ const token = await getAdoToken();
365
366
  if (!token) {
366
367
  log('warn', 'Skipping PR reconciliation — no ADO token available');
367
368
  return;
@@ -486,19 +487,19 @@ async function reconcilePrs(config) {
486
487
  }
487
488
 
488
489
  /**
489
- * Fetch live review status for a single PR from ADO (synchronous).
490
+ * Fetch live review status for a single PR from ADO (async).
490
491
  * Returns 'approved', 'changes-requested', 'waiting', or 'pending'.
491
492
  * Returns null if the check fails (token unavailable, API error).
492
493
  * Used as a pre-dispatch gate to avoid dispatching reviews for already-approved PRs.
493
494
  */
494
- function checkLiveReviewStatus(pr, project) {
495
+ async function checkLiveReviewStatus(pr, project) {
495
496
  try {
496
- const token = getAdoToken();
497
+ const token = await getAdoToken();
497
498
  if (!token) return null;
498
499
  const orgBase = shared.getAdoOrgBase(project);
499
500
  const prNum = (pr.id || '').replace(/^PR-/, '');
500
501
  const url = `${orgBase}/${project.adoProject}/_apis/git/repositories/${project.repositoryId}/pullrequests/${prNum}?api-version=7.1`;
501
- const result = exec(`curl -s --max-time 4 -H "Authorization: Bearer ${token}" "${url}"`, { encoding: 'utf-8', timeout: 5000, windowsHide: true });
502
+ const result = await execAsync(`curl -s --max-time 4 -H "Authorization: Bearer ${token}" "${url}"`, { encoding: 'utf-8', timeout: 5000, windowsHide: true });
502
503
  const prData = JSON.parse(result);
503
504
  const votes = (prData.reviewers || []).map(r => r.vote).filter(v => v !== undefined);
504
505
  if (votes.length === 0) return 'pending';
package/engine/github.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  const shared = require('./shared');
8
- const { exec, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeWrite, mutateJsonFileLocked, MINIONS_DIR, addPrLink, getPrLinks, log, ts, dateStamp, PR_STATUS } = shared;
8
+ const { exec, execAsync, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeWrite, mutateJsonFileLocked, MINIONS_DIR, addPrLink, getPrLinks, log, ts, dateStamp, PR_STATUS } = shared;
9
9
  const { getPrs } = require('./queries');
10
10
  const path = require('path');
11
11
 
@@ -69,10 +69,10 @@ function resetSlugBackoff(slug) {
69
69
  }
70
70
 
71
71
  /** Run a `gh api` call and parse JSON result. Returns null on failure. */
72
- function ghApi(endpoint, slug) {
72
+ async function ghApi(endpoint, slug) {
73
73
  try {
74
74
  const cmd = `gh api "repos/${slug}${endpoint}"`;
75
- const result = exec(cmd, { timeout: 30000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
75
+ const result = await execAsync(cmd, { timeout: 30000, encoding: 'utf-8' });
76
76
  return JSON.parse(result);
77
77
  } catch (e) {
78
78
  log('warn', `GitHub API error (${endpoint}): ${e.message}`);
@@ -84,8 +84,8 @@ function ghApi(endpoint, slug) {
84
84
  * Run a `gh api` call with per-slug backoff tracking. Returns null on failure.
85
85
  * On success, resets the slug's backoff. On failure, increments it.
86
86
  */
87
- function ghApiWithBackoff(endpoint, slug) {
88
- const result = ghApi(endpoint, slug);
87
+ async function ghApiWithBackoff(endpoint, slug) {
88
+ const result = await ghApi(endpoint, slug);
89
89
  if (result === null) {
90
90
  recordSlugFailure(slug);
91
91
  } else {
@@ -112,7 +112,7 @@ async function forEachActiveGhPr(config, callback) {
112
112
  if (activePrs.length === 0) continue;
113
113
 
114
114
  // Probe repo accessibility before iterating PRs — avoids N warnings per inaccessible repo
115
- const probe = ghApi('', slug);
115
+ const probe = await ghApi('', slug);
116
116
  if (probe === null) {
117
117
  recordSlugFailure(slug);
118
118
  continue;
@@ -171,7 +171,7 @@ async function forEachActiveGhPr(config, callback) {
171
171
  if (updated) {
172
172
  // Also update title/author/branch if still placeholder
173
173
  if (pr.title.includes('polling...') || pr.agent === 'human') {
174
- const prData = ghApi(`/pulls/${prNum}`, slug);
174
+ const prData = await ghApi(`/pulls/${prNum}`, slug);
175
175
  if (prData) {
176
176
  if (pr.title.includes('polling...')) pr.title = (prData.title || pr.title).slice(0, 120);
177
177
  if (pr.agent === 'human' && prData.user?.login) pr.agent = prData.user.login;
@@ -203,7 +203,7 @@ async function forEachActiveGhPr(config, callback) {
203
203
 
204
204
  async function pollPrStatus(config) {
205
205
  const totalUpdated = await forEachActiveGhPr(config, async (project, pr, prNum, slug) => {
206
- const prData = ghApi(`/pulls/${prNum}`, slug);
206
+ const prData = await ghApi(`/pulls/${prNum}`, slug);
207
207
  if (!prData) return false;
208
208
 
209
209
  let updated = false;
@@ -243,7 +243,7 @@ async function pollPrStatus(config) {
243
243
  }
244
244
 
245
245
  // Review status from GitHub reviews
246
- const reviews = ghApi(`/pulls/${prNum}/reviews`, slug);
246
+ const reviews = await ghApi(`/pulls/${prNum}/reviews`, slug);
247
247
  if (reviews && Array.isArray(reviews)) {
248
248
  // Get latest review per user
249
249
  const latestByUser = new Map();
@@ -306,7 +306,7 @@ async function pollPrStatus(config) {
306
306
 
307
307
  // Check status / checks
308
308
  if (prData.state === 'open' && prData.head?.sha) {
309
- const checksData = ghApi(`/commits/${prData.head.sha}/check-runs`, slug);
309
+ const checksData = await ghApi(`/commits/${prData.head.sha}/check-runs`, slug);
310
310
  if (checksData && checksData.check_runs) {
311
311
  const runs = checksData.check_runs;
312
312
  let buildStatus = 'none';
@@ -352,11 +352,11 @@ async function pollPrStatus(config) {
352
352
  async function pollPrHumanComments(config) {
353
353
  const totalUpdated = await forEachActiveGhPr(config, async (project, pr, prNum, slug) => {
354
354
  // Get issue comments (general PR comments)
355
- const comments = ghApi(`/issues/${prNum}/comments`, slug);
355
+ const comments = await ghApi(`/issues/${prNum}/comments`, slug);
356
356
  if (!comments || !Array.isArray(comments)) return false;
357
357
 
358
358
  // Also get review comments (inline code comments)
359
- const reviewComments = ghApi(`/pulls/${prNum}/comments`, slug);
359
+ const reviewComments = await ghApi(`/pulls/${prNum}/comments`, slug);
360
360
  const allComments = [
361
361
  ...(comments || []).map(c => ({ ...c, _type: 'issue' })),
362
362
  ...(Array.isArray(reviewComments) ? reviewComments : []).map(c => ({ ...c, _type: 'review' }))
@@ -438,7 +438,7 @@ async function reconcilePrs(config) {
438
438
  if (isSlugInBackoff(slug)) continue;
439
439
 
440
440
  // Fetch open PRs
441
- const prsData = ghApi('/pulls?state=open&per_page=100', slug);
441
+ const prsData = await ghApi('/pulls?state=open&per_page=100', slug);
442
442
  if (!prsData || !Array.isArray(prsData)) {
443
443
  recordSlugFailure(slug);
444
444
  continue;
@@ -541,12 +541,12 @@ async function reconcilePrs(config) {
541
541
  * Fetch live review status for a single PR from GitHub. Returns 'approved', 'changes-requested',
542
542
  * 'waiting', or 'pending'. Returns null if the check fails.
543
543
  */
544
- function checkLiveReviewStatus(pr, project) {
544
+ async function checkLiveReviewStatus(pr, project) {
545
545
  try {
546
546
  const slug = getRepoSlug(project);
547
547
  if (!slug) return null;
548
548
  const prNum = (pr.id || '').replace(/^PR-/, '');
549
- const reviews = ghApi(`/pulls/${prNum}/reviews`, slug);
549
+ const reviews = await ghApi(`/pulls/${prNum}/reviews`, slug);
550
550
  if (!reviews || !Array.isArray(reviews)) return null;
551
551
  const latestByUser = new Map();
552
552
  for (const r of reviews) {
@@ -689,7 +689,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
689
689
 
690
690
  // ─── Post-Completion Hooks ──────────────────────────────────────────────────
691
691
 
692
- function updatePrAfterReview(agentId, pr, project, config) {
692
+ async function updatePrAfterReview(agentId, pr, project, config) {
693
693
 
694
694
  if (!pr?.id) return;
695
695
 
@@ -707,7 +707,7 @@ function updatePrAfterReview(agentId, pr, project, config) {
707
707
  const checkFn = host === 'github'
708
708
  ? require('./github').checkLiveReviewStatus
709
709
  : require('./ado').checkLiveReviewStatus;
710
- const liveStatus = checkFn(pr, projectObj);
710
+ const liveStatus = await checkFn(pr, projectObj);
711
711
  // Use live status only if it's a decisive verdict (not 'pending' — review may not have propagated yet)
712
712
  if (liveStatus && liveStatus !== 'pending') postReviewStatus = liveStatus;
713
713
  }
@@ -1142,7 +1142,7 @@ function handleDecompositionResult(stdout, meta, config) {
1142
1142
  return 0;
1143
1143
  }
1144
1144
 
1145
- function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1145
+ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1146
1146
 
1147
1147
  const type = dispatchItem.type;
1148
1148
  const meta = dispatchItem.meta;
@@ -1319,7 +1319,7 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1319
1319
  }
1320
1320
  }
1321
1321
 
1322
- if (type === WORK_TYPE.REVIEW) updatePrAfterReview(agentId, meta?.pr, meta?.project, config);
1322
+ if (type === WORK_TYPE.REVIEW) await updatePrAfterReview(agentId, meta?.pr, meta?.project, config);
1323
1323
  if (type === WORK_TYPE.FIX) updatePrAfterFix(meta?.pr, meta?.project, meta?.source);
1324
1324
  checkForLearnings(agentId, config.agents[agentId], dispatchItem.task);
1325
1325
  if (effectiveSuccess) {
package/engine/shared.js CHANGED
@@ -304,7 +304,7 @@ function writeToInbox(agentId, slug, content, _inboxDir) {
304
304
  // ── Process Spawning ────────────────────────────────────────────────────────
305
305
  // All child process calls go through these to ensure windowsHide: true
306
306
 
307
- const { execSync: _execSync, spawnSync: _spawnSync, spawn: _spawn } = require('child_process');
307
+ const { execSync: _execSync, spawnSync: _spawnSync, spawn: _spawn, exec: _cbExec } = require('child_process');
308
308
 
309
309
  function exec(cmd, opts = {}) {
310
310
  return _execSync(cmd, { windowsHide: true, ...opts });
@@ -322,6 +322,31 @@ function execSilent(cmd, opts = {}) {
322
322
  return _execSync(cmd, { stdio: 'pipe', windowsHide: true, ...opts });
323
323
  }
324
324
 
325
+ /**
326
+ * Async version of exec() — runs a shell command without blocking the event loop.
327
+ * Returns a Promise that resolves with { stdout, stderr } or rejects on error/timeout.
328
+ * Drop-in replacement for sync `exec()` in async contexts.
329
+ *
330
+ * @param {string} cmd - Shell command to run
331
+ * @param {object} opts - Options (same as child_process.exec: timeout, cwd, encoding, env, etc.)
332
+ * @returns {Promise<string>} stdout (trimmed if encoding is set)
333
+ */
334
+ function execAsync(cmd, opts = {}) {
335
+ const { timeout, ...rest } = opts;
336
+ return new Promise((resolve, reject) => {
337
+ const child = _cbExec(cmd, { windowsHide: true, encoding: 'utf8', ...rest, timeout: timeout || 30000 }, (err, stdout, stderr) => {
338
+ if (err) {
339
+ err.stderr = stderr;
340
+ err.stdout = stdout;
341
+ return reject(err);
342
+ }
343
+ resolve(stdout);
344
+ });
345
+ // Safety: ensure child is killed if parent process exits
346
+ child.unref && child.unref();
347
+ });
348
+ }
349
+
325
350
  /**
326
351
  * Detect the default branch for a git repo. Tries in order:
327
352
  * 1. The configured mainBranch (if it exists as a local or remote ref)
@@ -787,6 +812,7 @@ module.exports = {
787
812
  uniquePath,
788
813
  writeToInbox,
789
814
  exec,
815
+ execAsync,
790
816
  execSilent,
791
817
  resolveMainBranch,
792
818
  run,
package/engine/timeout.js CHANGED
@@ -149,8 +149,8 @@ function checkTimeouts(config) {
149
149
 
150
150
  completeDispatch(item.id, isSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR, 'Completed (detected from output)');
151
151
 
152
- // Run post-completion hooks via shared helper
153
- runPostCompletionHooks(item, item.agent, isSuccess ? 0 : 1, liveLog, config);
152
+ // Run post-completion hooks via shared helper (async — fire and forget in timeout context)
153
+ runPostCompletionHooks(item, item.agent, isSuccess ? 0 : 1, liveLog, config).catch(e => log('warn', 'post-completion hooks: ' + e.message));
154
154
 
155
155
  if (hasProcess) {
156
156
  shared.killImmediate(activeProcesses.get(item.id)?.proc);
package/engine.js CHANGED
@@ -24,7 +24,7 @@
24
24
  const fs = require('fs');
25
25
  const path = require('path');
26
26
  const shared = require('./engine/shared');
27
- const { exec, execSilent, runFile, ts, ENGINE_DEFAULTS: DEFAULTS,
27
+ const { exec, execAsync, execSilent, runFile, ts, ENGINE_DEFAULTS: DEFAULTS,
28
28
  WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT } = shared;
29
29
  const queries = require('./engine/queries');
30
30
 
@@ -141,6 +141,10 @@ const activeProcesses = new Map(); // dispatchId → { proc, agentId, startedAt
141
141
  // tempAgents imported from engine/routing.js
142
142
  let engineRestartGraceUntil = 0; // timestamp — suppress orphan detection until this time
143
143
 
144
+ // Per-tick cache of refs that failed to fetch — avoids repeating 30s ETIMEDOUT for same missing ref
145
+ // Cleared at the start of each tick cycle (see tickInner)
146
+ const _failedRefCache = new Set();
147
+
144
148
  // Resolve dependency plan item IDs to their PR branches
145
149
  function resolveDependencyBranches(depIds, sourcePlan, project, config) {
146
150
  const results = []; // [{ branch, prId }]
@@ -171,9 +175,9 @@ function resolveDependencyBranches(depIds, sourcePlan, project, config) {
171
175
  }
172
176
 
173
177
  // Find an existing worktree already checked out on a given branch
174
- function findExistingWorktree(repoDir, branchName) {
178
+ async function findExistingWorktree(repoDir, branchName) {
175
179
  try {
176
- const out = exec(`git worktree list --porcelain`, { cwd: repoDir, stdio: 'pipe', timeout: 10000 }).toString();
180
+ const out = await execAsync(`git worktree list --porcelain`, { cwd: repoDir, timeout: 10000 });
177
181
  const branchRef = `branch refs/heads/${branchName}`;
178
182
  const lines = out.split('\n');
179
183
  for (let i = 0; i < lines.length; i++) {
@@ -215,17 +219,17 @@ function removeStaleIndexLock(rootDir) {
215
219
  } catch (e) { log('warn', 'git: ' + e.message); }
216
220
  }
217
221
 
218
- function runWorktreeAdd(rootDir, worktreePath, args, gitOpts, worktreeCreateRetries) {
222
+ async function runWorktreeAdd(rootDir, worktreePath, args, gitOpts, worktreeCreateRetries) {
219
223
  let lastErr = null;
220
224
  const retries = Math.max(0, Number(worktreeCreateRetries) || 0);
221
225
  for (let attempt = 0; attempt <= retries; attempt++) {
222
226
  try {
223
227
  if (attempt > 0) {
224
- try { exec('git worktree prune', { ...gitOpts, cwd: rootDir, timeout: 15000 }); } catch (e) { log('warn', 'git: ' + e.message); }
228
+ try { await execAsync('git worktree prune', { ...gitOpts, cwd: rootDir, timeout: 15000 }); } catch (e) { log('warn', 'git: ' + e.message); }
225
229
  removeStaleIndexLock(rootDir);
226
230
  log('warn', `Retrying git worktree add (attempt ${attempt + 1}/${retries + 1}) for ${path.basename(worktreePath)}`);
227
231
  }
228
- exec(`git worktree add "${worktreePath}" ${args}`, { ...gitOpts, cwd: rootDir });
232
+ await execAsync(`git worktree add "${worktreePath}" ${args}`, { ...gitOpts, cwd: rootDir });
229
233
  return;
230
234
  } catch (err) {
231
235
  lastErr = err;
@@ -235,14 +239,14 @@ function runWorktreeAdd(rootDir, worktreePath, args, gitOpts, worktreeCreateRetr
235
239
  if (lastErr) throw lastErr;
236
240
  }
237
241
 
238
- function recoverPartialWorktree(rootDir, worktreePath, branchName, gitOpts) {
242
+ async function recoverPartialWorktree(rootDir, worktreePath, branchName, gitOpts) {
239
243
  if (!branchName) return false;
240
- const existingWt = findExistingWorktree(rootDir, branchName);
244
+ const existingWt = await findExistingWorktree(rootDir, branchName);
241
245
  if (existingWt && fs.existsSync(existingWt)) return true;
242
246
  if (!fs.existsSync(worktreePath)) return false;
243
247
  try {
244
- exec(`git -C "${worktreePath}" rev-parse --is-inside-work-tree`, { ...gitOpts, timeout: 10000 });
245
- exec(`git -C "${worktreePath}" rev-parse --abbrev-ref HEAD`, { ...gitOpts, timeout: 10000 });
248
+ await execAsync(`git -C "${worktreePath}" rev-parse --is-inside-work-tree`, { ...gitOpts, timeout: 10000 });
249
+ await execAsync(`git -C "${worktreePath}" rev-parse --abbrev-ref HEAD`, { ...gitOpts, timeout: 10000 });
246
250
  log('warn', `Recovered partially-created worktree for ${branchName} at ${worktreePath}`);
247
251
  return true;
248
252
  } catch {
@@ -250,7 +254,7 @@ function recoverPartialWorktree(rootDir, worktreePath, branchName, gitOpts) {
250
254
  }
251
255
  }
252
256
 
253
- function spawnAgent(dispatchItem, config) {
257
+ async function spawnAgent(dispatchItem, config) {
254
258
  const { id, agent: agentId, prompt: taskPrompt, type, meta } = dispatchItem;
255
259
  const claudeConfig = config.claude || {};
256
260
  const engineConfig = config.engine || {};
@@ -279,12 +283,12 @@ function spawnAgent(dispatchItem, config) {
279
283
  worktreePath = path.resolve(rootDir, engineConfig.worktreeRoot || '../worktrees', wtDirName);
280
284
 
281
285
  // If branch is already checked out in an existing worktree, reuse it
282
- const existingWt = findExistingWorktree(rootDir, branchName);
286
+ const existingWt = await findExistingWorktree(rootDir, branchName);
283
287
  if (existingWt) {
284
288
  worktreePath = existingWt;
285
289
  log('info', `Reusing existing worktree for ${branchName}: ${existingWt}`);
286
- try { exec(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch (e) { log('warn', 'git: ' + e.message); }
287
- try { exec(`git pull origin "${branchName}"`, { ..._gitOpts, cwd: existingWt }); } catch (e) { log('warn', 'git: ' + e.message); }
290
+ try { await execAsync(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch (e) { log('warn', 'git: ' + e.message); }
291
+ try { await execAsync(`git pull origin "${branchName}"`, { ..._gitOpts, cwd: existingWt }); } catch (e) { log('warn', 'git: ' + e.message); }
288
292
  } else if (['meeting', 'ask', 'explore', 'plan-to-prd', 'plan'].includes(type)) {
289
293
  // Read-only tasks — no worktree needed, run in rootDir
290
294
  log('info', `${type}: read-only task, no worktree needed — running in rootDir`);
@@ -295,18 +299,18 @@ function spawnAgent(dispatchItem, config) {
295
299
  if (!fs.existsSync(worktreePath)) {
296
300
  const isSharedBranch = meta?.branchStrategy === 'shared-branch' || meta?.useExistingBranch;
297
301
  // Prune stale worktree entries before creating (handles leftover entries from crashed runs)
298
- try { exec(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
302
+ try { await execAsync(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
299
303
  // Remove stale index.lock before creating worktree (Windows crashes can leave this behind)
300
304
  removeStaleIndexLock(rootDir);
301
305
 
302
306
  if (isSharedBranch) {
303
307
  log('info', `Creating worktree for shared branch: ${worktreePath} on ${branchName}`);
304
- try { exec(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch (e) { log('warn', 'git: ' + e.message); }
308
+ try { await execAsync(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch (e) { log('warn', 'git: ' + e.message); }
305
309
  try {
306
- runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
310
+ await runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
307
311
  } catch (eShared) {
308
312
  if (eShared.message?.includes('already used by worktree') || eShared.message?.includes('already checked out')) {
309
- const existingWtPath = findExistingWorktree(rootDir, branchName);
313
+ const existingWtPath = await findExistingWorktree(rootDir, branchName);
310
314
  if (existingWtPath && fs.existsSync(existingWtPath)) {
311
315
  log('info', `Shared branch ${branchName} already checked out at ${existingWtPath} — reusing`);
312
316
  worktreePath = existingWtPath;
@@ -315,42 +319,42 @@ function spawnAgent(dispatchItem, config) {
315
319
  // Branch doesn't exist yet (first item in plan) — create it from main
316
320
  const mainRef = sanitizeBranch(shared.resolveMainBranch(rootDir, project.mainBranch));
317
321
  log('info', `Shared branch ${branchName} not found — creating from ${mainRef}`);
318
- runWorktreeAdd(rootDir, worktreePath, `-b "${branchName}" ${mainRef}`, _worktreeGitOpts, worktreeCreateRetries);
322
+ await runWorktreeAdd(rootDir, worktreePath, `-b "${branchName}" ${mainRef}`, _worktreeGitOpts, worktreeCreateRetries);
319
323
  } else { throw eShared; }
320
324
  }
321
325
  } else {
322
326
  log('info', `Creating worktree: ${worktreePath} on branch ${branchName}`);
323
327
  const mainRef = sanitizeBranch(shared.resolveMainBranch(rootDir, project.mainBranch));
324
328
  try {
325
- runWorktreeAdd(rootDir, worktreePath, `-b "${branchName}" ${mainRef}`, _worktreeGitOpts, worktreeCreateRetries);
329
+ await runWorktreeAdd(rootDir, worktreePath, `-b "${branchName}" ${mainRef}`, _worktreeGitOpts, worktreeCreateRetries);
326
330
  } catch (e1) {
327
331
  const branchExists = e1.message?.includes('already exists');
328
332
  log('warn', `Worktree -b failed for ${branchName}: ${e1.message?.split('\n')[0]}`);
329
333
  if (!branchExists) {
330
334
  // Transient error (lock, timeout) — prune, clean, and retry -b once more
331
335
  log('info', `Retrying -b create after prune for ${branchName}`);
332
- try { exec(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 15000 }); } catch { /* optional */ }
336
+ try { await execAsync(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 15000 }); } catch { /* optional */ }
333
337
  removeStaleIndexLock(rootDir);
334
338
  // Clean up partial worktree directory from failed attempt
335
339
  try { if (fs.existsSync(worktreePath)) fs.rmSync(worktreePath, { recursive: true, force: true }); } catch { /* optional */ }
336
340
  try {
337
- runWorktreeAdd(rootDir, worktreePath, `-b "${branchName}" ${mainRef}`, _worktreeGitOpts, 0);
341
+ await runWorktreeAdd(rootDir, worktreePath, `-b "${branchName}" ${mainRef}`, _worktreeGitOpts, 0);
338
342
  } catch (e1b) {
339
343
  log('error', `Worktree -b retry also failed for ${branchName}: ${e1b.message?.split('\n')[0]}`);
340
344
  throw e1b;
341
345
  }
342
346
  } else {
343
347
  // Branch already exists — try checkout without -b
344
- try { exec(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch (e) { log('warn', 'git: ' + e.message); }
348
+ try { await execAsync(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch (e) { log('warn', 'git: ' + e.message); }
345
349
  try {
346
- runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
350
+ await runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
347
351
  log('info', `Reusing existing branch: ${branchName}`);
348
352
  } catch (e2) {
349
353
  // "already checked out" or "already used by worktree" — find and reuse or recover
350
354
  const alreadyUsed = e2.message?.includes('already checked out') || e2.message?.includes('already used by worktree')
351
355
  || e1.message?.includes('already checked out') || e1.message?.includes('already used by worktree');
352
356
  if (alreadyUsed) {
353
- const existingWtPath = findExistingWorktree(rootDir, branchName);
357
+ const existingWtPath = await findExistingWorktree(rootDir, branchName);
354
358
  if (existingWtPath && fs.existsSync(existingWtPath)) {
355
359
  // Bug fix: read dispatch under file lock so check-and-act is atomic
356
360
  let activelyUsed = false;
@@ -369,12 +373,12 @@ function spawnAgent(dispatchItem, config) {
369
373
  worktreePath = existingWtPath;
370
374
  } else if (existingWtPath && !fs.existsSync(existingWtPath)) {
371
375
  log('warn', `Branch ${branchName} tracked in missing dir ${existingWtPath} — pruning and recreating`);
372
- try { exec(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
373
- runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
376
+ try { await execAsync(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
377
+ await runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
374
378
  log('info', `Recovered worktree for ${branchName} after stale entry prune`);
375
379
  } else {
376
- try { exec(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
377
- runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
380
+ try { await execAsync(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
381
+ await runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
378
382
  }
379
383
  } else {
380
384
  throw e2;
@@ -385,10 +389,10 @@ function spawnAgent(dispatchItem, config) {
385
389
  }
386
390
  } else if (meta?.branchStrategy === 'shared-branch') {
387
391
  log('info', `Pulling latest on shared branch ${branchName}`);
388
- try { exec(`git pull origin "${branchName}"`, { ..._gitOpts, cwd: worktreePath }); } catch (e) { log('warn', 'git: ' + e.message); }
392
+ try { await execAsync(`git pull origin "${branchName}"`, { ..._gitOpts, cwd: worktreePath }); } catch (e) { log('warn', 'git: ' + e.message); }
389
393
  }
390
394
  } catch (err) {
391
- if (recoverPartialWorktree(rootDir, worktreePath, branchName, _gitOpts)) {
395
+ if (await recoverPartialWorktree(rootDir, worktreePath, branchName, _gitOpts)) {
392
396
  cwd = worktreePath;
393
397
  log('warn', `Proceeding with recovered worktree after add failure for ${branchName}`);
394
398
  } else {
@@ -407,11 +411,17 @@ function spawnAgent(dispatchItem, config) {
407
411
  try {
408
412
  const depBranches = resolveDependencyBranches(depIds, meta?.item?.sourcePlan, project, config);
409
413
  for (const { branch: depBranch, prId } of depBranches) {
414
+ // Skip refs already known to be missing this tick (avoids repeated 30s ETIMEDOUT)
415
+ if (_failedRefCache.has(depBranch)) {
416
+ log('warn', `Skipping dependency ${depBranch} — already failed to fetch this tick`);
417
+ continue;
418
+ }
410
419
  try {
411
- exec(`git fetch origin "${depBranch}"`, { ..._gitOpts, cwd: rootDir });
412
- exec(`git merge "origin/${depBranch}" --no-edit`, { ..._gitOpts, cwd: worktreePath });
420
+ await execAsync(`git fetch origin "${depBranch}"`, { ..._gitOpts, cwd: rootDir });
421
+ await execAsync(`git merge "origin/${depBranch}" --no-edit`, { ..._gitOpts, cwd: worktreePath });
413
422
  log('info', `Merged dependency branch ${depBranch} (${prId}) into worktree ${branchName}`);
414
423
  } catch (mergeErr) {
424
+ _failedRefCache.add(depBranch);
415
425
  log('warn', `Failed to merge dependency ${depBranch} into ${branchName}: ${mergeErr.message}`);
416
426
  }
417
427
  }
@@ -704,7 +714,7 @@ function spawnAgent(dispatchItem, config) {
704
714
  }
705
715
 
706
716
  // Parse output and run all post-completion hooks
707
- const { resultSummary, autoRecovered } = runPostCompletionHooks(dispatchItem, agentId, code, stdout, config);
717
+ const { resultSummary, autoRecovered } = await runPostCompletionHooks(dispatchItem, agentId, code, stdout, config);
708
718
 
709
719
  // Move from active to completed in dispatch (single source of truth for agent status)
710
720
  // autoRecovered: agent failed (e.g. heartbeat timeout) but created PRs — treat as success
@@ -2351,6 +2361,7 @@ async function tickInner() {
2351
2361
 
2352
2362
  const config = getConfig();
2353
2363
  tickCount++;
2364
+ _failedRefCache.clear(); // Reset per-tick failed-ref cache
2354
2365
 
2355
2366
  // Helper: run a phase, log + continue on error
2356
2367
  const safe = (label, fn) => { try { fn(); } catch (e) { log('warn', `${label}: ${e.message}`); } };
@@ -2563,7 +2574,7 @@ async function tickInner() {
2563
2574
  for (const item of toDispatch) {
2564
2575
  if (!dispatched.has(item.id)) {
2565
2576
  let proc;
2566
- try { proc = spawnAgent(item, config); } catch (spawnErr) {
2577
+ try { proc = await spawnAgent(item, config); } catch (spawnErr) {
2567
2578
  log('error', `spawnAgent exception for ${item.id}: ${spawnErr.message}`);
2568
2579
  proc = null;
2569
2580
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.531",
3
+ "version": "0.1.533",
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"