@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 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
- dashboardUrl: `http://127.0.0.1:${DASH_PORT}/api/health`,
769
+ dashboardPid: dashProc.pid,
770
+ dashboardPort: DASH_PORT,
770
771
  });
771
772
  if (!result.ok) {
772
773
  console.error(formatRestartHealthError(result));
@@ -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 (opt-in)') +
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 &amp; 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' && CONFIG.engine && CONFIG.engine.ccUseWorkerPool && sessionKey) {
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' && CONFIG.engine && CONFIG.engine.ccUseWorkerPool && sessionKey) {
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 = !!(CONFIG.engine && CONFIG.engine.ccUseWorkerPool && sessionKey);
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 = !!(CONFIG.engine && CONFIG.engine.ccUseWorkerPool && sessionKey);
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 (CONFIG.engine && CONFIG.engine.ccUseWorkerPool) {
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 && engineConfig.ccUseWorkerPool) {
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');
@@ -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 = 'http://127.0.0.1:7331/api/health',
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
- const dashboard = await getJson(dashboardUrl, 3000);
96
- const dashboardStatus = dashboard && dashboard.json && dashboard.json.status;
97
- const dashboardOk = !!(dashboard && dashboard.ok && dashboardStatus === 'healthy');
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 detail = dashboard && dashboard.error
107
- ? dashboard.error.message
108
- : dashboard && dashboard.statusCode
109
- ? `HTTP ${dashboard.statusCode}${dashboardStatus ? `, status=${dashboardStatus}` : ''}`
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: { url: dashboardUrl, ok: !!(dashboard && dashboard.ok), statusCode: dashboard && dashboard.statusCode, status: dashboardStatus },
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.1922",
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"