@yemi33/minions 0.1.1923 → 0.1.1925
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/dashboard/js/settings.js +1 -1
- package/dashboard.js +70 -59
- package/engine/copilot-models.json +1 -1
- package/engine/shared.js +22 -1
- package/engine.js +131 -1
- package/package.json +1 -1
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
|
@@ -1190,21 +1190,24 @@ function getDiskVersion() {
|
|
|
1190
1190
|
}
|
|
1191
1191
|
|
|
1192
1192
|
// ── MCP server discovery ─────────────────────────────────────────────────────
|
|
1193
|
-
//
|
|
1194
|
-
// • `
|
|
1195
|
-
// •
|
|
1193
|
+
// Reads MCP server registrations directly from each CLI's config file:
|
|
1194
|
+
// • `~/.claude.json` → `mcpServers` (Claude user scope)
|
|
1195
|
+
// • `~/.copilot/mcp-config.json` → `mcpServers` (Copilot user scope)
|
|
1196
1196
|
// • Each registered project's `<localPath>/.mcp.json` → workspace scope
|
|
1197
1197
|
//
|
|
1198
|
-
//
|
|
1199
|
-
//
|
|
1200
|
-
//
|
|
1198
|
+
// We used to shell out to `claude mcp list` + `copilot mcp list --json`, but
|
|
1199
|
+
// `claude mcp list` blocks on a TTY check when the dashboard daemon has no
|
|
1200
|
+
// parent console (bin/minions.js spawns it with windowsHide:true), so every
|
|
1201
|
+
// refresh timed out and the cache stayed empty. File reads are zero-spawn and
|
|
1202
|
+
// the config files are the authoritative source for registration.
|
|
1203
|
+
//
|
|
1204
|
+
// Cost: we lose live connection status (✓ Connected / ! Needs auth) and the
|
|
1205
|
+
// Claude plugin: <plugin>:<server> rows. The UI shows status only in a hover
|
|
1206
|
+
// tooltip, and plugin servers are rare; reliability matters more.
|
|
1201
1207
|
|
|
1202
1208
|
const _MCP_CACHE_TTL = 5 * 60 * 1000;
|
|
1203
|
-
const _MCP_CLAUDE_TIMEOUT = 30000;
|
|
1204
|
-
const _MCP_COPILOT_TIMEOUT = 15000;
|
|
1205
1209
|
let _mcpServersCache = null;
|
|
1206
1210
|
let _mcpServersCacheTs = 0;
|
|
1207
|
-
let _mcpRefreshInflight = null;
|
|
1208
1211
|
|
|
1209
1212
|
function _parseClaudeMcpListLine(line) {
|
|
1210
1213
|
const trimmed = (line || '').trim();
|
|
@@ -1248,19 +1251,43 @@ function _parseCopilotMcpListJson(raw) {
|
|
|
1248
1251
|
} catch { return []; }
|
|
1249
1252
|
}
|
|
1250
1253
|
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1254
|
+
// Read Claude's user-scope MCP servers from `~/.claude.json` directly.
|
|
1255
|
+
// Shelling out to `claude mcp list` is unreliable from the dashboard daemon —
|
|
1256
|
+
// the CLI's MCP health probes block on a TTY check when the parent has no
|
|
1257
|
+
// console (bin/minions.js launches the dashboard with windowsHide:true), and
|
|
1258
|
+
// neither exec() nor spawn(..., {detached:true}) reliably sidesteps it.
|
|
1259
|
+
// Config-file reads are zero-spawn, instant, and the file is the authoritative
|
|
1260
|
+
// source for which servers are registered.
|
|
1261
|
+
function _readClaudeUserMcpServers() {
|
|
1262
|
+
try {
|
|
1263
|
+
const data = safeJsonObj(path.join(os.homedir(), '.claude.json'));
|
|
1264
|
+
const servers = data?.mcpServers || {};
|
|
1265
|
+
return Object.entries(servers).map(([name, cfg]) => ({
|
|
1266
|
+
name,
|
|
1267
|
+
source: 'Claude user',
|
|
1268
|
+
command: cfg.command || cfg.url || '',
|
|
1269
|
+
args: Array.isArray(cfg.args) ? cfg.args.join(' ') : (cfg.args || ''),
|
|
1270
|
+
}));
|
|
1271
|
+
} catch { return []; }
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// Copilot stores its MCP config at `~/.copilot/mcp-config.json` (may have a
|
|
1275
|
+
// UTF-8 BOM — Windows tooling tends to write one).
|
|
1276
|
+
function _readCopilotUserMcpServers() {
|
|
1277
|
+
try {
|
|
1278
|
+
const configPath = path.join(os.homedir(), '.copilot', 'mcp-config.json');
|
|
1279
|
+
if (!fs.existsSync(configPath)) return [];
|
|
1280
|
+
let raw = fs.readFileSync(configPath, 'utf8');
|
|
1281
|
+
if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
|
|
1282
|
+
const data = JSON.parse(raw);
|
|
1283
|
+
const servers = data?.mcpServers || {};
|
|
1284
|
+
return Object.entries(servers).map(([name, cfg]) => ({
|
|
1285
|
+
name,
|
|
1286
|
+
source: 'Copilot',
|
|
1287
|
+
command: cfg.command || cfg.url || '',
|
|
1288
|
+
args: Array.isArray(cfg.args) ? cfg.args.join(' ') : (cfg.args || ''),
|
|
1289
|
+
}));
|
|
1290
|
+
} catch { return []; }
|
|
1264
1291
|
}
|
|
1265
1292
|
|
|
1266
1293
|
function _readWorkspaceMcpServers(projects) {
|
|
@@ -1293,42 +1320,26 @@ function _dedupeMcpServers(entries) {
|
|
|
1293
1320
|
return out;
|
|
1294
1321
|
}
|
|
1295
1322
|
|
|
1296
|
-
async function _refreshMcpServersCache() {
|
|
1297
|
-
if (_mcpRefreshInflight) return _mcpRefreshInflight;
|
|
1298
|
-
_mcpRefreshInflight = (async () => {
|
|
1299
|
-
try {
|
|
1300
|
-
const [claudeOut, copilotOut] = await Promise.all([
|
|
1301
|
-
_runCliAsync('claude', ['mcp', 'list'], _MCP_CLAUDE_TIMEOUT),
|
|
1302
|
-
_runCliAsync('copilot', ['mcp', 'list', '--json'], _MCP_COPILOT_TIMEOUT),
|
|
1303
|
-
]);
|
|
1304
|
-
const claudeEntries = (claudeOut || '').split(/\r?\n/).map(_parseClaudeMcpListLine).filter(Boolean);
|
|
1305
|
-
const copilotEntries = _parseCopilotMcpListJson(copilotOut || '{}');
|
|
1306
|
-
const workspaceEntries = _readWorkspaceMcpServers(PROJECTS);
|
|
1307
|
-
_mcpServersCache = _dedupeMcpServers([...claudeEntries, ...copilotEntries, ...workspaceEntries]);
|
|
1308
|
-
_mcpServersCacheTs = Date.now();
|
|
1309
|
-
} catch (e) {
|
|
1310
|
-
console.error('[mcp-discovery] refresh failed:', e.message?.split('\n')?.[0]);
|
|
1311
|
-
if (!_mcpServersCache) _mcpServersCache = [];
|
|
1312
|
-
_mcpServersCacheTs = Date.now();
|
|
1313
|
-
} finally {
|
|
1314
|
-
_mcpRefreshInflight = null;
|
|
1315
|
-
}
|
|
1316
|
-
})();
|
|
1317
|
-
return _mcpRefreshInflight;
|
|
1318
|
-
}
|
|
1319
|
-
|
|
1320
1323
|
function getMcpServers() {
|
|
1321
1324
|
const now = Date.now();
|
|
1322
|
-
if (
|
|
1323
|
-
|
|
1325
|
+
if (_mcpServersCache && (now - _mcpServersCacheTs) < _MCP_CACHE_TTL) {
|
|
1326
|
+
return _mcpServersCache;
|
|
1327
|
+
}
|
|
1328
|
+
try {
|
|
1329
|
+
const entries = [
|
|
1330
|
+
..._readClaudeUserMcpServers(),
|
|
1331
|
+
..._readCopilotUserMcpServers(),
|
|
1332
|
+
..._readWorkspaceMcpServers(PROJECTS),
|
|
1333
|
+
];
|
|
1334
|
+
_mcpServersCache = _dedupeMcpServers(entries);
|
|
1335
|
+
} catch (e) {
|
|
1336
|
+
console.error('[mcp-discovery] refresh failed:', e.message?.split('\n')?.[0]);
|
|
1337
|
+
if (!_mcpServersCache) _mcpServersCache = [];
|
|
1324
1338
|
}
|
|
1325
|
-
|
|
1339
|
+
_mcpServersCacheTs = now;
|
|
1340
|
+
return _mcpServersCache;
|
|
1326
1341
|
}
|
|
1327
1342
|
|
|
1328
|
-
// Kick off first discovery on startup so the dashboard has data before the
|
|
1329
|
-
// first slow-state rebuild.
|
|
1330
|
-
_refreshMcpServersCache().catch(() => {});
|
|
1331
|
-
|
|
1332
1343
|
function parsePinnedEntries(content) {
|
|
1333
1344
|
if (!content) return [];
|
|
1334
1345
|
const entries = [];
|
|
@@ -2622,7 +2633,7 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
|
|
|
2622
2633
|
// alongside docSessions._docHash would risk the next call hitting the
|
|
2623
2634
|
// "doc unchanged → skip context" branch after the idle reaper killed the
|
|
2624
2635
|
// worker, silently starving the new ACP session of the doc body.
|
|
2625
|
-
if (store === 'doc' &&
|
|
2636
|
+
if (store === 'doc' && shared.resolveCcUseWorkerPool(CONFIG.engine) && sessionKey) {
|
|
2626
2637
|
const poolPrompt = (function buildPoolPrompt() {
|
|
2627
2638
|
const parts = !skipStatePreamble ? [`## Current Minions State (${new Date().toISOString().slice(0, 16)})\n\n${buildCCStatePreamble()}`] : [];
|
|
2628
2639
|
if (extraContext) parts.push(extraContext);
|
|
@@ -2763,7 +2774,7 @@ async function ccCallStreaming(message, { store = 'cc', sessionKey, extraContext
|
|
|
2763
2774
|
// keeps receiving accumulated text per the callLLMStreaming contract.
|
|
2764
2775
|
// We also do NOT call updateSession() — see ccCall for the
|
|
2765
2776
|
// docSessions-eviction-vs-_docHash staleness rationale.
|
|
2766
|
-
if (store === 'doc' &&
|
|
2777
|
+
if (store === 'doc' && shared.resolveCcUseWorkerPool(CONFIG.engine) && sessionKey) {
|
|
2767
2778
|
const poolPrompt = (function buildPoolPrompt() {
|
|
2768
2779
|
const parts = !skipStatePreamble ? [`## Current Minions State (${new Date().toISOString().slice(0, 16)})\n\n${buildCCStatePreamble()}`] : [];
|
|
2769
2780
|
if (extraContext) parts.push(extraContext);
|
|
@@ -3227,7 +3238,7 @@ async function ccDocCall({ message, document, title, filePath, selection, canEdi
|
|
|
3227
3238
|
// that has never seen the doc. See _buildDocChatPass for the read-side
|
|
3228
3239
|
// rationale; see the post-call write-back below for the symmetric write
|
|
3229
3240
|
// guard.
|
|
3230
|
-
const usePool = !!(
|
|
3241
|
+
const usePool = !!(shared.resolveCcUseWorkerPool(CONFIG.engine) && sessionKey);
|
|
3231
3242
|
|
|
3232
3243
|
// Build the pass once for the first call; the retry helper invokes runOnce()
|
|
3233
3244
|
// with no args after invalidating the session, so a fresh build there reflects
|
|
@@ -3308,7 +3319,7 @@ async function ccDocCallStreaming({ message, document, title, filePath, selectio
|
|
|
3308
3319
|
// Sub-task D of W-mp2w003600196c51: see ccDocCall above for the
|
|
3309
3320
|
// read-side / write-side rationale; the streaming variant mirrors the
|
|
3310
3321
|
// same usePool guard.
|
|
3311
|
-
const usePool = !!(
|
|
3322
|
+
const usePool = !!(shared.resolveCcUseWorkerPool(CONFIG.engine) && sessionKey);
|
|
3312
3323
|
|
|
3313
3324
|
// Build the pass once; see ccDocCall for the dedup rationale.
|
|
3314
3325
|
const initialPass = _buildDocChatPass({
|
|
@@ -6089,7 +6100,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6089
6100
|
// per-tab handles in dashboard state, and matches "tab close" semantics
|
|
6090
6101
|
// — if the user explicitly aborted, we don't owe them a warm process).
|
|
6091
6102
|
// Off when the flag is off so legacy SIGTERM-only behavior is preserved.
|
|
6092
|
-
if (
|
|
6103
|
+
if (shared.resolveCcUseWorkerPool(CONFIG.engine)) {
|
|
6093
6104
|
try { ccWorkerPool.closeTab(tabId); } catch { /* swallow */ }
|
|
6094
6105
|
}
|
|
6095
6106
|
_clearCcLiveStream(tabId);
|
|
@@ -6234,7 +6245,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6234
6245
|
* test enforces engine.js does not import cc-worker-pool).
|
|
6235
6246
|
*/
|
|
6236
6247
|
function _invokeCcStream({ prompt, sessionId, liveState, toolUses, model, effort, maxTurns, engineConfig, systemPrompt = CC_STATIC_SYSTEM_PROMPT, tabId }) {
|
|
6237
|
-
if (engineConfig
|
|
6248
|
+
if (shared.resolveCcUseWorkerPool(engineConfig)) {
|
|
6238
6249
|
return _invokeCcStreamViaPool({ prompt, liveState, model, effort, engineConfig, systemPrompt, tabId });
|
|
6239
6250
|
}
|
|
6240
6251
|
const { callLLMStreaming } = require('./engine/llm');
|
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.1925",
|
|
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"
|