@yemi33/minions 0.1.532 → 0.1.534

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.534 (2026-04-07)
4
+
5
+ ### Fixes
6
+ - update pipeline work item status on dispatch (#443)
7
+ - convert blocking spawnSync/execSync to async execAsync (#447)
8
+
3
9
  ## 0.1.532 (2026-04-07)
4
10
 
5
11
  ### Fixes
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) {
@@ -184,7 +184,6 @@ function executeTaskStage(stage, stageState, run, config) {
184
184
  createdIds.push(id);
185
185
  }
186
186
  });
187
-
188
187
  return { status: PIPELINE_STATUS.RUNNING, artifacts: { workItems: createdIds } };
189
188
  }
190
189
 
@@ -278,7 +277,7 @@ async function executePlanStage(stage, stageState, run, config) {
278
277
 
279
278
  safeWrite(filePath, content);
280
279
 
281
- // Create plan-to-prd work item
280
+ // Create plan-to-prd work item — atomic write to prevent race with dispatch status updates
282
281
  const wiPath = path.join(__dirname, '..', 'work-items.json');
283
282
  const wiId = `PL-${run.runId.slice(4, 12)}-${stage.id}-prd`;
284
283
  mutateWorkItems(wiPath, workItems => {
package/engine/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
@@ -1929,13 +1939,26 @@ function discoverCentralWorkItems(config) {
1929
1939
  const items = safeJson(centralPath) || [];
1930
1940
  const projects = getProjects(config);
1931
1941
  const newWork = [];
1942
+ // Collect mutations to apply atomically inside lock callback (avoids TOCTOU)
1943
+ const mutations = new Map(); // item.id → { field: value, ... }
1932
1944
 
1933
1945
  for (const item of items) {
1934
1946
  try {
1935
1947
  if (item.status !== WI_STATUS.QUEUED && item.status !== WI_STATUS.PENDING) continue;
1936
1948
 
1937
1949
  const key = `central-work-${item.id}`;
1938
- if (isAlreadyDispatched(key) || isOnCooldown(key, 0)) continue;
1950
+ // Self-heal: if already dispatched but work item is still pending, fix the status
1951
+ if (isAlreadyDispatched(key)) {
1952
+ const m = {};
1953
+ if (item.status === WI_STATUS.PENDING) { m.status = WI_STATUS.DISPATCHED; }
1954
+ if (!item.dispatched_to) {
1955
+ const existing = getDispatch().active?.find(d => d.meta?.dispatchKey === key);
1956
+ if (existing?.agent) { m.dispatched_to = existing.agent; }
1957
+ }
1958
+ if (Object.keys(m).length > 0) mutations.set(item.id, m);
1959
+ continue;
1960
+ }
1961
+ if (isOnCooldown(key, 0)) continue;
1939
1962
 
1940
1963
  const workType = item.type || 'implement';
1941
1964
  const isFanOut = item.scope === 'fan-out';
@@ -2019,11 +2042,13 @@ function discoverCentralWorkItems(config) {
2019
2042
  });
2020
2043
  }
2021
2044
 
2022
- item.status = WI_STATUS.DISPATCHED;
2023
- item.dispatched_at = ts();
2024
- item.dispatched_to = idleAgents.map(a => a.id).join(', ');
2025
- item.scope = 'fan-out';
2026
- item.fanOutAgents = idleAgents.map(a => a.id);
2045
+ mutations.set(item.id, {
2046
+ status: WI_STATUS.DISPATCHED,
2047
+ dispatched_at: ts(),
2048
+ dispatched_to: idleAgents.map(a => a.id).join(', '),
2049
+ scope: 'fan-out',
2050
+ fanOutAgents: idleAgents.map(a => a.id),
2051
+ });
2027
2052
  setCooldown(key);
2028
2053
  log('info', `Fan-out: ${item.id} dispatched to ${idleAgents.length} agents: ${idleAgents.map(a => a.name).join(', ')}`);
2029
2054
 
@@ -2075,11 +2100,10 @@ function discoverCentralWorkItems(config) {
2075
2100
  const cpCount = (item._checkpointCount || 0) + 1;
2076
2101
  if (cpCount > 3) {
2077
2102
  log('warn', `Work item ${item.id} exceeded 3 checkpoint-resumes — marking as needs-human-review`);
2078
- item.status = WI_STATUS.NEEDS_REVIEW;
2079
- item._checkpointCount = cpCount;
2103
+ mutations.set(item.id, { status: WI_STATUS.NEEDS_REVIEW, _checkpointCount: cpCount });
2080
2104
  continue;
2081
2105
  }
2082
- item._checkpointCount = cpCount;
2106
+ mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, { _checkpointCount: cpCount }));
2083
2107
  const cpSummary = [
2084
2108
  `## Checkpoint (Resume #${cpCount}/3)`,
2085
2109
  '',
@@ -2107,7 +2131,7 @@ function discoverCentralWorkItems(config) {
2107
2131
  vars.notes_content = '';
2108
2132
  try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch { /* optional */ }
2109
2133
  // Track expected plan filename in meta for chainPlanToPrd
2110
- item._planFileName = planFileName;
2134
+ mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, { _planFileName: planFileName }));
2111
2135
  }
2112
2136
 
2113
2137
  // Inject plan-to-prd variables — read the plan file content for the playbook
@@ -2162,6 +2186,13 @@ function discoverCentralWorkItems(config) {
2162
2186
  continue;
2163
2187
  }
2164
2188
 
2189
+ const dispatchMutation = {
2190
+ status: WI_STATUS.DISPATCHED,
2191
+ dispatched_at: ts(),
2192
+ dispatched_to: agentId,
2193
+ };
2194
+ mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, dispatchMutation));
2195
+
2165
2196
  newWork.push({
2166
2197
  type: workType,
2167
2198
  agent: agentId,
@@ -2169,18 +2200,25 @@ function discoverCentralWorkItems(config) {
2169
2200
  agentRole,
2170
2201
  task: item.title || item.description?.slice(0, 80) || item.id,
2171
2202
  prompt,
2172
- meta: { dispatchKey: key, source: 'central-work-item', item, planFileName: item.planFile || item._planFileName || null, branch: item.branch || item.featureBranch || `work/${item.id}` }
2203
+ meta: { dispatchKey: key, source: 'central-work-item', item, planFileName: item.planFile || mutations.get(item.id)?._planFileName || null, branch: item.branch || item.featureBranch || `work/${item.id}` }
2173
2204
  });
2174
2205
 
2175
- item.status = WI_STATUS.DISPATCHED;
2176
- item.dispatched_at = ts();
2177
- item.dispatched_to = agentId;
2178
2206
  setCooldown(key);
2179
2207
  }
