@yemi33/minions 0.1.1922 → 0.1.1924
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/bin/minions.js +2 -1
- package/dashboard/js/settings.js +1 -1
- package/dashboard.js +6 -6
- package/engine/restart-health.js +65 -11
- package/engine/shared.js +22 -1
- package/engine.js +131 -1
- package/package.json +1 -1
package/bin/minions.js
CHANGED
|
@@ -766,7 +766,8 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
766
766
|
void (async () => {
|
|
767
767
|
const result = await waitForRestartHealth({
|
|
768
768
|
minionsHome: MINIONS_HOME,
|
|
769
|
-
|
|
769
|
+
dashboardPid: dashProc.pid,
|
|
770
|
+
dashboardPort: DASH_PORT,
|
|
770
771
|
});
|
|
771
772
|
if (!result.ok) {
|
|
772
773
|
console.error(formatRestartHealthError(result));
|
package/dashboard/js/settings.js
CHANGED
|
@@ -97,7 +97,7 @@ async function openSettings() {
|
|
|
97
97
|
settingsToggle('Allow Temp Agents', 'set-allowTempAgents', !!e.allowTempAgents, 'Spawn ephemeral agents when all permanent agents are busy') +
|
|
98
98
|
settingsToggle('Auto-archive Plans', 'set-autoArchive', !!e.autoArchive, 'Automatically archive plans after verify completes (off = manual archive via dashboard)') +
|
|
99
99
|
settingsToggle('Auto-complete PRs', 'set-autoCompletePrs', !!e.autoCompletePrs, 'Auto-merge PRs when builds pass and review is approved (opt-in)') +
|
|
100
|
-
settingsToggle('CC Worker Pool', 'set-ccUseWorkerPool', !!e.ccUseWorkerPool, 'Route Command Center / doc-chat through a persistent copilot --acp worker per tab instead of spawning a fresh CLI per turn (
|
|
100
|
+
settingsToggle('CC Worker Pool', 'set-ccUseWorkerPool', (e.ccUseWorkerPool === undefined ? ((e.ccCli || e.defaultCli) === 'copilot') : !!e.ccUseWorkerPool), 'Route Command Center / doc-chat through a persistent copilot --acp worker per tab instead of spawning a fresh CLI per turn. Default ON for copilot runtime (cold-spawn is ~20s on Windows); default OFF for claude.') +
|
|
101
101
|
'</div>' +
|
|
102
102
|
|
|
103
103
|
'<h3 style="font-size:13px;color:var(--blue);margin-bottom:8px">PR Polling & Dispatch Gates</h3>' +
|
package/dashboard.js
CHANGED
|
@@ -2622,7 +2622,7 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
|
|
|
2622
2622
|
// alongside docSessions._docHash would risk the next call hitting the
|
|
2623
2623
|
// "doc unchanged → skip context" branch after the idle reaper killed the
|
|
2624
2624
|
// worker, silently starving the new ACP session of the doc body.
|
|
2625
|
-
if (store === 'doc' &&
|
|
2625
|
+
if (store === 'doc' && shared.resolveCcUseWorkerPool(CONFIG.engine) && sessionKey) {
|
|
2626
2626
|
const poolPrompt = (function buildPoolPrompt() {
|
|
2627
2627
|
const parts = !skipStatePreamble ? [`## Current Minions State (${new Date().toISOString().slice(0, 16)})\n\n${buildCCStatePreamble()}`] : [];
|
|
2628
2628
|
if (extraContext) parts.push(extraContext);
|
|
@@ -2763,7 +2763,7 @@ async function ccCallStreaming(message, { store = 'cc', sessionKey, extraContext
|
|
|
2763
2763
|
// keeps receiving accumulated text per the callLLMStreaming contract.
|
|
2764
2764
|
// We also do NOT call updateSession() — see ccCall for the
|
|
2765
2765
|
// docSessions-eviction-vs-_docHash staleness rationale.
|
|
2766
|
-
if (store === 'doc' &&
|
|
2766
|
+
if (store === 'doc' && shared.resolveCcUseWorkerPool(CONFIG.engine) && sessionKey) {
|
|
2767
2767
|
const poolPrompt = (function buildPoolPrompt() {
|
|
2768
2768
|
const parts = !skipStatePreamble ? [`## Current Minions State (${new Date().toISOString().slice(0, 16)})\n\n${buildCCStatePreamble()}`] : [];
|
|
2769
2769
|
if (extraContext) parts.push(extraContext);
|
|
@@ -3227,7 +3227,7 @@ async function ccDocCall({ message, document, title, filePath, selection, canEdi
|
|
|
3227
3227
|
// that has never seen the doc. See _buildDocChatPass for the read-side
|
|
3228
3228
|
// rationale; see the post-call write-back below for the symmetric write
|
|
3229
3229
|
// guard.
|
|
3230
|
-
const usePool = !!(
|
|
3230
|
+
const usePool = !!(shared.resolveCcUseWorkerPool(CONFIG.engine) && sessionKey);
|
|
3231
3231
|
|
|
3232
3232
|
// Build the pass once for the first call; the retry helper invokes runOnce()
|
|
3233
3233
|
// with no args after invalidating the session, so a fresh build there reflects
|
|
@@ -3308,7 +3308,7 @@ async function ccDocCallStreaming({ message, document, title, filePath, selectio
|
|
|
3308
3308
|
// Sub-task D of W-mp2w003600196c51: see ccDocCall above for the
|
|
3309
3309
|
// read-side / write-side rationale; the streaming variant mirrors the
|
|
3310
3310
|
// same usePool guard.
|
|
3311
|
-
const usePool = !!(
|
|
3311
|
+
const usePool = !!(shared.resolveCcUseWorkerPool(CONFIG.engine) && sessionKey);
|
|
3312
3312
|
|
|
3313
3313
|
// Build the pass once; see ccDocCall for the dedup rationale.
|
|
3314
3314
|
const initialPass = _buildDocChatPass({
|
|
@@ -6089,7 +6089,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6089
6089
|
// per-tab handles in dashboard state, and matches "tab close" semantics
|
|
6090
6090
|
// — if the user explicitly aborted, we don't owe them a warm process).
|
|
6091
6091
|
// Off when the flag is off so legacy SIGTERM-only behavior is preserved.
|
|
6092
|
-
if (
|
|
6092
|
+
if (shared.resolveCcUseWorkerPool(CONFIG.engine)) {
|
|
6093
6093
|
try { ccWorkerPool.closeTab(tabId); } catch { /* swallow */ }
|
|
6094
6094
|
}
|
|
6095
6095
|
_clearCcLiveStream(tabId);
|
|
@@ -6234,7 +6234,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6234
6234
|
* test enforces engine.js does not import cc-worker-pool).
|
|
6235
6235
|
*/
|
|
6236
6236
|
function _invokeCcStream({ prompt, sessionId, liveState, toolUses, model, effort, maxTurns, engineConfig, systemPrompt = CC_STATIC_SYSTEM_PROMPT, tabId }) {
|
|
6237
|
-
if (engineConfig
|
|
6237
|
+
if (shared.resolveCcUseWorkerPool(engineConfig)) {
|
|
6238
6238
|
return _invokeCcStreamViaPool({ prompt, liveState, model, effort, engineConfig, systemPrompt, tabId });
|
|
6239
6239
|
}
|
|
6240
6240
|
const { callLLMStreaming } = require('./engine/llm');
|
package/engine/restart-health.js
CHANGED
|
@@ -43,6 +43,25 @@ function isProcessAlive(pid) {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
function isPortListening(port) {
|
|
47
|
+
const n = Number(port);
|
|
48
|
+
if (!Number.isInteger(n) || n <= 0) return false;
|
|
49
|
+
try {
|
|
50
|
+
if (process.platform === 'win32') {
|
|
51
|
+
const out = execSync(`netstat -ano -p TCP`, {
|
|
52
|
+
encoding: 'utf8', windowsHide: true, timeout: 3000, maxBuffer: 4 * 1024 * 1024,
|
|
53
|
+
});
|
|
54
|
+
const re = new RegExp(`\\s127\\.0\\.0\\.1:${n}\\s+\\S+\\s+LISTENING`, 'i');
|
|
55
|
+
const re6 = new RegExp(`\\s\\[::1?\\]:${n}\\s+\\S+\\s+LISTENING`, 'i');
|
|
56
|
+
return re.test(out) || re6.test(out);
|
|
57
|
+
}
|
|
58
|
+
const out = execSync(`ss -ltn 'sport = :${n}' 2>/dev/null || netstat -ltn 2>/dev/null`, {
|
|
59
|
+
encoding: 'utf8', timeout: 3000, shell: true,
|
|
60
|
+
});
|
|
61
|
+
return new RegExp(`[:.]${n}\\b`).test(out);
|
|
62
|
+
} catch { return false; }
|
|
63
|
+
}
|
|
64
|
+
|
|
46
65
|
function httpGetJson(url, timeoutMs = 1000) {
|
|
47
66
|
return new Promise(resolve => {
|
|
48
67
|
let settled = false;
|
|
@@ -81,9 +100,12 @@ function httpGetJson(url, timeoutMs = 1000) {
|
|
|
81
100
|
async function checkRestartHealth(options = {}) {
|
|
82
101
|
const {
|
|
83
102
|
minionsHome,
|
|
84
|
-
dashboardUrl
|
|
103
|
+
dashboardUrl,
|
|
104
|
+
dashboardPid,
|
|
105
|
+
dashboardPort = 7331,
|
|
85
106
|
readControl = readEngineControl,
|
|
86
107
|
isProcessAlive: isAlive = isProcessAlive,
|
|
108
|
+
isPortListening: portCheck = isPortListening,
|
|
87
109
|
httpGetJson: getJson = httpGetJson,
|
|
88
110
|
} = options;
|
|
89
111
|
|
|
@@ -92,9 +114,42 @@ async function checkRestartHealth(options = {}) {
|
|
|
92
114
|
const engineAlive = pid ? isAlive(pid) : false;
|
|
93
115
|
const engineOk = control && control.state === 'running' && engineAlive;
|
|
94
116
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
117
|
+
// Two strategies for the dashboard check:
|
|
118
|
+
// 1) Process + port-listening (preferred from `minions restart` since
|
|
119
|
+
// `bin/minions.js` knows the dashboard PID it just spawned). This avoids
|
|
120
|
+
// a roundtrip through the dashboard's Node event loop, which can be
|
|
121
|
+
// blocked for 15-25s during a cold `getStatus()` rebuild while still
|
|
122
|
+
// being healthy.
|
|
123
|
+
// 2) HTTP `/api/health` — legacy path retained for tests that mock
|
|
124
|
+
// httpGetJson + dashboardUrl, and for external probes that genuinely
|
|
125
|
+
// want a response-level check.
|
|
126
|
+
// Strategy 1 wins when `dashboardPid` is supplied; otherwise we fall back to
|
|
127
|
+
// HTTP using the default URL or whatever the caller injected.
|
|
128
|
+
let dashboardOk;
|
|
129
|
+
let dashboardDetail;
|
|
130
|
+
let dashboardKind;
|
|
131
|
+
let dashboardSnapshot;
|
|
132
|
+
if (dashboardPid != null && !dashboardUrl) {
|
|
133
|
+
dashboardKind = 'process';
|
|
134
|
+
const dpid = normalizePid(dashboardPid);
|
|
135
|
+
const dashAlive = dpid ? isAlive(dpid) : false;
|
|
136
|
+
const portOpen = portCheck(dashboardPort);
|
|
137
|
+
dashboardOk = !!(dashAlive && portOpen);
|
|
138
|
+
dashboardDetail = `pid=${dpid || 'none'} alive=${dashAlive ? 'yes' : 'no'} port=${dashboardPort} listening=${portOpen ? 'yes' : 'no'}`;
|
|
139
|
+
dashboardSnapshot = { kind: 'process', pid: dpid, alive: dashAlive, port: dashboardPort, listening: portOpen };
|
|
140
|
+
} else {
|
|
141
|
+
dashboardKind = 'http';
|
|
142
|
+
const url = dashboardUrl || `http://127.0.0.1:${dashboardPort}/api/health`;
|
|
143
|
+
const dashboard = await getJson(url, 3000);
|
|
144
|
+
const dashboardStatus = dashboard && dashboard.json && dashboard.json.status;
|
|
145
|
+
dashboardOk = !!(dashboard && dashboard.ok && dashboardStatus === 'healthy');
|
|
146
|
+
dashboardDetail = dashboard && dashboard.error
|
|
147
|
+
? dashboard.error.message
|
|
148
|
+
: dashboard && dashboard.statusCode
|
|
149
|
+
? `HTTP ${dashboard.statusCode}${dashboardStatus ? `, status=${dashboardStatus}` : ''}`
|
|
150
|
+
: 'no response';
|
|
151
|
+
dashboardSnapshot = { kind: 'http', url, ok: !!(dashboard && dashboard.ok), statusCode: dashboard && dashboard.statusCode, status: dashboardStatus };
|
|
152
|
+
}
|
|
98
153
|
|
|
99
154
|
const errors = [];
|
|
100
155
|
if (!engineOk) {
|
|
@@ -103,18 +158,16 @@ async function checkRestartHealth(options = {}) {
|
|
|
103
158
|
errors.push(`Engine is not healthy (state=${state}, pid=${pidLabel}, alive=${engineAlive ? 'yes' : 'no'})`);
|
|
104
159
|
}
|
|
105
160
|
if (!dashboardOk) {
|
|
106
|
-
const
|
|
107
|
-
?
|
|
108
|
-
: dashboard
|
|
109
|
-
|
|
110
|
-
: 'no response';
|
|
111
|
-
errors.push(`Dashboard failed health check at ${dashboardUrl} (${detail})`);
|
|
161
|
+
const where = dashboardKind === 'http'
|
|
162
|
+
? (dashboardUrl || `http://127.0.0.1:${dashboardPort}/api/health`)
|
|
163
|
+
: `dashboard pid=${normalizePid(dashboardPid) || 'none'} port=${dashboardPort}`;
|
|
164
|
+
errors.push(`Dashboard failed health check at ${where} (${dashboardDetail})`);
|
|
112
165
|
}
|
|
113
166
|
|
|
114
167
|
return {
|
|
115
168
|
ok: engineOk && dashboardOk,
|
|
116
169
|
engine: { state: control && control.state, pid, alive: engineAlive },
|
|
117
|
-
dashboard:
|
|
170
|
+
dashboard: dashboardSnapshot,
|
|
118
171
|
errors,
|
|
119
172
|
};
|
|
120
173
|
}
|
|
@@ -163,6 +216,7 @@ module.exports = {
|
|
|
163
216
|
_private: {
|
|
164
217
|
httpGetJson,
|
|
165
218
|
isProcessAlive,
|
|
219
|
+
isPortListening,
|
|
166
220
|
readEngineControl,
|
|
167
221
|
normalizePid,
|
|
168
222
|
},
|
package/engine/shared.js
CHANGED
|
@@ -1213,6 +1213,27 @@ function resolveCcCli(engine) {
|
|
|
1213
1213
|
return ENGINE_DEFAULTS.defaultCli;
|
|
1214
1214
|
}
|
|
1215
1215
|
|
|
1216
|
+
/**
|
|
1217
|
+
* Resolve whether the Command Center / doc-chat path should route through the
|
|
1218
|
+
* persistent ACP worker pool. Priority:
|
|
1219
|
+
* 1. `engine.ccUseWorkerPool` explicit true/false — operator override always wins
|
|
1220
|
+
* 2. Runtime-aware default: ON when CC runtime resolves to `copilot`
|
|
1221
|
+
* (Copilot's cold-spawn is 18-21s on Windows — pool turns subsequent turns
|
|
1222
|
+
* from 47-140s back into a few seconds). Claude's cold-spawn is much
|
|
1223
|
+
* cheaper and the ACP pool wasn't designed for it, so it stays OFF there.
|
|
1224
|
+
* 3. ENGINE_DEFAULTS.ccUseWorkerPool — final fallback
|
|
1225
|
+
*
|
|
1226
|
+
* Strict boolean check on the override so a literal `false` opts out even on
|
|
1227
|
+
* Copilot, matching `_isMeaningful` semantics for boolean flags.
|
|
1228
|
+
*/
|
|
1229
|
+
function resolveCcUseWorkerPool(engine) {
|
|
1230
|
+
if (engine && (engine.ccUseWorkerPool === true || engine.ccUseWorkerPool === false)) {
|
|
1231
|
+
return engine.ccUseWorkerPool;
|
|
1232
|
+
}
|
|
1233
|
+
if (resolveCcCli(engine) === 'copilot') return true;
|
|
1234
|
+
return !!ENGINE_DEFAULTS.ccUseWorkerPool;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1216
1237
|
/**
|
|
1217
1238
|
* Resolve the model for a per-agent spawn. Priority:
|
|
1218
1239
|
* 1. `agent.model` — per-agent override
|
|
@@ -3698,7 +3719,7 @@ module.exports = {
|
|
|
3698
3719
|
KB_CATEGORIES,
|
|
3699
3720
|
classifyInboxItem,
|
|
3700
3721
|
ENGINE_DEFAULTS,
|
|
3701
|
-
resolveAgentCli, resolveCcCli, resolveAgentModel, resolveCcModel,
|
|
3722
|
+
resolveAgentCli, resolveCcCli, resolveCcUseWorkerPool, resolveAgentModel, resolveCcModel,
|
|
3702
3723
|
resolveAgentMaxBudget, resolveAgentBareMode,
|
|
3703
3724
|
applyLegacyCcModelMigration, _resetLegacyCcModelMigrationFlag,
|
|
3704
3725
|
runtimeConfigWarnings,
|
package/engine.js
CHANGED
|
@@ -606,6 +606,111 @@ async function pruneStaleWorktreeForBranch(rootDir, branchName, gitOpts) {
|
|
|
606
606
|
return removed;
|
|
607
607
|
}
|
|
608
608
|
|
|
609
|
+
// ─── assertCleanSharedWorktree (#2439) ──────────────────────────────────────
|
|
610
|
+
// Engine-side preflight that prevents shared-branch dispatches from spawning
|
|
611
|
+
// into a dirty worktree. Without this, the shared-branch playbook's "git status
|
|
612
|
+
// + bail out" guard fires AFTER dispatch — converting an orchestration hygiene
|
|
613
|
+
// problem into a fake implementation failure that also cascades to dependent
|
|
614
|
+
// work items.
|
|
615
|
+
//
|
|
616
|
+
// Behavior:
|
|
617
|
+
// 1. Run `git status --porcelain` in the target worktree.
|
|
618
|
+
// 2. If clean → { clean: true, healed: false }.
|
|
619
|
+
// 3. If dirty, decide whether self-heal is safe:
|
|
620
|
+
// - Block (preserve dirty tree for inspection) when ANOTHER active
|
|
621
|
+
// dispatch claims the same branch (ownership ambiguity).
|
|
622
|
+
// - Block when local commits exist that aren't on origin/<branch>
|
|
623
|
+
// (would lose unpushed work).
|
|
624
|
+
// - Otherwise reset --hard + clean -fd, then re-verify.
|
|
625
|
+
// 4. Return { clean, healed, reason, dirtyFiles } so the caller can fail
|
|
626
|
+
// fast with a first-class DIRTY_WORKTREE reason and retry semantics.
|
|
627
|
+
async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, dispatchId, gitOpts = {}) {
|
|
628
|
+
const result = { clean: false, healed: false, reason: null, dirtyFiles: [] };
|
|
629
|
+
// 1. Status probe
|
|
630
|
+
let statusOut = '';
|
|
631
|
+
try {
|
|
632
|
+
const r = await execAsync('git status --porcelain', { ...gitOpts, cwd: worktreePath, timeout: 10000 });
|
|
633
|
+
statusOut = (r || '').toString().trim();
|
|
634
|
+
} catch (e) {
|
|
635
|
+
result.reason = 'status-failed';
|
|
636
|
+
result.error = e.message;
|
|
637
|
+
return result;
|
|
638
|
+
}
|
|
639
|
+
if (!statusOut) {
|
|
640
|
+
result.clean = true;
|
|
641
|
+
return result;
|
|
642
|
+
}
|
|
643
|
+
result.dirtyFiles = statusOut.split('\n').map(l => l.trim()).filter(Boolean);
|
|
644
|
+
|
|
645
|
+
// 2. Ownership check — another live dispatch on the same branch?
|
|
646
|
+
const sanitized = sanitizeBranch(branchName);
|
|
647
|
+
let otherActive = false;
|
|
648
|
+
try {
|
|
649
|
+
mutateDispatch((dp) => {
|
|
650
|
+
otherActive = (dp.active || []).some(d => {
|
|
651
|
+
if (d.id === dispatchId) return false;
|
|
652
|
+
const dBranch = d.meta?.branch ? sanitizeBranch(d.meta.branch) : '';
|
|
653
|
+
return dBranch === sanitized;
|
|
654
|
+
});
|
|
655
|
+
return dp;
|
|
656
|
+
});
|
|
657
|
+
} catch (e) {
|
|
658
|
+
log('warn', `assertCleanSharedWorktree: dispatch read failed (${e.message}) — assuming contended`);
|
|
659
|
+
otherActive = true;
|
|
660
|
+
}
|
|
661
|
+
if (otherActive) {
|
|
662
|
+
result.reason = 'other-dispatch-active';
|
|
663
|
+
return result;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// 3. Unpushed-commit check — refuse to clean when local work would be lost.
|
|
667
|
+
// `git log @{u}..HEAD --oneline` returns non-empty when there are commits
|
|
668
|
+
// ahead of the upstream. If there is no upstream at all, treat as unsafe
|
|
669
|
+
// to clean (we can't prove the local commits exist on origin).
|
|
670
|
+
let hasUnpushed = false;
|
|
671
|
+
try {
|
|
672
|
+
const r = await execAsync('git log @{u}..HEAD --oneline', { ...gitOpts, cwd: worktreePath, timeout: 10000 });
|
|
673
|
+
hasUnpushed = (r || '').toString().trim().length > 0;
|
|
674
|
+
} catch (e) {
|
|
675
|
+
// No upstream configured (e.g., branch never pushed) — be conservative.
|
|
676
|
+
hasUnpushed = true;
|
|
677
|
+
}
|
|
678
|
+
if (hasUnpushed) {
|
|
679
|
+
result.reason = 'has-unpushed-commits';
|
|
680
|
+
return result;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// 4. Safe to self-heal: reset + clean
|
|
684
|
+
try {
|
|
685
|
+
await execAsync('git reset --hard HEAD', { ...gitOpts, cwd: worktreePath, timeout: 30000 });
|
|
686
|
+
await execAsync('git clean -fd', { ...gitOpts, cwd: worktreePath, timeout: 30000 });
|
|
687
|
+
} catch (e) {
|
|
688
|
+
result.reason = 'clean-failed';
|
|
689
|
+
result.error = e.message;
|
|
690
|
+
return result;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// 5. Re-verify
|
|
694
|
+
try {
|
|
695
|
+
const r2 = await execAsync('git status --porcelain', { ...gitOpts, cwd: worktreePath, timeout: 10000 });
|
|
696
|
+
const after = (r2 || '').toString().trim();
|
|
697
|
+
if (after) {
|
|
698
|
+
result.reason = 'dirty-after-clean';
|
|
699
|
+
result.dirtyFiles = after.split('\n').map(l => l.trim()).filter(Boolean);
|
|
700
|
+
return result;
|
|
701
|
+
}
|
|
702
|
+
} catch (e) {
|
|
703
|
+
result.reason = 'recheck-failed';
|
|
704
|
+
result.error = e.message;
|
|
705
|
+
return result;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
log('info', `assertCleanSharedWorktree: auto-healed dirty shared-branch worktree ${worktreePath} on ${branchName} (${result.dirtyFiles.length} dirty files reset)`);
|
|
709
|
+
result.clean = true;
|
|
710
|
+
result.healed = true;
|
|
711
|
+
return result;
|
|
712
|
+
}
|
|
713
|
+
|
|
609
714
|
async function recoverPartialWorktree(rootDir, worktreePath, branchName, gitOpts) {
|
|
610
715
|
if (!branchName) return false;
|
|
611
716
|
const existingWt = await findExistingWorktree(rootDir, branchName);
|
|
@@ -856,6 +961,31 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
856
961
|
}
|
|
857
962
|
}
|
|
858
963
|
|
|
964
|
+
// Shared-branch preflight (#2439): refuse to dispatch into a dirty shared
|
|
965
|
+
// worktree. Auto-heals when safe (no other dispatch claims the branch and
|
|
966
|
+
// no unpushed commits); blocks dispatch with a retryable DIRTY_WORKTREE
|
|
967
|
+
// failure otherwise. Without this, the playbook's `git status + bail` guard
|
|
968
|
+
// fires AFTER spawn — converting orchestration hygiene into fake
|
|
969
|
+
// implementation failures and cascading dependent items.
|
|
970
|
+
if (worktreePath && fs.existsSync(worktreePath) && meta?.branchStrategy === 'shared-branch' && branchName) {
|
|
971
|
+
const cleanResult = await assertCleanSharedWorktree(rootDir, worktreePath, branchName, id, _gitOpts);
|
|
972
|
+
if (!cleanResult.clean) {
|
|
973
|
+
const previewFiles = (cleanResult.dirtyFiles || []).slice(0, 5).join(', ');
|
|
974
|
+
const reasonMsg = `DIRTY_WORKTREE: shared branch ${branchName} worktree at ${worktreePath} is dirty (${cleanResult.reason}); ${cleanResult.dirtyFiles?.length || 0} file(s)${previewFiles ? ': ' + previewFiles : ''}`;
|
|
975
|
+
log('error', reasonMsg);
|
|
976
|
+
_cleanupPromptFiles();
|
|
977
|
+
completeDispatch(
|
|
978
|
+
id,
|
|
979
|
+
DISPATCH_RESULT.ERROR,
|
|
980
|
+
reasonMsg.slice(0, 500),
|
|
981
|
+
`Engine preflight refused to spawn into a dirty shared-branch worktree (#2439). Reason: ${cleanResult.reason}.`,
|
|
982
|
+
{ agentRetryable: true },
|
|
983
|
+
);
|
|
984
|
+
cleanupTempAgent(agentId);
|
|
985
|
+
return null;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
859
989
|
// Merge dependency PR branches into worktree (applies to both reused and new worktrees)
|
|
860
990
|
if (worktreePath && fs.existsSync(worktreePath)) {
|
|
861
991
|
cwd = worktreePath;
|
|
@@ -5121,7 +5251,7 @@ module.exports = {
|
|
|
5121
5251
|
// Shared helpers (used by lifecycle.js and tests)
|
|
5122
5252
|
reconcileItemsWithPrs, detectDependencyCycles,
|
|
5123
5253
|
parseConflictFiles, pruneAncestorDeps, preflightMergeSimulation, // exported for testing
|
|
5124
|
-
isWorktreeRetryableError, removeStaleIndexLock, syncReusedWorktree, // exported for testing
|
|
5254
|
+
isWorktreeRetryableError, removeStaleIndexLock, syncReusedWorktree, assertCleanSharedWorktree, // exported for testing
|
|
5125
5255
|
pruneStaleWorktreeForBranch, // exported for testing
|
|
5126
5256
|
_maxTurnsForType, buildProjectContext, normalizeAc, _buildAgentSpawnFlags, _classifyAgentFailure, // exported for testing
|
|
5127
5257
|
promoteCheckpointSteeringForClose, // exported for testing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1924",
|
|
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"
|