@yemi33/minions 0.1.532 → 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,10 @@
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
+
3
8
  ## 0.1.532 (2026-04-07)
4
9
 
5
10
  ### 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) {
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.532",
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"