@yemi33/minions 0.1.1984 → 0.1.1986

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/engine.js CHANGED
@@ -30,6 +30,7 @@ const { exec, execAsync, execSilent, runFile, ts, ENGINE_DEFAULTS,
30
30
  FAILURE_CLASS } = shared;
31
31
  const { resolveRuntime } = require('./engine/runtimes');
32
32
  const { assertStaleHeadOk } = require('./engine/spawn-agent');
33
+ const adoGitAuth = require('./engine/ado-git-auth');
33
34
  const queries = require('./engine/queries');
34
35
 
35
36
  // ─── Paths ──────────────────────────────────────────────────────────────────
@@ -194,6 +195,64 @@ function parseConflictFiles(mergeOutput) {
194
195
  return [...new Set(files)]; // dedupe
195
196
  }
196
197
 
198
+ // Build the work item used by the dep-merge-failure auto-queue path
199
+ // (W-mpcwojgr000a0244). Routes the conflict-fix through the shared-branch
200
+ // dispatch path (`branchStrategy: 'shared-branch'` + `featureBranch:
201
+ // depConflictBranch`) so spawnAgent's `git worktree add <wt> origin/<branch>`
202
+ // (engine.js:1113) checks out the dep's existing branch directly. Commits land
203
+ // back on that branch via `git push`, the existing PR picks them up, and no
204
+ // redundant fresh-branch PR is opened.
205
+ //
206
+ // Cross-project caveat: the project field is stamped from the blocked item's
207
+ // project. If the dep PR lives in a DIFFERENT project, the agent would still
208
+ // be spawned in the blocked item's worktree root. That is a PRE-EXISTING
209
+ // limitation of the dep-conflict-fix path — single-project plans (the common
210
+ // case, and the trigger scenario P-wi1-bridge-readonly-{a,b,c}) are unaffected.
211
+ function buildDepConflictFixItem({
212
+ depConflictBranch,
213
+ depConflictFiles = [],
214
+ isInterDepConflict = false,
215
+ preflightConflictPrev = null,
216
+ mainBranch,
217
+ blockedItem = null,
218
+ projectName = null,
219
+ }) {
220
+ if (!depConflictBranch) throw new Error('buildDepConflictFixItem: depConflictBranch is required');
221
+ if (!mainBranch) throw new Error('buildDepConflictFixItem: mainBranch is required');
222
+ const conflictFixId = `conflict-fix-${depConflictBranch.replace(/[^a-zA-Z0-9-]/g, '-')}`;
223
+ const filesDesc = depConflictFiles.length > 0
224
+ ? `\n\nConflicting files:\n${depConflictFiles.map(f => '- ' + f).join('\n')}`
225
+ : '';
226
+ // Wording is explicit that the agent must push to the SAME branch — the
227
+ // shared-branch dispatch path puts them directly on `depConflictBranch`, so
228
+ // a fresh branch / new PR would defeat the whole point of this auto-queue.
229
+ const conflictFixDesc = isInterDepConflict && preflightConflictPrev
230
+ ? `Branch \`${depConflictBranch}\` conflicts with dependency branch \`${preflightConflictPrev}\`. Your worktree is already checked out on \`${depConflictBranch}\`. Rebase \`${depConflictBranch}\` onto \`${preflightConflictPrev}\` (or merge \`${preflightConflictPrev}\` into \`${depConflictBranch}\`), resolve conflicts, then \`git push\` to the SAME branch (\`--force-with-lease\` ok after rebase). Do NOT create a new branch and do NOT open a new PR. The existing PR for \`${depConflictBranch}\` will pick up your commits automatically.`
231
+ : `Branch \`${depConflictBranch}\` conflicts with \`${mainBranch}\`. Your worktree is already checked out on this branch. Merge \`${mainBranch}\` into it, resolve conflicts, then \`git push\` to the SAME branch — do NOT create a new branch and do NOT open a new PR. The existing PR for \`${depConflictBranch}\` will pick up your commits automatically.`;
232
+ const blockedSuffix = blockedItem
233
+ ? `\n\nBlocked downstream item: \`${blockedItem.id}\` — ${blockedItem.title || ''}`
234
+ : '';
235
+ return {
236
+ id: conflictFixId,
237
+ title: `Fix merge conflict: ${depConflictBranch} conflicts with ${isInterDepConflict ? preflightConflictPrev : mainBranch}`,
238
+ type: WORK_TYPE.FIX,
239
+ priority: 'high',
240
+ status: WI_STATUS.PENDING,
241
+ description: `${conflictFixDesc}${filesDesc}${blockedSuffix}`,
242
+ created: ts(),
243
+ createdBy: 'engine:dep-conflict-fix',
244
+ // Route through the shared-branch dispatch path (engine.js:4629, :4681,
245
+ // :1113) so the agent operates on the dep's existing branch — pushing
246
+ // straight into the existing PR instead of opening a new one.
247
+ branchStrategy: 'shared-branch',
248
+ featureBranch: depConflictBranch,
249
+ _branch: depConflictBranch,
250
+ _blockedItem: blockedItem ? blockedItem.id : null,
251
+ _isInterDepConflict: !!isInterDepConflict,
252
+ project: projectName || null,
253
+ };
254
+ }
255
+
197
256
  // Prune dep branches that are ancestors of other dep branches (#958)