2180
2208
  } catch (err) { log('warn', `discoverCentralWorkItems: skipping ${item.id}: ${err.message}`); }
2181
2209
  }
2182
2210
 
2183
- if (newWork.length > 0) safeWrite(centralPath, items);
2211
+ if (mutations.size > 0) {
2212
+ // True atomic read-modify-write — applies mutations to fresh locked data
2213
+ mutateJsonFileLocked(centralPath, (freshItems) => {
2214
+ if (!Array.isArray(freshItems)) freshItems = [];
2215
+ for (const fi of freshItems) {
2216
+ const m = mutations.get(fi.id);
2217
+ if (m) Object.assign(fi, m);
2218
+ }
2219
+ return freshItems;
2220
+ }, { defaultValue: [] });
2221
+ }
2184
2222
  return newWork;
2185
2223
  }
2186
2224
 
@@ -2228,24 +2266,33 @@ function discoverWork(config) {
2228
2266
  if (scheduledWork.length > 0) {
2229
2267
  const { createMeeting, getMeetings } = require('./engine/meeting');
2230
2268
  const centralPath = path.join(MINIONS_DIR, 'work-items.json');
2231
- const items = safeJson(centralPath) || [];
2232
- let added = 0;
2269
+ // Separate meetings (no work-items write) from task items
2270
+ const taskItems = [];
2233
2271
  for (const item of scheduledWork) {
2234
2272
  if (item.type === WORK_TYPE.MEETING) {
2235
- // Create a real multi-agent meeting instead of a single-agent work item
2236
2273
  const sched = (config.schedules || []).find(s => s.id === item._scheduleId);
2237
2274
  const participants = (sched && sched.participants) || [];
2238
2275
  const meeting = createMeeting({ title: item.title, agenda: item.description, participants });
2239
2276
  log('info', `Scheduled meeting created: ${item._scheduleId} → ${meeting.id} (${participants.length} participants)`);
2240
2277
  } else {
2241
- if (!items.some(i => i._scheduleId === item._scheduleId && i.status !== WI_STATUS.DONE && i.status !== WI_STATUS.FAILED)) {
2242
- items.push(item);
2243
- added++;
2244
- log('info', `Scheduled task fired: ${item._scheduleId} → ${item.title}`);
2245
- }
2278
+ taskItems.push(item);
2246
2279
  }
2247
2280
  }
2248
- if (added > 0) safeWrite(centralPath, items);
2281
+ if (taskItems.length > 0) {
2282
+ // Atomic write — prevents race with dispatch status updates on central work-items.json
2283
+ mutateJsonFileLocked(centralPath, (items) => {
2284
+ if (!Array.isArray(items)) items = [];
2285
+ let added = 0;
2286
+ for (const item of taskItems) {
2287
+ if (!items.some(i => i._scheduleId === item._scheduleId && i.status !== WI_STATUS.DONE && i.status !== WI_STATUS.FAILED)) {
2288
+ items.push(item);
2289
+ added++;
2290
+ log('info', `Scheduled task fired: ${item._scheduleId} → ${item.title}`);
2291
+ }
2292
+ }
2293
+ return items;
2294
+ }, { defaultValue: [] });
2295
+ }
2249
2296
  }
2250
2297
  } catch (e) { log('warn', 'discover scheduled work: ' + e.message); }
2251
2298
 
@@ -2351,6 +2398,7 @@ async function tickInner() {
2351
2398
 
2352
2399
  const config = getConfig();
2353
2400
  tickCount++;
2401
+ _failedRefCache.clear(); // Reset per-tick failed-ref cache
2354
2402
 
2355
2403
  // Helper: run a phase, log + continue on error
2356
2404
  const safe = (label, fn) => { try { fn(); } catch (e) { log('warn', `${label}: ${e.message}`); } };
@@ -2563,7 +2611,7 @@ async function tickInner() {
2563
2611
  for (const item of toDispatch) {
2564
2612
  if (!dispatched.has(item.id)) {
2565
2613
  let proc;
2566
- try { proc = spawnAgent(item, config); } catch (spawnErr) {
2614
+ try { proc = await spawnAgent(item, config); } catch (spawnErr) {
2567
2615
  log('error', `spawnAgent exception for ${item.id}: ${spawnErr.message}`);
2568
2616
  proc = null;
2569
2617
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.532",
3
+ "version": "0.1.534",
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"