@yemi33/minions 0.1.914 → 0.1.916
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 +4 -1
- package/dashboard/js/command-center.js +6 -0
- package/dashboard/js/render-work-items.js +3 -1
- package/engine/lifecycle.js +8 -2
- package/engine/recovery.js +6 -0
- package/engine/shared.js +3 -2
- package/engine.js +167 -29
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.916 (2026-04-13)
|
|
4
4
|
|
|
5
5
|
### Features
|
|
6
|
+
- CC tab unread dot + reopened badge on work items
|
|
6
7
|
- fix dep re-merge failure on retry with existing commits (#977)
|
|
7
8
|
- audit and harden log buffering implementation (#971)
|
|
8
9
|
- replace magic string 'active' with PR_STATUS.ACTIVE in lifecycle.js (#969)
|
|
@@ -17,6 +18,8 @@
|
|
|
17
18
|
- add red dot notification on CC tab when response completes (#934) (#946)
|
|
18
19
|
|
|
19
20
|
### Fixes
|
|
21
|
+
- dep merge ancestor pruning + pre-flight simulation (#958) (#979)
|
|
22
|
+
- add MAX_TURNS failure class and fix enum count test (#983)
|
|
20
23
|
- prevent Create Plan from meeting saving doc-chat context bleed (#980)
|
|
21
24
|
- plan completion incorrectly overrides awaiting-approval status (#970) (#978)
|
|
22
25
|
- CC queued messages sent to wrong tab after tab switch
|
|
@@ -90,6 +90,8 @@ function toggleCommandCenter() {
|
|
|
90
90
|
// Ensure at least one tab exists
|
|
91
91
|
if (_ccTabs.length === 0) ccNewTab(true);
|
|
92
92
|
clearNotifBadge(document.getElementById('cc-toggle-btn'));
|
|
93
|
+
var activeTabOnOpen = _ccActiveTab();
|
|
94
|
+
if (activeTabOnOpen) activeTabOnOpen._unread = false;
|
|
93
95
|
ccRenderTabBar();
|
|
94
96
|
document.getElementById('cc-input').focus();
|
|
95
97
|
ccRestoreMessages();
|
|
@@ -129,6 +131,7 @@ function ccSwitchTab(id) {
|
|
|
129
131
|
if (!tab) return;
|
|
130
132
|
// If there is an active request, keep it running but switch view
|
|
131
133
|
_ccActiveTabId = id;
|
|
134
|
+
tab._unread = false;
|
|
132
135
|
var el = document.getElementById('cc-messages');
|
|
133
136
|
el.innerHTML = '';
|
|
134
137
|
// Re-render messages from the tab's data
|
|
@@ -247,6 +250,7 @@ function ccRenderTabBar() {
|
|
|
247
250
|
var isActive = t.id === _ccActiveTabId;
|
|
248
251
|
html += '<div class="cc-tab' + (isActive ? ' active' : '') + (t._sending ? ' working' : '') + '" onclick="ccSwitchTab(\'' + t.id + '\')" title="' + escHtml(t.title) + '">';
|
|
249
252
|
html += '<span class="cc-tab-text">' + escHtml(t.title) + '</span>';
|
|
253
|
+
if (t._unread) html += '<span class="notif-badge done"></span>';
|
|
250
254
|
html += '<span class="cc-tab-close" onclick="event.stopPropagation();ccCloseTab(\'' + t.id + '\')">×</span>';
|
|
251
255
|
html += '</div>';
|
|
252
256
|
}
|
|
@@ -661,6 +665,8 @@ async function _ccDoSend(message, skipUserMsg, forceTabId) {
|
|
|
661
665
|
} finally {
|
|
662
666
|
if (activeTab) { activeTab._sending = false; activeTab._abortController = null; activeTab._429retries = 0; delete activeTab._streamedText; delete activeTab._toolsUsed; delete activeTab._sendStartedAt; }
|
|
663
667
|
_ccSending = (_ccTabs.some(function(t) { return t._sending; }));
|
|
668
|
+
// Mark tab unread if response completed on a background tab or while drawer is closed
|
|
669
|
+
if (activeTab && !_wasAborted && (activeTab.id !== _ccActiveTabId || !_ccOpen)) activeTab._unread = true;
|
|
664
670
|
ccRenderTabBar();
|
|
665
671
|
try { clearInterval(phaseTimer); } catch { /* may not be defined if error before reader */ }
|
|
666
672
|
try { localStorage.removeItem('cc-sending'); } catch {}
|
|
@@ -48,6 +48,7 @@ function wiRow(item) {
|
|
|
48
48
|
'<td>' + typeBadge(item.type) + '</td>' +
|
|
49
49
|
'<td>' + priBadge(item.priority) + '</td>' +
|
|
50
50
|
'<td>' + statusBadge(item.status || 'pending') +
|
|
51
|
+
(item._reopened ? ' <span style="font-size:9px;color:var(--purple);margin-left:4px" title="This item was reopened from a previously completed state">reopened</span>' : '') +
|
|
51
52
|
(item._pendingReason && item.status === 'pending' && item._pendingReason !== 'already_dispatched' ? ' <span style="font-size:9px;color:var(--muted);margin-left:4px" title="Pending reason: ' + escHtml(item._pendingReason) + '">' + escHtml(item._pendingReason.replace(/_/g, ' ')) + '</span>' : '') +
|
|
52
53
|
(item._pendingReason === 'already_dispatched' && item.status === 'pending' ? ' <span style="font-size:9px;color:var(--blue);margin-left:4px" title="In dispatch queue, waiting to be assigned">queued</span>' : '') +
|
|
53
54
|
(item._skipReason && item.status === 'pending' ? ' <span style="font-size:9px;color:var(--yellow);margin-left:4px" title="Dispatch blocked: ' + escHtml(item._skipReason) + (item._blockedBy ? ' (by ' + escHtml(item._blockedBy) + ')' : '') + '">' + escHtml(item._skipReason.replace(/_/g, ' ')) + (item._blockedBy ? ' <span style="color:var(--muted)">(' + escHtml(item._blockedBy) + ')</span>' : '') + '</span>' : '') +
|
|
@@ -431,7 +432,8 @@ function openWorkItemDetail(id) {
|
|
|
431
432
|
|
|
432
433
|
let html = '<div style="display:flex;flex-direction:column;gap:4px;font-size:13px">';
|
|
433
434
|
html += '<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px">' +
|
|
434
|
-
badge(statusCls, item.status || 'pending') +
|
|
435
|
+
badge(statusCls, item.status || 'pending') +
|
|
436
|
+
(item._reopened ? ' <span style="font-size:9px;color:var(--purple);margin-left:4px" title="This item was reopened from a previously completed state">reopened</span>' : '') + ' ' +
|
|
435
437
|
'<span class="dispatch-type ' + (item.type || 'implement') + '">' + escHtml(item.type || 'implement') + '</span>' +
|
|
436
438
|
'<span class="prd-item-priority ' + (item.priority || '') + '">' + escHtml(item.priority || 'medium') + '</span>' +
|
|
437
439
|
'</div>';
|
package/engine/lifecycle.js
CHANGED
|
@@ -1893,8 +1893,14 @@ function classifyFailure(code, stdout = '', stderr = '') {
|
|
|
1893
1893
|
return FAILURE_CLASS.MERGE_CONFLICT;
|
|
1894
1894
|
}
|
|
1895
1895
|
|
|
1896
|
-
//
|
|
1897
|
-
|
|
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
|
|
package/engine/recovery.js
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
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 &&
|
|
607
|
+
if (!depMergeFailed && prunedDeps.length > 0) {
|
|
530
608
|
const ancestorChecks = await Promise.all(
|
|
531
|
-
|
|
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 ${
|
|
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 &&
|
|
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
|
|
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
|
|
572
|
-
for (const { branch: reBranch, prId: rePrId } of
|
|
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 ${
|
|
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
|
-
//
|
|
582
|
-
//
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
3
|
+
"version": "0.1.916",
|
|
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"
|