@yemi33/minions 0.1.2322 → 0.1.2324

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.
@@ -69,8 +69,24 @@ function _renderProjectBranch(p) {
69
69
  ? ' <span class="muted">→ ' + mainBranch + '</span>'
70
70
  : '';
71
71
  const detachedSuffix = p.gitDetached ? ' <span class="muted">(detached)</span>' : '';
72
+ // opg-microsoft/minions#664 — the counts are always vs origin/<comparator>
73
+ // (configured mainBranch, falling back to remoteDefaultBranch; see
74
+ // engine/queries.js _probeProjectGitStatus), never vs the checked-out
75
+ // branch's own upstream. When branchDiffersFromMain the bare "↑N ↓M" chip
76
+ // reads as ambiguous (looks like it could be vs the current branch's
77
+ // tracking ref), so append the comparator name to the VISIBLE text too —
78
+ // not just the hover title — so the disambiguation doesn't require a
79
+ // hover. Skip the suffix when the checked-out branch IS the comparator
80
+ // since there's nothing to disambiguate.
81
+ const comparatorBranch = mainBranch || remoteDefault;
82
+ // Deliberately separate from branchDiffersFromMain (which only fires when a
83
+ // configured mainBranch is set, driving the "→ main" suffix): the ahead/
84
+ // behind comparator falls back to remoteDefaultBranch too, so the chip must
85
+ // disambiguate against whichever one actually produced the counts.
86
+ const branchDiffersFromComparator = !!(comparatorBranch && p.gitBranch !== (p.mainBranch || p.remoteDefaultBranch));
87
+ const comparatorSuffix = branchDiffersFromComparator ? ' vs ' + comparatorBranch : '';
72
88
  const aheadBehind = (ahead !== null && behind !== null && (ahead > 0 || behind > 0))
73
- ? ' <span class="project-ab" title="' + escHtml('Ahead ' + ahead + ' / behind ' + behind + ' vs origin/' + (p.mainBranch || p.remoteDefaultBranch)) + '">↑' + ahead + ' ↓' + behind + '</span>'
89
+ ? ' <span class="project-ab" title="' + escHtml('Ahead ' + ahead + ' / behind ' + behind + ' vs origin/' + (p.mainBranch || p.remoteDefaultBranch)) + '">↑' + ahead + ' ↓' + behind + comparatorSuffix + '</span>'
74
90
  : '';
75
91
  // Warning chip surfaces when the configured main disagrees with the remote
76
92
  // default branch (the #2732 case: config=master, origin/HEAD=main) OR when
@@ -202,8 +202,13 @@ function prRow(pr) {
202
202
  // clearly-labeled indicator — a still-red build after an unchanged fix
203
203
  // attempt usually means the failure isn't fixable by another agent pass
204
204
  // (flaky infra, external outage, etc.), hence "infra issue suspected".
205
- // No resume action is wired here (clearing this record is an engine-side
206
- // change out of scope for this UI-only follow-up) — informational only.
205
+ // Issue #671 clicking resumes it via /api/pull-requests/clear-build-fix-ineffective
206
+ // (resumeBuildFixIneffective below), which clears _buildFixIneffective so
207
+ // BUILD_FAILURE auto-fix dispatch can resume. A new push already resets
208
+ // this pause automatically (see shared.isBuildFixIneffectivePaused); the
209
+ // click handler exists for the case where an operator has already
210
+ // confirmed (e.g. a fresh CI run queued against a rebased/retried head)
211
+ // and doesn't want to wait for the next poll to notice.
207
212
  var buildFixIneffective = pr._buildFixIneffective;
208
213
  if (buildFixIneffective && typeof buildFixIneffective === 'object' && buildFixIneffective.paused === true) {
209
214
  var bfiTitle = 'Build-fix reported no branch change, and a live CI re-poll confirmed the build is still "'
@@ -211,11 +216,14 @@ function prRow(pr) {
211
216
  + (buildFixIneffective.headSha || 'unknown head') + ') after '
212
217
  + (buildFixIneffective.count || '?') + ' ineffective attempt(s). Reason: '
213
218
  + (buildFixIneffective.reason || 'unknown')
214
- + '. A new push to this branch will reset this pause.';
215
- pausedChips += ' <span class="pr-badge rejected pr-build-fix-ineffective-chip" '
216
- + 'style="font-size:var(--text-xs)" '
217
- + 'title="' + escapeHtml(bfiTitle) + '">'
218
- + ' infra issue suspected ▶</span>';
219
+ + '. A new push to this branch will reset this pause automatically, or click to resume now.';
220
+ pausedChips += ' <button class="pr-badge rejected pr-build-fix-ineffective-chip" '
221
+ + 'style="font-size:var(--text-xs);cursor:pointer;border:none" '
222
+ + 'title="' + escapeHtml(bfiTitle) + '" '
223
+ + 'data-pr-id="' + escapeHtml(String(pr.id || '')) + '" '
224
+ + 'data-pr-head-sha="' + escapeHtml(String(buildFixIneffective.headSha || '')) + '" '
225
+ + 'onclick="event.stopPropagation();resumeBuildFixIneffective(this)">'
226
+ + '⏸ infra issue suspected ▶</button>';
219
227
  }
220
228
  const titleText = pr.title || 'Untitled';
221
229
  // Polish (W-mqv5ccn7): flatten Markdown in the body so the preview shows
@@ -749,4 +757,38 @@ async function resumePausedCause(btn) {
749
757
  }
750
758
  }
751
759
 
752
- window.MinionsPrs = { prRow, prTableHtml, renderPrs, prPrev, prNext, openAllPrs, openModal, openAddPrModal, openPrDetail, unlinkPr, togglePrObserve, resumePausedCause };
760
+ // Issue #671 resume a paused build-fix-ineffective ("infra issue
761
+ // suspected") pause. Mirrors resumePausedCause above but targets the
762
+ // headSha-keyed _buildFixIneffective record via its own endpoint. Sends the
763
+ // headSha the chip was rendered with so the server can reject the clear as
764
+ // stale if the pause has since moved to a different commit.
765
+ async function resumeBuildFixIneffective(btn) {
766
+ if (!btn || btn.disabled) return;
767
+ const prId = btn.dataset.prId;
768
+ const headSha = btn.dataset.prHeadSha || '';
769
+ if (!prId) return;
770
+ if (!confirm('Resume build-fix automation on ' + prId + '?\n\nThis clears the "infra issue suspected" pause so the engine can re-dispatch build fixes on the next discovery pass.')) return;
771
+ btn.disabled = true;
772
+ const prevDisplay = btn.style.display;
773
+ btn.style.display = 'none';
774
+ showToast('pr-toast', 'Resumed build-fix automation on ' + prId, true);
775
+ try {
776
+ const res = await fetch('/api/pull-requests/clear-build-fix-ineffective', {
777
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
778
+ body: JSON.stringify({ prId, headSha })
779
+ });
780
+ if (!res.ok) {
781
+ const d = await res.json().catch(() => ({}));
782
+ btn.style.display = prevDisplay;
783
+ btn.disabled = false;
784
+ showToast('pr-toast', 'Failed: ' + (d.error || 'unknown'), false);
785
+ return;
786
+ }
787
+ } catch (e) {
788
+ btn.style.display = prevDisplay;
789
+ btn.disabled = false;
790
+ showToast('pr-toast', 'Error: ' + e.message, false);
791
+ }
792
+ }
793
+
794
+ window.MinionsPrs = { prRow, prTableHtml, renderPrs, prPrev, prNext, openAllPrs, openModal, openAddPrModal, openPrDetail, unlinkPr, togglePrObserve, resumePausedCause, resumeBuildFixIneffective };
@@ -231,7 +231,12 @@
231
231
  the standard TILE_VIEWS path in modals-tiles.js#renderPlansBody. There is no
232
232
  slim-specific Plans/PRD flyout markup anymore (W-mr28ko750010199f / "reuse,
233
233
  don't fork"): the classic /plans screen is the single source of truth for the
234
- Plans + PRD tabs and every lifecycle action. -->
234
+ Plans + PRD tabs and every lifecycle action.
235
+ The "Active PRs" cockpit tile follows the same iframe-embed pattern
236
+ (W-mr3l7y0m0007102a): it opens the LITERAL classic /prs screen in a chrome-off
237
+ iframe (/prs?embed=1) via modals-tiles.js#renderPrsBody, so the full PR
238
+ filter/sort UI + row actions (comment/track/link/pr-action) are the classic
239
+ screen — no slim-specific PR list to maintain. -->
235
240
 
236
241
  <!-- Pin editor — create (Pin Content action / "+ Pin") or edit an existing
237
242
  note. Submits to POST /api/pinned (create) or /api/pinned/update (edit). -->
@@ -81,17 +81,6 @@
81
81
  return card;
82
82
  }
83
83
 
84
- // Parse the org/project slug from a canonical PR id, dropping the host prefix
85
- // and the trailing "#<num>". E.g. "ado:office/iss/constellation#5315603" →
86
- // "office/iss/constellation", "github:yemi33/minions#2911" → "yemi33/minions".
87
- function parsePrSlug(id) {
88
- if (!id || typeof id !== 'string') return '';
89
- var hash = id.indexOf('#');
90
- var head = hash >= 0 ? id.slice(0, hash) : id;
91
- var colon = head.indexOf(':');
92
- return colon >= 0 ? head.slice(colon + 1) : head;
93
- }
94
-
95
84
  // Per-tile body renderers for the detail modal. Each reads the latest status
96
85
  // snapshot and fills the modal body; openTileModal looks them up by key.
97
86
  function renderEngineTileBody(body, data) {
@@ -160,107 +149,23 @@
160
149
  body.appendChild(frame);
161
150
  }
162
151
 
163
- function renderPrTileBody(body, data) {
164
- // Emoji glyphs make build/review status scannable at a glance; the
165
- // accompanying label keeps the row readable when the glyph alone is
166
- // ambiguous (✅ vs ✅, vs ❌, vs 💬). The raw status string also
167
- // stays in the title attribute for hover discoverability.
168
- // Keyed by normalized (lowercased) status; missing/unknown ➖.
169
- var BUILD_STATUS = {
170
- success: { glyph: '✅', label: 'passing' },
171
- succeeded: { glyph: '✅', label: 'passing' },
172
- passing: { glyph: '✅', label: 'passing' },
173
- failing: { glyph: '❌', label: 'failing' },
174
- failed: { glyph: '❌', label: 'failing' },
175
- error: { glyph: '❌', label: 'failing' },
176
- pending: { glyph: '', label: 'pending' },
177
- running: { glyph: '', label: 'running' },
178
- queued: { glyph: '⏳', label: 'queued' },
179
- in_progress: { glyph: '⏳', label: 'in progress' },
180
- };
181
- var REVIEW_STATUS = {
182
- approved: { glyph: '✅', label: 'approved' },
183
- 'changes-requested': { glyph: '❌', label: 'changes requested' },
184
- changes_requested: { glyph: '❌', label: 'changes requested' },
185
- 'requested-changes': { glyph: '❌', label: 'changes requested' },
186
- rejected: { glyph: '❌', label: 'rejected' },
187
- pending: { glyph: '💬', label: 'pending' },
188
- commented: { glyph: '💬', label: 'commented' },
189
- };
190
- function statusSpan(subsystem, raw, table) {
191
- var info = table[String(raw || '').toLowerCase()];
192
- var glyph = info ? info.glyph : '➖';
193
- var label = info ? info.label : (raw ? String(raw) : 'none');
194
- var span = document.createElement('span');
195
- span.className = 'pr-status';
196
- span.textContent = glyph + ' ' + subsystem + ' ' + label;
197
- span.title = subsystem + ': ' + (raw || 'unknown');
198
- return span;
199
- }
200
- var prs = Array.isArray(data.pullRequests) ? data.pullRequests : [];
201
- if (!prs.length) { tileEmpty(body, 'No tracked pull requests.'); return; }
202
- function prCard(p) {
203
- var num = p.prNumber || p.number || p.id;
204
- var build = p.buildStatus || '';
205
- var review = p.reviewStatus || '';
206
- var cls = p.status === 'merged' ? 'green'
207
- : (build === 'failing' ? 'red'
208
- : ((p.status === 'active' || p.status === 'linked') ? 'blue' : ''));
209
- var statusText = document.createElement('span');
210
- statusText.textContent = p.status || 'unknown';
211
- return tileCard({
212
- title: (num ? '#' + num + ' ' : '') + (p.title || '(untitled PR)'),
213
- // Project slug first (under title), then build/review row below — the
214
- // project is the strongest disambiguator across PRs and benefits from
215
- // being adjacent to the title.
216
- subtle: parsePrSlug(p.id),
217
- subtleBeforeMeta: true,
218
- metaNodes: [statusText, statusSpan('build', build, BUILD_STATUS), statusSpan('review', review, REVIEW_STATUS)],
219
- chip: { text: p.status || '—', cls: cls },
220
- href: p.url || null,
221
- });
222
- }
223
- // Partition once into three fixed buckets (status normalized to lowercase),
224
- // preserving each PR's relative order within its bucket. Mirrors shared
225
- // PR_STATUS (active/merged/abandoned/closed/linked); anything that does not
226
- // match the merged/abandoned buckets defaults to Active so a PR with an
227
- // unexpected status never silently disappears.
228
- var buckets = { active: [], merged: [], abandoned: [] };
229
- prs.forEach(function(p) {
230
- var s = String(p.status || '').toLowerCase();
231
- if (s === 'abandoned') buckets.abandoned.push(p);
232
- else if (s === 'merged' || s === 'completed' || s === 'closed') buckets.merged.push(p);
233
- else buckets.active.push(p);
234
- });
235
- // Fixed section order: Active (open) → Merged / Completed → Abandoned.
236
- // Empty sections are omitted entirely.
237
- var sections = [
238
- { label: 'Active', prs: buckets.active, open: true },
239
- { label: 'Merged / Completed', prs: buckets.merged, open: false },
240
- { label: 'Abandoned', prs: buckets.abandoned, open: false },
241
- ];
242
- sections.forEach(function(section) {
243
- if (!section.prs.length) return;
244
- var details = document.createElement('details');
245
- details.className = 'tile-section';
246
- if (section.open) details.open = true;
247
- var summary = document.createElement('summary');
248
- summary.className = 'tile-section-summary';
249
- var label = document.createElement('span');
250
- label.className = 'tile-section-label';
251
- label.textContent = section.label;
252
- var count = document.createElement('span');
253
- count.className = 'tile-section-count';
254
- count.textContent = '(' + section.prs.length + ')';
255
- summary.appendChild(label);
256
- summary.appendChild(count);
257
- details.appendChild(summary);
258
- var sectionBody = document.createElement('div');
259
- sectionBody.className = 'tile-section-body';
260
- section.prs.forEach(function(p) { sectionBody.appendChild(prCard(p)); });
261
- details.appendChild(sectionBody);
262
- body.appendChild(details);
263
- });
152
+ // Pull-requests tile body reuses the LITERAL classic dashboard /prs screen
153
+ // instead of a slim-specific bucketed PR list, so there is one PRs UI, not two
154
+ // (W-mr3l7y0m0007102a / "reuse, don't fork" mirrors renderQueuedWorkBody for
155
+ // the Work tile and renderPlansBody for the Plans tile). Slim and classic are
156
+ // two separate IIFE bundles with no shared scope, so we embed the real /prs
157
+ // screen in an iframe with the chrome-off ?embed=1 mode. The iframed page IS
158
+ // the classic screen — the full PR list with every filter/sort + row action
159
+ // (comment/track/link/pr-action), backed by the same /api/status pullRequests
160
+ // data + endpoints with zero duplicated rendering logic. Classic is reachable
161
+ // at /prs even with slim-ux ON (only / is taken over). Built with createElement
162
+ // (no innerHTML) to satisfy the dashboard no-unsanitized lint gate.
163
+ function renderPrsBody(body) {
164
+ var frame = document.createElement('iframe');
165
+ frame.className = 'slim-prs-embed';
166
+ frame.src = '/prs?embed=1';
167
+ frame.title = 'Pull Requests';
168
+ body.appendChild(frame);
264
169
  }
265
170
 
266
171
  function renderWatchTileBody(body, data) {
@@ -283,7 +188,7 @@
283
188
  dispatches: { title: 'Active dispatches', render: function(body, data) { renderDispatchTileBody(body, data, true); } },
284
189
  queued: { title: 'Queued work', render: function(body) { renderQueuedWorkBody(body); } },
285
190
  plans: { title: 'Plans', render: function(body) { renderPlansBody(body); } },
286
- prs: { title: 'Pull requests', render: renderPrTileBody },
191
+ prs: { title: 'Pull requests', render: function(body) { renderPrsBody(body); } },
287
192
  watches: { title: 'Watches', render: renderWatchTileBody },
288
193
  };
289
194
 
@@ -304,11 +209,13 @@
304
209
  // The "+ Link PR" header chip only applies to the PR list.
305
210
  var headerChip = document.getElementById('slim-tile-modal-linkpr');
306
211
  if (headerChip) headerChip.style.display = (key === 'prs') ? '' : 'none';
307
- // The queued + plans tiles embed a full classic screen (/work, /plans) in an
308
- // iframe — widen the modal + drop the body padding so the embedded screen
309
- // gets real estate. Both reuse the shared wide-modal treatment.
212
+ // The queued, plans + prs tiles embed a full classic screen (/work, /plans,
213
+ // /prs) in an iframe — widen the modal + drop the body padding so the
214
+ // embedded screen gets real estate. All three reuse the shared wide-modal
215
+ // treatment.
310
216
  modal.classList.toggle('tile-modal--work', key === 'queued');
311
217
  modal.classList.toggle('tile-modal--plans', key === 'plans');
218
+ modal.classList.toggle('tile-modal--prs', key === 'prs');
312
219
  view.render(body, lastStatusData || {});
313
220
  modal.classList.add('open');
314
221
  }
@@ -680,15 +680,19 @@
680
680
  /* Cockpit-tile detail modal: list of dispatches / PRs / watches, or a
681
681
  key/value engine readout (reuses .agent-detail-row). */
682
682
  #slim-tile-modal .modal { width: 720px; max-width: calc(100vw - 32px); }
683
- /* W-mqrdggys000f94a4 / W-mr28ko750010199f the Queued-work + Plans tiles
684
- embed a full classic screen (/work, /plans) in an iframe; widen the modal +
685
- remove body padding so the embedded screen gets the full width/height. */
683
+ /* W-mqrdggys000f94a4 / W-mr28ko750010199f / W-mr3l7y0m0007102a the
684
+ Queued-work + Plans + PRs tiles embed a full classic screen (/work,
685
+ /plans, /prs) in an iframe; widen the modal + remove body padding so the
686
+ embedded screen gets the full width/height. */
686
687
  #slim-tile-modal.tile-modal--work .modal,
687
- #slim-tile-modal.tile-modal--plans .modal { width: 1180px; }
688
+ #slim-tile-modal.tile-modal--plans .modal,
689
+ #slim-tile-modal.tile-modal--prs .modal { width: 1180px; }
688
690
  #slim-tile-modal.tile-modal--work .modal-body,
689
- #slim-tile-modal.tile-modal--plans .modal-body { padding: 0; }
691
+ #slim-tile-modal.tile-modal--plans .modal-body,
692
+ #slim-tile-modal.tile-modal--prs .modal-body { padding: 0; }
690
693
  .slim-work-embed,
691
- .slim-plans-embed {
694
+ .slim-plans-embed,
695
+ .slim-prs-embed {
692
696
  display: block; width: 100%; height: calc(100vh - 160px); min-height: 360px;
693
697
  border: 0; background: var(--bg);
694
698
  }
@@ -711,7 +715,6 @@
711
715
  }
712
716
  .tile-item-meta { font-size: var(--text-base); color: var(--muted); margin-top: 3px; word-break: break-word; }
713
717
  .tile-item-subtle { font-size: var(--text-sm); color: var(--muted); margin-top: 2px; word-break: break-word; }
714
- .pr-status { display: inline-block; margin: 0 1px; text-decoration: none; }
715
718
  .tile-chip {
716
719
  flex: 0 0 auto;
717
720
  font-size: var(--text-base); font-weight: 700; letter-spacing: 0.4px; text-transform: uppercase;
@@ -724,26 +727,6 @@
724
727
  .tile-chip.blue { background: rgba(88, 166, 255, 0.12); color: var(--blue); border-color: var(--blue); }
725
728
  .tile-empty { color: var(--muted); font-style: italic; font-size: var(--text-md); }
726
729
 
727
- /* Collapsible PR sections (Active / Merged-Completed / Abandoned). Uses
728
- native <details>/<summary> — no JS wiring needed for the toggle. */
729
- .tile-section { margin-bottom: 10px; }
730
- .tile-section:last-child { margin-bottom: 0; }
731
- .tile-section-summary {
732
- display: flex; align-items: center; gap: 6px;
733
- cursor: pointer; user-select: none;
734
- padding: 4px 2px; margin-bottom: 6px;
735
- list-style: none;
736
- font-size: var(--text-base); color: var(--muted);
737
- }
738
- .tile-section-summary::-webkit-details-marker { display: none; }
739
- .tile-section-summary::before {
740
- content: "▸"; color: var(--muted); font-size: var(--text-sm);
741
- transition: transform 0.15s ease;
742
- }
743
- .tile-section[open] > .tile-section-summary::before { content: "▾"; }
744
- .tile-section-label { font-weight: 700; letter-spacing: 0.3px; text-transform: uppercase; color: var(--text); }
745
- .tile-section-count { color: var(--muted); }
746
-
747
730
  /* Pinned-context list rows (Knowledge modal → Pinned Context tab). */
748
731
  .pinned-row {
749
732
  border: 1px solid var(--border);
package/dashboard.js CHANGED
@@ -28,10 +28,13 @@ const v8 = require('v8');
28
28
  const llm = require('./engine/llm');
29
29
  const { resolveRuntime } = require('./engine/runtimes');
30
30
 
31
- // Dashboard version stamp — captured at module load so it reflects the code actually running
31
+ // Dashboard version stamp — captured at module load so it reflects the code actually running.
32
+ // codeCommit is read via _readGitHeadShort (defined below — function declarations
33
+ // are hoisted, so the call here resolves fine) instead of spawning a `git`
34
+ // subprocess; see that function's doc comment for the rationale.
32
35
  const _dashboardVersion = {
33
36
  codeVersion: (() => { try { return require('./package.json').version; } catch {} try { return require('@yemi33/minions/package.json').version; } catch {} return null; })(),
34
- codeCommit: (() => { try { return require('child_process').execSync('git rev-parse --short HEAD', { cwd: __dirname, encoding: 'utf8', timeout: 5000, windowsHide: true }).trim(); } catch { return null; } })(),
37
+ codeCommit: _readGitHeadShort(__dirname).commit,
35
38
  startedAt: new Date().toISOString(),
36
39
  pid: process.pid,
37
40
  };
@@ -2252,13 +2255,16 @@ let _diskVersionCacheTs = 0;
2252
2255
  const DISK_VERSION_TTL = 60000; // re-check every 60s
2253
2256
 
2254
2257
  // Read the short HEAD commit by parsing `.git` directly — NO `git` subprocess.
2255
- // The old `execSync('git rev-parse --short HEAD', { timeout: 5000 })` spawned a
2256
- // synchronous child process on the dashboard's single event loop; under
2257
- // concurrent agent git activity (worktree add/merge holding repo locks) that
2258
- // subprocess could take its full 5s timeout, and because getDiskVersion runs
2259
- // inside the /api/status rebuild, every in-flight poll blocked for that whole
2260
- // window. Reading `.git/HEAD` + the resolved ref (loose ref, then packed-refs
2261
- // fallback) is a couple of tiny synchronous file reads with zero process spawn.
2258
+ // The old `execSync('git rev-parse --short HEAD', { timeout: 5000 })` (both here
2259
+ // and in the `_dashboardVersion.codeCommit` init near the top of this file)
2260
+ // spawned a synchronous child process on the dashboard's single event loop;
2261
+ // under concurrent agent git activity (worktree add/merge holding repo locks)
2262
+ // that subprocess could take its full 5s timeout, and because getDiskVersion
2263
+ // runs inside the /api/status rebuild, every in-flight poll blocked for that
2264
+ // whole window. Reading `.git/HEAD` + the resolved ref (loose ref, then
2265
+ // packed-refs fallback) is a couple of tiny synchronous file reads with zero
2266
+ // process spawn. Function declarations are hoisted, so `_dashboardVersion`'s
2267
+ // module-load-time init above can call this even though it's defined here.
2262
2268
  // Returns { commit, isGitRepo }: commit is the 7-char short SHA or null.
2263
2269
  function _readGitHeadShort(repoDir) {
2264
2270
  try {
@@ -3959,6 +3965,7 @@ function _buildSyntheticActionResultsForTurn(turnId, message, requestedAt) {
3959
3965
  if (entry.id) result.id = entry.id;
3960
3966
  if (entry.project) result.project = entry.project;
3961
3967
  if (entry.path) result.path = entry.path;
3968
+ if (entry.duplicate) result.duplicate = true;
3962
3969
  if (Array.isArray(entry.followups) && entry.followups.length) result.followups = entry.followups;
3963
3970
  results.push(result);
3964
3971
  }
@@ -6914,6 +6921,14 @@ const server = http.createServer(async (req, res) => {
6914
6921
  const createResult = createWorkItemWithDedup(wiPath, item);
6915
6922
  if (!createResult.created) {
6916
6923
  const duplicateId = createResult.duplicateOf || createResult.item?.id;
6924
+ // W-mr466ocx000l1191 — record the CC turn creation on the dedup path
6925
+ // too. The API call still succeeded (ok:true) from the caller's
6926
+ // perspective; without this, a CC turn that dispatches a work item
6927
+ // matching an existing one silently loses its confirmation chip
6928
+ // (_buildSyntheticActionResultsForTurn drains an empty list for the
6929
+ // turn, so donePayload.actions/actionResults come back empty even
6930
+ // though the LLM's tool call succeeded).
6931
+ recordCcTurnIfPresent(req, { kind: 'work-item', id: duplicateId, title: item.title, project: item.project || null, duplicate: true });
6917
6932
  return jsonReply(res, 200, { ok: true, id: duplicateId, duplicate: true, duplicateOf: duplicateId });
6918
6933
  }
6919
6934
  recordCcTurnIfPresent(req, { kind: 'work-item', id, title: item.title, project: item.project || null });
@@ -13902,6 +13917,55 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13902
13917
  return jsonReply(res, 200, { ok: true, cleared: cause, prId });
13903
13918
  }},
13904
13919
 
13920
+ // Issue #671 — sibling to clear-paused-cause above, but for the
13921
+ // headSha-keyed `_buildFixIneffective` ("infra issue suspected") pause
13922
+ // (see engine/lifecycle.js#recordBuildFixIneffective), which is tracked
13923
+ // separately from `_noOpFixes` and so isn't reachable via the endpoint
13924
+ // above. Once a fresh CI build is queued against a rebased/retried head,
13925
+ // this lets an operator clear the stale pause without hand-editing
13926
+ // pull-requests.json.
13927
+ { method: 'POST', path: '/api/pull-requests/clear-build-fix-ineffective', desc: 'Resume a paused build-fix-ineffective ("infra issue suspected") pause (issue #671) — clears _buildFixIneffective so BUILD_FAILURE auto-fix dispatch can resume', params: 'prId (canonical host:slug#number), headSha? (optional — if provided, must match the paused record\'s headSha or the clear is rejected as stale)', handler: async (req, res) => {
13928
+ const body = await readBody(req);
13929
+ const prId = typeof body?.prId === 'string' ? body.prId.trim() : '';
13930
+ const headSha = typeof body?.headSha === 'string' ? body.headSha.trim() : '';
13931
+ if (!prId) return jsonReply(res, 400, { error: 'prId required' });
13932
+ reloadConfig();
13933
+ const lifecycle = require('./engine/lifecycle');
13934
+ const prPaths = [
13935
+ ...shared.getProjects(CONFIG).map(p => shared.projectPrPath(p)),
13936
+ shared.centralPullRequestsPath(MINIONS_DIR),
13937
+ ];
13938
+ let prFound = false;
13939
+ let recordCleared = false;
13940
+ let staleHead = false;
13941
+ for (const prPath of prPaths) {
13942
+ if (recordCleared || staleHead) break;
13943
+ shared.mutatePullRequests(prPath, (prs) => {
13944
+ if (!Array.isArray(prs)) return prs;
13945
+ const target = prs.find(p => p && p.id === prId);
13946
+ if (!target) return prs;
13947
+ prFound = true;
13948
+ const record = target._buildFixIneffective;
13949
+ if (!record || typeof record !== 'object') return prs;
13950
+ // If the caller told us which head they saw the pause on (the
13951
+ // dashboard chip stamps this at render time), refuse to clear a
13952
+ // pause that has since moved on to a different head — that would
13953
+ // silently apply a stale click's intent to a newer commit.
13954
+ if (headSha && record.headSha && record.headSha !== headSha) {
13955
+ staleHead = true;
13956
+ return prs;
13957
+ }
13958
+ recordCleared = lifecycle.clearBuildFixIneffective(target);
13959
+ return prs;
13960
+ });
13961
+ }
13962
+ if (!prFound) return jsonReply(res, 404, { error: `PR ${prId} not found` });
13963
+ if (staleHead) return jsonReply(res, 409, { error: `build-fix-ineffective pause on PR ${prId} is for a different head than provided — refresh and retry` });
13964
+ if (!recordCleared) return jsonReply(res, 400, { error: `PR ${prId} has no tracked build-fix-ineffective pause` });
13965
+ invalidateStatusCache();
13966
+ return jsonReply(res, 200, { ok: true, cleared: 'build-fix-ineffective', prId });
13967
+ }},
13968
+
13905
13969
  // ─── P-f3c9d0e7: convenience pause/resume endpoints ─────────────────────
13906
13970
  // Single-call wrappers over `POST /api/settings { engine: { <flag>: bool } }`
13907
13971
  // for the two operator kill-switches:
@@ -31,7 +31,7 @@ Canonical envelope (`_buildCcErrorEnvelope` in `dashboard.js`):
31
31
 
32
32
  **No auto-retry policy.** The backend never re-spawns the LLM after an error envelope. The client never silently resends the user's turn. Retry is a single-click manual action — guards against silent budget burn on `budget-exceeded`, infinite loops on `auth-failure`, and accidental re-charges on `context-limit`. The 429 + reconnect paths (rate-limited fetch retry, SSE reconnect-after-disconnect) remain — those are transport-level, not error-envelope-level.
33
33
 
34
- **Session-reset notices (`sessionReset` / `sessionResetReason`).** The streaming `done` event can carry `sessionReset: true` with a `sessionResetReason` of `promptChanged` (Minions updated its CC prompt template), `runtimeChanged` (CC runtime switched), or `idleReap` (W-mr0qs0vw — the persistent `copilot --acp` worker was killed by the idle reaper after `engine.ccWorkerIdleTimeoutMs` of inactivity, so the next message cold-spawns a fresh session with no memory). Both classic (`dashboard/js/command-center.js`) and slim (`dashboard/slim/js/command-send.js` + `chat.js#appendSystemNotice`) render a centered, muted system notice; for `idleReap` the text is "Context window cleared after inactivity — fresh session started." The idle-reap flag is a one-shot drained per turn via `ccWorkerPool.consumeIdleReapNotice(tabId)`.
34
+ **Session-reset notices (`sessionReset` / `sessionResetReason`).** The streaming `done` event can carry `sessionReset: true` with a `sessionResetReason` of `promptChanged` (Minions updated its CC prompt template), `runtimeChanged` (CC runtime switched), or `idleReap` (W-mr0qs0vw — the persistent `copilot --acp` worker was killed by the idle reaper after `engine.ccWorkerIdleTimeoutMs` of inactivity, so the next message cold-spawns a fresh session with no memory). Both classic (`dashboard/js/command-center.js`) and slim (`dashboard/slim/js/command-send.js` + `chat.js#appendSystemNotice`) render a centered, muted system notice; for `idleReap` the text is "Context window cleared after inactivity — fresh session started." The idle-reap flag is a one-shot drained per turn via `ccWorkerPool.consumeIdleReapNotice(tabId)`. **`ccWorkerIdleTimeoutMs: 0`** is an explicit "never idle-reap" sentinel (W-mr1qbj90000d30bd) — it bypasses the normal `[60000, 28800000]` clamp, maps to an `Infinity` window in `cc-worker-pool.js#setIdleTimeoutMs`, and the warm worker then only dies on an explicit `closeTab`. Settings page exposes this as the "Never idle-reap CC workers" toggle.
35
35
 
36
36
  ## Image attachments (PL-cc-image-attachments)
37
37
 
@@ -54,6 +54,10 @@ To kill them now, run: node engine.js kill
54
54
 
55
55
  If an agent is killed as an orphan and the work item retries, cooldowns use exponential backoff (2^failures, max 8x) to prevent spam-retrying broken tasks.
56
56
 
57
+ ### 5. Crash-Loop Counter + Alert (W-mr2azk6i000y06e0)
58
+
59
+ The engine.js *process itself* (not an agent) can crash and get auto-respawned by three independent mechanisms: `engine/supervisor.js#checkEngine` (dead-PID), `engine/supervisor.js#checkEngineHung` (stale-heartbeat), and `engine/watchdog.js` (OS-scheduled external recovery); `dashboard.js`'s in-process 30s watchdog also auto-restarts on a dead PID (the user-triggered Restart Engine button is excluded). Each successful respawn calls `shared.recordEngineRespawn(source)`, which appends `{ts, source}` to `control.json`'s `engineRespawns[]` (rolling window, default `CRASH_LOOP_WINDOW_MS` = 10 min). Once respawns within the window reach `CRASH_LOOP_THRESHOLD` (default 3), `control.crashLoopAlert` is set (dashboard-visible via `/api/status`) and a deduped (one-per-day) `notes/inbox/` alert is written; the alert clears automatically once the window rolls past the incident. Override the window/threshold via `MINIONS_CRASH_LOOP_WINDOW_MS` / `MINIONS_CRASH_LOOP_THRESHOLD` env vars.
60
+
57
61
  ## Safe Restart Pattern
58
62
 
59
63
  ```bash
@@ -32,7 +32,7 @@ Implementation: `engine.js` builds a `liveProjectsInUse` set from the active dis
32
32
 
33
33
  Before spawning, `engine/live-checkout.js#prepareLiveCheckout` runs `git status --porcelain` from `project.localPath`. Any output (staged, unstaged, untracked) **returns** an explicit `{ ok:false, reason:'dirty', dirtyFiles:[…] }` result (it does NOT throw), which refuses the dispatch immediately:
34
34
 
35
- - Non-retryable `FAILURE_CLASS.LIVE_CHECKOUT_DIRTY` (added to `engine/dispatch.js`'s `neverRetry` set so the dispatcher never re-spawns mechanically). **Reserved for this confirmed-dirty result only** — a thrown helper/git error is `LIVE_CHECKOUT_FAILED`, see below (#305).
35
+ - Non-retryable `FAILURE_CLASS.LIVE_CHECKOUT_DIRTY` (added to `engine/dispatch.js`'s `neverRetry` set so the dispatcher never re-spawns mechanically). **Reserved for this confirmed-dirty result only** — a thrown helper/git error is `LIVE_CHECKOUT_FAILED`, see below (#305). **Auto-cleanup exception (W-mqzmkoqt000hbca2, #582):** when `liveCheckoutAutoReset` or `liveCheckoutAutoStash` is enabled (per-project or fleet-wide), `spawnAgent` treats the dirty refusal as retryable instead — the engine re-cleans the tree on every attempt, so the two-strike cap below is skipped and the dispatch keeps retrying up to the normal `maxRetries` cap. The `dispatch.js` `neverRetry` entry is kept only as a defensive fallback for callers that omit the explicit `agentRetryable` override.
36
36
  - Inbox alert written via `dispatch.writeInboxAlert('live-checkout-dirty-<wi-id>', body)`. The body lists the dirty files verbatim from `git status --porcelain`.
37
37
  - Work item stamped with `_pendingReason: 'live_checkout_dirty'` so the dashboard surfaces the block.
38
38
  - Completion summary: `live-checkout refused: N dirty file(s) in <localPath>`.
@@ -69,6 +69,9 @@ This is **DESTRUCTIVE** — it permanently discards the operator's uncommitted c
69
69
  - **Fleet-wide:** Dashboard → Settings → `Live-checkout auto-reset (fleet-wide)` (`engine.liveCheckoutAutoReset`).
70
70
  - **Per-project override:** set `liveCheckoutAutoReset: true|false` on the project object in `config.json` (see the config snippet under *Enabling live mode*). A per-project boolean overrides the fleet-wide default for that project. *(Per-project Settings-UI persistence is deferred — the per-project override is config.json-only for now.)*
71
71
 
72
+ #### 2c. Stale `.git/index.lock` self-heal (W-mr3tayu4)
73
+
74
+ Before any preflight git command runs, `prepareLiveCheckout` calls the shared, age-gated `shared.removeStaleIndexLock(localPath)` to clear a leftover `.git/index.lock` from a crashed/killed git process. Live mode never resets/cleans the operator tree, so nothing else would clear it, and — since `LIVE_CHECKOUT_FAILED` is retryable — every automatic retry used to hit the same stale lock and fail identically forever until an operator deleted it by hand. The remover only touches locks older than 5 minutes, so a lock held by a currently-running git process is never removed out from under it. `engine.js`'s own worktree-mode lock cleanup now delegates to the same shared helper.
72
75
 
73
76
  ### 3. No auto-pull, no `--force`, no fast-forward
74
77
 
@@ -275,6 +278,6 @@ Live-checkout mode is deliberately small. These are NOT supported and will not b
275
278
  | `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_MID_OPERATION` | Non-retryable refusal class for a mid-operation / detached-HEAD operator tree (in-progress merge/rebase/cherry-pick/revert/bisect or detached HEAD), distinct from the dirty-tree class. Emitted by `spawnAgent`'s mid-op / detached-HEAD refusal block (P-a7f3c1d9; wired P-c5a1f3b8). |
276
279
  | `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_BLOB_FETCH` | Non-retryable refusal class for an existing-branch checkout that could not hydrate the tree on a blobless GVFS partial clone (auth-less cache fetch, headless) — deterministic, so excluded from mechanical retry (PL-live-checkout-reliability-hardening). |
277
280
  | `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_WORKTREE_CONFLICT` | Non-retryable refusal class for an existing-branch checkout that refused because the branch is already checked out in another worktree — structural, so excluded from mechanical retry (W-mr28h2j2000y0de1). |
278
- | `engine.js` — `_liveCheckoutDirtyAttempts` counter | Dedicated two-strike dirty memory (survives the discovery + retry `_pendingReason` scrubs that defeated #434); first dirty failure retries once, second fails non-retryably (PL-live-checkout-reliability-hardening). |
281
+ | `engine.js` — `_liveCheckoutDirtyAttempts` counter | Dedicated two-strike dirty memory (survives the discovery + retry `_pendingReason` scrubs that defeated #434); first dirty failure retries once, second fails non-retryably (PL-live-checkout-reliability-hardening) — **unless** `liveCheckoutAutoReset`/`liveCheckoutAutoStash` is enabled, in which case the two-strike cap is skipped entirely and the dirty failure stays retryable up to `maxRetries` (W-mqzmkoqt000hbca2, #582). |
279
282
  | `engine/dispatch.js` — `isRetryableFailureReason` neverRetry | Excludes `LIVE_CHECKOUT_DIRTY`, `LIVE_CHECKOUT_MID_OPERATION`, `LIVE_CHECKOUT_BLOB_FETCH`, and `LIVE_CHECKOUT_WORKTREE_CONFLICT` from mechanical retry. |
280
283
  | `engine/timeout.js` header comment | Confirms no special live-mode kill handling. |
@@ -69,6 +69,10 @@ When all PRD items reach `done` or `failed` status, `checkPlanCompletion()` fire
69
69
  3. **PR work item** created for shared-branch plans (to merge the feature branch)
70
70
  4. **PRD status** persisted as `completed` with `_completionNotified`; files stay active until manual archive
71
71
 
72
+ `checkPlanCompletion()` also **self-heals a stale `completed` PRD**: if a completed plan later gains an unmaterialized item or one of its items regresses off a terminal status, the PRD is reverted to `active` (status flipped, `_completionNotified` cleared) so the materializer recreates the missing work item and the normal completion flow can run again.
73
+
74
+ **Separate lighter-weight path — `advancePrdStatusOnAllItemsDone()` (P-d7e8f9a1).** Called at the end of `syncPrdFromPrs` on the PR-poll cadence, this independently advances a PRD's top-level `status` to `completed` once every `missing_features` item has both (1) a work item in a `DONE_STATUSES` state and (2) its linked PR merged (or no linked PR, for non-code items). It skips frozen PRDs (`paused`, `rejected`, `awaiting-approval`, `completed`, `archived` — `_PRD_FROZEN_STATUSES`) and writes idempotently via `mutateJsonFileLocked` + `skipWriteIfUnchanged`. Unlike `checkPlanCompletion()`, it does not create the verify task, completion summary, or PR work item — those still only come from the `checkPlanCompletion()` path above.
75
+
72
76
  ## Verification Task
73
77
 
74
78
  The verification task (`planItemId: "VERIFY"`) is the final step. It:
@@ -118,7 +122,7 @@ The verify agent does not call `archivePlan()`. After the testing guide and any
118
122
 
119
123
  Manual archive behavior:
120
124
 
121
- 1. For PRD JSON files, the dashboard archives them **in-place** in `prd/` — the file is not moved. The JSON is stamped with `archived: true`, `status: "archived"`, and `archivedAt` (Phase 10 step 4.2b). The `.backup` sidecar is left intact since the file keeps its canonical path.
125
+ 1. For PRD JSON files, the dashboard archives them **in-place** in `prd/` — the file is not moved. The JSON is stamped with `archived: true`, `status: "archived"`, and `archivedAt` (Phase 10 step 4.2b). **No `.backup` sidecar is written or restored for any `prd/*.json`** — Phase 10 step 4.3 dropped the PRD backup/restore lifecycle entirely (root-level PRDs used to get one to survive the now-retired rename race; SQL is canonical, so a stale `.backup` is pure resurrection fuel, not a safety net).
122
126
  2. If the PRD has `source_plan`, the matching `plans/<source>.md` is moved to `plans/archive/<source>.md`.
123
127
  3. Pending or queued work items linked to the archived PRD are cancelled with `_cancelledBy: "plan-archived"`; completed work items remain as history.
124
128
  4. Plan worktrees are cleaned up by the archive handler/lifecycle cleanup path.
@@ -1153,6 +1153,52 @@ function hasReusableSignal(content) {
1153
1153
  return REUSABLE_SIGNAL_RE.test(content || '');
1154
1154
  }
1155
1155
 
1156
+ // ── Engine-alert KB dedup (W-mr3pi9de, sibling of W-mr3lokxs) ────────────────
1157
+ // Recurring engine-system alerts (e.g. the worktree-skip-live guard firing
1158
+ // across many different worktrees, or the same live-checkout-dirty incident
1159
+ // firing every tick) used to be written as full-size KB copies with -2/-3/…
1160
+ // numeric suffixes via uniquePath, multiplying storage N times per day.
1161
+ // Instead we content-hash each alert and skip writing when a same-hash entry
1162
+ // already exists in the target category. The hash is stored in the entry's
1163
+ // frontmatter (`alertHash:`) so future passes can dedup against it cheaply.
1164
+
1165
+ // Normalized, length-bounded content hash. Collapses whitespace so trivially
1166
+ // re-rendered dumps (e.g. differing timestamps) still collapse to one hash.
1167
+ function _alertContentHash(content) {
1168
+ const raw = String(content || '');
1169
+ const normalized = raw.replace(/\s+/g, ' ').trim().slice(0, 4000);
1170
+ return crypto.createHash('sha256').update(normalized + ':' + raw.length).digest('hex');
1171
+ }
1172
+
1173
+ // Read only the leading bytes of a file (frontmatter lives at the very top) so
1174
+ // scanning a directory that also holds huge legacy full-body dumps never loads
1175
+ // those multi-MB bodies into memory.
1176
+ function _readFileHead(fp, bytes = 512) {
1177
+ let fd;
1178
+ try {
1179
+ fd = fs.openSync(fp, 'r');
1180
+ const buf = Buffer.alloc(bytes);
1181
+ const n = fs.readSync(fd, buf, 0, bytes, 0);
1182
+ return buf.slice(0, n).toString('utf8');
1183
+ } catch { return ''; }
1184
+ finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* best-effort */ } } }
1185
+ }
1186
+
1187
+ // Does an existing KB entry in `dir` already carry this alertHash frontmatter?
1188
+ function _alertHashExists(dir, hash) {
1189
+ if (!hash || !dir || !fs.existsSync(dir)) return false;
1190
+ let files;
1191
+ try { files = fs.readdirSync(dir); } catch { return false; }
1192
+ const re = new RegExp(`^alertHash:\\s*${hash}\\s*$`, 'm');
1193
+ for (const f of files) {
1194
+ if (!f.endsWith('.md')) continue;
1195
+ const head = _readFileHead(path.join(dir, f));
1196
+ const fm = head.match(/^---\r?\n([\s\S]*?)\r?\n---/);
1197
+ if (fm && re.test(fm[1])) return true;
1198
+ }
1199
+ return false;
1200
+ }
1201
+
1156
1202
  /**
1157
1203
  * Build a condensed KB stub for narrowly-scoped, one-off notes instead of
1158
1204
  * duplicating the full body (issue #604 — classifyToKnowledgeBase was copying
@@ -1223,18 +1269,32 @@ async function classifyToKnowledgeBase(items, config) {
1223
1269
  // Reserve full-body KB copies for content that's actually general/reusable
1224
1270
  // (patterns, conventions, gotchas); narrowly-scoped one-off notes get a
1225
1271
  // condensed stub + link back to the archived note instead (issue #604).
1226
- const reusable = category === 'conventions' || hasReusableSignal(content);
1272
+ //
1273
+ // W-mr3pi9de — engine-authored system alerts (worktree-skip-live guard
1274
+ // fires, dirty-tree refusals, blocked dispatches, managed-spawn failures)
1275
+ // are operational noise, NEVER durable reusable knowledge. Force them
1276
+ // non-reusable regardless of category or any incidental heading-shaped
1277
+ // line in the body that would otherwise trip hasReusableSignal, and
1278
+ // content-hash-dedup recurring identical/near-identical incidents instead
1279
+ // of appending -2/-3 full copies with a colliding title.
1280
+ const isSystemAlert = shared.isEngineSystemAlert(item.name, content);
1281
+ const reusable = !isSystemAlert && (category === 'conventions' || hasReusableSignal(content));
1227
1282
  const archiveRelPath = `notes/archive/${dateStamp()}-${item.name}`;
1228
1283
  const kbBody = reusable
1229
1284
  ? content
1230
1285
  : buildCondensedKbBody(content, titleMatch ? titleMatch[0] : null, archiveRelPath);
1231
1286
 
1232
- const frontmatter = `---\nsource: ${item.name}\nagent: ${agent}\ncategory: ${category}\ndate: ${dateStamp()}\nreusable: ${reusable}\n---\n\n`;
1233
- try {
1234
- safeWrite(kbPath, frontmatter + kbBody);
1235
- classified++;
1236
- } catch (err) {
1237
- log('warn', `Failed to classify ${item.name} to knowledge base: ${err.message}`);
1287
+ const alertHash = isSystemAlert ? _alertContentHash(content) : null;
1288
+ if (isSystemAlert && _alertHashExists(categoryDirs[category], alertHash)) {
1289
+ log('info', `KB dedup: skipping recurring engine-alert ${item.name} — duplicate of an existing knowledge/${category} entry`);
1290
+ } else {
1291
+ const frontmatter = `---\nsource: ${item.name}\nagent: ${agent}\ncategory: ${category}\ndate: ${dateStamp()}\nreusable: ${reusable}${isSystemAlert ? `\nalertHash: ${alertHash}` : ''}\n---\n\n`;
1292
+ try {
1293
+ safeWrite(kbPath, frontmatter + kbBody);
1294
+ classified++;
1295
+ } catch (err) {
1296
+ log('warn', `Failed to classify ${item.name} to knowledge base: ${err.message}`);
1297
+ }
1238
1298
  }
1239
1299
 
1240
1300
  // Per-agent memory routing — strict superset of broadcast consolidation.
@@ -1397,4 +1457,6 @@ module.exports = {
1397
1457
  NOTES_MAX_BYTES,
1398
1458
  hasReusableSignal,
1399
1459
  buildCondensedKbBody,
1460
+ _alertContentHash,
1461
+ _alertHashExists,
1400
1462
  };
@@ -2943,6 +2943,18 @@ function recordPrNoOpFixAttempt(target, cause, source, dispatchItem, branchChang
2943
2943
  return target._noOpFixes[cause];
2944
2944
  }
2945
2945
 
2946
+ // #671 — Clears the `_buildFixIneffective` ("infra issue suspected") pause so
2947
+ // BUILD_FAILURE auto-fix dispatch can resume. Distinct from
2948
+ // `clearPrNoOpFixAttempt` (below) because this pause is headSha-keyed rather
2949
+ // than cause-keyed — see `recordBuildFixIneffective` / `shared.isBuildFixIneffectivePaused`.
2950
+ // Returns true when a record was actually removed so callers (the dashboard
2951
+ // resume endpoint) can distinguish "cleared" from "nothing to clear".
2952
+ function clearBuildFixIneffective(target) {
2953
+ if (!target?._buildFixIneffective) return false;
2954
+ delete target._buildFixIneffective;
2955
+ return true;
2956
+ }
2957
+
2946
2958
  function clearPrNoOpFixAttempt(target, cause) {
2947
2959
  if (!target?._noOpFixes || !target._noOpFixes[cause]) return;
2948
2960
  delete target._noOpFixes[cause];
@@ -6821,6 +6833,9 @@ module.exports = {
6821
6833
  // Issue #2969 — exported so dashboard.js can clear a paused cause from the
6822
6834
  // resume endpoint.
6823
6835
  clearPrNoOpFixAttempt,
6836
+ // Issue #671 — exported so dashboard.js can clear the build-fix-ineffective
6837
+ // ("infra issue suspected") pause from the resume endpoint.
6838
+ clearBuildFixIneffective,
6824
6839
  // W-mpx44p05000ze8d8 — exported so engine/ado.js + engine/github.js can
6825
6840
  // drop stale `_noOpFixes` records during their PR status poll, and so
6826
6841
  // unit tests can exercise the sweep helper directly.
@@ -220,6 +220,25 @@ function _collectAutoCleanablePaths(dirtyFiles) {
220
220
  return cleanable.length > 0 ? cleanable : null;
221
221
  }
222
222
 
223
+ // W-mr3lokxs — Cap a porcelain dirty-file list before it is embedded verbatim
224
+ // into a live-checkout-dirty inbox alert body. On GVFS/virtual repos (see the
225
+ // "~120k virtual dirty files" note in prepareLiveCheckout below) a single
226
+ // refusal can report tens of thousands of "modified" paths; embedding the full
227
+ // list produced multi-MB / tens-of-MB alert bodies that then bloated the
228
+ // knowledge base (240 dirty-worktree dumps ≈ 113 MB). Keep the first `maxLines`
229
+ // entries and replace the remainder with a single truncation-notice line so the
230
+ // alert stays human-scannable and bounded. Pure/deterministic — exported for
231
+ // unit testing.
232
+ const DIRTY_ALERT_MAX_LINES = 200;
233
+ function capDirtyFileLines(dirtyFiles, maxLines = DIRTY_ALERT_MAX_LINES) {
234
+ const lines = Array.isArray(dirtyFiles) ? dirtyFiles : [];
235
+ const cap = Number.isInteger(maxLines) && maxLines > 0 ? maxLines : DIRTY_ALERT_MAX_LINES;
236
+ if (lines.length <= cap) return lines.slice();
237
+ const kept = lines.slice(0, cap);
238
+ kept.push(`... and ${lines.length - cap} more (run \`git status --porcelain=v1 -b\` locally for the full list)`);
239
+ return kept;
240
+ }
241
+
223
242
  // issue #608 — detects a branch name that the ENGINE created for a live-mode
224
243
  // dispatch (never the operator's own branch). Matches ONLY the literal
225
244
  // `work/<wi-id>` convention produced by `shared.deriveWorkItemBranchName` —
@@ -1251,5 +1270,7 @@ module.exports = {
1251
1270
  _looksLikeAgentBranch,
1252
1271
  _extractBranchFromPorcelainHeader,
1253
1272
  _commitAgentWipWithRetry,
1273
+ capDirtyFileLines,
1274
+ DIRTY_ALERT_MAX_LINES,
1254
1275
  LIVE_CHECKOUT_AUTO_CLEAN_PATTERNS,
1255
1276
  };
package/engine/shared.js CHANGED
@@ -2827,11 +2827,44 @@ const KB_READABLE_CATEGORIES = Object.freeze([
2827
2827
  'consolidated', 'consolidation', 'consolidations',
2828
2828
  'team-memory', 'general', 'patterns',
2829
2829
  ]);
2830
+ /**
2831
+ * Detect engine-authored system alerts (dirty-tree refusals, worktree-skip-live
2832
+ * guard notices, blocked/conflicted live-checkout dispatches, managed-spawn
2833
+ * failures, ADO-auth alerts, …). These are operational noise, NOT durable
2834
+ * reusable knowledge, and the same underlying incident recurs verbatim across
2835
+ * many different inbox filenames (one per worktree/date), so consolidation
2836
+ * must never copy their full body into knowledge/ as a distinct "new" entry
2837
+ * per recurrence — see W-mr3pi9de (worktree-skip-live note titles were
2838
+ * identical across every firing regardless of worktree, producing 30+
2839
+ * duplicate-titled knowledge/project-notes/*.md entries on a single day) and
2840
+ * the sibling W-mr3lokxs fix for live-checkout-dirty alerts.
2841
+ *
2842
+ * `engine/dispatch.js#writeInboxAlert` names every file `engine-alert-<slug>-
2843
+ * <date>.md`, so the filename prefix is one signal. `shared.js#_writeWorktree
2844
+ * SkipLiveInboxNote` (and any other engine-authored note) stamps frontmatter
2845
+ * `agent: engine`, accepted as a second signal so alerts written under a
2846
+ * different filename convention are still recognized.
2847
+ */
2848
+ function isEngineSystemAlert(name, content) {
2849
+ const nameLower = (name || '').toLowerCase();
2850
+ if (nameLower.includes('engine-alert-') || nameLower.includes('engine-worktree-skip-live-')) return true;
2851
+ const fmMatch = String(content || '').match(/^---\r?\n([\s\S]*?)\r?\n---/);
2852
+ if (fmMatch && /^agent:\s*engine\s*$/im.test(fmMatch[1])) return true;
2853
+ return false;
2854
+ }
2855
+
2830
2856
  /**
2831
2857
  * Classify an inbox item into a knowledge base category.
2832
2858
  * Single source of truth — used by consolidation.js (both LLM and regex paths).
2833
2859
  */
2834
2860
  function classifyInboxItem(name, content) {
2861
+ // W-mr3pi9de / W-mr3lokxs — engine-authored system alerts are operational
2862
+ // noise. Route them to 'project-notes' BEFORE the content-substring
2863
+ // heuristics below, so a note whose body incidentally contains substrings
2864
+ // like 'lint', 'review', or 'pr-' can't be misclassified into
2865
+ // build-reports/reviews. classifyToKnowledgeBase additionally forces these
2866
+ // non-reusable and content-hash-dedups them regardless of category.
2867
+ if (isEngineSystemAlert(name, content)) return 'project-notes';
2835
2868
  const nameLower = (name || '').toLowerCase();
2836
2869
  const contentLower = (content || '').toLowerCase();
2837
2870
  if (nameLower.includes('review') || nameLower.includes('pr-') || nameLower.includes('pr4') || nameLower.includes('feedback')) return 'reviews';
@@ -8893,7 +8926,13 @@ function _writeWorktreeSkipLiveInboxNote(worktreePath, callerTag, blockingInfo)
8893
8926
  `date: ${date}`,
8894
8927
  '---',
8895
8928
  '',
8896
- `# Engine skipped worktree wipe live dispatch guard fired (W-mq5rwwss000f30a7)`,
8929
+ // W-mr3pi9de embed the actual worktree basename, not a hardcoded
8930
+ // literal. The previous fixed string made every note (and therefore
8931
+ // every knowledge/project-notes/*.md entry consolidation wrote from
8932
+ // it) share byte-identical titles regardless of which worktree fired
8933
+ // the guard, defeating uniquePath's -2/-3/… suffixing and flooding the
8934
+ // KB with near-duplicate-content entries under different numbers.
8935
+ `# Engine skipped worktree wipe — live dispatch guard fired (${base})`,
8897
8936
  '',
8898
8937
  `- caller: ${callerTag || 'unknown'}`,
8899
8938
  `- worktree: ${worktreePath}`,
@@ -9434,6 +9473,7 @@ module.exports = {
9434
9473
  KB_CATEGORIES,
9435
9474
  KB_READABLE_CATEGORIES,
9436
9475
  classifyInboxItem,
9476
+ isEngineSystemAlert,
9437
9477
  ENGINE_DEFAULTS,
9438
9478
  resolvePollFlag, // P-c4d8e1a3 — granular per-poller flag resolution
9439
9479
  resolveAgentCli, resolveCcCli, resolveCcUseWorkerPool, resolveAgentModel, resolveCcModel,
package/engine.js CHANGED
@@ -2583,7 +2583,7 @@ async function spawnAgent(dispatchItem, config) {
2583
2583
  '## Dirty files (`git status --porcelain=v1 -b`)',
2584
2584
  '',
2585
2585
  '```',
2586
- ...(_dirtyFiles.length > 0 ? _dirtyFiles : ['(none reported)']),
2586
+ ...(_dirtyFiles.length > 0 ? _liveCheckout.capDirtyFileLines(_dirtyFiles) : ['(none reported)']),
2587
2587
  '```',
2588
2588
  '',
2589
2589
  _autoCleanupEnabled
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2322",
3
+ "version": "0.1.2324",
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"