@yemi33/minions 0.1.1921 → 0.1.1923

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));
package/dashboard.js CHANGED
@@ -2443,6 +2443,144 @@ async function _preflightModelCheck({ runtime: cliOverride, model: modelOverride
2443
2443
  };
2444
2444
  }
2445
2445
 
2446
+ /**
2447
+ * Sub-task D of W-mp2w003600196c51 (CC perf): pool-routed implementation of
2448
+ * the doc-chat LLM call. Mirrors _invokeCcStreamViaPool (the CC streaming
2449
+ * pool path from sub-task C), shaped to plug into ccCall / ccCallStreaming
2450
+ * — the doc-chat call sites that live at module scope rather than inside
2451
+ * the SSE handler closure.
2452
+ *
2453
+ * Pool key: `doc-chat:` + sessionKey (filePath or title). Different file =
2454
+ * different key = natural eviction (the pool kills/respawns the worker on
2455
+ * tab change). For one-shot flows (`freshSession: true`, e.g. Create Plan
2456
+ * from meeting) we use a unique short-lived tab key so the call cannot
2457
+ * inherit (or pollute) the persistent doc-chat conversation for that file.
2458
+ *
2459
+ * Behavior contract (matches callLLMStreaming so callers stay unchanged):
2460
+ * - Returns a Promise resolving to
2461
+ * `{ text, sessionId, code, usage, raw, stderr }`.
2462
+ * - `.abort` attached to the returned Promise. Abort cancels the in-flight
2463
+ * ACP prompt AND closes the worker tab — same "user-disconnect = strong
2464
+ * signal, drop the warm process" semantics CC uses in
2465
+ * handleCommandCenterAbort. For one-shot tabs, the tab is closed in
2466
+ * `finalize()` regardless of abort so we don't leak workers.
2467
+ * - `onChunk` receives FULL ACCUMULATED text (the pool emits per-chunk
2468
+ * deltas; we accumulate before forwarding to match the existing
2469
+ * contract SSE consumers depend on).
2470
+ * - `usage` is `{}` because ACP `session/update` notifications don't
2471
+ * surface token counts; trackEngineUsage is a no-op on `{}`.
2472
+ * - Tool calls are not surfaced (sub-task B/C don't plumb `tool_call`
2473
+ * notifications into a callback). Matches CC's pool trade-off.
2474
+ * - Honors `timeoutMs`. On timeout: cancels the prompt, closes the tab
2475
+ * (so the next call rebuilds against a clean process), resolves with
2476
+ * `{ code: 1, stderr: 'doc-chat-pool: timeout after Xms' }`. The
2477
+ * legacy direct path's `timeout` kills the CLI process; closing the
2478
+ * tab is the pool equivalent.
2479
+ *
2480
+ * Why we deliberately skip `docSessions` updates on this path: pool ACP
2481
+ * session IDs are valid only inside the live worker process. Persisting
2482
+ * them risks the next call seeing a "session unchanged + _docHash
2483
+ * matches → skip document content" path after the idle reaper (10 min)
2484
+ * kills the worker, silently starving the new ACP session of the
2485
+ * document body. Always re-sending extraContext is correctness-safe; the
2486
+ * pool's warm-process saving is preserved regardless.
2487
+ */
2488
+ function _invokeDocChatViaPool({ prompt, model, effort, engineConfig, systemPrompt, sessionKey, freshSession, timeoutMs, onChunk }) {
2489
+ const oneShot = !!freshSession;
2490
+ const tabKey = oneShot
2491
+ ? 'doc-chat:fresh:' + shared.uid()
2492
+ : 'doc-chat:' + (sessionKey || 'default');
2493
+ let cancelled = false;
2494
+ let settled = false;
2495
+ let accumulated = '';
2496
+ let sessionHandle = null;
2497
+ let timeoutTimer = null;
2498
+ let resolveResult;
2499
+ const promise = new Promise((resolve) => { resolveResult = resolve; });
2500
+ const finalize = (envelope) => {
2501
+ if (settled) return;
2502
+ settled = true;
2503
+ if (timeoutTimer) { clearTimeout(timeoutTimer); timeoutTimer = null; }
2504
+ if (oneShot) {
2505
+ // One-shot worker: tear down so freshSession callers don't leak a
2506
+ // persistent ACP process per call.
2507
+ try { ccWorkerPool.closeTab(tabKey); } catch { /* swallow */ }
2508
+ }
2509
+ resolveResult(envelope);
2510
+ };
2511
+ promise.abort = () => {
2512
+ cancelled = true;
2513
+ try { sessionHandle && sessionHandle.cancel(); } catch { /* swallow */ }
2514
+ // User-disconnect = strong "give up this conversation" signal. Mirror
2515
+ // handleCommandCenterAbort's closeTab for the same reason: prevents
2516
+ // partial/cancelled assistant output from polluting the next turn's
2517
+ // ACP context. The cost (lose warmth on next visit) is accepted.
2518
+ try { ccWorkerPool.closeTab(tabKey); } catch { /* swallow */ }
2519
+ };
2520
+ if (timeoutMs && Number.isFinite(timeoutMs) && timeoutMs > 0) {
2521
+ timeoutTimer = setTimeout(() => {
2522
+ try { sessionHandle && sessionHandle.cancel(); } catch { /* swallow */ }
2523
+ try { ccWorkerPool.closeTab(tabKey); } catch { /* swallow */ }
2524
+ finalize({
2525
+ text: accumulated,
2526
+ sessionId: sessionHandle ? sessionHandle.sessionId : null,
2527
+ code: 1,
2528
+ usage: {},
2529
+ raw: accumulated,
2530
+ stderr: `doc-chat-pool: timeout after ${timeoutMs}ms`,
2531
+ });
2532
+ }, timeoutMs);
2533
+ if (typeof timeoutTimer.unref === 'function') timeoutTimer.unref();
2534
+ }
2535
+ (async () => {
2536
+ try {
2537
+ sessionHandle = await ccWorkerPool.getSession({
2538
+ tabId: tabKey,
2539
+ model,
2540
+ effort,
2541
+ mcpServers: (engineConfig && engineConfig.mcpServers) || [],
2542
+ systemPromptHash: _docChatPromptHash,
2543
+ });
2544
+ } catch (err) {
2545
+ return finalize({
2546
+ text: '',
2547
+ sessionId: null,
2548
+ code: 1,
2549
+ usage: {},
2550
+ raw: '',
2551
+ stderr: String((err && err.message) || err || 'cc-worker-pool spawn failed'),
2552
+ });
2553
+ }
2554
+ if (cancelled) {
2555
+ try { sessionHandle.cancel(); } catch { /* swallow */ }
2556
+ return finalize({ text: accumulated, sessionId: sessionHandle.sessionId, code: 0, usage: {}, raw: accumulated, stderr: '' });
2557
+ }
2558
+ await sessionHandle.stream(prompt, {
2559
+ systemPromptText: systemPrompt,
2560
+ onChunk: (delta) => {
2561
+ accumulated += delta;
2562
+ if (onChunk) {
2563
+ try { onChunk(accumulated); } catch { /* swallow */ }
2564
+ }
2565
+ },
2566
+ onDone: () => {
2567
+ finalize({ text: accumulated, sessionId: sessionHandle.sessionId, code: 0, usage: {}, raw: accumulated, stderr: '' });
2568
+ },
2569
+ onError: (err) => {
2570
+ finalize({
2571
+ text: accumulated,
2572
+ sessionId: sessionHandle.sessionId,
2573
+ code: cancelled ? 0 : 1,
2574
+ usage: {},
2575
+ raw: accumulated,
2576
+ stderr: String((err && err.message) || err || 'cc-worker-pool stream error'),
2577
+ });
2578
+ },
2579
+ });
2580
+ })();
2581
+ return promise;
2582
+ }
2583
+
2446
2584
  /**
2447
2585
  * Core LLM call — shared by CC panel and doc modals.
2448
2586
  * @param {string} message - User message
@@ -2454,8 +2592,13 @@ async function _preflightModelCheck({ runtime: cliOverride, model: modelOverride
2454
2592
  * @param {number} opts.timeout - Timeout in ms
2455
2593
  * @param {number} opts.maxTurns - Max tool-use turns
2456
2594
  * @param {string} opts.allowedTools - Comma-separated tool list
2595
+ * @param {boolean} [opts.freshSession] - When true (doc-chat one-shot flows like Create Plan
2596
+ * from meeting), pool-routed calls (sub-task D of W-mp2w003600196c51) use a unique
2597
+ * short-lived tabKey so the one-shot cannot inherit/pollute the persistent doc-chat
2598
+ * conversation for the same file. The legacy direct path is unaffected — freshSession
2599
+ * semantics there are owned by ccDocCall, which deletes docSessions before invoking.
2457
2600
  */
