@yemi33/minions 0.1.2054 → 0.1.2056

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.
@@ -73,7 +73,7 @@ const RENDER_VERSIONS = {
73
73
  prd: 1,
74
74
  prs: 2,
75
75
  archivedPrds: 1,
76
- engine: 3,
76
+ engine: 4,
77
77
  version: 1,
78
78
  adoThrottle: 1,
79
79
  ghThrottle: 1,
@@ -147,18 +147,57 @@ function _formatCcDrawerLabel(autoMode) {
147
147
  return runtimeLabel + (model ? ' (' + model + ')' : '') + '-powered. Full minions context. Enter to send, Shift+Enter for newline.';
148
148
  }
149
149
 
150
- // W-mpnc4u8c001d9d6c — #engine-quick-stats "Next tick in Xs" countdown.
150
+ // W-mpnc4u8c001d9d6c / W-mpodheao0006a37a — #engine-quick-stats "Next tick in"
151
+ // countdown.
151
152
  //
152
153
  // Origin is engine.lastTickAt (stamped by tickInner at the start of every
153
154
  // tick, see engine.js) plus engine.tickInterval (server-side config, surfaced
154
155
  // via _buildStatusFastState). The chip updates every 1s without re-rendering
155
156
  // the surrounding row — mirrors the _tickAgentRuntimes pattern in
156
- // dashboard/js/render-agents.js. The countdown clamps at 0 and shows "due"
157
- // when the engine is mid-tick (PR polls etc. can push tick-to-tick wall time
158
- // past the nominal interval). When the engine isn't running, show "—" so a
159
- // stale lastTickAt doesn't render a ticking countdown the engine can't honor.
157
+ // dashboard/js/render-agents.js.
158
+ //
159
+ // Cadence semantics (W-mpodheao0006a37a): tickInterval is the MINIMUM gap
160
+ // between tick starts (engine.js#cli sets `setInterval(() => e.tick(), interval)`
161
+ // and engine.tick() short-circuits if a previous tick is still running). Slow
162
+ // ticks (PR polls, reconcilers) delay the next slot, so actual tick-to-tick
163
+ // gaps can exceed tickInterval. Once the countdown crosses zero the chip
164
+ // shows "running" (not "due" — the old label implied the engine was overdue,
165
+ // which lies about the floor-not-ceiling semantics). When we have ≥3
166
+ // observed deltas in _engineCadenceDeltas, the running label is augmented
167
+ // with the observed median cadence: "running (~Ns cadence)".
168
+ //
169
+ // When the engine isn't running, show "—" so a stale lastTickAt doesn't
170
+ // render a ticking countdown the engine can't honor.
160
171
  var _engineCountdown = { lastTickAt: 0, tickInterval: 0, engineState: 'stopped' };
161
172
  var _engineNextTickTimer = null;
173
+ // Ring buffer of the most recent observed tick-to-tick deltas (ms). Updated
174
+ // inside _processStatusUpdate whenever data.engine.lastTickAt advances. Used
175
+ // by _formatNextTickText to surface an observed cadence in the overshoot
176
+ // path. Capped at _engineCadenceMax entries (FIFO) so the median stays
177
+ // representative of recent behavior.
178
+ var _engineCadenceDeltas = [];
179
+ var _engineCadenceMax = 5;
180
+ var _engineCadenceLastSeenTickAt = 0;
181
+
182
+ function _recordEngineTickObservation(lastTickAt) {
183
+ if (!lastTickAt) return;
184
+ if (!_engineCadenceLastSeenTickAt) {
185
+ _engineCadenceLastSeenTickAt = lastTickAt;
186
+ return;
187
+ }
188
+ if (lastTickAt <= _engineCadenceLastSeenTickAt) return;
189
+ var delta = lastTickAt - _engineCadenceLastSeenTickAt;
190
+ _engineCadenceLastSeenTickAt = lastTickAt;
191
+ _engineCadenceDeltas.push(delta);
192
+ if (_engineCadenceDeltas.length > _engineCadenceMax) _engineCadenceDeltas.shift();
193
+ }
194
+
195
+ function _medianCadenceMs() {
196
+ if (_engineCadenceDeltas.length < 3) return 0;
197
+ var sorted = _engineCadenceDeltas.slice().sort(function(a, b) { return a - b; });
198
+ var mid = Math.floor(sorted.length / 2);
199
+ return sorted.length % 2 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2);
200
+ }
162
201
 
