@yemi33/minions 0.1.1987 → 0.1.1989

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/README.md CHANGED
@@ -657,7 +657,7 @@ To move to a new machine: `npm install -g @yemi33/minions && minions init --forc
657
657
  # Core orchestration
658
658
  shared.js queries.js cli.js
659
659
  lifecycle.js dispatch.js cooldown.js
660
- timeout.js steering.js recovery.js
660
+ timeout.js steering.js
661
661
  pre-dispatch-eval.js
662
662
  # Discovery, routing, playbooks
663
663
  routing.js playbook.js cleanup.js
@@ -1013,7 +1013,27 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
1013
1013
  for (var li = 0; li < lines.length; li++) {
1014
1014
  var line = lines[li];
1015
1015
  if (!line.startsWith('data: ')) continue;
1016
- try { await _handleEvent(JSON.parse(line.slice(6))); } catch {}
1016
+ // W-mpdavudb000v8446 these used to swallow ALL errors via `catch {}`,
1017
+ // hiding JSON.parse failures AND any DOM/render exception thrown by
1018
+ // _handleEvent. When chunks=2 outcome=done server-side but the user
1019
+ // saw thinking dots forever, this was the most likely observability
1020
+ // hole: a render error in updateStreamDiv / addMsg / renderMd would
1021
+ // disappear silently and the loop would keep reading. Log with enough
1022
+ // context (event type, tab, error message) to triage from the browser
1023
+ // console without dumping raw event payloads (which may include user
1024
+ // content). Failure is still non-fatal — we keep reading the stream
1025
+ // so the `done` event still has a chance to flip terminalEventSeen.
1026
+ var rawJson = line.slice(6);
1027
+ var evt;
1028
+ try { evt = JSON.parse(rawJson); }
1029
+ catch (parseErr) {
1030
+ try { console.error('[cc-sse] parse-failed', { tab: activeTabId, len: rawJson.length, error: String(parseErr && parseErr.message || parseErr) }); } catch (_e) {}
1031
+ continue;
1032
+ }
1033
+ try { await _handleEvent(evt); }
1034
+ catch (handleErr) {
1035
+ try { console.error('[cc-sse] handle-failed', { tab: activeTabId, type: evt && evt.type, error: String(handleErr && handleErr.message || handleErr), stack: handleErr && handleErr.stack }); } catch (_e) {}
1036
+ }
1017
1037
  }
1018
1038
  }
1019
1039
  if (buf.trim()) {
@@ -1021,7 +1041,17 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
1021
1041
  for (var ri = 0; ri < remainingLines.length; ri++) {
1022
1042
  var rline = remainingLines[ri];
1023
1043
  if (!rline.startsWith('data: ')) continue;
1024
- try { await _handleEvent(JSON.parse(rline.slice(6))); } catch {}
1044
+ var trailRaw = rline.slice(6);
1045
+ var trailEvt;
1046
+ try { trailEvt = JSON.parse(trailRaw); }
1047
+ catch (parseErr) {
1048
+ try { console.error('[cc-sse] parse-failed-trailing', { tab: activeTabId, len: trailRaw.length, error: String(parseErr && parseErr.message || parseErr) }); } catch (_e) {}
1049
+ continue;
1050
+ }
1051
+ try { await _handleEvent(trailEvt); }
1052
+ catch (handleErr) {
1053
+ try { console.error('[cc-sse] handle-failed-trailing', { tab: activeTabId, type: trailEvt && trailEvt.type, error: String(handleErr && handleErr.message || handleErr), stack: handleErr && handleErr.stack }); } catch (_e) {}
1054
+ }
1025
1055
  }
1026
1056
  }
1027
1057
  return { interrupted: !terminalEventSeen, reconnectable: true };
@@ -1,41 +1,19 @@
1
1
  // dashboard/js/qa.js — QA tab wiring (W-mpd5ewhj000oc5c5).
2
2
  //
3
- // The QA tab is the canonical home for long-running test/validation surfaces.
4
- // Phase 1 (this WI) mounts the managed-spawn + keep-processes panels into the
5
- // QA page using the shared mount API on render-managed.js + render-other.js.
6
- // The Engine page keeps its own mount registered eagerly inside those modules
7
- // so dual-render works (engine.html and qa.html show the same data, fed by
8
- // the same poll loop in refresh.js — no extra fetches, no extra SSE streams).
3
+ // The QA tab hosts validation runbooks (human-driven and agent-driven) against
4
+ // running managed instances. It does NOT mirror the live-process inventory
5
+ // that lives on /engine (Managed Processes + Keep-Processes panels). Runbook
6
+ // rows in the next WI will link to their targets by name rather than duplicate
7
+ // the inventory tables (W-mpdad3mq000m53bb).
9
8
  //
10
- // SSE log streaming is lazy: openManagedLog() opens a single EventSource on
11
- // user click and closeManagedLog() aborts it. The modal is a singleton, so
12
- // opening from either tab doesn't multiply connections (cf. render-managed.js
13
- // "single-stream invariant").
14
- //
15
- // Out of scope: actual runbook dispatch wiring. The placeholder card with the
16
- // disabled "+ New runbook" button is the only UX hook for the next phase.
9
+ // The only wiring this file owns is the switchPage SSE-close hook: if the
10
+ // user has the managed-log modal (defined in render-managed.js) open on the
11
+ // engine page and navigates away, we close the EventSource so it doesn't
12
+ // keep streaming behind the new page. The hook is harmless when the QA page
13
+ // never opens the modal itself, and matches the broader page-navigation
14
+ // contract for SSE cleanup.
17
15
 
18
16
  (function () {
19
- function _registerQaMounts() {
20
- if (typeof mountManagedProcessesPanel === 'function') {
21
- mountManagedProcessesPanel({
22
- contentId: 'qa-managed-processes-content',
23
- countId: 'qa-managed-processes-count',
24
- });
25
- }
26
- if (typeof mountKeepProcessesPanel === 'function') {
27
- mountKeepProcessesPanel({
28
- contentId: 'qa-keep-processes-content',
29
- countId: 'qa-keep-processes-count',
30
- });
31
- }
32
- }
33
-
34
- // The page fragment is in the DOM at script-load time (all .page divs are
35
- // assembled into layout.html at build), so registering immediately is safe.
36
- // The mount API is no-op when the QA fragment is missing (defensive).
37
- _registerQaMounts();
38
-
39
17
  // Close any open managed-log SSE stream when the user navigates away from a
40
18
  // page that triggered it — the modal otherwise floats over the new page and
41
19
  // the EventSource keeps streaming. Hooks into the existing switchPage()
@@ -48,6 +26,4 @@
48
26
  };
49
27
  window.switchPage.__qaWrapped = true;
50
28
  }
51
-
52
- window.MinionsQA = { _registerQaMounts };
53
29
  })();
@@ -103,16 +103,23 @@ function _processStatusUpdate(data) {
103
103
  prunePrdRequeueState(window._lastWorkItems);
104
104
  if (_changed('engineLog', data.engineLog)) renderEngineLog(data.engineLog || []);
105
105
  if (_changed('metrics', data.metrics)) renderMetrics(data.metrics || {});
106
- // keep_processes panel renders on every page where its mount is in the DOM
107
- // (Engine page + QA page W-mpd5ewhj000oc5c5). Cheap call (one fetch); the
108
- // renderer iterates all registered mounts and skips when none are present.
109
- if (typeof renderKeepProcesses === 'function') {
110
- try { renderKeepProcesses(); } catch {}
111
- }
112
- // managed-processes panel — same mount-point pattern, ETag-gated so
113
- // unchanged ticks return 304 with no body (P-6e2a8b13).
106
+ // managed-processes panel ETag-gated so unchanged ticks return 304 with
107
+ // no body (P-6e2a8b13). Sequenced BEFORE the keep-processes call below via
108
+ // .then() so the keep renderer reads a populated managed-PID cache for
109
+ // dedup (W-mpdad3mq000m53bb). _processStatusUpdate isn't async, so we
110
+ // chain on the returned Promise instead of awaiting.
111
+ let _managedRender = Promise.resolve();
114
112
  if (typeof renderManagedProcesses === 'function') {
115
- try { renderManagedProcesses(); } catch {}
113
+ try { _managedRender = Promise.resolve(renderManagedProcesses()); } catch {}
114
+ }
115
+ // keep_processes panel renders on every page where its mount is in the DOM.
116
+ // Cheap call (one fetch); the renderer iterates all registered mounts, skips
117
+ // when none are present, and suppresses any PID already tracked as a managed
118
+ // process via MinionsManagedProcesses.getLastItems().
119
+ if (typeof renderKeepProcesses === 'function') {
120
+ _managedRender
121
+ .catch(function () { /* keep render even if managed fetch failed — getLastItems() returns the last good cache (or []) */ })
122
+ .then(function () { try { renderKeepProcesses(); } catch {} });
116
123
  }
117
124
  if (_changed('workItems', data.workItems)) renderWorkItems(data.workItems || []);
118
125
  if (_changed('skills', data.skills)) renderSkills(data.skills || []);
@@ -45,6 +45,12 @@ function unmountManagedProcessesPanel(contentId) {
45
45
  }
46
46
  }
47
47
 
48
+ // Read-only accessor for the last successfully-fetched managed-process items.
49
+ // Used by render-other.js renderKeepProcesses() to suppress PIDs that are
50
+ // already tracked as managed processes (W-mpdad3mq000m53bb dedup). Returns
51
+ // the live cache reference — callers must not mutate it.
52
+ function getLastItems() { return _managedProcessesLastItems || []; }
53
+
48
54
  function _fmtAgo(ms) {
49
55
  if (!ms || ms < 0) return '0s';
50
56
  const s = Math.floor(ms / 1000);
@@ -302,4 +308,5 @@ window.MinionsManagedProcesses = {
302
308
  closeManagedLog,
303
309
  mountManagedProcessesPanel,
304
310
  unmountManagedProcessesPanel,
311
+ getLastItems,
305
312
  };
@@ -529,15 +529,45 @@ async function renderKeepProcesses() {
529
529
  } catch (e) {
530
530
  fetchErr = e;
531
531
  }
532
+ // Dedup against managed-processes: agents that declare a process via
533
+ // managed-spawn.json AND leave it running via keep_processes show up in
534
+ // both panels. Managed is canonical (engine owns the lifecycle), so we
535
+ // suppress any PID already tracked there from the keep-processes table
536
+ // (W-mpdad3mq000m53bb).
537
+ const managed = (window.MinionsManagedProcesses && typeof window.MinionsManagedProcesses.getLastItems === 'function')
538
+ ? window.MinionsManagedProcesses.getLastItems()
539
+ : [];
540
+ const managedPidSet = new Set(
541
+ (Array.isArray(managed) ? managed : [])
542
+ .map(m => Number(m && m.pid))
543
+ .filter(n => Number.isFinite(n))
544
+ );
545
+ let rawCount = 0;
546
+ let filtered = items;
547
+ if (!fetchErr && items) {
548
+ rawCount = items.length;
549
+ filtered = [];
550
+ for (const it of items) {
551
+ if (!it.valid) { filtered.push(it); continue; }
552
+ // Shallow clone so we don't mutate the fetched array — caller may
553
+ // re-render the same payload on a 304 tick.
554
+ const pids = Array.isArray(it.pids) ? it.pids.filter(p => !managedPidSet.has(Number(p && p.pid))) : [];
555
+ if (!pids.length) continue;
556
+ filtered.push({ ...it, pids });
557
+ }
558
+ }
532
559
  if (fetchErr) {
533
560
  countText = '?';
534
561
  html = '<span style="color:var(--red)">Failed to load: ' + escHtml(fetchErr.message) + '</span>';
535
- } else if (!items.length) {
562
+ } else if (!filtered.length) {
536
563
  countText = '0';
537
- html = '<p class="empty">No agents have left processes running. Set <code>meta.keep_processes: true</code> on a work item to enable.</p>';
564
+ const baseEmpty = '<p class="empty">No agents have left processes running. Set <code>meta.keep_processes: true</code> on a work item to enable.</p>';
565
+ html = (rawCount > 0)
566
+ ? baseEmpty.replace('</p>', ' <span style="color:var(--muted)">(all PIDs are tracked as managed processes above)</span></p>')
567
+ : baseEmpty;
538
568
  } else {
539
- countText = String(items.length);
540
- html = items.map(function (it) {
569
+ countText = String(filtered.length);
570
+ html = filtered.map(function (it) {
541
571
  if (!it.valid) {
542
572
  return '<div style="border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:8px;background:var(--surface2)">' +
543
573
  '<div style="color:var(--red);font-weight:600">' + escHtml(it.agentId) + ' INVALID</div>' +
@@ -106,6 +106,7 @@ async function openSettings() {
106
106
  settingsToggle('GitHub Polling', 'set-ghPollEnabled', e.ghPollEnabled !== false, 'Keeps GitHub PR build results, votes, and comments fresh each tick; GitHub PR dispatch gates are inert when this is off') +
107
107
  '</div>' +
108
108
  '<div style="margin-top:10px;padding-top:10px;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:4px">' +
109
+ settingsToggle('Auto-apply review vote to PR', 'set-autoApplyReviewVote', !!e.autoApplyReviewVote, 'When ON, Minions review verdicts (APPROVE / REQUEST_CHANGES) automatically flip the platform vote on ADO/GitHub. When OFF (default), verdicts are informational only and the human casts the final vote.') +
109
110
  settingsToggle('Auto-fix Builds', 'set-autoFixBuilds', e.autoFixBuilds !== false, 'Shared dispatch gate: auto-fix agent when a PR build fails; also requires that PR provider polling is enabled') +
110
111
  settingsToggle('Auto-fix Conflicts', 'set-autoFixConflicts', e.autoFixConflicts !== false, 'Shared dispatch gate: auto-fix agent when a PR merge conflict is detected; also requires that PR provider polling is enabled') +
111
112
  settingsToggle('Auto-review PRs', 'set-autoReviewPrs', e.autoReviewPrs !== false, 'Shared dispatch gate: review agent for newly opened agent PRs; also requires that PR provider polling is enabled') +
@@ -568,6 +569,7 @@ async function saveSettings() {
568
569
  autoDecompose: document.getElementById('set-autoDecompose').checked,
569
570
  allowTempAgents: document.getElementById('set-allowTempAgents').checked,
570
571
  autoArchive: document.getElementById('set-autoArchive').checked,
572
+ autoApplyReviewVote: document.getElementById('set-autoApplyReviewVote').checked,
571
573
  autoFixBuilds: document.getElementById('set-autoFixBuilds').checked,
572
574
  autoFixConflicts: document.getElementById('set-autoFixConflicts').checked,
573
575
  autoReviewPrs: document.getElementById('set-autoReviewPrs').checked,
@@ -21,13 +21,13 @@
21
21
  </section>
22
22
  <section id="keep-processes-section">
23
23
  <h2>Keep-Processes <span class="count" id="keep-processes-count">0</span>
24
- <span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">processes left running by agents (W-mp68q6ke0010de68 — opt-in keep_processes flag)</span>
24
+ <span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">processes left running by agents</span>
25
25
  </h2>
26
26
  <div id="keep-processes-content"><p class="empty">No agents have left processes running. Set <code>meta.keep_processes: true</code> on a work item to enable.</p></div>
27
27
  </section>
28
28
  <section id="managed-processes-section">
29
29
  <h2>Managed Processes <span class="count" id="managed-processes-count">0</span>
30
- <span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">engine-managed long-running services (P-6e2a8b13 — managed-spawn primitive)</span>
30
+ <span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">engine-managed long-running services</span>
31
31
  </h2>
32
32
  <div id="managed-processes-content"><p class="empty">No managed processes. Agents declare them via <code>agents/&lt;id&gt;/managed-spawn.json</code>.</p></div>
33
33
  </section>
@@ -1,18 +1,6 @@
1
1
  <section>
2
- <h2>Live Processes <span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">canonical home for managed instances + agent-left processes (W-mpd5ewhj000oc5c5)</span></h2>
3
- <p class="empty" style="margin:4px 0 12px 0">The QA tab is the foundation for human-driven and agent-driven validation against running managed instances. Phase 1 surfaces the live process inventory; runbook dispatch lands in a follow-up WI.</p>
4
- </section>
5
- <section id="qa-managed-processes-section">
6
- <h2>Managed Processes <span class="count" id="qa-managed-processes-count">0</span>
7
- <span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">engine-managed long-running services (P-6e2a8b13 — managed-spawn primitive)</span>
8
- </h2>
9
- <div id="qa-managed-processes-content"><p class="empty">No managed processes. Agents declare them via <code>agents/&lt;id&gt;/managed-spawn.json</code>.</p></div>
10
- </section>
11
- <section id="qa-keep-processes-section">
12
- <h2>Keep-Processes <span class="count" id="qa-keep-processes-count">0</span>
13
- <span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">processes left running by agents (W-mp68q6ke0010de68 — opt-in keep_processes flag)</span>
14
- </h2>
15
- <div id="qa-keep-processes-content"><p class="empty">No agents have left processes running. Set <code>meta.keep_processes: true</code> on a work item to enable.</p></div>
2
+ <h2>QA</h2>
3
+ <p class="empty" style="margin:4px 0 12px 0">Canonical home for human-driven and agent-driven validation against running managed instances. Runbook dispatch lands in a follow-up WI.</p>
16
4
  </section>
17
5
  <section id="qa-runbooks-section">
18
6
  <h2>Validation Runbooks <span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">human or agent-driven smoke / E2E flows against the live instances above</span></h2>
package/dashboard.js CHANGED
@@ -462,6 +462,38 @@ function resolveManualPrLinkProject(url, projectName, projects = PROJECTS) {
462
462
  };
463
463
  }
464
464
 
465
+ // Lenient ADO match: repo segment may be a GUID that matches repositoryId
466
+ if (matches.length === 0 && prScope.startsWith('ado:')) {
467
+ const lenientMatches = projects.filter(p => shared.isAdoPrScopeCompatible(prScope, p));
468
+ if (lenientMatches.length === 1) {
469
+ const targetProject = lenientMatches[0];
470
+ return {
471
+ project: targetProject,
472
+ resolution: {
473
+ reason: 'inferred',
474
+ scope: prScope,
475
+ project: targetProject.name || '',
476
+ storage: 'project',
477
+ message: `Inferred project "${targetProject.name}" from PR scope ${prScope} (ADO repository ID match).`,
478
+ },
479
+ };
480
+ }
481
+ if (lenientMatches.length > 1) {
482
+ const names = lenientMatches.map(p => p.name).filter(Boolean);
483
+ return {
484
+ project: null,
485
+ resolution: {
486
+ reason: 'ambiguous',
487
+ scope: prScope,
488
+ project: 'central',
489
+ storage: 'central',
490
+ matches: names,
491
+ message: `PR scope ${prScope} matches multiple configured projects (${names.join(', ')}); linked in central PR tracking. Select a project to attach it.`,
492
+ },
493
+ };
494
+ }
495
+ }
496
+
465
497
  if (matches.length > 1) {
466
498
  const names = matches.map(p => p.name).filter(Boolean);
467
499
  return {
@@ -2287,6 +2319,54 @@ For all state files, look under \`${MINIONS_DIR}\`.${indexSection}`;
2287
2319
  return result;