2458
- async function ccCall(message, { store = 'cc', sessionKey, extraContext, label = 'command-center', timeout = CC_CALL_TIMEOUT_MS, maxTurns, allowedTools = 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch', skipStatePreamble = false, skipPreflight = false, model, onAbortReady, systemPrompt = CC_STATIC_SYSTEM_PROMPT, transcript, turnId } = {}) {
2601
+ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label = 'command-center', timeout = CC_CALL_TIMEOUT_MS, maxTurns, allowedTools = 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch', skipStatePreamble = false, skipPreflight = false, model, onAbortReady, systemPrompt = CC_STATIC_SYSTEM_PROMPT, transcript, turnId, freshSession = false } = {}) {
2459
2602
  if (!maxTurns) maxTurns = CONFIG.engine?.ccMaxTurns || shared.ENGINE_DEFAULTS.ccMaxTurns;
2460
2603
  if (!model) model = CONFIG.engine?.ccModel || shared.ENGINE_DEFAULTS.ccModel;
2461
2604
  const ccEffort = CONFIG.engine?.ccEffort || shared.ENGINE_DEFAULTS.ccEffort;
@@ -2468,6 +2611,37 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
2468
2611
  }
2469
2612
  }
2470
2613
 
2614
+ // Sub-task D of W-mp2w003600196c51: route doc-chat through the persistent
2615
+ // ACP worker pool when ccUseWorkerPool is on. The pool keeps one warm
2616
+ // process per `doc-chat:<sessionKey>` tabId, saving the session/new + MCP
2617
+ // boot cost on subsequent doc-chat turns. CC's path is wired separately
2618
+ // in _invokeCcStream (handleCommandCenterStream); CC's non-stream ccCall
2619
+ // path intentionally stays on the per-turn spawn (this branch is
2620
+ // store==='doc' only). We deliberately do NOT call updateSession() — pool
2621
+ // ACP session IDs aren't durable across worker eviction; persisting them
2622
+ // alongside docSessions._docHash would risk the next call hitting the
2623
+ // "doc unchanged → skip context" branch after the idle reaper killed the
2624
+ // worker, silently starving the new ACP session of the doc body.
2625
+ if (store === 'doc' && CONFIG.engine && CONFIG.engine.ccUseWorkerPool && sessionKey) {
2626
+ const poolPrompt = (function buildPoolPrompt() {
2627
+ const parts = !skipStatePreamble ? [`## Current Minions State (${new Date().toISOString().slice(0, 16)})\n\n${buildCCStatePreamble()}`] : [];
2628
+ if (extraContext) parts.push(extraContext);
2629
+ const turnHeader = _ccTurnHeaderPart(turnId);
2630
+ if (turnHeader) parts.push(turnHeader);
2631
+ parts.push(message);
2632
+ return parts.join('\n\n---\n\n');
2633
+ })();
2634
+ const p = _invokeDocChatViaPool({
2635
+ prompt: poolPrompt, sessionKey, model, effort: ccEffort,
2636
+ engineConfig: CONFIG.engine, systemPrompt,
2637
+ freshSession, timeoutMs: timeout,
2638
+ });
2639
+ if (onAbortReady) onAbortReady(p.abort);
2640
+ const result = await p;
2641
+ llm.trackEngineUsage(label, result.usage);
2642
+ return result;
2643
+ }
2644
+
2471
2645
  const existing = resolveSession(store, sessionKey);