198
257
  // When B already contains A's commits, merging both A and B causes conflicts.
199
258
  async function pruneAncestorDeps(deps, gitOpts, cwd) {
@@ -527,7 +586,16 @@ async function syncReusedWorktree(rootDir, worktreePath, branchName, gitOpts = {
527
586
  async function findExistingWorktree(repoDir, branchName) {
528
587
  try {
529
588
  const out = await shared.shellSafeGit(['worktree', 'list', '--porcelain'], { cwd: repoDir, timeout: 10000 });
530
- const found = shared.parseWorktreePorcelain(out).find(w => w.branch === branchName);
589
+ // Skip worktrees at or inside `repoDir` itself: the main checkout (always
590
+ // first in the porcelain output) sits AT repoDir, and nested worktrees
591
+ // under it would trip assertWorktreeOutsideProject downstream anyway.
592
+ // Returning either as "reusable" creates an unresolvable preflight
593
+ // rejection loop when the branch happens to be checked out at the
594
+ // project root (common when an operator works on the minions repo
595
+ // locally while the engine tries to review that same branch).
596
+ const found = shared.parseWorktreePorcelain(out).find(w =>
597
+ w.branch === branchName && !shared.isPathInsideOrEqual(w.path, repoDir)
598
+ );
531
599
  if (found && fs.existsSync(found.path)) return found.path;
532
600
  } catch (e) { log('warn', 'git: ' + e.message); }
533
601
  return null;
@@ -816,7 +884,12 @@ async function spawnAgent(dispatchItem, config) {
816
884
  let branchName = _preBranchName;
817
885
  const worktreeCreateTimeout = Math.max(60000, Number(engineConfig.worktreeCreateTimeout) || ENGINE_DEFAULTS.worktreeCreateTimeout);
818
886
  const worktreeCreateRetries = Math.max(0, Math.min(3, Number(engineConfig.worktreeCreateRetries) || ENGINE_DEFAULTS.worktreeCreateRetries));
819
- const _gitOpts = { stdio: 'pipe', timeout: 30000, windowsHide: true, env: shared.gitEnv() };
887
+ // W-mpcuc8i80003a7b3 for ADO projects, inject a per-invocation
888
+ // `-c http.<host>.extraHeader=Authorization: Bearer <token>` so headless
889
+ // git ops survive missing/expired Git Credential Manager state. Returns
890
+ // [] for GitHub/local projects so this is a no-op there.
891
+ const _adoGitExtraArgs = adoGitAuth.getAdoGitExtraArgs(project);
892
+ const _gitOpts = { stdio: 'pipe', timeout: 30000, windowsHide: true, env: shared.gitEnv(), gitExtraArgs: _adoGitExtraArgs };
820
893
  const _worktreeGitOpts = { ..._gitOpts, timeout: worktreeCreateTimeout };
821
894
 
822
895
  // Build the initial prompt before worktree setup, then refresh shared-branch
@@ -862,14 +935,34 @@ async function spawnAgent(dispatchItem, config) {
862
935
  : taskPromptWithReport;
863
936
  };
864
937
  let fullTaskPrompt = buildFullTaskPrompt(taskPrompt);
865
- const tmpDir = path.join(ENGINE_DIR, 'tmp');
866
- if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
867
938
  const safeId = id.replace(/[:\\/*?"<>|]/g, '-');
868
- const promptPath = path.join(tmpDir, `prompt-${safeId}.md`);
939
+ // P-f6-tmp-toctou: per-dispatch unique tmp dir closes the symlink-plant
940
+ // window on engine/tmp/prompt-<id>.md (mkdtempSync + chmod 0o700). Filename
941
+ // pattern inside the dir is unchanged so spawn-agent.js's prompt→pid regex
942
+ // derivation still works.
943
+ const dispatchTmpDir = shared.createDispatchTmpDir(id);
944
+ const promptPath = path.join(dispatchTmpDir, `prompt-${safeId}.md`);
869
945
  safeWrite(promptPath, fullTaskPrompt);
870
- const sysPromptPath = path.join(tmpDir, `sysprompt-${safeId}.md`);
946
+ const sysPromptPath = path.join(dispatchTmpDir, `sysprompt-${safeId}.md`);
871
947
  safeWrite(sysPromptPath, systemPrompt);
872
- const _cleanupPromptFiles = () => { safeUnlink(promptPath); safeUnlink(sysPromptPath); };
948
+ // Stamp the tmpDir on the dispatch entry so cleanup/orphan-reap/kill paths
949
+ // (dispatch.js, meeting.js, cli.js, cleanup.js, timeout.js) can find the
950
+ // dir via shared.dispatchPidCandidates / findDispatchPidFile. Best-effort —
951
+ // backward-compat fallbacks still scan dispatch-<safeId>-* dirs by id.
952
+ try {
953
+ dispatchItem.tmpDir = dispatchTmpDir;
954
+ const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
955
+ mutateJsonFileLocked(dispatchPath, (dispatch) => {
956
+ for (const queue of ['pending', 'active', 'completed']) {
957
+ const arr = Array.isArray(dispatch?.[queue]) ? dispatch[queue] : null;
958
+ if (!arr) continue;
959
+ const found = arr.find(d => d && d.id === id);
960
+ if (found) found.tmpDir = dispatchTmpDir;
961
+ }
962
+ return dispatch;
963
+ }, { defaultValue: { pending: [], active: [], completed: [] } });
964
+ } catch (e) { log('warn', `spawnAgent: failed to persist tmpDir for ${id}: ${e.message}`); }
965
+ const _cleanupPromptFiles = () => { shared.removeDispatchTmpDir(dispatchTmpDir); };
873
966
  // Convert a WORKTREE_NESTED_IN_PROJECT throw into a fail-fast non-retryable
874
967
  // dispatch failure (W-mp62taw2000ubcc3). The error's `.code` is set by
875
968
  // shared.assertWorktreeOutsideProject so we don't have to parse the message.
@@ -1173,6 +1266,12 @@ async function spawnAgent(dispatchItem, config) {
1173
1266
  let depMergeFailed = false;
1174
1267
  let depConflictBranch = null; // track which dep branch caused the conflict
1175
1268
  let depConflictFiles = []; // conflicting file names parsed from git output
1269
+ // W-mpcuc8i80003a7b3 — track whether ANY git op in the dep phase
1270
+ // failed with an ADO auth signature. If so, we escalate as
1271
+ // FAILURE_CLASS.AUTH (non-retryable + dedup'd inbox alert) instead
1272
+ // of FAILURE_CLASS.MERGE_CONFLICT (which retries 3x against the
1273
+ // same broken auth path).
1274
+ let _depAuthFailed = false;
1176
1275
  // Fetch all dependency branches in parallel (git fetches are independent)
1177
1276
  const fetchable = depBranches.filter(d => !_failedRefCache.has(d.branch));
1178
1277
  const unfetchable = depBranches.filter(d => _failedRefCache.has(d.branch));
@@ -1188,7 +1287,10 @@ async function spawnAgent(dispatchItem, config) {
1188
1287
  }
1189
1288
  const fetchResults = await Promise.allSettled(
1190
1289
  fetchable.map(({ branch: depBranch }) =>
1191
- shared.shellSafeGit(['fetch', 'origin', depBranch], { ..._gitOpts, cwd: rootDir }).then(() => depBranch)
1290
+ // runAdoGit for ADO projects auto-retries once on auth failure
1291
+ // with a refreshed bearer token (handles mid-dispatch expiry).
1292
+ // For non-ADO projects it's a thin pass-through to shellSafeGit.
1293
+ adoGitAuth.runAdoGit(project, ['fetch', 'origin', depBranch], { ..._gitOpts, cwd: rootDir }).then(() => depBranch)
1192
1294
  )
1193
1295
  );
1194
1296
  const hasFetchFailures = fetchResults.some(r => r.status === 'rejected');
@@ -1221,6 +1323,12 @@ async function spawnAgent(dispatchItem, config) {
1221
1323
  }
1222
1324
  _failedRefCache.add(failedBranch);
1223
1325
  log('warn', `Failed to fetch dependency ${failedBranch}: ${errMsg}`);
1326
+ // W-mpcuc8i80003a7b3 — detect ADO bearer-token / GCM credential
1327
+ // failures so we escalate as FAILURE_CLASS.AUTH below instead of
1328
+ // mis-classifying as a merge conflict and burning 3 retries.
1329
+ if (adoGitAuth.isAdoAuthFailure(fetchResults[i].reason)) {
1330
+ _depAuthFailed = true;
1331
+ }
1224
1332
  depMergeFailed = true;
1225
1333
  }
1226
1334
  }
@@ -1302,6 +1410,11 @@ async function spawnAgent(dispatchItem, config) {
1302
1410
  // Merge failed — possibly due to diverged history from a force-pushed (rebased) dep branch.
1303
1411
  // Abort partial merge, reset worktree to clean main base, and re-merge all deps from scratch.
1304
1412
  log('warn', `Merge of ${depBranch} into ${branchName} failed: ${mergeErr.message} — attempting reset and re-merge of all deps`);
1413
+ // W-mpcuc8i80003a7b3 — defense in depth. `git merge origin/<dep>`
1414
+ // is local so an auth failure here is unexpected, but mark the
1415
+ // flag so the final dispatch failure routes through the AUTH
1416
+ // path instead of MERGE_CONFLICT.
1417
+ if (adoGitAuth.isAdoAuthFailure(mergeErr)) _depAuthFailed = true;
1305
1418
  try { await shared.shellSafeGit(['merge', '--abort'], { ..._gitOpts, cwd: worktreePath }); } catch (_) { /* no merge in progress */ }
1306
1419
  const mainRef = sanitizeBranch(shared.resolveMainBranch(rootDir, project.mainBranch));
1307
1420
  try {
@@ -1316,6 +1429,7 @@ async function spawnAgent(dispatchItem, config) {
1316
1429
  } catch (resetErr) {
1317
1430
  const errOutput = (resetErr.message || '') + '\n' + (resetErr.stdout?.toString?.() || '') + '\n' + (resetErr.stderr?.toString?.() || '');
1318
1431
  log('warn', `Failed to reset and re-merge deps for ${branchName}: ${resetErr.message}`);
1432
+ if (adoGitAuth.isAdoAuthFailure(resetErr)) _depAuthFailed = true;
1319
1433
  try { await shared.shellSafeGit(['merge', '--abort'], { ..._gitOpts, cwd: worktreePath }); } catch (_) { /* no merge in progress */ }
1320
1434
  // Post-mortem: incremental simulation to identify which dep caused the conflict (#958)
1321
1435
  // Uses same chained merge-tree approach as pre-flight to catch inter-dep conflicts
@@ -1366,6 +1480,58 @@ async function spawnAgent(dispatchItem, config) {
1366
1480
  _phaseT.depMergeEnd = Date.now();
1367
1481
  if (depMergeFailed) {
1368
1482
  _cleanupPromptFiles();
1483
+ // W-mpcuc8i80003a7b3 — short-circuit to AUTH classification when any
1484
+ // git op in the dep phase looked like an ADO credential failure.
1485
+ // This BYPASSES the merge-conflict path entirely: no retries (auth
1486
+ // is structural — re-running won't fix missing az / GCM creds),
1487
+ // no conflict-fix WI (would just re-trigger the same auth wall),
1488
+ // and a single dedup'd inbox alert pointing at the recovery steps.
1489
+ if (_depAuthFailed) {
1490
+ const projName = project.name || 'unknown';
1491
+ const authFailReason = `ADO git authentication failed for project ${projName}: dependency fetch could not authenticate to origin (likely missing/expired az CLI token, no cached GCM credentials, or token broker unavailable in headless context)`;
1492
+ try {
1493
+ writeInboxAlert(`ado-auth-${projName}`, [
1494
+ `# ADO git authentication failed — ${projName}`,
1495
+ '',
1496
+ `Work item: \`${meta?.item?.id || '(unknown)'}\``,
1497
+ `Branch: \`${branchName || '(unknown)'}\``,
1498
+ '',
1499
+ '## Symptom',
1500
+ '',
1501
+ 'Engine could not fetch dependency branches from the ADO origin. ',
1502
+ 'Git Credential Manager has no cached credentials and falls back to ',
1503
+ 'a TTY prompt, which the headless engine cannot satisfy:',
1504
+ '',
1505
+ '```',
1506
+ "fatal: could not read Username for 'https://<adoOrg>.visualstudio.com'",
1507
+ '```',
1508
+ '',
1509
+ '## Recovery',
1510
+ '',
1511
+ 'From an interactive shell on the engine host, refresh ADO credentials:',
1512
+ '',
1513
+ '```bash',
1514
+ '# Option A — Azure CLI (preferred)',
1515
+ 'az login',
1516
+ 'az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken -o tsv',
1517
+ '',
1518
+ '# Option B — azureauth (corp environments)',
1519
+ 'azureauth ado token --mode iwa --mode broker --output token --timeout 1',
1520
+ '```',
1521
+ '',
1522
+ 'Once a valid token can be acquired, the engine will pick it up automatically on the next tick (30-min cache + 10-min acquire backoff).',
1523
+ '',
1524
+ '## Why no auto-retry',
1525
+ '',
1526
+ 'This dispatch is failed as `FAILURE_CLASS.AUTH` (non-retryable). Mechanical retry would burn slots against the same broken credential path. After you fix the credentials, manually retry the work item via the dashboard or `/api/work-items/retry`.',
1527
+ ].join('\n'));
1528
+ } catch (alertErr) {
1529
+ log('warn', `Failed to write ADO auth inbox alert: ${alertErr.message}`);
1530
+ }
1531
+ completeDispatch(id, DISPATCH_RESULT.ERROR, authFailReason, '', { failureClass: FAILURE_CLASS.AUTH, agentRetryable: false });
1532
+ cleanupTempAgent(agentId);
1533
+ return;
1534
+ }
1369
1535
  // Build actionable failReason identifying the conflicting branch and files (#958)
1370
1536
  const mainBranch = sanitizeBranch(shared.resolveMainBranch(rootDir, project.mainBranch));
1371
1537
  let failReason = 'Dependency merge failed';
@@ -1380,39 +1546,29 @@ async function spawnAgent(dispatchItem, config) {
1380
1546
  }
1381
1547
  completeDispatch(id, DISPATCH_RESULT.ERROR, failReason, '', { failureClass: FAILURE_CLASS.MERGE_CONFLICT });
1382
1548
 
1383
- // Auto-queue conflict-fix work item when a specific dep branch is identified
1549
+ // Auto-queue conflict-fix work item when a specific dep branch is identified.
1550
+ // Routes through the shared-branch dispatch path (see buildDepConflictFixItem)
1551
+ // so commits land on the dep's existing PR branch (W-mpcwojgr000a0244).
1384
1552
  if (depConflictBranch && meta?.item?.id && project) {
1385
1553
  try {
1386
1554
  const wiPath = project.name
1387
1555
  ? projectWorkItemsPath(project)
1388
1556
  : path.join(MINIONS_DIR, 'work-items.json');
1389
- const conflictFixId = `conflict-fix-${depConflictBranch.replace(/[^a-zA-Z0-9-]/g, '-')}`;
1390
- const filesDesc = depConflictFiles.length > 0
1391
- ? `\n\nConflicting files:\n${depConflictFiles.map(f => '- ' + f).join('\n')}`
1392
- : '';
1393
- // Inter-dep conflict: rebase onto the conflicting dep; dep-vs-main: merge main (#958)
1394
- const conflictFixDesc = _isInterDepConflict && _preflightConflictPrev
1395
- ? `Branch \`${depConflictBranch}\` conflicts with dependency branch \`${_preflightConflictPrev}\`. Rebase \`${depConflictBranch}\` onto \`${_preflightConflictPrev}\` (or merge \`${_preflightConflictPrev}\` into \`${depConflictBranch}\`) and resolve conflicts, then push.`
1396
- : `Branch \`${depConflictBranch}\` conflicts with \`${mainBranch}\`. Merge ${mainBranch} into the branch and resolve conflicts, then push.`;
1557
+ const newItem = buildDepConflictFixItem({
1558
+ depConflictBranch,
1559
+ depConflictFiles,
1560
+ isInterDepConflict: _isInterDepConflict,
1561
+ preflightConflictPrev: _preflightConflictPrev,
1562
+ mainBranch,
1563
+ blockedItem: meta.item,
1564
+ projectName: project.name || null,
1565
+ });
1397
1566
  mutateWorkItems(wiPath, items => {
1398
1567
  // Don't create duplicate conflict-fix items
1399
- const existing = items.find(i => i.id === conflictFixId && i.status !== WI_STATUS.DONE && i.status !== WI_STATUS.FAILED && i.status !== WI_STATUS.CANCELLED);
1568
+ const existing = items.find(i => i.id === newItem.id && i.status !== WI_STATUS.DONE && i.status !== WI_STATUS.FAILED && i.status !== WI_STATUS.CANCELLED);
1400
1569
  if (existing) return;
1401
- items.push({
1402
- id: conflictFixId,
1403
- title: `Fix merge conflict: ${depConflictBranch} conflicts with ${_isInterDepConflict ? _preflightConflictPrev : mainBranch}`,
1404
- type: WORK_TYPE.FIX,
1405
- priority: 'high',
1406
- status: WI_STATUS.PENDING,
1407
- description: `${conflictFixDesc}${filesDesc}\n\nBlocked downstream item: \`${meta.item.id}\` — ${meta.item.title || ''}`,
1408
- created: ts(),
1409
- createdBy: 'engine:dep-conflict-fix',
1410
- _branch: depConflictBranch,
1411
- _blockedItem: meta.item.id,
1412
- _isInterDepConflict: _isInterDepConflict || false,
1413
- project: project.name || null,
1414
- });
1415
- log('info', `Auto-queued conflict-fix work item ${conflictFixId} for ${depConflictBranch} (blocked: ${meta.item.id})`);
1570
+ items.push(newItem);
1571
+ log('info', `Auto-queued conflict-fix work item ${newItem.id} for ${depConflictBranch} (blocked: ${meta.item.id})`);
1416
1572
  });
1417
1573
  } catch (e) { log('warn', `Failed to auto-queue conflict-fix: ${e.message}`); }
1418
1574
  }
@@ -1707,6 +1863,9 @@ async function spawnAgent(dispatchItem, config) {
1707
1863
  if (pidFilePath) {
1708
1864
  try { safeUnlink(pidFilePath); } catch { /* may not exist yet */ }
1709
1865
  }
1866
+ // P-f6-tmp-toctou: rm the per-dispatch tmp dir too so prompt/sysprompt
1867
+ // sidecars don't leak when runFile throws before onAgentClose can run.
1868
+ try { _cleanupPromptFiles(); } catch { /* cleanup is best-effort */ }
1710
1869
  cleanupTempAgent(agentId);
1711
1870
  throw spawnErr;
1712
1871
  }
@@ -1847,7 +2006,7 @@ async function spawnAgent(dispatchItem, config) {
1847
2006
  const pendingForResume = steering.buildPendingSteeringPrompt(agentId);
1848
2007
  const steerPromptBody = pendingForResume.prompt || steerMsg;
1849
2008
  const steerPrompt = `Message from your human teammate:\n\n${steerPromptBody}\n\nRespond to this, then continue working on your current task.`;
1850
- const steerPromptPath = path.join(ENGINE_DIR, 'tmp', `prompt-steer-${safeId}.md`);
2009
+ const steerPromptPath = path.join(dispatchTmpDir, `prompt-steer-${safeId}.md`);
1851
2010
  try { safeWrite(steerPromptPath, steerPrompt); } catch (e) {
1852
2011
  log('warn', `Steering: failed to write prompt for ${agentId}: ${e.message}`);
1853
2012
  try { fs.appendFileSync(liveOutputPath, `\n[steering-failed] Could not write prompt. Message was: ${steerMsg}\n`); } catch {}
@@ -2705,10 +2864,11 @@ async function spawnAgent(dispatchItem, config) {
2705
2864
  } catch (e) { log('warn', `keep-processes acceptance: failed to set _pendingReason: ${e.message}`); }
2706
2865
  }
2707
2866
 
2708
- // Cleanup temp files (including PID file now that dispatch is complete)
2709
- try { fs.unlinkSync(sysPromptPath); } catch { /* cleanup */ }
2710
- try { fs.unlinkSync(promptPath); } catch { /* cleanup */ }
2711
- try { fs.unlinkSync(promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid')); } catch { /* cleanup */ }
2867
+ // Cleanup temp files (whole per-dispatch dir, including PID/sysprompt
2868
+ // tmp/prompt — P-f6-tmp-toctou). removeDispatchTmpDir validates the path
2869
+ // resolves under engine/tmp/dispatch-* before rmSync, so a corrupted
2870
+ // dispatch.json field can't redirect this to an arbitrary path.
2871
+ try { shared.removeDispatchTmpDir(dispatchTmpDir); } catch { /* cleanup */ }
2712
2872
 
2713
2873
  log('info', `Agent ${agentId} completed. Output saved to ${archivePath}`);
2714
2874
 
@@ -3981,16 +4141,24 @@ async function discoverFromPrs(config, project) {
3981
4141
  const currentHeadSha = String(pr.headSha || pr._adoSourceCommit || pr._adoHeadCommit || '').trim();
3982
4142
  const lastHumanDispatch = pr._lastDispatchByCause?.[shared.PR_FIX_CAUSE.HUMAN_FEEDBACK];
3983
4143
  const currentCommentId = String(pr.humanFeedback?.lastProcessedCommentId || '');
3984
- if (lastHumanDispatch?.outcome === 'noop'
4144
+ // Issue #2632: this same-head/same-comment guard MUST be cause-local. A
4145
+ // previous `continue` here aborted the whole PR iteration and starved
4146
+ // the build-failure / re-review / conflict-fix evaluation blocks below
4147
+ // (live repro on ADO PR `office-bohemia#5215610`: had `buildStatus=failing`
4148
+ // + `buildFailureSignature` but no `build-fix-*` dispatch was ever
4149
+ // queued). Skip only the human-feedback dispatch path; leave
4150
+ // `fixDispatched=false` so downstream causes are still evaluated.
4151
+ const skipHumanFeedback = !!(lastHumanDispatch?.outcome === 'noop'
3985
4152
  && lastHumanDispatch.headSha
3986
4153
  && currentHeadSha
3987
4154
  && lastHumanDispatch.headSha === currentHeadSha
3988
4155
  && lastHumanDispatch.lastProcessedCommentId
3989
4156
  && currentCommentId
3990
- && lastHumanDispatch.lastProcessedCommentId === currentCommentId) {
4157
+ && lastHumanDispatch.lastProcessedCommentId === currentCommentId);
4158
+ if (skipHumanFeedback) {
3991
4159
  log('info', `Skipping human-feedback fix for ${pr.id}: last human-feedback dispatch was noop on the same head ${currentHeadSha.slice(0, 8)} and same comment ${currentCommentId.slice(0, 32)} (${(lastHumanDispatch.reason || '').slice(0, 120)})`);
3992
- continue;
3993
4160
  }
4161
+ if (!skipHumanFeedback) {
3994
4162
  const key = humanFixKey;
3995
4163
  if (isPrAutomationCauseHandledOrPending(project, pr, humanCauseKey)) continue;
3996
4164
  let staleCoalesced = [];
@@ -4035,6 +4203,7 @@ async function discoverFromPrs(config, project) {
4035
4203
  review_note: reviewNote,
4036
4204
  }, `Fix ${pr.id}: ${pr.title || ''} — human feedback`, { dispatchKey: key, automationCauseKey: humanCauseKey, source: 'pr-human-feedback', pr, branch: prBranch, project: projMeta });
4037
4205
  if (item) { newWork.push(item); fixDispatched = true; }
4206
+ } // end if (!skipHumanFeedback) — cause-local guard for #2632
4038
4207
  }
4039
4208
 
4040
4209
  // Re-review after fix: trigger when a fix was pushed after the last minions review,
@@ -4150,13 +4319,18 @@ async function discoverFromPrs(config, project) {
4150
4319
  // a new commit landed (live repro on PR #2433).
4151
4320
  const currentHeadSha = String(pr.headSha || pr._adoSourceCommit || pr._adoHeadCommit || '').trim();
4152
4321
  const lastBuildDispatch = pr._lastDispatchByCause?.[shared.PR_FIX_CAUSE.BUILD_FAILURE];
4153
- if (lastBuildDispatch?.outcome === 'noop'
4322
+ // Issue #2632 audit: cause-local guard. A previous `continue` here
4323
+ // aborted the whole PR iteration and starved the conflict-fix block
4324
+ // below — symmetric to the human-feedback bug. Skip only the build-fix
4325
+ // dispatch path; downstream merge-conflict resolution must still run.
4326
+ const skipBuildFix = !!(lastBuildDispatch?.outcome === 'noop'
4154
4327
  && lastBuildDispatch.headSha
4155
4328
  && currentHeadSha
4156
- && lastBuildDispatch.headSha === currentHeadSha) {
4329
+ && lastBuildDispatch.headSha === currentHeadSha);
4330
+ if (skipBuildFix) {
4157
4331
  log('info', `Skipping build-fix for ${pr.id}: last build-failure dispatch was noop on the same head ${currentHeadSha.slice(0, 8)} (${(lastBuildDispatch.reason || '').slice(0, 120)})`);
4158
- continue;
4159
4332
  }
4333
+ if (!skipBuildFix) {
4160
4334
  const buildCauseKey = getPrAutomationCauseKey('build', pr);
4161
4335
  const key = getPrAutomationDispatchKey(`build-fix-${project?.name || 'default'}-${prDisplayId}`, buildCauseKey);
4162
4336
  if (isPrAutomationCauseHandledOrPending(project, pr, buildCauseKey)) continue;
@@ -4245,6 +4419,7 @@ async function discoverFromPrs(config, project) {
4245
4419
  });
4246
4420
  } catch (e) { log('warn', 'mark build fail notified: ' + e.message); }
4247
4421
  }
4422
+ } // end if (!skipBuildFix) — cause-local guard for #2632 audit
4248
4423
  }
4249
4424
 
4250
4425
  // PRs with merge conflicts — dispatch fix to resolve (gated by provider polling + autoFixConflicts)
@@ -4886,10 +5061,19 @@ function materializeSpecsAsWorkItems(config, project) {
4886
5061
  let recentSpecs = [];
4887
5062
  for (const pattern of filePatterns) {
4888
5063
  try {
4889
- const result = exec(
4890
- `git log --diff-filter=AM --name-only --pretty=format:"COMMIT:%H|%s" --since="${sinceDate}" -- "${pattern}"`,
4891
- { cwd: root, encoding: 'utf8', timeout: 10000 }
4892
- ).trim();
5064
+ // P-f7-git-log: argv form via execFileSync (shell:false) so file patterns
5065
+ // from operator config.json reach git as literal pathspec args, never as
5066
+ // shell tokens. Spread the pattern after the '--' pathspec separator.
5067
+ const args = [
5068
+ 'log',
5069
+ '--diff-filter=AM',
5070
+ '--name-only',
5071
+ '--pretty=format:COMMIT:%H|%s',
5072
+ `--since=${sinceDate}`,
5073
+ '--',
5074
+ pattern,
5075
+ ];
5076
+ const result = shared.shellSafeGitSync(args, { cwd: root, timeout: 10000 }).trim();
4893
5077
  if (!result) continue;
4894
5078
 
4895
5079
  let currentCommit = null;
@@ -5724,12 +5908,15 @@ async function tickInner() {
5724
5908
  process.exit(0);
5725
5909
  }
5726
5910
 
5727
- // Write heartbeat so dashboard can detect stale engine
5728
- try { mutateControl(c => ({ ...c, heartbeat: Date.now() })); } catch (e) { log('warn', 'write heartbeat: ' + e.message); }
5911
+ // W-mpcyvff6000pf828 (#2653) — control.heartbeat is written by a dedicated
5912
+ // 15s interval in engine/cli.js (createHeartbeatWriter), decoupled from
5913
+ // tickInner so a slow tick (cold runtime spawn, sequential PR polls, slow
5914
+ // worktree create) cannot starve heartbeats and flip the dashboard to STALE
5915
+ // on an otherwise healthy engine.
5729
5916
 
5730
5917
  // P-c2e5a1d9-a — Initial wiring guard: bail immediately if a force-release
5731
- // reclaimed our lock while the heartbeat write was in flight. Per-phase
5732
- // guards inside the rest of tickInner are sub-task -b's scope.
5918
+ // reclaimed our lock while the startup control-state read was in flight.
5919
+ // Per-phase guards inside the rest of tickInner are sub-task -b's scope.
5733
5920
  if (_isTickStale(myGeneration)) return;
5734
5921
 
5735
5922
  const config = getConfig();
@@ -6394,14 +6581,17 @@ module.exports = {
6394
6581
  // Discovery
6395
6582
  discoverWork, discoverFromPrs, discoverFromWorkItems, discoverCentralWorkItems,
6396
6583
  materializePlansAsWorkItems,
6584
+ materializeSpecsAsWorkItems, // exported for testing (P-f7-git-log)
6397
6585
  reservePrdFilename, // exported for testing (P-9b7e5d3c)
6398
6586
  sweepStaleArchivedPrdBackups, // exported for testing
6399
6587
 
6400
6588
  // Shared helpers (used by lifecycle.js and tests)
6401
6589
  reconcileItemsWithPrs, detectDependencyCycles,
6402
6590
  parseConflictFiles, pruneAncestorDeps, preflightMergeSimulation, // exported for testing
6591
+ buildDepConflictFixItem, // exported for testing (W-mpcwojgr000a0244)
6403
6592
  isWorktreeRetryableError, removeStaleIndexLock, syncReusedWorktree, assertCleanSharedWorktree, // exported for testing
6404
6593
  pruneStaleWorktreeForBranch, // exported for testing
6594
+ findExistingWorktree, // exported for testing
6405
6595
  _maxTurnsForType, buildProjectContext, normalizeAc, _buildAgentSpawnFlags, _classifyAgentFailure, // exported for testing
6406
6596
  promoteCheckpointSteeringForClose, // exported for testing
6407
6597
  normalizePrBranch, resolvePrBranch, prCausePart, getPrCauseHead, getPrCauseBase, getPrAutomationCauseKey, getPrAutomationDispatchKey, // exported for testing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1984",
3
+ "version": "0.1.1986",
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"