2288
2320
  }
2289
2321
 
2322
+ // ── Compact state refresh for resumed CC sessions (W-mpeap2ug0016e69c) ──
2323
+ // Injected every turn on resume so new projects, MCPs, and agent changes
2324
+ // are visible without opening a new session. Smaller than the full preamble
2325
+ // (skips API/CLI indexes) and uses a shorter cache TTL.
2326
+
2327
+ let _refreshCache = null;
2328
+ let _refreshCacheTs = 0;
2329
+ const REFRESH_TTL = 10000; // 10s — short TTL so state propagates quickly on resume
2330
+
2331
+ function _resetRefreshCache() {
2332
+ _refreshCache = null;
2333
+ _refreshCacheTs = 0;
2334
+ }
2335
+
2336
+ function buildCCStateRefresh() {
2337
+ const now = Date.now();
2338
+ if (_refreshCache && now - _refreshCacheTs < REFRESH_TTL) return _refreshCache;
2339
+
2340
+ const ts = new Date().toISOString().slice(0, 16);
2341
+ const agents = getAgents().map(a => `- ${a.name}: ${a.status}`).join('\n');
2342
+ const projects = PROJECTS.map(p => `- ${p.name} (${p.repo || p.localPath})`).join('\n');
2343
+
2344
+ // MCP servers — just names + source for orientation
2345
+ let mcpLine = '(none discovered)';
2346
+ try {
2347
+ const mcps = getMcpServers();
2348
+ if (mcps && mcps.length > 0) {
2349
+ const maxShow = 15;
2350
+ const shown = mcps.slice(0, maxShow).map(m => m.name).join(', ');
2351
+ mcpLine = mcps.length > maxShow ? `${shown} …and ${mcps.length - maxShow} more` : shown;
2352
+ }
2353
+ } catch { /* optional */ }
2354
+
2355
+ const result = `### State Refresh (${ts})
2356
+
2357
+ **Projects:** ${PROJECTS.length}
2358
+ ${projects || '(none)'}
2359
+
2360
+ **MCP Tools:** ${mcpLine}
2361
+
2362
+ **Agents:**
2363
+ ${agents || '(none)'}`;
2364
+
2365
+ _refreshCache = result;
2366
+ _refreshCacheTs = now;
2367
+ return result;
2368
+ }
2369
+
2290
2370
  // The ===ACTIONS=== delimiter parser tiers (findCCActionsHeader,