2472
2646
  let sessionId = existing ? existing.sessionId : null;
2473
2647
  const currentRuntime = shared.resolveCcCli(CONFIG.engine);
@@ -2570,7 +2744,7 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
2570
2744
  return result;
2571
2745
  }
2572
2746
 
2573
- async function ccCallStreaming(message, { store = 'cc', sessionKey, extraContext, label = 'command-center', timeout = CC_CALL_TIMEOUT_MS, maxTurns, allowedTools = 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch', skipStatePreamble = false, skipPreflight = false, model, onAbortReady, onChunk, onToolUse, onRetry, systemPrompt = CC_STATIC_SYSTEM_PROMPT, transcript, turnId } = {}) {
2747
+ async function ccCallStreaming(message, { store = 'cc', sessionKey, extraContext, label = 'command-center', timeout = CC_CALL_TIMEOUT_MS, maxTurns, allowedTools = 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch', skipStatePreamble = false, skipPreflight = false, model, onAbortReady, onChunk, onToolUse, onRetry, systemPrompt = CC_STATIC_SYSTEM_PROMPT, transcript, turnId, freshSession = false } = {}) {
2574
2748
  if (!maxTurns) maxTurns = CONFIG.engine?.ccMaxTurns || shared.ENGINE_DEFAULTS.ccMaxTurns;
2575
2749
  if (!model) model = CONFIG.engine?.ccModel || shared.ENGINE_DEFAULTS.ccModel;
2576
2750
  const ccEffort = CONFIG.engine?.ccEffort || shared.ENGINE_DEFAULTS.ccEffort;
@@ -2583,6 +2757,33 @@ async function ccCallStreaming(message, { store = 'cc', sessionKey, extraContext
2583
2757
  }
2584
2758
  }
2585
2759
 
2760
+ // Sub-task D of W-mp2w003600196c51: streaming counterpart of the ccCall
2761
+ // pool branch. See ccCall for the rationale; the streaming variant
2762
+ // additionally forwards onChunk so the SSE writer (handleDocChatStream)
2763
+ // keeps receiving accumulated text per the callLLMStreaming contract.
2764
+ // We also do NOT call updateSession() — see ccCall for the
2765
+ // docSessions-eviction-vs-_docHash staleness rationale.
2766
+ if (store === 'doc' && CONFIG.engine && CONFIG.engine.ccUseWorkerPool && sessionKey) {
2767
+ const poolPrompt = (function buildPoolPrompt() {
2768
+ const parts = !skipStatePreamble ? [`## Current Minions State (${new Date().toISOString().slice(0, 16)})\n\n${buildCCStatePreamble()}`] : [];
2769
+ if (extraContext) parts.push(extraContext);
2770
+ const turnHeader = _ccTurnHeaderPart(turnId);
2771
+ if (turnHeader) parts.push(turnHeader);
2772
+ parts.push(message);
2773
+ return parts.join('\n\n---\n\n');
2774
+ })();
2775
+ const p = _invokeDocChatViaPool({
2776
+ prompt: poolPrompt, sessionKey, model, effort: ccEffort,
2777
+ engineConfig: CONFIG.engine, systemPrompt,
2778
+ onChunk,
2779
+ freshSession, timeoutMs: timeout,
2780
+ });
2781
+ if (onAbortReady) onAbortReady(p.abort);
2782
+ const result = await p;
2783
+ llm.trackEngineUsage(label, result.usage);
2784
+ return result;
2785
+ }
2786
+
2586
2787
  const existing = resolveSession(store, sessionKey);
2587
2788
  let sessionId = existing ? existing.sessionId : null;
2588
2789
  const currentRuntime = shared.resolveCcCli(CONFIG.engine);
@@ -2798,8 +2999,19 @@ function _docChatResultLooksSuccessful(result) {
2798
2999
  // Build the doc-chat extraContext for a single ccCall pass — refreshed on retry
2799
3000
  // so a fresh-session retry includes the full document instead of relying on the
2800
3001
  // dead session's prior turn for context.
2801
- function _buildDocChatPass({ docSlice, title, filePath, selection, canEdit, isJson, sessionKey, freshSession }) {
2802
- const existing = freshSession ? null : resolveSession('doc', sessionKey);
3002
+ //
3003
+ // usePool: when true (caller is on the worker-pool path), force `existing=null`
3004
+ // so the docUnchanged optimization never fires. Pool ACP session IDs are valid
3005
+ // only inside the live worker process; when the idle reaper kills the worker
3006
+ // (10 min), the server restarts, or the operator flips ccUseWorkerPool with
3007
+ // prior legacy history on disk, a stale docSessions._docHash matching the
3008
+ // current doc would otherwise emit docUnchanged:true → _formatDocChatContext
3009
+ // replaces the doc body with "unchanged from the previous turn" → the fresh
3010
+ // ACP worker answers from an empty document context. Always re-sending the doc
3011
+ // body on the pool path is the simplest correctness-safe contract; the pool's
3012
+ // warm-process savings are preserved regardless.
3013
+ function _buildDocChatPass({ docSlice, title, filePath, selection, canEdit, isJson, sessionKey, freshSession, usePool }) {
3014
+ const existing = (freshSession || usePool) ? null : resolveSession('doc', sessionKey);
2803
3015
  const docHash = require('crypto').createHash('md5').update(docSlice).digest('hex').slice(0, 8);
2804
3016
  const docUnchanged = existing?.sessionId && existing._docHash === docHash;
2805
3017
  const extraContext = _formatDocChatContext({
@@ -3006,16 +3218,27 @@ async function ccDocCall({ message, document, title, filePath, selection, canEdi
3006
3218
  // Skip persistDocSessions() here — the post-call cleanup below handles persistence.
3007
3219
  }
3008
3220
 
3221
+ // Sub-task D of W-mp2w003600196c51: when the worker pool is enabled,
3222
+ // ccCall routes this call through _invokeDocChatViaPool. The pool path
3223
+ // gets a fresh ACP worker on flag flip / reaper / restart, so the
3224
+ // docUnchanged optimization (read side) and the _docHash write-back
3225
+ // (write side) must both be disabled — otherwise the next call silently
3226
+ // ships the "unchanged from the previous turn" sentinel against a worker
3227
+ // that has never seen the doc. See _buildDocChatPass for the read-side
3228
+ // rationale; see the post-call write-back below for the symmetric write
3229
+ // guard.
3230
+ const usePool = !!(CONFIG.engine && CONFIG.engine.ccUseWorkerPool && sessionKey);
3231
+
3009
3232
  // Build the pass once for the first call; the retry helper invokes runOnce()
3010
3233
  // with no args after invalidating the session, so a fresh build there reflects
3011
3234
  // the new (no-session) state correctly.
3012
3235
  const initialPass = _buildDocChatPass({
3013
- docSlice, title, filePath, selection, canEdit, isJson, sessionKey, freshSession,
3236
+ docSlice, title, filePath, selection, canEdit, isJson, sessionKey, freshSession, usePool,
3014
3237
  });
3015
3238
 
3016
3239
  const runOnce = async (passOverride) => {
3017
3240
  const { extraContext } = passOverride || _buildDocChatPass({
3018
- docSlice, title, filePath, selection, canEdit, isJson, sessionKey, freshSession,
3241
+ docSlice, title, filePath, selection, canEdit, isJson, sessionKey, freshSession, usePool,
3019
3242
  });
3020
3243
  return ccCall(message, {
3021
3244
  store: 'doc', sessionKey,
@@ -3030,6 +3253,11 @@ async function ccDocCall({ message, document, title, filePath, selection, canEdi
3030
3253
  turnId,
3031
3254
  ...(model ? { model } : {}),
3032
3255
  onAbortReady,
3256
+ // Sub-task D of W-mp2w003600196c51: forward freshSession so the pool
3257
+ // path can use a one-shot tabKey for one-shot flows (Create Plan
3258
+ // from meeting etc.) — prevents the one-shot from inheriting OR
3259
+ // polluting the persistent doc-chat conversation for the same file.
3260
+ freshSession,
3033
3261
  });
3034
3262
  };
3035
3263
 
@@ -3041,8 +3269,10 @@ async function ccDocCall({ message, document, title, filePath, selection, canEdi
3041
3269
  // bleed into future interactions under the same key.
3042
3270
  docSessions.delete(sessionKey);
3043
3271
  schedulePersistDocSessions();
3044
- } else if (_docChatResultLooksSuccessful(result) && result.sessionId) {
3045
- // Store doc hash for next call's unchanged check
3272
+ } else if (!usePool && _docChatResultLooksSuccessful(result) && result.sessionId) {
3273
+ // Store doc hash for next call's unchanged check (legacy path only —
3274
+ // pool path skips this so a future call cannot silently ride a stale
3275
+ // hash into the docUnchanged branch against a fresh ACP worker).
3046
3276
  const session = resolveSession('doc', sessionKey);
3047
3277
  if (session) session._docHash = initialPass.docHash;
3048
3278
  }
@@ -3075,14 +3305,19 @@ async function ccDocCallStreaming({ message, document, title, filePath, selectio
3075
3305
  docSessions.delete(sessionKey);
3076
3306
  }
3077
3307
 
3308
+ // Sub-task D of W-mp2w003600196c51: see ccDocCall above for the
3309
+ // read-side / write-side rationale; the streaming variant mirrors the
3310
+ // same usePool guard.
3311
+ const usePool = !!(CONFIG.engine && CONFIG.engine.ccUseWorkerPool && sessionKey);
3312
+
3078
3313
  // Build the pass once; see ccDocCall for the dedup rationale.
3079
3314
  const initialPass = _buildDocChatPass({
3080
- docSlice, title, filePath, selection, canEdit, isJson, sessionKey, freshSession,
3315
+ docSlice, title, filePath, selection, canEdit, isJson, sessionKey, freshSession, usePool,
3081
3316
  });
3082
3317
 
3083
3318
  const runOnce = async (passOverride) => {
3084
3319
  const { extraContext } = passOverride || _buildDocChatPass({
3085
- docSlice, title, filePath, selection, canEdit, isJson, sessionKey, freshSession,
3320
+ docSlice, title, filePath, selection, canEdit, isJson, sessionKey, freshSession, usePool,
3086
3321
  });
3087
3322
  return ccCallStreaming(message, {
3088
3323
  store: 'doc', sessionKey,
@@ -3099,6 +3334,9 @@ async function ccDocCallStreaming({ message, document, title, filePath, selectio
3099
3334
  onChunk: streamStripper,
3100
3335
  onToolUse,
3101
3336
  onRetry,
3337
+ // Sub-task D of W-mp2w003600196c51: forward freshSession — see
3338
+ // ccDocCall for the one-shot pool-isolation rationale.
3339
+ freshSession,
3102
3340
  });
3103
3341
  };
3104
3342
 
@@ -3108,7 +3346,8 @@ async function ccDocCallStreaming({ message, document, title, filePath, selectio
3108
3346
  if (freshSession && sessionKey) {
3109
3347
  docSessions.delete(sessionKey);
3110
3348
  schedulePersistDocSessions();
3111
- } else if (_docChatResultLooksSuccessful(result) && result.sessionId) {
3349
+ } else if (!usePool && _docChatResultLooksSuccessful(result) && result.sessionId) {
3350
+ // Legacy path only — see ccDocCall for the pool-path skip rationale.
3112
3351
  const session = resolveSession('doc', sessionKey);
3113
3352
  if (session) session._docHash = initialPass.docHash;
3114
3353
  }
@@ -8085,6 +8324,9 @@ module.exports = {
8085
8324
  _normalizeMeetingParticipants: normalizeMeetingParticipants,
8086
8325
  parsePinnedEntries,
8087
8326
  _formatDocChatContext,
8327
+ _buildDocChatPass,
8328
+ _docSessionsForTesting: docSessions,
8329
+ _docChatPromptHashForTesting: _docChatPromptHash,
8088
8330
  _isCompletedMeetingJson,
8089
8331
  _finalizeDocChatEdit,
8090
8332
  _makeDocChatStreamStripper,
@@ -0,0 +1,5 @@
1
+ {
2
+ "runtime": "copilot",
3
+ "models": null,
4
+ "cachedAt": "2026-05-13T23:13:36.239Z"
5
+ }
@@ -176,9 +176,10 @@ function addToDispatch(item) {
176
176
 
177
177
  // ─── Pre-Dispatch Acceptance Criteria Gate (P-a2d6b9c7, Ripley §3) ──────────
178
178
  //
179
- // Optional cheap-LLM validation gate that runs *before* queue insertion so
179
+ // Cheap-LLM validation gate that runs *before* queue insertion so
180
180
  // impossible/ambiguous work items are routed to a review queue rather than
181
- // burning a full agent run. Opt-in via ENGINE_DEFAULTS.enablePreDispatchEval.
181
+ // burning a full agent run. On by default via ENGINE_DEFAULTS.enablePreDispatchEval
182
+ // (P-d2a9f6e5); per-project disable available via the dashboard config UI.
182
183
  //
183
184
  // Wired from engine.js discoverWork(); kept as a separate async wrapper so
184
185
  // the existing synchronous addToDispatch() call sites are unaffected.
@@ -248,7 +249,17 @@ async function addToDispatchWithValidation(item, opts = {}) {
248
249
 
249
250
  const wi = item?.meta?.item;
250
251
  const criteria = wi && (wi.acceptance_criteria || wi.acceptanceCriteria);
251
- if (!Array.isArray(criteria) || criteria.length === 0) {
252
+ const hasCriteria = Array.isArray(criteria) && criteria.length > 0;
253
+ // P-d2a9f6e5: also let the validator run when criteria are absent but the
254
+ // work item carries a rich description — defense-in-depth for noop dispatches
255
+ // authored from description-only WIs (same incident class as P-a4f7c2d8).
256
+ // The threshold lives in pre-dispatch-eval.js (DESCRIPTION_MIN_CHARS); we
257
+ // duplicate the bypass check here so we don't even spin up the validator
258
+ // when there's nothing usable to evaluate.
259
+ const { DESCRIPTION_MIN_CHARS } = require('./pre-dispatch-eval');
260
+ const description = ((wi && wi.description) || '').trim();
261
+ const hasUsableDescription = description.length >= DESCRIPTION_MIN_CHARS;
262
+ if (!hasCriteria && !hasUsableDescription) {
252
263
  return addToDispatch(item);
253
264
  }
254
265
 
@@ -454,6 +465,11 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
454
465
  const agentRetryable = normalizeRetryableDecision(opts.agentRetryable ?? opts.retryable);
455
466
  let item = null;
456
467
  let completedSourceWorkItem = false;
468
+ // Pre-spawn failures (e.g. worktree creation) move the item from pending →
469
+ // completed without it ever entering active. Track this so the human-feedback
470
+ // dedupe-clear below can skip clearing the completed entry — otherwise the
471
+ // engine immediately re-queues a fix that has no chance of succeeding (#2454).
472
+ let wasPending = false;
457
473
 
458
474
  mutateDispatch((dispatch) => {
459
475
  // Check active list first
@@ -463,7 +479,10 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
463
479
  } else {
464
480
  // Also check pending list (e.g., worktree failure before spawn)
465
481
  idx = dispatch.pending.findIndex(d => d.id === id);
466
- if (idx >= 0) item = dispatch.pending.splice(idx, 1)[0];
482
+ if (idx >= 0) {
483
+ item = dispatch.pending.splice(idx, 1)[0];
484
+ wasPending = true;
485
+ }
467
486
  }
468
487
 
469
488
  if (!item) return dispatch;
@@ -625,14 +644,21 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
625
644
  else log('info', `Skipped pendingFix restore for ${prId} — PR is no longer tracked`);
626
645
  } catch (e) { log('warn', `restore pendingFix: ${e.message}`); }
627
646
  }
628
- // Clear completed dispatch entry so dedup doesn't block re-dispatch
629
- if (item.meta?.dispatchKey) {
647
+ // Clear completed dispatch entry so dedup doesn't block re-dispatch.
648
+ // SKIP this clear when the failure happened before spawn (e.g. worktree
649
+ // creation failure, #2454) — otherwise the engine sees no recent dispatch
650
+ // history, clears the cooldown as "stale", and re-queues immediately on
651
+ // the very next tick, looping forever for structural failures like a
652
+ // stale-locked worktree entry that the agent can't fix on its own.
653
+ if (item.meta?.dispatchKey && !wasPending) {
630
654
  try {
631
655
  mutateDispatch((dp) => {
632
656
  dp.completed = Array.isArray(dp.completed) ? dp.completed.filter(d => d.meta?.dispatchKey !== item.meta.dispatchKey) : [];
633
657
  return dp;
634
658
  });
635
659
  } catch (e) { log('warn', 'clear human-feedback dispatch for retry: ' + e.message); }
660
+ } else if (item.meta?.dispatchKey && wasPending) {
661
+ log('info', `Preserving completed dispatch entry for ${item.meta.dispatchKey} (pre-spawn failure) — cooldown will throttle retry`);
636
662
  }
637
663
  }
638
664
  }
@@ -6,10 +6,17 @@
6
6
  *
7
7
  * Conservative scope (per task contract):
8
8
  * - Validation only — never rewrites or "fixes" criteria.
9
- * - Opt-in via `ENGINE_DEFAULTS.enablePreDispatchEval` (default false) so it
10
- * can be enabled per-environment without forcing fleet-wide rollout.
9
+ * - Default behavior is governed by `ENGINE_DEFAULTS.enablePreDispatchEval`
10
+ * (P-d2a9f6e5: now `true` by default; per-project override via the
11
+ * dashboard config UI).
12
+ * - When `acceptance_criteria` are absent but the work item carries a rich
13
+ * `description` (≥ DESCRIPTION_MIN_CHARS after trim), the validator
14
+ * evaluates the description instead of fail-open bypassing — so noop
15
+ * dispatches authored from a description-only WI are still caught
16
+ * (P-d2a9f6e5; defense-in-depth for the same incident class as
17
+ * P-a4f7c2d8 dashboard depends_on bug).
11
18
  * - Fail-open: any LLM error / runtime-unavailable / parse failure resolves
12
- * `{ valid: true }` so the gate cannot wedge dispatch on its own.
19
+ * `{ valid: true }` so the gate cannot wedge dispatch.
13
20
  *
14
21
  * Wired from engine/dispatch.js → addToDispatchWithValidation().
15
22
  *
@@ -17,6 +24,8 @@
17
24
  * knowledge/architecture/2026-05-11-ripley-daily-architecture-bug-review-ripley-s-investigati.md
18
25
  * (Daily Architecture & Bug Review — 2026-05-11). Lambert + Rebecca debate
19
26
  * rounds reaffirmed: validate-only, no auto-rewrite, opt-in flag.
27
+ * P-d2a9f6e5 (architecture meeting 2026-05-13): flip default to true and
28
+ * broaden validator to read description when criteria are missing.
20
29
  */
21
30
 
22
31
  const shared = require('./shared');
@@ -26,6 +35,10 @@ const { callLLM } = require('./llm');
26
35
  const SYSTEM_PROMPT = 'Output only JSON.';
27
36
  const DEFAULT_TIMEOUT_MS = 60000;
28
37
  const DEFAULT_MODEL = 'haiku'; // claude shorthand; the runtime adapter expands it (see engine/runtimes/claude.js resolveModel)
38
+ // Minimum trimmed description length that justifies a description-only LLM
39
+ // validation. Below this we fail-open without burning an LLM call — short
40
+ // descriptions don't carry enough signal for the gate to add value.
41
+ const DESCRIPTION_MIN_CHARS = 80;
29
42
 
30
43
  function _extractCriteria(workItem) {
31
44
  if (!workItem || typeof workItem !== 'object') return [];
@@ -36,18 +49,30 @@ function _extractCriteria(workItem) {
36
49
  return [];
37
50
  }
38
51
 
52
+ function _extractDescription(workItem) {
53
+ if (!workItem || typeof workItem !== 'object') return '';
54
+ return (workItem.description || '').trim();
55
+ }
56
+
39
57
  function _buildPrompt(workItem, criteria) {
40
58
  const title = workItem.title || workItem.name || workItem.id || 'untitled';
41
- const description = (workItem.description || '').trim();
59
+ const description = _extractDescription(workItem);
42
60
  const lines = [
43
61
  `Work item: ${title}`,
44
62
  ];
63
+ const hasCriteria = Array.isArray(criteria) && criteria.length > 0;
45
64
  if (description) lines.push('', 'Description:', description);
46
- lines.push('', 'Acceptance criteria:');
47
- for (const c of criteria) lines.push(`- ${c}`);
48
- lines.push('',
49
- 'Are these acceptance criteria clear, actionable, and testable?',
50
- 'Reply with JSON: {"valid": true|false, "reason": "..."}.');
65
+ if (hasCriteria) {
66
+ lines.push('', 'Acceptance criteria:');
67
+ for (const c of criteria) lines.push(`- ${c}`);
68
+ lines.push('',
69
+ 'Are these acceptance criteria clear, actionable, and testable?',
70
+ 'Reply with JSON: {"valid": true|false, "reason": "..."}.');
71
+ } else {
72
+ lines.push('',
73
+ 'Is this work item description clear, actionable, and testable?',
74
+ 'Reply with JSON: {"valid": true|false, "reason": "..."}.');
75
+ }
51
76
  return lines.join('\n');
52
77
  }
53
78
 
@@ -67,6 +92,12 @@ function _parseResponse(text) {
67
92
  /**
68
93
  * Validate a work item's acceptance criteria with a fast/cheap LLM call.
69
94
  *
95
+ * Validation modes (P-d2a9f6e5):
96
+ * 1. Criteria present → evaluate the criteria (legacy behavior).
97
+ * 2. No criteria but description ≥ DESCRIPTION_MIN_CHARS → evaluate the
98
+ * description (defense-in-depth for description-only work items).
99
+ * 3. Otherwise → fail-open with `{ valid: true }`.
100
+ *
70
101
  * @param {object} workItem - work item with `acceptance_criteria` (or
71
102
  * `acceptanceCriteria`) plus title/description for context.
72
103
  * @param {object} [opts]
@@ -78,7 +109,11 @@ function _parseResponse(text) {
78
109
  */
79
110
  async function validateAcceptanceCriteria(workItem, opts = {}) {
80
111
  const criteria = _extractCriteria(workItem);
81
- if (criteria.length === 0) {
112
+ const description = _extractDescription(workItem);
113
+ const hasCriteria = criteria.length > 0;
114
+ const hasUsableDescription = description.length >= DESCRIPTION_MIN_CHARS;
115
+
116
+ if (!hasCriteria && !hasUsableDescription) {
82
117
  return { valid: true, reason: 'no acceptance criteria to validate' };
83
118
  }
84
119
 
@@ -115,9 +150,12 @@ async function validateAcceptanceCriteria(workItem, opts = {}) {
115
150
  log('warn', 'pre-dispatch-eval: response missing boolean valid field — failing open');
116
151
  return { valid: true, reason: 'validator response unparseable' };
117
152
  }
153
+ const defaultReason = hasCriteria
154
+ ? (parsed.valid ? 'criteria look testable' : 'criteria not clear/actionable/testable')
155
+ : (parsed.valid ? 'description looks testable' : 'description not clear/actionable/testable');
118
156
  return {
119
157
  valid: parsed.valid,
120
- reason: String(parsed.reason || '').trim() || (parsed.valid ? 'criteria look testable' : 'criteria not clear/actionable/testable'),
158
+ reason: String(parsed.reason || '').trim() || defaultReason,
121
159
  };
122
160
  }
123
161
 
@@ -125,6 +163,8 @@ module.exports = {
125
163
  validateAcceptanceCriteria,
126
164
  // Exposed for unit testing — engine code MUST go through validateAcceptanceCriteria.
127
165
  _extractCriteria,
166
+ _extractDescription,
128
167
  _buildPrompt,
129
168
  _parseResponse,
169
+ DESCRIPTION_MIN_CHARS,
130
170
  };
@@ -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
@@ -1100,7 +1100,7 @@ const ENGINE_DEFAULTS = {
1100
1100
  botCommentLogins: [], // P-a3f9b2c1: opt-in shared-minions GH login list — comments from these logins are suppressed ONLY when body matches positive-signal markers (Verification SUCCESS / VERDICT:APPROVE / noop:true). Narrower than ignoredCommentAuthors which suppresses all comments by login.
1101
1101
  agentBusyReassignMs: 600000, // 10min — reassign work item to another agent if preferred agent is busy beyond this threshold
1102
1102
  ccEffort: null, // effort level for CC/doc-chat (null, 'low', 'medium', 'high')
1103
- enablePreDispatchEval: false, // opt-in: cheap LLM gate before queueing — see engine/pre-dispatch-eval.js (Ripley §3 recommendation, 2026-05-11 architecture review)
1103
+ enablePreDispatchEval: true, // P-d2a9f6e5: cheap LLM gate before queueing — on by default. See engine/pre-dispatch-eval.js (Ripley §3 recommendation, 2026-05-11 architecture review). Validates from acceptance_criteria when present, falls back to description when criteria are absent but description is rich (≥80 chars). Fail-open on any validator error.
1104
1104
 
1105
1105
  // ── Runtime fleet (P-3b8e5f1d) ──────────────────────────────────────────────
1106
1106
  // Single source of truth for which CLI runtime + model every spawn uses.
package/engine.js CHANGED
@@ -573,6 +573,39 @@ async function runWorktreeAdd(rootDir, worktreePath, args, gitOpts, worktreeCrea
573
573
  if (lastErr) throw lastErr;
574
574
  }
575
575
 
576
+ // Detect and remove worktree registrations whose backing directory is missing
577
+ // on disk. `git worktree add` fails with "branch is already used by worktree
578
+ // at <path>" when a prior crash left such an entry behind, sometimes still in
579
+ // `locked: initializing` state. Single -f errors out with "cannot remove a
580
+ // locked working tree, lock reason: initializing" — must use -f -f.
581
+ // Returns the number of stale entries removed (so callers can decide whether
582
+ // to retry the worktree add).
583
+ async function pruneStaleWorktreeForBranch(rootDir, branchName, gitOpts) {
584
+ if (!branchName) return 0;
585
+ let trees = [];
586
+ try {
587
+ const out = await execAsync(`git worktree list --porcelain`, { ...gitOpts, cwd: rootDir, timeout: 10000 });
588
+ trees = shared.parseWorktreePorcelain(out);
589
+ } catch (e) {
590
+ log('warn', `pruneStaleWorktreeForBranch list: ${e.message?.split('\n')[0]}`);
591
+ return 0;
592
+ }
593
+ const stale = trees.filter(w => w.branch === branchName && w.path && !fs.existsSync(w.path));
594
+ if (stale.length === 0) return 0;
595
+ let removed = 0;
596
+ for (const w of stale) {
597
+ try {
598
+ await execAsync(`git worktree remove -f -f "${w.path}"`, { ...gitOpts, cwd: rootDir, timeout: 15000 });
599
+ removed++;
600
+ log('warn', `Removed stale worktree entry for ${branchName} at missing path ${w.path}${w.locked ? ' (was locked)' : ''}`);
601
+ } catch (e) {
602
+ log('warn', `git worktree remove -f -f failed for stale ${w.path}: ${e.message?.split('\n')[0]}`);
603
+ }
604
+ }
605
+ try { await execAsync(`git worktree prune`, { ...gitOpts, cwd: rootDir, timeout: 10000 }); } catch { /* best-effort */ }
606
+ return removed;
607
+ }
608
+
576
609
  async function recoverPartialWorktree(rootDir, worktreePath, branchName, gitOpts) {
577
610
  if (!branchName) return false;
578
611
  const existingWt = await findExistingWorktree(rootDir, branchName);
@@ -717,7 +750,16 @@ async function spawnAgent(dispatchItem, config) {
717
750
  shared.assertWorktreeOutsideProject(existingWtPath, rootDir);
718
751
  log('info', `Shared branch ${branchName} already checked out at ${existingWtPath} — reusing`);
719
752
  worktreePath = existingWtPath;
720
- } else { throw eShared; }
753
+ } else {
754
+ // Branch is registered but path is missing on disk (#2454).
755
+ // git keeps the entry — sometimes locked: initializing — and
756
+ // every subsequent add fails identically. Force-prune and retry.
757
+ const pruned = await pruneStaleWorktreeForBranch(rootDir, branchName, _gitOpts);
758
+ if (pruned > 0) {
759
+ log('info', `Pruned ${pruned} stale worktree entry(ies) for shared branch ${branchName}; retrying worktree add`);
760
+ await runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, 0);
761
+ } else { throw eShared; }
762
+ }
721
763
  } else if (eShared.message?.includes('invalid reference') || eShared.message?.includes('not a valid ref')) {
722
764
  // Branch doesn't exist yet (first item in plan) — create it from main
723
765
  const mainRef = sanitizeBranch(shared.resolveMainBranch(rootDir, project.mainBranch));
@@ -800,9 +842,14 @@ async function spawnAgent(dispatchItem, config) {
800
842
  cwd = worktreePath;
801
843
  log('warn', `Proceeding with recovered worktree after add failure for ${branchName}`);
802
844
  } else {
845
+ // Include err.stderr (where git's "fatal: ..." text lives) and use a
846
+ // larger budget than 200 chars — the issue title is unreadable when
847
+ // the slice cuts off mid-"fatal:" leaving just "fa" (#2454).
848
+ const stderrSnippet = err.stderr ? '\n' + err.stderr.toString().trim() : '';
849
+ const fullMsg = `${err.message || ''}${stderrSnippet}`.trim();
803
850
  log('error', `Failed to create worktree for ${branchName}: ${err.message}${err.stderr ? '\n' + err.stderr.toString().slice(0, 500) : ''}`);
804
851
  _cleanupPromptFiles();
805
- completeDispatch(id, DISPATCH_RESULT.ERROR, 'Worktree creation failed: ' + (err.message || '').slice(0, 200));
852
+ completeDispatch(id, DISPATCH_RESULT.ERROR, 'Worktree creation failed: ' + fullMsg.slice(0, 800));
806
853
  cleanupTempAgent(agentId);
807
854
  return null;
808
855
  }
@@ -5075,6 +5122,7 @@ module.exports = {
5075
5122
  reconcileItemsWithPrs, detectDependencyCycles,
5076
5123
  parseConflictFiles, pruneAncestorDeps, preflightMergeSimulation, // exported for testing
5077
5124
  isWorktreeRetryableError, removeStaleIndexLock, syncReusedWorktree, // exported for testing
5125
+ pruneStaleWorktreeForBranch, // exported for testing
5078
5126
  _maxTurnsForType, buildProjectContext, normalizeAc, _buildAgentSpawnFlags, _classifyAgentFailure, // exported for testing
5079
5127
  promoteCheckpointSteeringForClose, // exported for testing
5080
5128
  normalizePrBranch, resolvePrBranch, prCausePart, getPrCauseHead, getPrCauseBase, getPrAutomationCauseKey, getPrAutomationDispatchKey, // exported for testing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1921",
3
+ "version": "0.1.1923",
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"