163
202
  function _formatNextTickText() {
164
203
  var state = _engineCountdown.engineState;
@@ -167,8 +206,11 @@ function _formatNextTickText() {
167
206
  var interval = _engineCountdown.tickInterval;
168
207
  if (!last || !interval) return '—';
169
208
  var remainingMs = last + interval - Date.now();
170
- if (remainingMs <= 0) return 'due';
171
- return Math.ceil(remainingMs / 1000) + 's';
209
+ if (remainingMs > 0) return Math.ceil(remainingMs / 1000) + 's';
210
+ if (state !== 'running') return '';
211
+ var medianMs = _medianCadenceMs();
212
+ if (medianMs > 0) return 'running (~' + Math.round(medianMs / 1000) + 's cadence)';
213
+ return 'running';
172
214
  }
173
215
 
174
216
  function _updateNextTickChip() {
@@ -297,6 +339,9 @@ function _processStatusUpdate(data) {
297
339
  _engineCountdown.lastTickAt = Number(data.engine.lastTickAt) || 0;
298
340
  _engineCountdown.tickInterval = Number(data.engine.tickInterval) || 0;
299
341
  _engineCountdown.engineState = data.engine.state || 'stopped';
342
+ // Feed the cadence ring buffer so the overshoot label can surface the
343
+ // observed tick-to-tick gap (W-mpodheao0006a37a).
344
+ _recordEngineTickObservation(_engineCountdown.lastTickAt);
300
345
  // eslint-disable-next-line no-unsanitized/property -- reason: composed from internal engine metrics (pid, lastTickAt/tickInterval, worktreeCount) and a literal id; no user data flows in
301
346
  qs.innerHTML = '<span>PID: <b>' + pid + '</b></span>' +
302
347
  '<span>Next tick in: <b id="engine-next-tick">' + _formatNextTickText() + '</b></span>' +
@@ -693,6 +738,21 @@ async function refresh() {
693
738
 
694
739
  refresh();
695
740
  _syncPinsFromServer(); // Load server-side pins on startup
741
+ // W-mpmwxkrw000872ec — reconcile the font-size preference from the server
742
+ // once on cold load. The inline bootstrap in layout.html has already applied
743
+ // localStorage's value (if any) to avoid flash; this fetch promotes the
744
+ // server value when it differs (e.g. fresh browser, different machine).
745
+ (function _reconcileFontSizeFromServer() {
746
+ fetch('/api/settings').then(function(r) { return r.ok ? r.json() : null; }).then(function(data) {
747
+ if (!data || !data.engine) return;
748
+ var serverValue = data.engine.fontSize || 'small';
749
+ var localValue = 'small';
750
+ try { localValue = localStorage.getItem('minions:font-size') || 'small'; } catch (e) { /* private mode */ }
751
+ if (serverValue !== localValue && typeof window.applyFontSizePreference === 'function') {
752
+ window.applyFontSizePreference(serverValue);
753
+ }
754
+ }).catch(function() { /* offline / 4xx — keep localStorage value */ });
755
+ })();
696
756
 
697
757
  // Poll for status updates (SSE caused HTTP/1.1 connection exhaustion — CC fetch would fail)
698
758
  setInterval(refresh, 4000);
@@ -1,5 +1,17 @@
1
1
  // settings.js — Settings panel functions extracted from dashboard.html
2
2
 
3
+ // W-mpmwxkrw000872ec — single source of truth for applying the font-size
4
+ // preference at runtime. Mirrors the inline bootstrap script in
5
+ // layout.html (which runs before this file loads). Exposed on window so the
6
+ // refresh.js reconciler can call it after the first /api/settings fetch.
7
+ const FONT_SIZE_VALUES = ['small', 'medium', 'large', 'xlarge'];
8
+ function applyFontSizePreference(value) {
9
+ const v = FONT_SIZE_VALUES.includes(value) ? value : 'small';
10
+ try { document.documentElement.setAttribute('data-font-size', v); } catch (e) { /* no DOM */ }
11
+ try { localStorage.setItem('minions:font-size', v); } catch (e) { /* private mode / disabled */ }
12
+ }
13
+ try { window.applyFontSizePreference = applyFontSizePreference; } catch (e) { /* non-browser */ }
14
+
3
15
  let _settingsData = null;
4
16
  // Async runtime/model discovery can resolve out of order when the operator
5
17
  // flips between runtimes quickly. Without a per-target token, a slower Copilot
@@ -166,6 +178,29 @@ async function openSettings() {
166
178
  settingsField('Eval Max Cost', 'set-evalMaxCost', e.evalMaxCost === null || e.evalMaxCost === undefined ? '' : e.evalMaxCost, '$', 'USD ceiling per work item across all eval iterations (blank = no limit)') +
167
179
  '</div>';
168
180
 
181
+ // W-mpmwxkrw000872ec — Appearance pane. Hosts dashboard-wide visual
182
+ // preferences (font-size scale today; reserved for future theme picks).
183
+ // Kept narrow + featured near the rail top so the setting is discoverable
184
+ // rather than buried under generic Engine headers.
185
+ const paneAppearance =
186
+ '<h3>Appearance</h3>' +
187
+ '<div class="settings-pane-sub">Dashboard-wide visual preferences. Persisted in browser localStorage + server-side settings so they survive a reload from a cold cache.</div>' +
188
+ '<div class="settings-grid-2">' +
189
+ '<div data-search="font size scale appearance dashboard">' +
190
+ '<label style="font-size:10px;color:var(--muted);display:block;margin-bottom:2px">Font Size</label>' +
191
+ '<select id="set-fontSize" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:12px">' +
192
+ (function() {
193
+ var current = (e.fontSize || 'small');
194
+ var opts = [['small', 'Small (current default)'], ['medium', 'Medium'], ['large', 'Large'], ['xlarge', 'Extra Large']];
195
+ return opts.map(function(o) {
196
+ return '<option value="' + o[0] + '"' + (current === o[0] ? ' selected' : '') + '>' + o[1] + '</option>';
197
+ }).join('');
198
+ })() +
199
+ '</select>' +
200
+ '<div style="font-size:9px;color:var(--muted);margin-top:1px">Scales the whole dashboard. Persisted in browser + server.</div>' +
201
+ '</div>' +
202
+ '</div>';
203
+
169
204
  const projectsHtml = (data.projects || []).map(function(p) {
170
205
  // Cross-reference live /api/status to pull in the auto-detected
171
206
  // remoteDefaultBranch (read-only) — settings already has localPath +
@@ -230,7 +265,7 @@ async function openSettings() {
230
265
  '<div class="settings-pane-sub">How many agents run at once and how long they get before being killed for silence or runaway duration.</div>' +
231
266
  '<div class="settings-grid-2">' +
232
267
  settingsField('Max Concurrent Agents', 'set-maxConcurrent', e.maxConcurrent || 5, '', 'Max agents working simultaneously') +
233
- settingsField('Tick Interval', 'set-tickInterval', e.tickInterval || 60000, 'ms', 'How often the engine runs discovery + dispatch') +
268
+ settingsField('Min Tick Interval', 'set-tickInterval', e.tickInterval || 60000, 'ms', 'Minimum gap between tick starts. Slow ticks (PR polls, reconcilers) may delay the next slot see `running` indicator on /engine.') +
234
269
  settingsField('Agent Timeout', 'set-agentTimeout', e.agentTimeout || 18000000, 'ms', 'Kill agent after this duration') +
235
270
  settingsField('Heartbeat Timeout', 'set-heartbeatTimeout', e.heartbeatTimeout || 300000, 'ms', 'No output = dead after this') +
236
271
  settingsField('Idle Alert', 'set-idleAlertMinutes', e.idleAlertMinutes || 15, 'min', 'Alert after agent idle this long') +
@@ -378,6 +413,7 @@ async function openSettings() {
378
413
  const sections = [
379
414
  { id: 'runtime', label: 'Runtime & Models', featured: true, html: paneRuntime },
380
415
  { id: 'autofix', label: 'Auto-fix & Review Loop', featured: true, html: paneAutoFix },
416
+ { id: 'appearance', label: 'Appearance', html: paneAppearance },
381
417
  { id: 'projects', label: 'Projects', html: paneProjects },
382
418
  { id: 'polling', label: 'Polling', html: panePolling },
383
419
  { id: 'concurrency', label: 'Concurrency & Timeouts', html: paneConcurrency },
@@ -848,6 +884,9 @@ async function saveSettings() {
848
884
  copilotReasoningSummaries: !!document.getElementById('set-copilotReasoningSummaries')?.checked,
849
885
  maxBudgetUsd: (document.getElementById('set-maxBudgetUsd')?.value ?? '').trim(),
850
886
  disableModelDiscovery: !!document.getElementById('set-disableModelDiscovery')?.checked,
887
+ // W-mpmwxkrw000872ec — global font-size scale. Allowlist validation
888
+ // (and clamp messaging) lives server-side in handleSettingsUpdate.
889
+ fontSize: document.getElementById('set-fontSize')?.value || 'small',
851
890
  maxTurnsByType: (function() {
852
891
  var mbt = {};
853
892
  var types = ['explore', 'ask', 'review', 'implement', 'fix', 'test', 'verify', 'plan', 'decompose'];
@@ -912,6 +951,10 @@ async function saveSettings() {
912
951
  status.style.color = 'var(--green)';
913
952
  showToast('cmd-toast', 'Settings saved', true);
914
953
  }
954
+ // W-mpmwxkrw000872ec — apply font-size immediately + persist to
955
+ // localStorage so a hard reload still reflects the new pick (server is
956
+ // the cold-load authority; localStorage is the no-flash fast path).
957
+ try { applyFontSizePreference(enginePayload.fontSize); } catch (err) { /* tolerated — change shows on next reload */ }
915
958
  if (saveBtn) { saveBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/></svg> Saved'; saveBtn.style.color = 'var(--green)'; saveBtn.style.borderColor = 'var(--green)'; }
916
959
  setTimeout(function() {
917
960
  if (saveBtn) { saveBtn.disabled = false; saveBtn.style.opacity = ''; saveBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/></svg> Save'; }
@@ -5,6 +5,19 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Minions Mission Control{{title_suffix}}</title>
7
7
  <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>{{favicon_emoji}}</text></svg>">
8
+ <script>
9
+ // W-mpmwxkrw000872ec — apply the saved font-size BEFORE the stylesheet
10
+ // parses so the first paint already reflects the user's preference (no
11
+ // flash of unstyled font size). localStorage is the fast path; the server
12
+ // value is reconciled later in refresh.js's first tick.
13
+ (function() {
14
+ try {
15
+ var v = localStorage.getItem('minions:font-size');
16
+ var valid = { small: 1, medium: 1, large: 1, xlarge: 1 };
17
+ document.documentElement.setAttribute('data-font-size', (v && valid[v]) ? v : 'small');
18
+ } catch (e) { /* private mode / disabled storage — fall through to default */ }
19
+ })();
20
+ </script>
8
21
  <style>/* __CSS__ */</style>
9
22
  </head>
10
23
  <body>
@@ -1,4 +1,12 @@
1
1
  :root {
2
+ /* W-mpmwxkrw000872ec — global dashboard font-size scale. Driven by
3
+ [data-font-size] on <html>. 'small' is the historic default (1.0) so
4
+ existing users see no change. Body-level CSS `zoom` is the cheapest
5
+ way to scale every px/em/rem rule across the SPA (typography tokens
6
+ below, slim.html, and the many inline `font-size:11px` styles) without
7
+ rewriting every rule. Scales modals, drawers, and fixed-position
8
+ elements because they all live inside <body>. */
9
+ --minions-font-scale: 1;
2
10
  --bg: #0d1117; --surface: #161b22; --surface2: #21262d; --border: #30363d;
3
11
  --text: #e6edf3; --muted: #8b949e; --green: #3fb950; --yellow: #d29922;
4
12
  --blue: #58a6ff; --purple: #bc8cff; --red: #f85149; --orange: #e3b341;
@@ -23,9 +31,17 @@
23
31
  /* Transitions */
24
32
  --transition-fast: 0.15s; --transition-base: 0.2s; --transition-slow: 0.3s;
25
33
  }
34
+ /* W-mpmwxkrw000872ec — font-size scale overrides. Values picked so the
35
+ largest tier (~1.30) tops out near 18px effective for the default 14px
36
+ body without overflowing chips/badges/tables at /, /pull-requests, /qa,
37
+ /settings (spot-checked). */
38
+ :root[data-font-size="small"] { --minions-font-scale: 1; }
39
+ :root[data-font-size="medium"] { --minions-font-scale: 1.08; }
40
+ :root[data-font-size="large"] { --minions-font-scale: 1.18; }
41
+ :root[data-font-size="xlarge"] { --minions-font-scale: 1.30; }
26
42
  * { box-sizing: border-box; margin: 0; padding: 0; }
27
43
  html, body { height: 100%; margin: 0; overflow: hidden; }
28
- body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif; font-size: var(--text-xl); display: flex; flex-direction: column; }
44
+ body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif; font-size: var(--text-xl); display: flex; flex-direction: column; zoom: var(--minions-font-scale, 1); }
29
45
 
30
46
  header {
31
47
  background: var(--surface); border-bottom: 1px solid var(--border);
@@ -730,8 +746,6 @@
730
746
  .settings-rail-btn { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: var(--space-3) var(--space-6); background: transparent; border: none; border-left: 3px solid transparent; color: var(--muted); font-size: var(--text-md); font-weight: 400; font-family: inherit; cursor: pointer; text-align: left; transition: all var(--transition-fast); }
731
747
  .settings-rail-btn:hover { color: var(--text); background: var(--surface); }
732
748
  .settings-rail-btn.active { color: var(--blue); border-left-color: var(--blue); background: var(--surface); font-weight: 600; }
733
- .settings-rail-btn.featured { color: var(--text); }
734
- .settings-rail-btn.featured.active { color: var(--blue); }
735
749
  .settings-rail-btn.no-match { opacity: 0.3; }
736
750
  .settings-rail-btn .match-count { font-size: var(--text-xs); color: var(--blue); background: var(--bg); border-radius: var(--radius-xl); padding: 1px 6px; min-width: 18px; text-align: center; }
737
751
  .settings-content { flex: 1; overflow-y: auto; padding: var(--space-7) var(--space-8); min-width: 0; }
package/dashboard.js CHANGED
@@ -9100,6 +9100,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9100
9100
  else if (valid.includes(e.copilotStreamMode)) _setEngineConfig('copilotStreamMode', e.copilotStreamMode);
9101
9101
  else _clamped.push(`copilotStreamMode: "${e.copilotStreamMode}" not in [on, off] (kept previous value)`);
9102
9102
  }
9103
+ // W-mpmwxkrw000872ec — fontSize allowlist. Clamps invalid values
9104
+ // (rather than silently failing) so the dashboard bootstrap never
9105
+ // ends up with an unknown data-font-size attribute.
9106
+ if (e.fontSize !== undefined) {
9107
+ const valid = ['small', 'medium', 'large', 'xlarge'];
9108
+ if (_isClear(e.fontSize)) _deleteEngineConfig('fontSize');
9109
+ else if (valid.includes(e.fontSize)) _setEngineConfig('fontSize', e.fontSize);
9110
+ else _clamped.push(`fontSize: "${e.fontSize}" not in [${valid.join(', ')}] (kept previous value)`);
9111
+ }
9103
9112
  // maxBudgetUsd uses ?? semantics — 0 is a valid cap (read-only / dry-run agents).
9104
9113
  if (e.maxBudgetUsd !== undefined) {
9105
9114
  if (_isClear(e.maxBudgetUsd)) _deleteEngineConfig('maxBudgetUsd');
@@ -2067,6 +2067,90 @@ function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId =
2067
2067
  delete next.fixedAt;
2068
2068
  target.minionsReview = next;
2069
2069
  };
2070
+ // W-mpoeirqx0007712a — Build-fix push verification. The agent may report
2071
+ // SUCCESS while the git push silently failed to advance the remote head
2072
+ // (stale-worktree push rejected non-fast-forward, agent ignores non-zero
2073
+ // `git push` exit, etc). detectPrFixBranchChange falls back to
2074
+ // local-head / worktree-diff evidence in those scenarios and returns
2075
+ // `changed: true` even though origin/<branch> never moved. Without a
2076
+ // guard here, the optimistic stamp + 10-min buildFixGracePeriod
2077
+ // suppresses re-dispatch against a still-failing build that was never
2078
+ // actually fixed (live repro: opg-microsoft/minions PR #57).
2079
+ //
2080
+ // Only `evidence: 'remote-head'` proves the push landed. For
2081
+ // BUILD_FAILURE with changed=true AND evidence explicitly set to one of
2082
+ // the unverified types, increment `_buildFixPushFailedCount`, write an
2083
+ // inbox alert, route through recordPrNoOpFixAttempt so the cause stays
2084
+ // unhandled, and never write `_buildFixPushedAt`. When the counter
2085
+ // reaches `engine.maxBuildFixRetries`, flip `_buildFixNeedsHumanRebase`
2086
+ // so the engine stops retrying.
2087
+ //
2088
+ // Note: callers that omit `branchChange.evidence` (legacy / tests
2089
+ // predating evidence plumbing) still hit the trusted-push path below to
2090
+ // preserve backward compatibility — only the explicitly unverified
2091
+ // evidence kinds trigger this guard.
2092
+ const _unverifiedPushEvidence = new Set(['local-head', 'worktree-diff']);
2093
+ if (cause === shared.PR_FIX_CAUSE.BUILD_FAILURE
2094
+ && explicitlyChangedBranch
2095
+ && options.branchChange?.changed === true
2096
+ && _unverifiedPushEvidence.has(options.branchChange?.evidence)) {
2097
+ const maxRetries = options.config?.engine?.maxBuildFixRetries
2098
+ ?? ENGINE_DEFAULTS.maxBuildFixRetries;
2099
+ target._buildFixPushFailedCount = (Number(target._buildFixPushFailedCount) || 0) + 1;
2100
+ const reachedCap = target._buildFixPushFailedCount >= maxRetries;
2101
+ if (reachedCap) {
2102
+ target._buildFixNeedsHumanRebase = ts();
2103
+ }
2104
+ const beforeHeadStr = String(options.branchChange?.beforeHead || '').slice(0, 40);
2105
+ const afterHeadStr = String(options.branchChange?.afterHead || '').slice(0, 40);
2106
+ const evidenceStr = String(options.branchChange?.evidence || 'unknown');
2107
+ try {
2108
+ const wiId = options.dispatchItem?.meta?.item?.id || null;
2109
+ const noteBody = `# Build-fix push not verified for ${pr.id}\n\n`
2110
+ + `**PR:** ${pr.url || pr.id}\n`
2111
+ + `**Branch:** ${pr.branch || '(unknown)'}\n`
2112
+ + `**Cause:** build-failure\n`
2113
+ + `**Pre-dispatch head:** ${beforeHeadStr || '(unknown)'}\n`
2114
+ + `**Post-completion head (live):** ${afterHeadStr || '(unknown)'}\n`
2115
+ + `**Branch-change evidence:** ${evidenceStr}\n`
2116
+ + `**Attempt:** ${target._buildFixPushFailedCount}/${maxRetries}\n\n`
2117
+ + (reachedCap
2118
+ ? `⚠️ **Reached \`engine.maxBuildFixRetries\` (${maxRetries}).** PR flagged \`_buildFixNeedsHumanRebase\` — engine will stop auto-retrying. Likely root cause: worktree stale vs origin/master, push rejected non-fast-forward, or branch protection blocks the engine identity.\n`
2119
+ : `_Engine will re-dispatch on the next \`discoverFromPrs\` pass (counter < cap)._\n`)
2120
+ + `\nThe agent reported SUCCESS but the remote head did not advance — the optimistic \`_buildFixPushedAt\` stamp was suppressed to avoid the ${(ENGINE_DEFAULTS.buildFixGracePeriod / 60000) | 0}-minute grace-period blackout.\n`;
2121
+ shared.writeToInbox(
2122
+ 'engine',
2123
+ `build-fix-push-unverified-${pr.prNumber || pr.id}`,
2124
+ noteBody,
2125
+ null,
2126
+ { wi: wiId, pr: pr.id, cause: shared.PR_FIX_CAUSE.BUILD_FAILURE }
2127
+ );
2128
+ } catch (err) {
2129
+ log('warn', `build-fix push-verify inbox alert for ${pr.id}: ${err.message}`);
2130
+ }
2131
+ // Route through the noop path so the cause stays unhandled, the noop
2132
+ // counter advances symmetrically with the genuine-noop case, and the
2133
+ // existing `delete target._buildFixPushedAt` cleanup (line ~2016) runs.
2134
+ const verifyBranchChange = {
2135
+ changed: false,
2136
+ beforeHead: options.branchChange?.beforeHead,
2137
+ afterHead: options.branchChange?.afterHead,
2138
+ evidence: 'push-unverified',
2139
+ };
2140
+ const noopReason = `build-fix push unverified (evidence: ${evidenceStr}); attempt ${target._buildFixPushFailedCount}/${maxRetries}${reachedCap ? ' — needs-human-rebase' : ''}`;
2141
+ const record = recordPrNoOpFixAttempt(target, cause, source, options.dispatchItem, verifyBranchChange, options.config, noopReason);
2142
+ result = {
2143
+ noOp: true,
2144
+ cause,
2145
+ paused: !!record.paused,
2146
+ count: record.count,
2147
+ pushUnverified: true,
2148
+ pushFailedCount: target._buildFixPushFailedCount,
2149
+ needsHumanRebase: reachedCap,
2150
+ };
2151
+ log('warn', `Updated ${pr.id} → build-fix push unverified (${target._buildFixPushFailedCount}/${maxRetries}, evidence=${evidenceStr})${reachedCap ? ' [needs-human-rebase]' : ''}; remote head ${beforeHeadStr.slice(0, 8)} did not advance — inbox alert written, cause left unhandled for re-dispatch`);
2152
+ return prs;
2153
+ }
2070
2154
  if (explicitlyChangedBranch && options.branchChange?.changed === false) {
2071
2155
  const record = recordPrNoOpFixAttempt(target, cause, source, options.dispatchItem, options.branchChange, options.config, options.noopReason);
2072
2156
  result = { noOp: true, cause, paused: !!record.paused, count: record.count };
@@ -2086,6 +2170,19 @@ function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId =
2086
2170
  return prs;
2087
2171
  }
2088
2172
  clearPrNoOpFixAttempt(target, cause);
2173
+ // W-mpoeirqx0007712a — verified-push stamping for BUILD_FAILURE. Reaching
2174
+ // this point with explicitlyChangedBranch=true means the unverified-push
2175
+ // guard above did NOT trigger, so either evidence === 'remote-head'
2176
+ // (live remote refs prove the branch advanced) OR no branchChange info
2177
+ // was supplied (legacy callers that didn't pass branchChange — keep
2178
+ // existing behavior of trusting the agent's branchChanged claim).
2179
+ // Clear the push-failure counter on confirmed success so future
2180
+ // regressions start fresh.
2181
+ if (cause === shared.PR_FIX_CAUSE.BUILD_FAILURE && explicitlyChangedBranch) {
2182
+ target._buildFixPushedAt = ts();
2183
+ delete target._buildFixPushFailedCount;
2184
+ delete target._buildFixNeedsHumanRebase;
2185
+ }
2089
2186
  if (source === 'pr-human-feedback') {
2090
2187
  const clearPendingFix = shouldClearHumanFeedbackPendingFix(target, pr, automationCauseKey);
2091
2188
  if (target.humanFeedback && clearPendingFix) target.humanFeedback.pendingFix = false;
package/engine/shared.js CHANGED
@@ -1800,7 +1800,15 @@ const ENGINE_DEFAULTS = {
1800
1800
  logBufferSize: 50, // flush immediately when buffer exceeds this many entries
1801
1801
  lockRetries: 0, // no retries — single 5s timeout window with 25ms polling (200 attempts) is sufficient; stale lock recovery at 60s handles crashes
1802
1802
  lockRetryBackoffMs: 500, // base backoff between lock retries (doubles each attempt: 500ms, 1s, 2s, ...)
1803
- buildFixGracePeriod: 600000, // 10min — wait for CI to run after build fix before re-dispatching
1803
+ buildFixGracePeriod: 600000, // 10min — wait for CI to run after a verified build-fix push before re-dispatching
1804
+ // W-mpoeirqx0007712a: cap re-dispatch attempts when build-fix pushes
1805
+ // silently fail to advance the remote head (stale-worktree push rejected,
1806
+ // agent ignores non-zero git push exit and reports SUCCESS, etc).
1807
+ // updatePrAfterFix increments `_buildFixPushFailedCount` whenever the
1808
+ // post-completion branchChange has non-remote-head evidence; when the
1809
+ // counter reaches this cap, the PR is flagged `_buildFixNeedsHumanRebase`
1810
+ // so the dispatcher stops auto-retrying and a human can rescue the branch.
1811
+ maxBuildFixRetries: 3,
1804
1812
  adoPollEnabled: true, // poll ADO PR status, comments, and reconciliation on each tick cycle
1805
1813
  ghPollEnabled: true, // poll GitHub PR status, comments, and reconciliation on each tick cycle
1806
1814
  prPollStatusEvery: 12, // poll PR build/review/merge status every N ticks for both ADO and GitHub (~12 min at default interval)
@@ -1870,6 +1878,13 @@ const ENGINE_DEFAULTS = {
1870
1878
  ccUseWorkerPool: false, // Sub-task C of W-mp2w003600196c51 (CC perf): when true AND CC runtime is copilot, _invokeCcStream routes through engine/cc-worker-pool.js (persistent `copilot --acp` per CC tab) instead of spawning a fresh CLI per turn. Off by default — opt-in feature flag. **Structurally copilot-only**: the pool spawns `copilot --acp` (Agent Client Protocol); Claude Code does not implement ACP, so resolveCcUseWorkerPool returns false on non-copilot CC runtimes even with explicit-true (W-mphlriic00095f69 — prevents silent runtime switch). Engine/agent dispatch path stays per-process regardless.
1871
1879
  maxBudgetUsd: undefined, // fleet USD ceiling for --max-budget-usd (per-agent override: agents.<id>.maxBudgetUsd). Honors 0 via ?? so a literal cap of $0 works
1872
1880
  disableModelDiscovery: false, // skip runtime.listModels() REST calls fleet-wide (settings UI falls back to free-text)
1881
+ // W-mpmwxkrw000872ec — dashboard global font-size scale. Drives the
1882
+ // [data-font-size] attribute on <html> via the inline bootstrap script in
1883
+ // layout.html (localStorage fast path) and is reconciled from the server
1884
+ // value on cold loads. Valid: 'small' (current default), 'medium', 'large',
1885
+ // 'xlarge'. Allowlist validation lives in handleSettingsUpdate so invalid
1886
+ // values are clamped rather than silently breaking the bootstrap.
1887
+ fontSize: 'small',
1873
1888
  maxPendingContexts: 20, // cap pendingContexts arrays in cooldowns.json to prevent unbounded growth
1874
1889
  maxPendingContextEntryBytes: 256 * 1024, // 256 KB — cap each pendingContexts entry to prevent huge PR comments from bloating cooldowns.json
1875
1890
  maxDispatchPromptBytes: 1024 * 1024, // 1 MB — dispatch items with prompts larger than this sidecar to engine/contexts/ to prevent dispatch.json OOM (#1167)
package/engine.js CHANGED
@@ -4929,15 +4929,16 @@ async function discoverFromPrs(config, project) {
4929
4929
  }, `Fix build failure on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, cooldownKey: key, automationCauseKey: buildCauseKey, source: 'pr', pr, branch: prBranch, project: projMeta });
4930
4930
  if (item) {
4931
4931
  newWork.push(item); fixDispatched = true;
4932
- try {
4933
- const prPath = projectPrPath(project);
4934
- mutatePullRequests(prPath, prs => {
4935
- const target = shared.findPrRecord(prs, pr, project);
4936
- if (target) {
4937
- target._buildFixPushedAt = ts();
4938
- }
4939
- });
4940
- } catch (e) { log('warn', 'mark build fix dispatched: ' + e.message); }
4932
+ // W-mpoeirqx0007712a — DO NOT stamp `_buildFixPushedAt` at dispatch
4933
+ // time. The optimistic stamp here used to suppress re-dispatch for
4934
+ // the buildFixGracePeriod window even when the agent never pushed
4935
+ // (stale-worktree push silently rejected, agent reported SUCCESS
4936
+ // anyway). `_buildFixPushedAt` is now written only by
4937
+ // lifecycle.updatePrAfterFix after the post-completion branchChange
4938
+ // confirms the remote head actually advanced (evidence ===
4939
+ // 'remote-head'). In-flight dispatches are already deduplicated by
4940
+ // `isPrAutomationCausePending` + `isAlreadyDispatched` above, so no
4941
+ // race window opens by removing the optimistic stamp.
4941
4942
  }
4942
4943
 
4943
4944
  if (pr.agent && !pr._buildFailNotified) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2054",
3
+ "version": "0.1.2056",
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"