@yemi33/minions 0.1.914 → 0.1.915

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,6 +1,6 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.914 (2026-04-13)
3
+ ## 0.1.915 (2026-04-13)
4
4
 
5
5
  ### Features
6
6
  - fix dep re-merge failure on retry with existing commits (#977)
@@ -17,6 +17,8 @@
17
17
  - add red dot notification on CC tab when response completes (#934) (#946)
18
18
 
19
19
  ### Fixes
20
+ - dep merge ancestor pruning + pre-flight simulation (#958) (#979)
21
+ - add MAX_TURNS failure class and fix enum count test (#983)
20
22
  - prevent Create Plan from meeting saving doc-chat context bleed (#980)
21
23
  - plan completion incorrectly overrides awaiting-approval status (#970) (#978)
22
24
  - CC queued messages sent to wrong tab after tab switch
@@ -1893,8 +1893,14 @@ function classifyFailure(code, stdout = '', stderr = '') {
1893
1893
  return FAILURE_CLASS.MERGE_CONFLICT;
1894
1894
  }
1895
1895
 
1896
- // Context window / max turns exhausted
1897
- if (/context window|max.*turns.*reached|error_max_turns|terminal_reason.*max_turns|token limit|conversation.*too long|context.*length.*exceeded/i.test(combined)) {
1896
+ // Max turns exhausted (error_max_turns) work in progress, retryable
1897
+ // Must be checked BEFORE OUT_OF_CONTEXT to avoid misclassification as non-retryable
1898
+ if (/error_max_turns|"subtype"\s*:\s*"error_max_turns"|terminal_reason.*max_turns|max.*turns.*reached/i.test(combined)) {
1899
+ return FAILURE_CLASS.MAX_TURNS;
1900
+ }
1901
+
1902
+ // Context window exhausted (token limit, context length — NOT max turns)
1903
+ if (/context window|token limit|conversation.*too long|context.*length.*exceeded/i.test(combined)) {
1898
1904
  return FAILURE_CLASS.OUT_OF_CONTEXT;
1899
1905
  }
1900
1906
 
@@ -64,6 +64,12 @@ const RECOVERY_RECIPES = new Map([
64
64
  freshSession: false,
65
65
  description: 'Network/API error — retry with exponential backoff',
66
66
  }],
67
+ [FAILURE_CLASS.MAX_TURNS, {
68
+ maxAttempts: 3,
69
+ escalation: ESCALATION_POLICY.RETRY_SAME,
70
+ freshSession: false,
71
+ description: 'Max turns reached — work in progress, retry same agent to continue',
72
+ }],
67
73
  [FAILURE_CLASS.OUT_OF_CONTEXT, {
68
74
  maxAttempts: 1,
69
75
  escalation: ESCALATION_POLICY.HUMAN_REVIEW,
package/engine/shared.js CHANGED
@@ -672,12 +672,13 @@ const FAILURE_CLASS = {
672
672
  EMPTY_OUTPUT: 'empty-output', // Agent produced no meaningful output
673
673
  SPAWN_ERROR: 'spawn-error', // Process failed to start or crashed immediately
674
674
  NETWORK_ERROR: 'network-error', // API rate limit, DNS, connectivity
675
- OUT_OF_CONTEXT: 'out-of-context', // Context window exhausted, max turns reached
675
+ OUT_OF_CONTEXT: 'out-of-context', // Context window exhausted (token limit, context length)
676
+ MAX_TURNS: 'max-turns', // Claude CLI error_max_turns — work in progress, retryable
676
677
  UNKNOWN: 'unknown', // Unclassified failure
677
678
  };
678
679
  const ESCALATION_POLICY = {
679
680
  NO_RETRY: 'no-retry', // CONFIG_ERROR, PERMISSION_BLOCKED — never retry
680
- RETRY_SAME: 'retry-same', // MERGE_CONFLICT, BUILD_FAILURE — retry same agent
681
+ RETRY_SAME: 'retry-same', // MERGE_CONFLICT, BUILD_FAILURE, MAX_TURNS — retry same agent
681
682
  RETRY_FRESH: 'retry-fresh', // TIMEOUT, SPAWN_ERROR — retry with fresh session
682
683
  HUMAN_REVIEW: 'human-review', // EMPTY_OUTPUT, OUT_OF_CONTEXT — flag for human
683
684
  AUTO: 'auto', // UNKNOWN, NETWORK_ERROR — use default retry logic
package/engine.js CHANGED
@@ -167,6 +167,68 @@ function parseConflictFiles(mergeOutput) {
167
167
  return [...new Set(files)]; // dedupe
168
168
  }
169
169
 
170
+ // Prune dep branches that are ancestors of other dep branches (#958)
171
+ // When B already contains A's commits, merging both A and B causes conflicts.
172
+ async function pruneAncestorDeps(deps, gitOpts, cwd) {
173
+ if (deps.length <= 1) return deps;
174
+ const ancestorIndices = new Set();
175
+ for (let i = 0; i < deps.length; i++) {
176
+ if (ancestorIndices.has(i)) continue;
177
+ for (let j = 0; j < deps.length; j++) {
178
+ if (i === j || ancestorIndices.has(j)) continue;
179
+ try {
180
+ await execAsync(`git merge-base --is-ancestor "origin/${deps[i].branch}" "origin/${deps[j].branch}"`, { ...gitOpts, cwd });
181
+ // deps[i] is an ancestor of deps[j] — prune deps[i]
182
+ ancestorIndices.add(i);
183
+ break;
184
+ } catch (_) { /* not an ancestor — that's fine */ }
185
+ }
186
+ }
187
+ return deps.filter((_, i) => !ancestorIndices.has(i));
188
+ }
189
+
190
+ // Pre-flight merge simulation using git merge-tree --write-tree (#958)
191
+ // Simulates sequential merges by chaining output tree SHAs via temporary commits.
192
+ // Returns { ok, conflictBranch, conflictFiles, isInterDep, prevBranch }
193
+ async function preflightMergeSimulation(deps, mainRef, gitOpts, cwd) {
194
+ if (deps.length === 0) return { ok: true };
195
+ let currentRef = `origin/${mainRef}`;
196
+ let prevBranch = mainRef;
197
+ for (let i = 0; i < deps.length; i++) {
198
+ const depBranch = deps[i].branch;
199
+ try {
200
+ const result = await execAsync(`git merge-tree --write-tree "${currentRef}" "origin/${depBranch}"`, { ...gitOpts, cwd });
201
+ const treeSha = (typeof result === 'string' ? result : (result.stdout?.toString?.() || '')).trim().split('\n')[0];
202
+ if (!treeSha) return { ok: true }; // can't parse tree SHA, skip pre-flight
203
+ // Create temp commit to chain for next dep (skip for last dep — no chaining needed)
204
+ if (i < deps.length - 1) {
205
+ try {
206
+ const commitResult = await execAsync(
207
+ `git commit-tree "${treeSha}" -p "${currentRef}" -p "origin/${depBranch}" -m "preflight-merge"`,
208
+ { ...gitOpts, cwd }
209
+ );
210
+ const commitSha = (typeof commitResult === 'string' ? commitResult : (commitResult.stdout?.toString?.() || '')).trim();
211
+ if (commitSha) {
212
+ prevBranch = depBranch;
213
+ currentRef = commitSha;
214
+ }
215
+ } catch (_) { return { ok: true }; } // commit-tree failed, skip pre-flight gracefully
216
+ }
217
+ } catch (err) {
218
+ const output = (err.stdout?.toString?.() || '') + '\n' + (err.stderr?.toString?.() || '');
219
+ const isInterDep = prevBranch !== mainRef;
220
+ return {
221
+ ok: false,
222
+ conflictBranch: depBranch,
223
+ conflictFiles: parseConflictFiles(output),
224
+ isInterDep,
225
+ prevBranch,
226
+ };
227
+ }
228
+ }
229
+ return { ok: true };
230
+ }
231
+
170
232
  const _FAST_WORK_TYPES = new Set([WORK_TYPE.EXPLORE, WORK_TYPE.ASK, WORK_TYPE.REVIEW]);
171
233
  const _MAX_TURNS_BY_TYPE = {
172
234
  [WORK_TYPE.EXPLORE]: 30, [WORK_TYPE.ASK]: 20, [WORK_TYPE.REVIEW]: 30,
@@ -524,11 +586,27 @@ async function spawnAgent(dispatchItem, config) {
524
586
  }
525
587
  // Merge successfully-fetched + recovered (local-only pushed) branches sequentially
526
588
  const fetched = fetchable.filter((_, i) => fetchResults[i].status === 'fulfilled' || recoveredBranches.has(fetchable[i].branch));
527
- // Skip dep re-merge if worktree HEAD already contains all dep commits (#973)
589
+ // Ancestor pruning: remove dep branches already contained in another (#958)
590
+ let prunedDeps = fetched;
591
+ let _isInterDepConflict = false;
592
+ let _preflightConflictPrev = null;
593
+ if (fetched.length > 1 && !depMergeFailed) {
594
+ try {
595
+ prunedDeps = await pruneAncestorDeps(fetched, _gitOpts, rootDir);
596
+ if (prunedDeps.length < fetched.length) {
597
+ const pruned = fetched.filter(d => !prunedDeps.includes(d));
598
+ log('info', `Ancestor pruning removed ${pruned.length} dep(s): ${pruned.map(d => d.branch).join(', ')}`);
599
+ }
600
+ } catch (e) {
601
+ log('warn', `Ancestor pruning failed, using all deps: ${e.message}`);
602
+ prunedDeps = fetched;
603
+ }
604
+ }
605
+ // Skip dep re-merge if worktree HEAD already contains all pruned dep commits (#973)
528
606
  let skipDepMerge = false;
529
- if (!depMergeFailed && fetched.length > 0) {
607
+ if (!depMergeFailed && prunedDeps.length > 0) {
530
608
  const ancestorChecks = await Promise.all(
531
- fetched.map(async ({ branch: depBranch }) => {
609
+ prunedDeps.map(async ({ branch: depBranch }) => {
532
610
  try {
533
611
  await execAsync(`git merge-base --is-ancestor "origin/${depBranch}" HEAD`, { ..._gitOpts, cwd: worktreePath });
534
612
  return true;
@@ -536,13 +614,30 @@ async function spawnAgent(dispatchItem, config) {
536
614
  })
537
615
  );
538
616
  if (ancestorChecks.every(Boolean)) {
539
- log('info', `All ${fetched.length} dep branch(es) already merged into ${branchName} — skipping dep re-merge`);
617
+ log('info', `All ${prunedDeps.length} dep branch(es) already merged into ${branchName} — skipping dep re-merge`);
540
618
  skipDepMerge = true;
541
619
  }
542
620
  }
621
+ // Pre-flight merge simulation: detect conflicts without touching the worktree (#958)
622
+ if (!depMergeFailed && !skipDepMerge && prunedDeps.length > 0) {
623
+ const pfMainRef = sanitizeBranch(shared.resolveMainBranch(rootDir, project.mainBranch));
624
+ try {
625
+ const sim = await preflightMergeSimulation(prunedDeps, pfMainRef, _gitOpts, rootDir);
626
+ if (!sim.ok) {
627
+ depMergeFailed = true;
628
+ depConflictBranch = sim.conflictBranch;
629
+ depConflictFiles = sim.conflictFiles;
630
+ _isInterDepConflict = sim.isInterDep;
631
+ _preflightConflictPrev = sim.prevBranch;
632
+ log('warn', `Pre-flight merge simulation detected conflict: ${sim.conflictBranch}${sim.isInterDep ? ` (inter-dep with ${sim.prevBranch})` : ''}`);
633
+ }
634
+ } catch (e) {
635
+ log('warn', `Pre-flight simulation failed, proceeding with real merge: ${e.message}`);
636
+ }
637
+ }
543
638
  // Stash uncommitted changes before dep merge if worktree is dirty (#973)
544
639
  let stashed = false;
545
- if (!depMergeFailed && !skipDepMerge && fetched.length > 0) {
640
+ if (!depMergeFailed && !skipDepMerge && prunedDeps.length > 0) {
546
641
  try {
547
642
  const statusOut = (await execAsync('git status --porcelain', { ..._gitOpts, cwd: worktreePath })).stdout.toString().trim();
548
643
  if (statusOut) {
@@ -555,7 +650,7 @@ async function spawnAgent(dispatchItem, config) {
555
650
  }
556
651
  }
557
652
  if (!depMergeFailed && !skipDepMerge) {
558
- for (const { branch: depBranch, prId } of fetched) {
653
+ for (const { branch: depBranch, prId } of prunedDeps) {
559
654
  try {
560
655
  await execAsync(`git merge "origin/${depBranch}" --no-edit`, { ..._gitOpts, cwd: worktreePath });
561
656
  log('info', `Merged dependency branch ${depBranch} (${prId}) into worktree ${branchName}`);
@@ -568,30 +663,42 @@ async function spawnAgent(dispatchItem, config) {
568
663
  try {
569
664
  await execAsync(`git reset --hard "origin/${mainRef}"`, { ..._gitOpts, cwd: worktreePath });
570
665
  log('info', `Reset worktree ${branchName} to origin/${mainRef} for clean dep re-merge`);
571
- // Re-merge ALL fetched dep branches from scratch on clean base
572
- for (const { branch: reBranch, prId: rePrId } of fetched) {
666
+ // Re-merge ALL pruned dep branches from scratch on clean base
667
+ for (const { branch: reBranch, prId: rePrId } of prunedDeps) {
573
668
  await execAsync(`git merge "origin/${reBranch}" --no-edit`, { ..._gitOpts, cwd: worktreePath });
574
669
  log('info', `Re-merged dependency branch ${reBranch} (${rePrId}) into worktree ${branchName}`);
575
670
  }
576
- log('info', `Successfully re-merged all ${fetched.length} dep branches after reset for ${branchName}`);
671
+ log('info', `Successfully re-merged all ${prunedDeps.length} dep branches after reset for ${branchName}`);
577
672
  } catch (resetErr) {
578
673
  const errOutput = (resetErr.message || '') + '\n' + (resetErr.stdout?.toString?.() || '') + '\n' + (resetErr.stderr?.toString?.() || '');
579
674
  log('warn', `Failed to reset and re-merge deps for ${branchName}: ${resetErr.message}`);
580
675
  try { await execAsync(`git merge --abort`, { ..._gitOpts, cwd: worktreePath }); } catch (_) { /* no merge in progress */ }
581
- // Identify which dep branch caused the conflict during re-merge
582
- // The last branch attempted in the re-merge loop is the one that failed
583
- for (const { branch: reBranch2 } of fetched) {
584
- try {
585
- const mainRef2 = sanitizeBranch(shared.resolveMainBranch(rootDir, project.mainBranch));
586
- const mergeBase = (await execAsync(`git merge-base "origin/${mainRef2}" "origin/${reBranch2}"`, { ..._gitOpts, cwd: rootDir })).stdout.toString().trim();
587
- const treeResult = await execAsync(`git merge-tree "${mergeBase}" "origin/${mainRef2}" "origin/${reBranch2}"`, { ..._gitOpts, cwd: rootDir });
588
- const treeOutput = treeResult.stdout?.toString?.() || '';
589
- if (treeOutput.includes('<<<<<<<') || treeOutput.includes('changed in both')) {
590
- depConflictBranch = reBranch2;
591
- depConflictFiles = parseConflictFiles(treeOutput);
592
- break;
593
- }
594
- } catch (_e) { /* merge-tree may fail continue checking other branches */ }
676
+ // Post-mortem: incremental simulation to identify which dep caused the conflict (#958)
677
+ // Uses same chained merge-tree approach as pre-flight to catch inter-dep conflicts
678
+ const pmMainRef = sanitizeBranch(shared.resolveMainBranch(rootDir, project.mainBranch));
679
+ try {
680
+ const sim = await preflightMergeSimulation(prunedDeps, pmMainRef, _gitOpts, rootDir);
681
+ if (!sim.ok) {
682
+ depConflictBranch = sim.conflictBranch;
683
+ depConflictFiles = sim.conflictFiles;
684
+ _isInterDepConflict = sim.isInterDep;
685
+ _preflightConflictPrev = sim.prevBranch;
686
+ }
687
+ } catch (_simErr) {
688
+ // Fallback: old per-branch isolation check via 3-arg git merge-tree
689
+ for (const { branch: reBranch2 } of prunedDeps) {
690
+ try {
691
+ const mainRef2 = sanitizeBranch(shared.resolveMainBranch(rootDir, project.mainBranch));
692
+ const mergeBase = (await execAsync(`git merge-base "origin/${mainRef2}" "origin/${reBranch2}"`, { ..._gitOpts, cwd: rootDir })).stdout.toString().trim();
693
+ const treeResult = await execAsync(`git merge-tree "${mergeBase}" "origin/${mainRef2}" "origin/${reBranch2}"`, { ..._gitOpts, cwd: rootDir });
694
+ const treeOutput = treeResult.stdout?.toString?.() || '';
695
+ if (treeOutput.includes('<<<<<<<') || treeOutput.includes('changed in both')) {
696
+ depConflictBranch = reBranch2;
697
+ depConflictFiles = parseConflictFiles(treeOutput);
698
+ break;
699
+ }
700
+ } catch (_e) { /* merge-tree may fail — continue checking other branches */ }
701
+ }
595
702
  }
596
703
  // Fallback: parse conflict files from the error output if merge-tree didn't identify them
597
704
  if (!depConflictBranch) {
@@ -614,10 +721,15 @@ async function spawnAgent(dispatchItem, config) {
614
721
  }
615
722
  if (depMergeFailed) {
616
723
  _cleanupPromptFiles();
617
- // Build actionable failReason identifying the conflicting branch and files
724
+ // Build actionable failReason identifying the conflicting branch and files (#958)
725
+ const mainBranch = sanitizeBranch(shared.resolveMainBranch(rootDir, project.mainBranch));
618
726
  let failReason = 'Dependency merge failed';
619
727
  if (depConflictBranch) {
620
- failReason = `Dependency merge failed: ${depConflictBranch} conflicts with ${sanitizeBranch(shared.resolveMainBranch(rootDir, project.mainBranch))}`;
728
+ if (_isInterDepConflict && _preflightConflictPrev) {
729
+ failReason = `Dependency merge failed: ${depConflictBranch} conflicts with dep ${_preflightConflictPrev}`;
730
+ } else {
731
+ failReason = `Dependency merge failed: ${depConflictBranch} conflicts with ${mainBranch}`;
732
+ }
621
733
  if (depConflictFiles.length > 0) failReason += ` in ${depConflictFiles.slice(0, 5).join(', ')}`;
622
734
  failReason += ' — dep branch needs updating';
623
735
  }
@@ -633,22 +745,26 @@ async function spawnAgent(dispatchItem, config) {
633
745
  const filesDesc = depConflictFiles.length > 0
634
746
  ? `\n\nConflicting files:\n${depConflictFiles.map(f => '- ' + f).join('\n')}`
635
747
  : '';
636
- const mainBranch = sanitizeBranch(shared.resolveMainBranch(rootDir, project.mainBranch));
748
+ // Inter-dep conflict: rebase onto the conflicting dep; dep-vs-main: merge main (#958)
749
+ const conflictFixDesc = _isInterDepConflict && _preflightConflictPrev
750
+ ? `Branch \`${depConflictBranch}\` conflicts with dependency branch \`${_preflightConflictPrev}\`. Rebase \`${depConflictBranch}\` onto \`${_preflightConflictPrev}\` (or merge \`${_preflightConflictPrev}\` into \`${depConflictBranch}\`) and resolve conflicts, then push.`
751
+ : `Branch \`${depConflictBranch}\` conflicts with \`${mainBranch}\`. Merge ${mainBranch} into the branch and resolve conflicts, then push.`;
637
752
  mutateWorkItems(wiPath, items => {
638
753
  // Don't create duplicate conflict-fix items
639
754
  const existing = items.find(i => i.id === conflictFixId && i.status !== WI_STATUS.DONE && i.status !== WI_STATUS.FAILED && i.status !== WI_STATUS.CANCELLED);
640
755
  if (existing) return;
641
756
  items.push({
642
757
  id: conflictFixId,
643
- title: `Fix merge conflict: ${depConflictBranch} conflicts with ${mainBranch}`,
758
+ title: `Fix merge conflict: ${depConflictBranch} conflicts with ${_isInterDepConflict ? _preflightConflictPrev : mainBranch}`,
644
759
  type: WORK_TYPE.FIX,
645
760
  priority: 'high',
646
761
  status: WI_STATUS.PENDING,
647
- description: `Branch \`${depConflictBranch}\` conflicts with \`${mainBranch}\`. Merge ${mainBranch} into the branch and resolve conflicts, then push.${filesDesc}\n\nBlocked downstream item: \`${meta.item.id}\` — ${meta.item.title || ''}`,
762
+ description: `${conflictFixDesc}${filesDesc}\n\nBlocked downstream item: \`${meta.item.id}\` — ${meta.item.title || ''}`,
648
763
  created: ts(),
649
764
  createdBy: 'engine:dep-conflict-fix',
650
765
  _branch: depConflictBranch,
651
766
  _blockedItem: meta.item.id,
767
+ _isInterDepConflict: _isInterDepConflict || false,
652
768
  project: project.name || null,
653
769
  });
654
770
  log('info', `Auto-queued conflict-fix work item ${conflictFixId} for ${depConflictBranch} (blocked: ${meta.item.id})`);
@@ -666,6 +782,28 @@ async function spawnAgent(dispatchItem, config) {
666
782
 
667
783
  updateAgentStatus(id, AGENT_STATUS.READY, 'Worktree ready, preparing to spawn process');
668
784
 
785
+ // Inject dirty file list when worktree has uncommitted changes (e.g., max_turns retry)
786
+ // This signals to the respawned agent that prior work exists in the worktree (#960)
787
+ if (worktreePath && fs.existsSync(worktreePath)) {
788
+ try {
789
+ const dirtyResult = await execAsync('git status --porcelain', { ..._gitOpts, cwd: worktreePath, timeout: 10000 });
790
+ const dirtyOutput = (dirtyResult.stdout || '').trim();
791
+ if (dirtyOutput) {
792
+ const dirtyFiles = dirtyOutput.split('\n').map(l => l.trim()).filter(Boolean);
793
+ const dirtySection = [
794
+ '\n## Uncommitted Work in Worktree\n',
795
+ 'The worktree has uncommitted changes from a previous agent run. Review these files and continue from where the previous agent left off.\n',
796
+ '```',
797
+ ...dirtyFiles,
798
+ '```\n',
799
+ ].join('\n');
800
+ // Append dirty file list to the already-written prompt file
801
+ try { fs.appendFileSync(promptPath, dirtySection); } catch (e) { log('warn', `dirty files inject: ${e.message}`); }
802
+ log('info', `Injected ${dirtyFiles.length} dirty files into prompt for ${id}`);
803
+ }
804
+ } catch (e) { log('warn', `git status --porcelain for dirty files: ${e.message}`); }
805
+ }
806
+
669
807
  // Safety check: warn if a write-capable task is running in the main repo without a worktree
670
808
  if (cwd === rootDir && ['implement', 'implement:large', 'fix', 'test', 'verify', 'plan-to-prd'].includes(type)) {
671
809
  log('warn', `Agent ${agentId} running ${type} task in main repo (no worktree) for ${id} — changes may land on master directly`);
@@ -3248,7 +3386,7 @@ module.exports = {
3248
3386
 
3249
3387
  // Shared helpers (used by lifecycle.js and tests)
3250
3388
  reconcileItemsWithPrs, detectDependencyCycles,
3251
- parseConflictFiles, // exported for testing
3389
+ parseConflictFiles, pruneAncestorDeps, preflightMergeSimulation, // exported for testing
3252
3390
 
3253
3391
  // Playbooks
3254
3392
  renderPlaybook, validatePlaybookVars, PLAYBOOK_REQUIRED_VARS, buildWorkItemDispatchVars,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.914",
3
+ "version": "0.1.915",
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"