2291
2371
  // findCCActionsPartialDelimiter, stripCCActionsForStream/Display) and the
2292
2372
  // _extractActionsJson Copilot fence-stripper were retired with the move to
@@ -2735,8 +2815,14 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
2735
2815
  const resumeHasOutOfBandCarryover = !!sessionId && _transcriptHasCarryoverContext(transcript, { outOfBandOnly: true, currentMessage: message });
2736
2816
  const freshNeedsCarryover = _transcriptHasCarryoverContext(transcript, { currentMessage: message });
2737
2817
 
2738
- function buildPrompt({ includePreamble = true, includeCarryover = false, includeResumeGuard = false, outOfBandOnly = false } = {}) {
2739
- const parts = (!skipStatePreamble && includePreamble) ? [`## Current Minions State (${new Date().toISOString().slice(0, 16)})\n\n${buildCCStatePreamble()}`] : [];
2818
+ function buildPrompt({ includePreamble = true, includeRefresh = false, includeCarryover = false, includeResumeGuard = false, outOfBandOnly = false } = {}) {
2819
+ let preamblePart = null;
2820
+ if (!skipStatePreamble && includePreamble) {
2821
+ preamblePart = `## Current Minions State (${new Date().toISOString().slice(0, 16)})\n\n${buildCCStatePreamble()}`;
2822
+ } else if (!skipStatePreamble && includeRefresh) {
2823
+ preamblePart = buildCCStateRefresh();
2824
+ }
2825
+ const parts = preamblePart ? [preamblePart] : [];
2740
2826
  if (extraContext) parts.push(extraContext);
2741
2827
  if (includeResumeGuard) parts.push(CC_RESUME_BOOKKEEPING_GUARD);
2742
2828
  if (includeCarryover) {
@@ -2755,6 +2841,7 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
2755
2841
  if (sessionId && maxTurns > 1) {
2756
2842
  const p1 = llm.callLLM(buildPrompt({
2757
2843
  includePreamble: false,
2844
+ includeRefresh: true,
2758
2845
  includeResumeGuard: resumeNeedsBookkeepingGuard,
2759
2846
  includeCarryover: resumeNeedsCarryover || resumeHasOutOfBandCarryover,
2760
2847
  outOfBandOnly: !resumeNeedsCarryover,
@@ -2877,8 +2964,14 @@ async function ccCallStreaming(message, { store = 'cc', sessionKey, extraContext
2877
2964
  const resumeHasOutOfBandCarryover = !!sessionId && _transcriptHasCarryoverContext(transcript, { outOfBandOnly: true, currentMessage: message });
2878
2965
  const freshNeedsCarryover = _transcriptHasCarryoverContext(transcript, { currentMessage: message });
2879
2966
 
2880
- function buildPrompt({ includePreamble = true, includeCarryover = false, includeResumeGuard = false, outOfBandOnly = false } = {}) {
2881
- const parts = (!skipStatePreamble && includePreamble) ? [`## Current Minions State (${new Date().toISOString().slice(0, 16)})\n\n${buildCCStatePreamble()}`] : [];
2967
+ function buildPrompt({ includePreamble = true, includeRefresh = false, includeCarryover = false, includeResumeGuard = false, outOfBandOnly = false } = {}) {
2968
+ let preamblePart = null;
2969
+ if (!skipStatePreamble && includePreamble) {
2970
+ preamblePart = `## Current Minions State (${new Date().toISOString().slice(0, 16)})\n\n${buildCCStatePreamble()}`;
2971
+ } else if (!skipStatePreamble && includeRefresh) {
2972
+ preamblePart = buildCCStateRefresh();
2973
+ }
2974
+ const parts = preamblePart ? [preamblePart] : [];
2882
2975
  if (extraContext) parts.push(extraContext);
2883
2976
  if (includeResumeGuard) parts.push(CC_RESUME_BOOKKEEPING_GUARD);
2884
2977
  if (includeCarryover) {
@@ -2896,6 +2989,7 @@ async function ccCallStreaming(message, { store = 'cc', sessionKey, extraContext
2896
2989
  if (sessionId && maxTurns > 1) {
2897
2990
  const p1 = llm.callLLMStreaming(buildPrompt({
2898
2991
  includePreamble: false,
2992
+ includeRefresh: true,
2899
2993
  includeResumeGuard: resumeNeedsBookkeepingGuard,
2900
2994
  includeCarryover: resumeNeedsCarryover || resumeHasOutOfBandCarryover,
2901
2995
  outOfBandOnly: !resumeNeedsCarryover,
@@ -4245,6 +4339,22 @@ const server = http.createServer(async (req, res) => {
4245
4339
  if (!Array.isArray(body.depends_on)) return jsonReply(res, 400, { error: 'depends_on must be an array of strings' });
4246
4340
  if (!body.depends_on.every(s => typeof s === 'string')) return jsonReply(res, 400, { error: 'depends_on entries must be strings' });
4247
4341
  }
4342
+ // Validate agent/agents against config.agents (W-mpeanskq001311cf)
4343
+ const knownAgents = CONFIG.agents && typeof CONFIG.agents === 'object' ? Object.keys(CONFIG.agents) : [];
4344
+ if (knownAgents.length > 0) {
4345
+ const allowTemp = !!CONFIG.engine?.allowTempAgents;
4346
+ const isValidAgent = (name) => knownAgents.includes(name) || (allowTemp && /^temp-/.test(name));
4347
+ if (body.agent && typeof body.agent === 'string' && body.agent.trim()) {
4348
+ if (!isValidAgent(body.agent.trim())) {
4349
+ return jsonReply(res, 400, { error: `Unknown agent "${body.agent}". Valid agents: ${knownAgents.join(', ')}`, validAgents: knownAgents });
4350
+ }
4351
+ }
4352
+ const agentsArr = Array.isArray(body.agents) ? body.agents.filter(Boolean) : [];
4353
+ const invalidAgents = agentsArr.filter(a => typeof a === 'string' && !isValidAgent(a.trim()));
4354
+ if (invalidAgents.length > 0) {
4355
+ return jsonReply(res, 400, { error: `Unknown agent(s) in agents array: ${invalidAgents.join(', ')}. Valid agents: ${knownAgents.join(', ')}`, validAgents: knownAgents });
4356
+ }
4357
+ }
4248
4358
  // Worktree-requiring types must own a project so the engine's spawnAgent
4249
4359
  // can resolve a per-project rootDir. With no project (and no single
4250
4360
  // auto-target via defaultWhenSingle), spawn falls back to MINIONS_DIR's
@@ -6784,20 +6894,64 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6784
6894
  let _ccStreamEnded = false;
6785
6895
  let _ccHeartbeatTimer = null;
6786
6896
  let _ccLastHeartbeatAt = Date.now();
6897
+ // W-mpdavudb000v8446 — SSE delivery telemetry. Previously writeCcEvent
6898
+ // swallowed all write failures (res.destroyed / res.write returning false
6899
+ // for backpressure / sync throw), and the [cc-timing] log only proved
6900
+ // onChunk/onDone fired — NOT that bytes left the kernel. When chunks=2
6901
+ // outcome=done but the user sees thinking dots forever, the gap was here.
6902
+ // Now writeCcEvent inspects res state and logs a structured [cc-sse-fail]
6903
+ // line whenever it cannot actually deliver a chunk/done frame to the wire.
6787
6904
  const writeCcEvent = (payload) => {
6788
- try {
6789
- const wire = 'data: ' + JSON.stringify(payload) + '\n\n';
6790
- res.write(wire);
6791
- if (payload && payload.type === 'chunk') {
6792
- _ccTelemetry.chunks++;
6793
- _ccTelemetry.bytes += Buffer.byteLength(String(payload.text || ''), 'utf8');
6794
- } else if (payload && payload.type === 'tool') {
6795
- _ccTelemetry.tools++;
6796
- }
6797
- return true;
6798
- } catch {
6905
+ const type = payload && payload.type;
6906
+ const isUserFacing = type === 'chunk' || type === 'done' || type === 'tool' || type === 'tool-update' || type === 'error';
6907
+ const _logFail = (reason, extra) => {
6908
+ if (!isUserFacing) return;
6909
+ try {
6910
+ const meta = {
6911
+ tab: tabId || _ccTelemetry.tabId || 'unknown',
6912
+ type,
6913
+ reason,
6914
+ destroyed: !!res.destroyed,
6915
+ writableEnded: !!res.writableEnded,
6916
+ writableFinished: !!res.writableFinished,
6917
+ streamEnded: _ccStreamEnded,
6918
+ ...(extra || {}),
6919
+ };
6920
+ shared.log('warn', `[cc-sse-fail] ${JSON.stringify(meta)}`);
6921
+ } catch { /* telemetry is best-effort */ }
6922
+ };
6923
+ if (res.destroyed || res.writableEnded) {
6924
+ _logFail(res.destroyed ? 'res-destroyed' : 'res-writable-ended');
6925
+ return false;
6926
+ }
6927
+ let wire;
6928
+ try { wire = 'data: ' + JSON.stringify(payload) + '\n\n'; }
6929
+ catch (err) {
6930
+ _logFail('json-serialize-failed', { error: String((err && err.message) || err).slice(0, 200) });
6799
6931
  return false;
6800
6932
  }
6933
+ let writeOk;
6934
+ try { writeOk = res.write(wire); }
6935
+ catch (err) {
6936
+ _logFail('res-write-threw', { error: String((err && err.message) || err).slice(0, 200), bytes: wire.length });
6937
+ return false;
6938
+ }
6939
+ if (writeOk === false) {
6940
+ // Backpressure — Node's writable buffer is over its highWaterMark.
6941
+ // The write IS still queued, so don't treat this as a failure, but
6942
+ // surface it so a slow consumer is visible in telemetry. Most CC
6943
+ // chunks are small enough that we never hit this in practice.
6944
+ try {
6945
+ shared.log('warn', `[cc-sse-backpressure] tab=${tabId || _ccTelemetry.tabId || 'unknown'} type=${type} bytes=${wire.length}`);
6946
+ } catch { /* telemetry is best-effort */ }
6947
+ }
6948
+ if (payload && payload.type === 'chunk') {
6949
+ _ccTelemetry.chunks++;
6950
+ _ccTelemetry.bytes += Buffer.byteLength(String(payload.text || ''), 'utf8');
6951
+ } else if (payload && payload.type === 'tool') {
6952
+ _ccTelemetry.tools++;
6953
+ }
6954
+ return true;
6801
6955
  };
6802
6956
  const stopCcHeartbeat = () => {
6803
6957
  if (_ccHeartbeatTimer) {
@@ -6970,7 +7124,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6970
7124
  const resumeNeedsCarryover = wasResume && _ccRuntimeNeedsResumeCarryover(currentRuntime);
6971
7125
  const resumeNeedsBookkeepingGuard = wasResume && _ccRuntimeNeedsResumeBookkeepingGuard(currentRuntime);
6972
7126
  const resumeHasOutOfBandCarryover = wasResume && _transcriptHasCarryoverContext(body.transcript, { outOfBandOnly: true, currentMessage: body.message });
6973
- const preamble = wasResume ? '' : buildCCStatePreamble();
7127
+ const preamble = wasResume ? buildCCStateRefresh() : buildCCStatePreamble();
6974
7128
  const includeFullCarryover = sessionReset || resumeNeedsCarryover;
6975
7129
  const resumeGuard = resumeNeedsBookkeepingGuard ? CC_RESUME_BOOKKEEPING_GUARD : '';
6976
7130
  const carryover = (includeFullCarryover || resumeHasOutOfBandCarryover)
@@ -9138,6 +9292,8 @@ module.exports = {
9138
9292
  _resolveScheduleProjectValue: resolveScheduleProjectValue,
9139
9293
  _collectArchivedWorkItems: collectArchivedWorkItems,
9140
9294
  buildCCStatePreamble,
9295
+ buildCCStateRefresh,
9296
+ _resetRefreshCache,
9141
9297
  _routesAsMeta,
9142
9298
  _server: server,
9143
9299
  _buildTranscriptCarryover,
@@ -68,7 +68,7 @@ Do **not** invent, regenerate, or share the nonce across dispatches — each spa
68
68
  | `summary` | string | Short prose describing what changed and how it was validated. Truncated to 500 chars in dashboard surfaces (`engine/queries.js`). Do not summarize validation as "tests passed" — name the commands that ran. |
69
69
  | `verdict` | string \| null | Required for review tasks: `approved` or `changes-requested`. `null` for non-review tasks. Aliases: `approve`, `request_changes`, `changes_requested`. |
70
70
  | `pr` | string | PR URL, `PR-<number>`, or `N/A`. The engine uses this to attach the PR to the work item; missing-PR detection treats anything other than a recognizable URL/PR id as missing unless `noop: true` is set. |
71
- | `failure_class` | string | One of the `failure_class` enum values below, or `N/A`. Drives retry policy in `engine/dispatch.js` and recovery routing in `engine/recovery.js`. |
71
+ | `failure_class` | string | One of the `failure_class` enum values below, or `N/A`. Drives retry policy in `engine/dispatch.js`. |
72
72
  | `retryable` | boolean | `true` if the engine should auto-retry the dispatch on failure. Overrides the default per-class retry policy when present. |
73
73
  | `needs_rerun` | boolean | `true` if the same work needs to be re-dispatched (vs. retried). Used by build-fix and review-fix loops. |
74
74
  | `artifacts` | array | Durable artifacts the agent created or updated; surfaces in the dashboard work-item detail modal. See [Artifacts](#artifacts). |
@@ -235,6 +235,5 @@ If the JSON report exists and is well-formed, the engine ignores the fenced bloc
235
235
  - `engine/shared.js` — `FAILURE_CLASS`, `COMPLETION_FIELDS`, `dispatchCompletionReportPath()`
236
236
  - `engine/lifecycle.js` — `parseCompletionReportFile()`, `parseCompletionNoop()`, `enforcePrAttachmentContract()`
237
237
  - `engine/dispatch.js` — `isRetryableFailureReason()`, `writeFailedAgentReport()`
238
- - `engine/recovery.js` — per-`failure_class` recovery recipes
239
238
  - `docs/rfc-completion-json.md` — original RFC describing the protocol's design
240
239
  - `playbooks/shared-rules.md` — the per-task "Completion Reports" instruction every playbook inherits
@@ -1,18 +1,4 @@
1
1
  [
2
- {
3
- "id": "managed-spawn-env-allowlist",
4
- "removedAt": "2026-05-18",
5
- "reason": "ENGINE_DEFAULTS.managedSpawn.envKeyAllowlist + envKeyAllowlistPrefixes removed; replaced by envKeyDenyPatterns + envKeyDenyOverrides. The allowlist shape required an engine PR for every new framework/project env prefix (W-mpbpa09c000rd513 tried per-project allowlist extension; user steered away — 'make sure that we are not hardcoding any env variables or being so rigid about it'). The denylist shape matches the actual credential-leakage threat model and lets plain project vars like CONSTELLATION_SERVER, DATABASE_URL, REDIS_HOST work with zero engine config while still blocking credential-shaped keys (AWS_*, *_TOKEN, *_SECRET, etc.). Per-project tightening is supported via project.managedSpawnExtraDenyPatterns (additive only, no per-project override list).",
6
- "removedLocations": [
7
- "engine/shared.js ENGINE_DEFAULTS.managedSpawn.envKeyAllowlist (15 keys)",
8
- "engine/shared.js ENGINE_DEFAULTS.managedSpawn.envKeyAllowlistPrefixes (8 prefixes)",
9
- "engine/managed-spawn.js _envKeyAllowed (rewritten to deny+override+shape model)",
10
- "engine/managed-spawn.js buildManagedSpawnHint (env-key guidance rewritten)",
11
- "PR #2624 (closed, superseded — added per-project allowlist union; replaced here by per-project deny tightening)",
12
- "test/unit/managed-spawn-validator.test.js 4e/4f/11a (rewritten for denylist semantics)"
13
- ],
14
- "notes": "Already removed in this PR; entry exists to track the breaking shape change in the deprecation log. Delete entry after 3 days per /cleanup-deprecated."
15
- },
16
2
  {
17
3
  "id": "config-poll-key-migration",
18
4
  "location": "engine/queries.js:123-162",
@@ -23,35 +9,20 @@
23
9
  },
24
10
  {
25
11
  "id": "legacy-done-aliases",
26
- "location": "engine/cleanup.js:799-894",
12
+ "location": "engine/cleanup.js:970-972",
27
13
  "constants": ["LEGACY_DONE_ALIASES", "LEGACY_NEEDS_REVIEW_STATUS"],
28
14
  "reason": "Read-side tolerance: cleanup sweep auto-migrates four obsolete work-item / PRD status strings ('in-pr', 'implemented', 'complete', 'needs-human-review') to the canonical 'done' / 'failed' values. The aliases are no longer written anywhere in the engine; the constants exist only to repair stale on-disk values from old engine versions.",
29
15
  "targetRemovalDate": null,
30
- "notes": "Keep indefinitely until telemetry / a sweep log shows zero migrations performed for 30 consecutive days across all known projects (work-items.json + prd/*.json). At that point the constants and both _migrateLegacyItem branches in engine/cleanup.js (definitions at :799-800; usage at :803-815 for work items and :880-887 for PRD missing_features) can be deleted. Total cost on disk today: 4 strings."
16
+ "notes": "Keep indefinitely until telemetry / a sweep log shows zero migrations performed for 30 consecutive days across all known projects (work-items.json + prd/*.json). At that point the constants and both _migrateLegacyItem branches in engine/cleanup.js (definitions at :970-972; usage at :973-1000 for work items and :1051-1057 for PRD missing_features) can be deleted. Total cost on disk today: 4 strings."
31
17
  },
32
18
  {
33
- "id": "native-teams-integration",
34
- "removedAt": "2026-05-14",
35
- "reason": "Native Microsoft Teams Bot Framework integration removed end-to-end. The Teams MCP server (teams-* tools, configured in the CC client outside this repo) supersedes the in-repo Bot Framework path. Removal closes the dual-implementation gap and drops the botbuilder dependency.",
36
- "removedLocations": [
37
- "engine/teams.js",
38
- "engine/teams-cards.js",
39
- "engine/teams-state.json (runtime state)",
40
- "engine/teams-inbox.json (runtime state, generated by the deleted /api/bot handler)",
41
- "dashboard.js: POST /api/bot route + handleTeamsBot, TEAMS_INBOX_PATH constant, CC mirror hooks (teamsPostCCResponse), plan-approval/rejection Teams notifications, settings GET/POST teams block",
42
- "dashboard/js/settings.js: Teams Integration settings UI + teamsPayload submit",
43
- "engine/lifecycle.js: teamsNotifyCompletion, teamsNotifyPlanEvent (verify-created + plan-completed), teamsNotifyPrEvent (post-merge)",
44
- "engine/github.js + engine/ado.js: teamsNotifyPrEvent on pr-approved and build-failed",
45
- "engine/preflight.js: Teams integration doctor check",
46
- "engine/cli.js: teamsInboxTimer + clearInterval on shutdown",
47
- "engine/shared.js: ENGINE_DEFAULTS.teams block",
48
- "package.json: botbuilder dependency (4.23.3)",
49
- "docs/teams-setup.md, docs/teams-production.md",
50
- "test/unit/auto-recovery.test.js: ~58 Teams test cases",
51
- "test/unit/preflight-behavioral.test.js: 4 doctor Teams checks + teams field in the docs-link coverage scenario",
52
- "README.md / CLAUDE.md / docs/README.md / TODO.md / docs/rfc-completion-json.md: prose references"
53
- ],
54
- "notes": "The Teams MCP (teams-* tools) lives outside this repo in the CC client config and is NOT affected. If Teams-style notifications are needed again, route them through the MCP layer or an external webhook watch action — do not re-introduce the Bot Framework SDK in-process."
19
+ "id": "completion-fallback-parsers",
20
+ "description": "parseStructuredCompletion and parseCompletionFieldSummary in engine/lifecycle.js",
21
+ "file": "engine/lifecycle.js",
22
+ "lines": "2747, 2848",
23
+ "telemetryGate": "_engine.completionFallbacks must read 0 across sweep window",
24
+ "enforcingTest": "test/unit/completion-fallback-telemetry.test.js:217-234",
25
+ "notes": "Do NOT set removedAt until telemetry confirms zero usage"
55
26
  }
56
27
  ]
57
28
 
@@ -184,7 +184,7 @@ The agent must not write the file in pieces. Empty, truncated, or malformed JSON
184
184
  | Value | When | Engine action |
185
185
  |-------|------|---------------|
186
186
  | `done` | Work complete; PR pushed (if applicable) | Mark WI `done`, sync PRD |
187
- | `partial` | Some progress; agent ran out of turns or hit a known stop point | Auto-retry per `RECOVERY_RECIPES` (`engine/recovery.js`) |
187
+ | `partial` | Some progress; agent ran out of turns or hit a known stop point | Auto-retry per runtime/agent retryability |
188
188
  | `failed` | Hard failure; no recovery attempted by agent | Use `failure.class` to pick recipe |
189
189
  | `noop` | Idempotent bail (review already posted, plan already shipped, etc.) | Mark WI `done` without retry, no failure metric |
190
190
  | `needs-review` | Agent could not classify; flag for human | Set WI `failed` with an explicit `failReason` |
@@ -257,7 +257,7 @@ When `completion.json` is absent or invalid: full fallback to stdout regex on ev
257
257
  |-------|--------|----------|----------------|
258
258
  | **0. Preparation** (no flag) | Day 0 | Engine writes `MINIONS_COMPLETION_PATH` env var. Engine reads completion.json *opportunistically* (uses it when present, falls back to regex when absent). Playbooks updated to write the file. `parseStructuredCompletion`'s ` ```completion ` block continues to be parsed and merged with `completion.json` during this phase only — agents who upgrade slowly still work. | — |
259
259
  | **1. Dual-mode** | Day 0 → Day 7 | Same as Phase 0, plus new metric `_engine.completionFile.{parsed,fallback,invalid}` per agent in `metrics.json`. Daily KB sweep posts a digest of fallback rates. | ≥95% of dispatches in the last 24h produce a parseable completion.json |
260
- | **2. Strict** (gated by `engine.requireCompletionFile = false` → `true`) | Day 7 → Day 10 | When the flag is `true`, missing/invalid completion.json marks the dispatch `failed` with `failure.class = 'config-error'` (no retry, see `RECOVERY_RECIPES`). Default still `false`. | All permanent agents observed clean for 3 consecutive days |
260
+ | **2. Strict** (gated by `engine.requireCompletionFile = false` → `true`) | Day 7 → Day 10 | When the flag is `true`, missing/invalid completion.json marks the dispatch `failed` with `failure.class = 'config-error'` (no retry). Default still `false`. | All permanent agents observed clean for 3 consecutive days |
261
261
  | **3. Default flip** | Day 10 | `engine.requireCompletionFile` default becomes `true`. Stdout regex parsers (`syncPrsFromOutput`, `parseReviewVerdict`, etc.) become deprecated shims, registered in `docs/deprecated.json` with a `cleanup` date 3 days out (per the existing `/cleanup-deprecated` skill convention). | — |
262
262
  | **4. Removal** | Day 13 | Stdout regex parsers deleted; ` ```completion ` block support removed. Only `completion.json` is read. | — |
263
263
 
@@ -96,6 +96,19 @@ const _internals = {
96
96
  const _tabs = new Map();
97
97
  let _reaperTimer = null;
98
98
 
99
+ // CC_POOL_TRACE-gated structured trace logger. Off by default; enable via
100
+ // `CC_POOL_TRACE=1 minions restart` to dump every getSession lifecycle
101
+ // transition, stream sessionId capture, and session/update notification
102
+ // match/mismatch to stderr. Added for W-mpdavudb000v8446 follow-up so the
103
+ // next investigation cycle can correlate engine state with the user-perceived
104
+ // first-message hang. NO PII — only tabId (caller-supplied), sessionIds
105
+ // (opaque ACP ids), and protocol flags. Safe to leave on in dev/staging.
106
+ function _trace(...parts) {
107
+ if (!process.env.CC_POOL_TRACE) return;
108
+ try { process.stderr.write('[cc-pool] ' + parts.join(' ') + '\n'); }
109
+ catch { /* swallow telemetry errors */ }
110
+ }
111
+
99
112
  function _hashMcpServers(mcpServers) {
100
113
  // Stable hash via JSON.stringify; mcpServers is an array of plain objects
101
114
  // in practice (name/command/env) so the natural key order is fine.
@@ -141,6 +154,19 @@ class Worker {
141
154
  // settles. Racing getSession() callers await this to avoid the
142
155
  // "warm-reuse path returns sessionId=null while init is still pending"
143
156
  // hang on first message of a freshly-warmed tab (W-mpd45blx00072f04).
157
+ //
158
+ // Follow-up investigation (W-mpdavudb000v8446) verified the post-ab141995
159
+ // engine path holds the necessary invariants (see
160
+ // test/unit/cc-worker-pool-fresh-tab-race.test.js):
161
+ // * after `await worker.initPromise`, worker.sessionId is the real id
162
+ // * Worker.stream sets inflight.sessionId to that same real id
163
+ // * session/prompt is written with sessionId === inflight.sessionId
164
+ // When the symptom recurs (intermittent first-message hang despite the
165
+ // fix), it's almost certainly downstream of the pool — SSE delivery,
166
+ // browser-side render, or telemetry overstating delivery. Set
167
+ // `CC_POOL_TRACE=1` to dump every state transition + sessionId snapshot
168
+ // through the pool to stderr so the next investigation can correlate
169
+ // engine state with the user-perceived hang.
144
170
  this.initPromise = null;
145
171
  }
146
172
 
@@ -253,8 +279,24 @@ class Worker {
253
279
  return;
254
280
  }
255
281
  // Notification (no id) — only `session/update` matters for streaming.
256
- if (obj.method === 'session/update' && obj.params && this.inflight) {
257
- if (obj.params.sessionId !== this.inflight.sessionId) return;
282
+ if (obj.method === 'session/update' && obj.params) {
283
+ // Trace EVERY session/update notification, including drops — this is
284
+ // exactly where the W-mpd45blx00072f04 hang manifested (chunks dropped
285
+ // because inflight.sessionId was null). Logging both the notification
286
+ // sid and the inflight sid lets the next investigation cycle prove
287
+ // whether the engine still drops chunks. (W-mpdavudb000v8446)
288
+ const notifSid = obj.params.sessionId;
289
+ const inflightSid = this.inflight ? this.inflight.sessionId : null;
290
+ const updKind = obj.params.update && obj.params.update.sessionUpdate;
291
+ if (!this.inflight) {
292
+ _trace(`tab=${this.tabId} session/update dropped: no inflight (notifSid=${notifSid} kind=${updKind})`);
293
+ return;
294
+ }
295
+ if (notifSid !== inflightSid) {
296
+ _trace(`tab=${this.tabId} session/update dropped: sid mismatch (notifSid=${notifSid} inflightSid=${inflightSid} kind=${updKind})`);
297
+ return;
298
+ }
299
+ _trace(`tab=${this.tabId} session/update delivered: sid=${notifSid} kind=${updKind}`);
258
300
  const update = obj.params.update;
259
301
  if (!update) return;
260
302
  if (update.sessionUpdate === 'agent_message_chunk') {
@@ -338,6 +380,14 @@ class Worker {
338
380
  settled: false,
339
381
  };
340
382
  this.inflight = inflight;
383
+ // W-mpdavudb000v8446 — trace the sessionId captured by inflight at the
384
+ // exact moment Worker.stream commits to a write. inflight.sessionId is
385
+ // the value session/update notifications must match against in
386
+ // _handleMessage; if it's ever null, every chunk for this turn is silently
387
+ // dropped (the ab141995 hang signature). Pair with the [cc-pool] dispatch
388
+ // log on the dashboard side to correlate engine state with user-perceived
389
+ // delivery.
390
+ _trace(`tab=${this.tabId} stream begin: worker.sessionId=${this.sessionId} inflight.sessionId=${inflight.sessionId} reqId=${id}`);
341
391
 
342
392
  if (signal && typeof signal.addEventListener === 'function') {
343
393
  inflight.signalHandler = () => this.cancel();
@@ -504,6 +554,7 @@ async function getSession({ tabId, model, effort, mcpServers, systemPromptHash,
504
554
  // 'new-session' — proc reused, fresh session/new (sysprompt hash changed)
505
555
  // 'cold-spawn' — fresh proc + initialize + session/new
506
556
  let lifecycle = 'warm-reuse';
557
+ _trace(`tab=${tabId} getSession entry: worker.exists=${!!worker} worker.sessionId=${worker?.sessionId ?? 'null'} worker.initPromise=${worker?.initPromise ? 'pending' : 'null'}`);
507
558
 
508
559
  if (worker) {
509
560
  // W-mpd45blx00072f04: if the existing worker is still mid-init (warm
@@ -516,6 +567,7 @@ async function getSession({ tabId, model, effort, mcpServers, systemPromptHash,
516
567
  // freshly-warmed CC tab hangs (no chunks streamed, eventual onDone
517
568
  // with empty text).
518
569
  if (worker.initPromise) {
570
+ _trace(`tab=${tabId} getSession await-init: joining in-flight initPromise`);
519
571
  try {
520
572
  await worker.initPromise;
521
573
  } catch (err) {
@@ -523,10 +575,12 @@ async function getSession({ tabId, model, effort, mcpServers, systemPromptHash,
523
575
  // (or is about to) delete _tabs[tabId] and close the worker in its
524
576
  // own catch handler. Surface the same error to this caller so the
525
577
  // dashboard's spawn-failed path runs instead of hanging.
578
+ _trace(`tab=${tabId} getSession await-init failed: ${err.message}`);
526
579
  throw err;
527
580
  }
528
581
  // Re-read in case the failing initPromise's cleanup already ran.
529
582
  worker = _tabs.get(tabId) || null;
583
+ _trace(`tab=${tabId} getSession await-init done: worker.exists=${!!worker} worker.sessionId=${worker?.sessionId ?? 'null'}`);
530
584
  }
531
585
  }
532
586
 
@@ -592,6 +646,22 @@ async function getSession({ tabId, model, effort, mcpServers, systemPromptHash,
592
646
 
593
647
  _ensureReaper();
594
648
 
649
+ // W-mpdavudb000v8446 — trace the handle being returned. If lifecycle is
650
+ // 'warm-reuse' but sessionId is null/empty, the engine has hit a state the
651
+ // ab141995 fix was supposed to prevent — surface it loudly. The empty-id
652
+ // case is also caught defensively below so callers can react instead of
653
+ // wedging on a null-sid session/prompt frame.
654
+ _trace(`tab=${tabId} getSession return: lifecycle=${lifecycle} sessionId=${worker.sessionId ?? 'null'}`);
655
+ if (!worker.sessionId) {
656
+ // This is the bug class the ab141995 fix closed; if it ever recurs the
657
+ // engine should fail loudly rather than hand back a half-initialized
658
+ // handle. Throwing here lets the dashboard surface spawn-failed instead
659
+ // of the silent thinking-dots-forever symptom.
660
+ throw new Error(
661
+ `cc-worker-pool: getSession returning handle with null sessionId (tab=${tabId} lifecycle=${lifecycle}) — engine race regression, see W-mpd45blx00072f04 / W-mpdavudb000v8446`
662
+ );
663
+ }
664
+
595
665
  return {
596
666
  sessionId: worker.sessionId,
597
667
  lifecycle,
@@ -1684,7 +1684,8 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
1684
1684
  const prevReviewStatus = reviewPr?.reviewStatus || '';
1685
1685
  const wasNegative = prevReviewStatus === 'changes-requested' || prevReviewStatus === 'waiting'
1686
1686
  || liveStatus === 'changes-requested' || liveStatus === 'waiting';
1687
- if (verdictRaw === 'approved' && !isSelfReview && wasNegative && projectObjForChecks) {
1687
+ const autoApplyVote = config?.engine?.autoApplyReviewVote ?? ENGINE_DEFAULTS.autoApplyReviewVote;
1688
+ if (autoApplyVote && verdictRaw === 'approved' && !isSelfReview && wasNegative && projectObjForChecks) {
1688
1689
  try {
1689
1690
  const reconcileFn = hostForChecks === 'github'
1690
1691
  ? require('./github').dismissPriorViewerChangesRequestedReviews
@@ -1707,7 +1708,7 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
1707
1708
  }
1708
1709
  }
1709
1710
 
1710
- if (liveStatus && liveStatus !== 'pending') postReviewStatus = liveStatus;
1711
+ if (autoApplyVote && liveStatus && liveStatus !== 'pending') postReviewStatus = liveStatus;
1711
1712
 
1712
1713
  // Fallback: if live check returned pending (e.g., GitHub self-approval blocked), use the agent's completion report.
1713
1714
  if (!postReviewStatus) {
package/engine/shared.js CHANGED
@@ -1676,6 +1676,7 @@ const ENGINE_DEFAULTS = {
1676
1676
  autoReviewPrs: true, // auto-dispatch review agents for newly opened agent PRs
1677
1677
  autoReReviewPrs: true, // auto-dispatch review agents after a PR fix is pushed
1678
1678
  autoFixReviewFeedback: true, // auto-dispatch fix agents for minions review changes-requested verdicts
1679
+ autoApplyReviewVote: false, // when true, review verdicts (APPROVE / REQUEST_CHANGES) automatically flip the platform vote; when false (default), verdicts are informational only
1679
1680
  autoFixHumanComments: true, // auto-dispatch fix agents for actionable human PR comments
1680
1681
  prNoOpFixPauseAttempts: 2, // pause one PR automation cause after repeated no-op fixes for unchanged evidence
1681
1682
  completionReportRetentionDays: 90, // retain completion report sidecars beyond capped dispatch history
@@ -1735,6 +1736,7 @@ const ENGINE_DEFAULTS = {
1735
1736
  ccEffort: null, // effort level for CC/doc-chat (null, 'low', 'medium', 'high')
1736
1737
  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.
1737
1738
  completionNonceRequired: false, // P-d2a8f6c1 (agent trust boundary F8): when true, a missing `nonce` field in the completion JSON hard-fails the dispatch with failure_class:'completion-nonce-mismatch'. Default false for one release so older agents/runtime caches that haven't picked up the prompt change degrade with a warning instead of breaking. Mismatched nonces hard-fail regardless of this flag. See docs/completion-reports.md → "Trust boundary".
1739
+ autoApplyReviewVote: false, // W-mpea9fyb0010febf: when true, review verdict flips the platform vote (ADO resetReviewerNegativeVote / GitHub dismissPriorViewerChangesRequestedReviews). When false (default), the verdict is recorded in pull-requests.json reviewStatus only — informational, no platform side-effect.
1738
1740
 
1739
1741
  // ── Runtime fleet (P-3b8e5f1d) ──────────────────────────────────────────────
1740
1742
  // Single source of truth for which CLI runtime + model every spawn uses.
@@ -3213,7 +3215,15 @@ function buildWorktreeDirName({
3213
3215
  const suffix = _worktreeNameSuffix(dispatchId, projectName, branchName);
3214
3216
  if (platform === 'win32') return `W-${suffix}`;
3215
3217
  const projectSlug = String(projectName || 'default').replace(/[^a-zA-Z0-9_-]/g, '-');
3216
- return `${projectSlug}-${sanitizeBranch(branchName || 'worktree')}-${suffix}`;
3218
+ // `sanitizeBranch` preserves `/` (legit in git ref names) but on POSIX that
3219
+ // turns the FS dir name into a nested path. Flatten by replacing `/` → `-`
3220
+ // so the dir name is a single basename. Without this, `path.join(parent,
3221
+ // dirName)` creates `parent/work/W-…/` on Linux for a `work/W-…` branch,
3222
+ // and `readdirSync(parent)` returns `work` (not the full name) — breaking
3223
+ // engine/worktree-gc.js's globalLiveDirNames lookup and evicting live
3224
+ // worktrees on boot.
3225
+ const branchSlug = sanitizeBranch(branchName || 'worktree').replace(/\//g, '-');
3226
+ return `${projectSlug}-${branchSlug}-${suffix}`;
3217
3227
  }
3218
3228
 
3219
3229
  /**
@@ -3677,6 +3687,31 @@ function isPrCompatibleWithProject(project, prRef, url = '') {
3677
3687
  return !getPrProjectScopeMismatch(project, prRef, url);
3678
3688
  }
3679
3689
 
3690
+ /**
3691
+ * Check if a parsed ADO PR scope is compatible with a project config,
3692
+ * considering that the repo segment in the URL might be either the friendly
3693
+ * repoName or the repositoryId (GUID). Case-insensitive comparison.
3694
+ */
3695
+ function isAdoPrScopeCompatible(parsedScope, project) {
3696
+ if (!parsedScope || !project) return false;
3697
+ const colonIdx = String(parsedScope).indexOf(':');
3698
+ if (colonIdx < 0) return false;
3699
+ const host = String(parsedScope).slice(0, colonIdx);
3700
+ if (host !== 'ado') return false;
3701
+ const rest = String(parsedScope).slice(colonIdx + 1);
3702
+ const parts = rest.split('/');
3703
+ if (parts.length !== 3) return false;
3704
+ const [scopeOrg, scopeProject, scopeRepo] = parts;
3705
+ const projOrg = normalizePrScopeSegment(project.adoOrg);
3706
+ const projAdoProject = normalizePrScopeSegment(project.adoProject);
3707
+ if (!projOrg || !projAdoProject) return false;
3708
+ if (scopeOrg !== projOrg || scopeProject !== projAdoProject) return false;
3709
+ const projRepoName = normalizePrScopeSegment(project.repoName);
3710
+ const projRepositoryId = normalizePrScopeSegment(project.repositoryId);
3711
+ if (!projRepoName && !projRepositoryId) return false;
3712
+ return scopeRepo === projRepoName || scopeRepo === projRepositoryId;
3713
+ }
3714
+
3680
3715
  /**
3681
3716
  * Build a canonical, repository-scoped PR identifier.
3682
3717
  *
@@ -4752,6 +4787,7 @@ module.exports = {
4752
4787
  getPrScopeInfo,
4753
4788
  getPrProjectScopeMismatch,
4754
4789
  isPrCompatibleWithProject,
4790
+ isAdoPrScopeCompatible,
4755
4791
  getCanonicalPrId,
4756
4792
  findPrRecord,
4757
4793
  snapshotPrRecord,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1987",
3
+ "version": "0.1.1989",
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"
@@ -1,130 +0,0 @@
1
- /**
2
- * engine/recovery.js — Recovery recipes for classified agent failures.
3
- * Maps FAILURE_CLASS values to per-class retry limits and escalation policies.
4
- * Zero external dependencies — uses only Node.js built-ins and imports from shared.js.
5
- */
6
-
7
- const { FAILURE_CLASS, ESCALATION_POLICY, ENGINE_DEFAULTS } = require('./shared');
8
-
9
- // ─── Recovery Recipes ───────────────────────────────────────────────────────
10
-
11
- /**
12
- * Each recipe defines:
13
- * maxAttempts — max retries for this failure class (0 = never retry)
14
- * escalation — ESCALATION_POLICY value
15
- * freshSession — whether to clear session.json before retry
16
- * description — human-readable explanation for logs/dashboard
17
- */
18
- const RECOVERY_RECIPES = new Map([
19
- [FAILURE_CLASS.CONFIG_ERROR, {
20
- maxAttempts: 0,
21
- escalation: ESCALATION_POLICY.NO_RETRY,
22
- freshSession: false,
23
- description: 'Configuration error — fix config before retrying',
24
- }],
25
- [FAILURE_CLASS.PERMISSION_BLOCKED, {
26
- maxAttempts: 0,
27
- escalation: ESCALATION_POLICY.NO_RETRY,
28
- freshSession: false,
29
- description: 'Permission/trust gate blocked — requires human intervention',
30
- }],
31
- [FAILURE_CLASS.AUTH, {
32
- maxAttempts: 0,
33
- escalation: ESCALATION_POLICY.NO_RETRY,
34
- freshSession: false,
35
- description: 'Git/network authentication failed (missing az login, expired token, GCM prompt) — requires human credential fix before retry',
36
- }],
37
- [FAILURE_CLASS.MERGE_CONFLICT, {
38
- maxAttempts: 2,
39
- escalation: ESCALATION_POLICY.RETRY_SAME,
40
- freshSession: false,
41
- description: 'Merge conflict — retry may succeed after dependency updates',
42
- }],
43
- [FAILURE_CLASS.BUILD_FAILURE, {
44
- maxAttempts: 2,
45
- escalation: ESCALATION_POLICY.RETRY_SAME,
46
- freshSession: false,
47
- description: 'Build/test failure — retry with same context for iterative fix',
48
- }],
49
- [FAILURE_CLASS.TIMEOUT, {
50
- maxAttempts: 1,
51
- escalation: ESCALATION_POLICY.RETRY_FRESH,
52
- freshSession: true,
53
- description: 'Timeout — retry with fresh session to avoid stuck state',
54
- }],
55
- [FAILURE_CLASS.EMPTY_OUTPUT, {
56
- maxAttempts: 1,
57
- escalation: ESCALATION_POLICY.HUMAN_REVIEW,
58
- freshSession: true,
59
- description: 'Empty output — agent produced nothing useful, flag for review',
60
- }],
61
- [FAILURE_CLASS.SPAWN_ERROR, {
62
- maxAttempts: 2,
63
- escalation: ESCALATION_POLICY.RETRY_FRESH,
64
- freshSession: true,
65
- description: 'Spawn error — retry with fresh session after transient failure',
66
- }],
67
- [FAILURE_CLASS.NETWORK_ERROR, {
68
- maxAttempts: 3,
69
- escalation: ESCALATION_POLICY.AUTO,
70
- freshSession: false,
71
- description: 'Network/API error — retry with exponential backoff',
72
- }],
73
- [FAILURE_CLASS.MAX_TURNS, {
74
- maxAttempts: 3,
75
- escalation: ESCALATION_POLICY.RETRY_SAME,
76
- freshSession: false,
77
- description: 'Max turns reached — work in progress, retry same agent to continue',
78
- }],
79
- [FAILURE_CLASS.OUT_OF_CONTEXT, {
80
- maxAttempts: 1,
81
- escalation: ESCALATION_POLICY.HUMAN_REVIEW,
82
- freshSession: true,
83
- description: 'Context exhausted — retry with fresh session, flag if repeated',
84
- }],
85
- [FAILURE_CLASS.WORKTREE_PREFLIGHT, {
86
- maxAttempts: 0,
87
- escalation: ESCALATION_POLICY.NO_RETRY,
88
- freshSession: false,
89
- description: 'Worktree preflight rejected — same inputs will recompute to the same rejection (drive-root rootDir, nested-in-project worktree). Fix the dispatch (attach a project, move MINIONS_DIR, or override engine.worktreeRoot) before retrying.',
90
- }],
91
- [FAILURE_CLASS.UNKNOWN, {
92
- maxAttempts: null, // null = fall back to ENGINE_DEFAULTS.maxRetries
93
- escalation: ESCALATION_POLICY.AUTO,
94
- freshSession: false,
95
- description: 'Unclassified failure — use default retry behavior',
96
- }],
97
- ]);
98
-
99
- // ─── Public API ─────────────────────────────────────────────────────────────
100
-
101
- /**
102
- * Get the recovery recipe for a failure class.
103
- * @param {string} failureClass — one of FAILURE_CLASS values
104
- * @returns {object} recipe with maxAttempts, escalation, freshSession, description
105
- */
106
- function getRecoveryRecipe(failureClass) {
107
- return RECOVERY_RECIPES.get(failureClass) || RECOVERY_RECIPES.get(FAILURE_CLASS.UNKNOWN);
108
- }
109
-
110
- /**
111
- * Determine whether a failed dispatch should be retried based on its failure class
112
- * and current attempt count.
113
- * @param {string} failureClass — one of FAILURE_CLASS values (or empty for unclassified)
114
- * @param {number} attemptCount — how many times this item has already been retried
115
- * @returns {boolean} true if another retry is allowed
116
- */
117
- function shouldRetry(failureClass, attemptCount = 0) {
118
- const recipe = getRecoveryRecipe(failureClass || FAILURE_CLASS.UNKNOWN);
119
- // null maxAttempts = fall back to global ENGINE_DEFAULTS.maxRetries
120
- const limit = recipe.maxAttempts !== null ? recipe.maxAttempts : ENGINE_DEFAULTS.maxRetries;
121
- return attemptCount < limit;
122
- }
123
-
124
- // ─── Exports ────────────────────────────────────────────────────────────────
125
-
126
- module.exports = {
127
- RECOVERY_RECIPES,
128
- getRecoveryRecipe,
129
- shouldRetry,
130
- };