@yemi33/minions 0.1.2441 โ†’ 0.1.2443

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.
@@ -503,7 +503,7 @@ async function openSettings() {
503
503
  '<h3>Polling</h3>' +
504
504
  '<div class="settings-pane-sub">Cadence for fetching PR build status, votes, and comments from the platforms. Disabling a provider here turns the matching Auto-fix gates into no-ops. The <em>Pause All Polling</em> kill-switch below overrides both provider toggles and inerts every PR auto-dispatch gate (Auto-fix Builds / Conflicts / Review / etc.) until cleared.</div>' +
505
505
  '<div class="settings-stack" style="margin-bottom:16px">' +
506
- settingsToggle('๐Ÿ›‘ Pause ALL polling', 'set-pollingPaused', !!e.pollingPaused, 'Halts every PR poll, reconciliation, rebase processing, and work-discovery scan on the next tick. Reversible without restart. Use when hitting API rate limits.') +
506
+ settingsToggle('๐Ÿ›‘ Pause ALL polling', 'set-pollingPaused', !!e.pollingPaused, 'Halts every PR status/comment poll AND provider reconciliation (ADO + GitHub + shared-branch) on the next tick, and inerts every PR-derived auto-dispatch gate. Reversible without restart. Use when hitting API rate limits.') +
507
507
  '</div>' +
508
508
  '<div class="settings-pane-sub" style="margin-bottom:8px">Legacy bundle toggles โ€” granular controls below override these when set.</div>' +
509
509
  '<div class="settings-stack" style="margin-bottom:12px">' +
@@ -511,7 +511,7 @@ async function openSettings() {
511
511
  settingsToggle('GitHub Polling', 'set-ghPollEnabled', e.ghPollEnabled !== false, 'Legacy bundle macro โ€” when OFF, silences all three GitHub axes (status, comments, reconcile) and GitHub PR dispatch gates are inert when this is off. Per-axis flags below take priority when explicitly set. Keep ON unless you want a one-knob GitHub kill.') +
512
512
  '</div>' +
513
513
  '<details class="settings-collapsible" style="margin-bottom:12px"><summary>Granular per-poller controls (P-c4d8e1a3)</summary>' +
514
- '<div class="settings-pane-sub" style="margin-top:8px">Override individual ADO/GitHub poll axes + the pending-rebase processor. Explicit values here win; if unset, the legacy ADO/GitHub Polling macros above apply; otherwise the default is ON. <strong>Status</strong> + <strong>Comments</strong> still honor the <em>Pause All Polling</em> kill-switch above; <strong>Reconcile</strong> and <strong>Process Pending Rebases</strong> are recovery sweeps and ignore it (by design).</div>' +
514
+ '<div class="settings-pane-sub" style="margin-top:8px">Override individual ADO/GitHub poll axes + the pending-rebase processor. Explicit values here win; if unset, the legacy ADO/GitHub Polling macros above apply; otherwise the default is ON. <strong>Status</strong>, <strong>Comments</strong> and <strong>Reconcile</strong> all honor the <em>Pause All Polling</em> kill-switch above (reconcile included since W-ms5eljc60090f0d5 โ€” it issues provider API calls); only <strong>Process Pending Rebases</strong> ignores it (by design โ€” it makes no provider poll call).</div>' +
515
515
  '<div class="settings-stack" style="margin-top:8px">' +
516
516
  settingsToggle('ADO PR Status Poll', 'set-adoPrStatusPollEnabled', e.adoPrStatusPollEnabled !== false, 'Granular: ADO PR build/merge/review status poll (section 2.6). Default ON. When OFF, ADO PR status will not refresh, but reconcile + comments still run (unless their own granular flag is OFF).') +
517
517
  settingsToggle('ADO PR Comments Poll', 'set-adoPrCommentsPollEnabled', e.adoPrCommentsPollEnabled !== false, 'Granular: ADO PR human-comments poll (section 2.7). Default ON. When OFF, ADO PR comments will not surface โ€” auto-fix-human-comments still composes against this flag for ADO.') +
@@ -28,8 +28,8 @@ tick()
28
28
  syncPrdFromPrs() Backfill PRD item status from active PRs
29
29
  checkPlanCompletion() Mark plans completed when all features are terminal (done/failed/cancelled)
30
30
  2.7 pollPrHumanComments() Poll PR threads for human comments (wall-clock cadence from prPollCommentsEvery ร— tickInterval, default โ‰ˆ 12min)
31
- reconcilePrs() (ADO+GH) Reconciliation sweep (runs regardless of poll flags)
32
- reconcileSharedBranchPrs() Backfill the PR store for shared-branch plans whose aggregate remote PR is untracked/under-linked (runs unconditionally, like the other recovery sweeps)
31
+ reconcilePrs() (ADO+GH) Reconciliation sweep (honors the pollingPaused master kill-switch + the per-provider *PrReconcileEnabled flags)
32
+ reconcileSharedBranchPrs() Backfill the PR store for shared-branch plans whose aggregate remote PR is untracked/under-linked (skipped while pollingPaused is ON)
33
33
  2.9 stalled-dispatch sweep Auto-retry failed items blocking pending deps (every 120 ticks)
34
34
  3a. pruneStalePrDispatchesAsync() Clear pending PR dispatches whose underlying PRs no longer warrant action
35
35
  3. dispatch existing pending Spawn agents for items already durable in dispatch.pending (up to maxConcurrent) โ€” runs BEFORE discovery so a slow pre-dispatch validator can't starve already-queued work
@@ -74,7 +74,7 @@ Inside `discoverFromPrs()`, ADO and GitHub projects first resolve their own prov
74
74
 
75
75
  **`autoReReviewPrs` gates both re-review paths (`P-e8b1c4d2`).** Re-reviews fire from two places: (1) the open-loop **discovery** path in `engine.js:discoverFromPrs` (PR shows `reviewStatus=waiting` after a fix push), and (2) the **closure-loop** in `engine/lifecycle.js:dispatchReReviewForFix` (a fix WI completes whose meta carries `addresses_review_wi`, queuing the next review against the same PR). The `engine.autoReReviewPrs` toggle is read at BOTH sites so flipping it OFF mutes the entire re-review cycle โ€” not just the discovery sweep. Default ON; flip via Dashboard โ†’ Settings โ†’ Auto-fix & Review Loop, or `engine.autoReReviewPrs: false` in `config.json`.
76
76
 
77
- **Hard-stop kill-switch (`pollingPaused`).** `engine.pollingPaused: true` is a master override that wins over both `adoPollEnabled` and `ghPollEnabled`. When ON, section 2.6/2.7 of the tick cycle skips `pollPrStatus` and `pollPrHumanComments` for both providers, and `discoverFromPrs` forces `pollEnabled=false` for every project so every per-PR auto-dispatch gate (`autoReviewPrs` / `autoFixBuilds` / `autoFixConflicts` / `autoFixReviewFeedback` / `autoFixHumanComments`) becomes inert. Reconciliation (the recovery sweep that follows section 2.7) is intentionally not gated. The engine logs `[engine] PR polling paused โ€” โ€ฆ` once on the transition from unpaused โ†’ paused; routine PR poll log lines resume when cleared. Flip via Dashboard โ†’ Settings โ†’ Polling, or set `engine.pollingPaused: true` in `config.json`. Default OFF โ€” fresh installs behave identically to before this knob existed.
77
+ **Hard-stop kill-switch (`pollingPaused`).** `engine.pollingPaused: true` is a master override that wins over both `adoPollEnabled` and `ghPollEnabled`. When ON, section 2.6/2.7 of the tick cycle skips `pollPrStatus` and `pollPrHumanComments` for both providers, `discoverFromPrs` forces `pollEnabled=false` for every project so every per-PR auto-dispatch gate (`autoReviewPrs` / `autoFixBuilds` / `autoFixConflicts` / `autoFixReviewFeedback` / `autoFixHumanComments`) becomes inert, and โ€” since W-ms5eljc60090f0d5 โ€” the reconcile tail (`reconcilePrs`, `ghReconcilePrs`, `reconcileSharedBranchPrs`) is skipped too. Reconciliation was previously exempt as a "recovery sweep", but those sweeps walk every tracked PR and issue provider API calls, so the documented emergency pause did not actually stop provider network traffic. The per-provider `*PrReconcileEnabled` flags remain independent whenever the master switch is OFF. The engine logs `[engine] PR polling paused โ€” โ€ฆ` once on the transition from unpaused โ†’ paused; routine PR poll log lines resume when cleared. Flip via Dashboard โ†’ Settings โ†’ Polling, `POST /api/engine/polling/pause`, or set `engine.pollingPaused: true` in `config.json`. Default OFF โ€” fresh installs behave identically to before this knob existed.
78
78
 
79
79
  **Hard-stop kill-switch (`autoFixPaused`).** `engine.autoFixPaused: true` is a narrower master override that wins over every auto-fix dispatch gate. When ON, `discoverFromPrs` forces `autoFixBuilds` / `autoFixConflicts` / `autoFixReviewFeedback` / `autoFixHumanComments` to false for every project, so no fix agent is auto-dispatched against any PR. Review dispatch (`autoReviewPrs` / `autoReReviewPrs`), PR status / human-comment polling, and reconciliation are intentionally not gated โ€” operators can pause a fix-storm during an incident while still seeing fresh review verdicts and build status. The engine logs `[engine] auto-fix paused โ€” โ€ฆ` once on the transition from unpaused โ†’ paused; routine discovery resumes when cleared. Flip via Dashboard โ†’ Settings โ†’ Auto-fix & Review Loop, or set `engine.autoFixPaused: true` in `config.json`. Default OFF โ€” fresh installs behave identically to before this knob existed.
80
80
 
@@ -84,10 +84,10 @@ Inside `discoverFromPrs()`, ADO and GitHub projects first resolve their own prov
84
84
  |------|---------|-------|--------------------------------|
85
85
  | `adoPrStatusPollEnabled` | `true` | Section 2.6 (ADO `pollPrStatus`) | Yes โ€” status polls honor the master killswitch |
86
86
  | `adoPrCommentsPollEnabled` | `true` | Section 2.7 (ADO `pollPrHumanComments`) | Yes |
87
- | `adoPrReconcileEnabled` | `true` | Section 2.7 tail (ADO `reconcilePrs`) | No โ€” reconcile is a recovery sweep |
87
+ | `adoPrReconcileEnabled` | `true` | Section 2.7 tail (ADO `reconcilePrs`) | Yes โ€” the master killswitch stops provider reconciliation too (W-ms5eljc60090f0d5) |
88
88
  | `ghPrStatusPollEnabled` | `true` | Section 2.6 (GitHub `ghPollPrStatus`) | Yes |
89
89
  | `ghPrCommentsPollEnabled` | `true` | Section 2.7 (GitHub `ghPollPrHumanComments`) | Yes |
90
- | `ghPrReconcileEnabled` | `true` | Section 2.7 tail (GitHub `ghReconcilePrs`) | No โ€” reconcile is a recovery sweep |
90
+ | `ghPrReconcileEnabled` | `true` | Section 2.7 tail (GitHub `ghReconcilePrs`) | Yes โ€” the master killswitch stops provider reconciliation too (W-ms5eljc60090f0d5) |
91
91
  | `processPendingRebasesEnabled` | `true` | Section 2.6 tail (`processPendingRebases`) | No โ€” rebase processor has no legacy macro |
92
92
 
93
93
  Resolution order (`shared.resolvePollFlag(engineCfg, granularKey, legacyMacroKey)`): (1) granular flag explicitly set โ†’ wins; (2) legacy macro is `false` โ†’ propagates `false` to all three axes of that provider (including reconcile); (3) otherwise โ†’ `ENGINE_DEFAULTS[granularKey]` (`true`).
@@ -116,12 +116,12 @@ warns when required permission-bypass flags are absent.
116
116
  ```powershell
117
117
  # Test A: stdin without -p โ€” works.
118
118
  "Say only the word: pong" |
119
- copilot --output-format json -s --allow-all --no-ask-user --autopilot --log-level error
119
+ copilot --output-format json -s --allow-all --no-ask-user --autopilot
120
120
  # EXIT=0; user.message.data.content = "Say only the word: pong\r\n"; assistant replied "pong".
121
121
 
122
122
  # Test B: -p "<40_000-char string>" โ€” Windows OS rejects spawn.
123
123
  $big = "x" * 40000
124
- copilot -p $big --output-format json -s --allow-all --no-ask-user --autopilot --log-level error
124
+ copilot -p $big --output-format json -s --allow-all --no-ask-user --autopilot
125
125
  # Program 'copilot.exe' failed to run:
126
126
  # The filename or extension is too long.
127
127
  # (CreateProcess ARG_MAX limit, ~32 KB on Windows)
@@ -176,7 +176,7 @@ Empirically confirmed flags for non-interactive Copilot invocations:
176
176
  | `--allow-all` | **required** | Equivalent to `--allow-all-tools --allow-all-paths --allow-all-urls`. Without this the CLI prompts for every tool/path use, which deadlocks in stdin/stdout mode. |
177
177
  | `--no-ask-user` | **required** | Removes the `ask_user` tool. Without it the agent can stall waiting for human input. |
178
178
  | `--autopilot` | for multi-turn agency | Enables `task_complete`-driven multi-turn loop. **Without it** the session ends after one assistant response (see ยง3.1). |
179
- | `--log-level error` | recommended | Suppresses INFO/DEBUG diagnostics that aren't part of the JSONL stream. |
179
+ | `--log-level <level>` | **do not emit** | Copilot CLI 1.0.76-1 exits silently with code 1 for explicit non-default levels. Omit the flag and use the CLI default; `--output-format json` still keeps stdout machine-readable. |
180
180
  | `--disable-builtin-mcps` | gated by config | Disables `github-mcp-server`. Default-on for Minions (`copilotDisableBuiltinMcps: true`) to prevent split-brain PR creation. |
181
181
  | `--no-color` | always emitted | Keeps any incidental CLI rendering free of ANSI color. |
182
182
  | `--plain-diff` | always emitted | Keeps incidental diff rendering plain even though normal output is JSONL. |
@@ -499,7 +499,7 @@ Constructed test: created `AGENTS.md` in cwd with content
499
499
 
500
500
  ```text
501
501
  # A) Default behavior โ€” AGENTS.md is loaded
502
- PS> "Just say hello." | copilot --output-format json -s --allow-all --no-ask-user --autopilot --log-level error
502
+ PS> "Just say hello." | copilot --output-format json -s --allow-all --no-ask-user --autopilot
503
503
  {"type":"assistant.message", ..., "content": "Hello. __AGENTS_LOADED__"} โ† marker present
504
504
 
505
505
  ```
@@ -602,11 +602,11 @@ When implementing `engine/runtimes/copilot.js`:
602
602
  - Recognize npm shims returned by PATH and resolve the package's JavaScript
603
603
  entry; no separate npm-prefix scan is needed.
604
604
  3. `buildArgs(opts)` always emits:
605
- `--output-format json -s --no-color --plain-diff --allow-all --no-ask-user --autopilot --log-level error`
605
+ `--output-format json -s --no-color --plain-diff --allow-all --no-ask-user --autopilot`
606
606
  plus the conditional flags from ยง3, plus `--disable-builtin-mcps` per
607
607
  `opts.disableBuiltinMcps`,
608
608
  plus `--attachment <path>` (repeatable) for each image in `opts.images` (ยง10a).
609
- **Never** emit `--verbose`.
609
+ **Never** emit `--verbose` or `--log-level`.
610
610
  4. `buildPrompt()` injects `<system>...</system>\n\n` when sysprompt is
611
611
  non-empty on a fresh call; a resumed call (`opts.sessionId`) passes through
612
612
  only the new user prompt (ยง2).
@@ -36,10 +36,10 @@ There are two independent operator-facing master flags:
36
36
 
37
37
  | Flag | Default | Effect |
38
38
  |------------------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
39
- | `pollingPaused` | `false` | Skips `pollPrStatus` + `pollPrHumanComments` for both providers. Forces `pollEnabled=false` in `discoverFromPrs` so every per-PR auto-dispatch gate (`autoReviewPrs` / `autoFixBuilds` / โ€ฆ) becomes inert. Reconciliation (recovery sweep) intentionally still runs. Default OFF โ€” fresh installs behave identically. |
39
+ | `pollingPaused` | `false` | Skips `pollPrStatus` + `pollPrHumanComments` for both providers, plus the reconcile tail (`reconcilePrs` / `ghReconcilePrs` / `reconcileSharedBranchPrs`) โ€” the master switch stops ALL provider network polling (W-ms5eljc60090f0d5). Forces `pollEnabled=false` in `discoverFromPrs` so every per-PR auto-dispatch gate (`autoReviewPrs` / `autoFixBuilds` / โ€ฆ) becomes inert. Default OFF โ€” fresh installs behave identically. |
40
40
  | `autoFixPaused` | `false` | Narrower: forces `autoFixBuilds` / `autoFixConflicts` / `autoFixReviewFeedback` / `autoFixHumanComments` to `false` for every project. Review dispatch, polling, and reconciliation are intentionally not gated, so operators can stop a fix-storm while still seeing fresh verdicts and build status. Default OFF. |
41
41
 
42
- These compose: setting `pollingPaused: true` plus `autoFixPaused: true` halts *all* automated PR work (review + fix + polling). Setting only `autoFixPaused: true` halts only auto-fix dispatch. Setting only `pollingPaused: true` halts polling and effectively halts dispatch (because the per-PR gates rely on cached poll state), but leaves reconciliation running and lets manual `/api/pull-requests/observe` or operator-driven dispatch still proceed.
42
+ These compose: setting `pollingPaused: true` plus `autoFixPaused: true` halts *all* automated PR work (review + fix + polling). Setting only `autoFixPaused: true` halts only auto-fix dispatch. Setting only `pollingPaused: true` halts polling *and reconciliation* (all provider network traffic) and effectively halts dispatch (because the per-PR gates rely on cached poll state), while still letting manual `/api/pull-requests/observe` or operator-driven dispatch proceed.
43
43
 
44
44
  Flip via Dashboard โ†’ Settings โ†’ Polling (`pollingPaused`) / Auto-fix & Review Loop (`autoFixPaused`), or set `engine.<flag>: true` in `config.json`.
45
45
 
package/engine/ado.js CHANGED
@@ -774,6 +774,43 @@ function _hasPendingReReviewWi(pr) {
774
774
 
775
775
  let _adoTokenCache = { token: null, expiresAt: 0 };
776
776
 
777
+ // W-ms5eljc60090f0d5 โ€” Single-flight guard for token acquisition. `forEachActivePr`
778
+ // runs up to five requests concurrently and `adoFetch` re-acquires on every HTML
779
+ // (auth-redirect) body, so without this latch one expired token produced one
780
+ // `az`/`azureauth` shell-out PER in-flight request (a refresh stampede that
781
+ // degrades the whole engine host). Concurrent callers awaiting the same missing
782
+ // token share this promise; it is cleared on BOTH success and failure so a
783
+ // rejected acquisition can never wedge the latch permanently.
784
+ let _adoTokenInflight = null;
785
+
786
+ // Test seam for the acquisition shell-out. Production callers leave this null and
787
+ // get `acquireAdoToken`; tests set it so `adoFetch`'s internal `getAdoToken()`
788
+ // call (which takes no opts) can be exercised without spawning az/azureauth.
789
+ let _adoTokenAcquireOverride = null;
790
+ function _setAdoTokenAcquirerForTest(fn) { _adoTokenAcquireOverride = (typeof fn === 'function') ? fn : null; }
791
+
792
+ /**
793
+ * Generation-safe cache invalidation. An ADO response only proves that the token
794
+ * IT used is dead โ€” a slower request still carrying a superseded token must not
795
+ * evict the token a concurrent refresh already installed, or every late response
796
+ * re-opens the stampede window. Returns true only when the cache was actually
797
+ * cleared.
798
+ */
799
+ function _invalidateAdoTokenIfCurrent(usedToken) {
800
+ const used = typeof usedToken === 'string' ? usedToken : '';
801
+ if (!used) return false; // request had no token to invalidate
802
+ if (!_adoTokenCache.token) return false; // already cleared by a sibling response
803
+ if (_adoTokenCache.token !== used) return false; // a NEWER token is cached โ€” leave it alone
804
+ _adoTokenCache = { token: null, expiresAt: 0 };
805
+ return true;
806
+ }
807
+
808
+ /** Read-only cache peek โ€” exported for testing only. */
809
+ function _peekAdoTokenCacheForTest() { return { ..._adoTokenCache }; }
810
+
811
+ /** Read-only in-flight peek โ€” exported for testing only. */
812
+ function _getAdoTokenInflightForTest() { return _adoTokenInflight; }
813
+
777
814
  // โ”€โ”€ ADO token acquisition backoff + health (W-mqidhwcc000m897e) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
778
815
  // Graduated backoff replaces the old flat 10-min blackout: a single transient
779
816
  // blip (broker hiccup, az cold-start, network) now backs off 30s and doubles
@@ -965,13 +1002,30 @@ function isAdoAuthError(err) {
965
1002
 
966
1003
  async function getAdoToken(opts = {}) {
967
1004
  // _acquire is a test seam โ€” production callers pass no args (unchanged).
968
- const acquire = opts._acquire || acquireAdoToken;
1005
+ const acquire = opts._acquire || _adoTokenAcquireOverride || acquireAdoToken;
969
1006
  if (_adoTokenCache.token && Date.now() < _adoTokenCache.expiresAt) {
970
1007
  return _adoTokenCache.token;
971
1008
  }
1009
+ // Single-flight: an acquisition is already running, so join it instead of
1010
+ // spawning a second `az`/`azureauth` process. Checked BEFORE the backoff gate
1011
+ // because the in-flight attempt has not recorded a failure yet.
1012
+ if (_adoTokenInflight) return _adoTokenInflight;
972
1013
  // Graduated backoff โ€” skip acquisition (return null) while backing off so a
973
1014
  // transient blip doesn't blackout polling/reconciliation for the full cap.
974
1015
  if (_adoTokenBackoff.isBackingOff()) return null;
1016
+ // NOTE: everything from the cache read to this assignment is synchronous, so
1017
+ // concurrent callers cannot interleave and both start an acquisition.
1018
+ const inflight = _acquireAdoTokenOnce(acquire);
1019
+ _adoTokenInflight = inflight;
1020
+ // Clear on BOTH settle paths. `_acquireAdoTokenOnce` swallows its own errors,
1021
+ // but the identity check keeps a late clear from clobbering a newer attempt.
1022
+ const clear = () => { if (_adoTokenInflight === inflight) _adoTokenInflight = null; };
1023
+ inflight.then(clear, clear);
1024
+ return inflight;
1025
+ }
1026
+
1027
+ /** One acquisition attempt. Never rejects โ€” resolves to a token string or null. */
1028
+ async function _acquireAdoTokenOnce(acquire) {
975
1029
  try {
976
1030
  const { token } = await acquire({ execAsync, timeout: 15000 });
977
1031
  _adoTokenCache = { token, expiresAt: Date.now() + 30 * 60 * 1000 };
@@ -1025,10 +1079,15 @@ async function adoFetch(url, token, opts = {}) {
1025
1079
  if (!res.ok) throw new Error(`ADO API ${method} ${res.status}: ${res.statusText}`);
1026
1080
  const text = await res.text();
1027
1081
  if (!text || text.trimStart().startsWith('<')) {
1028
- _adoTokenCache = { token: null, expiresAt: 0 };
1082
+ // Generation-safe: only evict the cache when THIS request's token is still
1083
+ // the cached one. A late response carrying a superseded token must not wipe
1084
+ // the token a concurrent refresh already installed. Combined with the
1085
+ // single-flight latch in getAdoToken, N concurrent HTML responses on one
1086
+ // stale token cost exactly ONE acquisition, not N.
1087
+ _invalidateAdoTokenIfCurrent(token);
1029
1088
  if (_retryCount < MAX_RETRIES) {
1030
1089
  const freshToken = await getAdoToken();
1031
- if (freshToken) {
1090
+ if (freshToken && freshToken !== token) {
1032
1091
  log('info', 'ADO token expired mid-session โ€” refreshed and retrying');
1033
1092
  return adoFetch(url, freshToken, { ...opts, _retryCount: _retryCount + 1 });
1034
1093
  }
@@ -2889,6 +2948,7 @@ function _setAdoThrottleForTest(state, orgBase = 'dev.azure.com/__test__') {
2889
2948
  * Lets tests exercise functions that call getAdoToken() without invoking azureauth.
2890
2949
  * Pass null to force getAdoToken() to return null synchronously (no exec). */
2891
2950
  function _setAdoTokenForTest(token) {
2951
+ _adoTokenInflight = null;
2892
2952
  if (token == null) {
2893
2953
  // Clear cache AND open a long backoff window so getAdoToken short-circuits
2894
2954
  // to null without spawning azureauth โ€” otherwise tests would hang on the
@@ -2907,6 +2967,8 @@ function _setAdoTokenForTest(token) {
2907
2967
  /** Reset all token backoff + health + dedup state โ€” exported for testing only. */
2908
2968
  function _resetAdoTokenForTest() {
2909
2969
  _adoTokenCache = { token: null, expiresAt: 0 };
2970
+ _adoTokenInflight = null;
2971
+ _adoTokenAcquireOverride = null;
2910
2972
  _adoTokenBackoff._reset();
2911
2973
  _adoTokenHealth = { lastSuccessAt: 0, lastFailureReason: null, classification: null };
2912
2974
  _adoTokenAuthNoteSent = false;
@@ -3099,6 +3161,11 @@ module.exports = {
3099
3161
  _resetAdoTokenForTest, // exported for testing
3100
3162
  _setAdoTokenBackoffForTest, // exported for testing
3101
3163
  _setAdoTokenAuthNoteWriterForTest, // exported for testing
3164
+ // W-ms5eljc60090f0d5 โ€” single-flight acquisition + generation-safe invalidation
3165
+ _invalidateAdoTokenIfCurrent, // exported for testing
3166
+ _setAdoTokenAcquirerForTest, // exported for testing
3167
+ _peekAdoTokenCacheForTest, // exported for testing
3168
+ _getAdoTokenInflightForTest, // exported for testing
3102
3169
  isAdoThrottled,
3103
3170
  getAdoThrottleState,
3104
3171
  fetchAdoPrMetadata,
@@ -434,12 +434,15 @@ function _mapEffort(level) {
434
434
  //
435
435
  // Always-on baseline (per docs/copilot-cli-schema.md ยง3 and the PRD spec):
436
436
  // --output-format json -s --no-color --plain-diff --autopilot
437
- // --allow-all --no-ask-user --log-level error
437
+ // --allow-all --no-ask-user
438
438
  //
439
439
  // Conditional flags only emitted when their corresponding opt is set/truthy.
440
440
  // Copilot has no --verbose flag โ€” never emit it. The `bare` / `maxBudget` /
441
441
  // `fallbackModel` opts are silently ignored (their capability flags are false
442
442
  // so engine code shouldn't pass them, but we tolerate them gracefully).
443
+ // Do not emit --log-level: Copilot CLI 1.0.76-1 exits silently with code 1
444
+ // when given an explicit non-default level. Omitting it preserves the CLI's
445
+ // compatible default while --output-format json keeps stdout machine-readable.
443
446
 
444
447
  // Map MIME type to file extension for --attachment temp file naming.
445
448
  const _MIME_EXT = {
@@ -475,7 +478,6 @@ function buildArgs(opts = {}) {
475
478
  '--autopilot',
476
479
  '--allow-all',
477
480
  '--no-ask-user',
478
- '--log-level', 'error',
479
481
  ];
480
482
 
481
483
  if (Array.isArray(addDirs)) {
package/engine/shared.js CHANGED
@@ -3201,8 +3201,12 @@ const ENGINE_DEFAULTS = {
3201
3201
  // autoReviewPrs / autoFixReviewFeedback / autoFixHumanComments) all
3202
3202
  // become inert. Default false so a fresh install behaves identically to
3203
3203
  // before this knob existed. Flip via Dashboard โ†’ Settings โ†’ Polling, or
3204
- // set `engine.pollingPaused: true` in config.json. Reconciliation still
3205
- // runs (it's a recovery sweep, not a convenience poll) โ€” see section 2.7.
3204
+ // set `engine.pollingPaused: true` in config.json. W-ms5eljc60090f0d5: the
3205
+ // section 2.7 reconcile tail (reconcilePrs / ghReconcilePrs /
3206
+ // reconcileSharedBranchPrs) is suppressed too โ€” those sweeps walk every
3207
+ // tracked PR and issue provider API calls, so an emergency pause that left
3208
+ // them running did not actually stop provider network traffic. The
3209
+ // per-provider *PrReconcileEnabled flags stay independent while this is off.
3206
3210
  pollingPaused: false, // hard-stop kill-switch / master override (see comment above)
3207
3211
  autoReReviewPrs: true, // auto-dispatch review agents after a PR fix is pushed
3208
3212
  autoFixReviewFeedback: true, // auto-dispatch fix agents for minions review changes-requested verdicts
package/engine.js CHANGED
@@ -12158,13 +12158,69 @@ const TICK_TIMEOUT_MS = 300000; // 5 min โ€” force-release tick lock if stuck
12158
12158
  // the entire tick loop indefinitely. The outer TICK_TIMEOUT_MS guard only
12159
12159
  // rescues the tick lock; withTickTimeout surfaces the hung operation as an
12160
12160
  // error on that tick so subsequent phases can still run.
12161
- function withTickTimeout(fn, label, ms) {
12161
+ //
12162
+ // W-ms5eljc60090f0d5 โ€” Promise.race does NOT cancel `fn()`. A phase that blew
12163
+ // its deadline keeps running, so a later tick used to launch a SECOND copy of
12164
+ // the same phase on top of it (two concurrent pollPrStatus sweeps re-issuing
12165
+ // the same provider requests, doubling token/API pressure). `_phaseInFlight`
12166
+ // tracks the real underlying promise per label and makes the next invocation
12167
+ // of a still-running phase a fast rejection (`tick-phase-busy: โ€ฆ`) instead of
12168
+ // an overlapping run. Every call site already catches and logs, so the tick
12169
+ // keeps making progress and pending dispatches are still reached.
12170
+ //
12171
+ // Escape hatch: a promise that NEVER settles would otherwise silence its label
12172
+ // forever (fatal for `discoverWork`). Once a slot is older than
12173
+ // TICK_PHASE_ABANDON_MS the guard abandons tracking of the wedged run, logs
12174
+ // loudly, and lets a fresh invocation through โ€” bounding the pathological
12175
+ // overlap to once per abandon-window instead of once per 10s tick.
12176
+ const _phaseInFlight = new Map(); // label -> { promise, startedAt }
12177
+
12178
+ /** How long a still-running phase may hold its slot before the guard gives up on
12179
+ * it. Deliberately generous (>= the outer tick-lock force-release window) so it
12180
+ * only ever fires for genuinely wedged work, never for a merely slow sweep. */
12181
+ const TICK_PHASE_ABANDON_MS = 300000; // 5 min
12182
+
12183
+ /** True while `label`'s previous invocation is still running (even if it already
12184
+ * blew its tickOpTimeoutMs deadline). Exported for testing. */
12185
+ function isTickPhaseInFlight(label) { return _phaseInFlight.has(label); }
12186
+
12187
+ /** Drop all phase-in-flight tracking โ€” exported for testing only. */
12188
+ function _resetTickPhasesForTest() { _phaseInFlight.clear(); }
12189
+
12190
+ function withTickTimeout(fn, label, ms, opts = {}) {
12191
+ // abandonMs is a test seam โ€” production callers pass no opts.
12192
+ const abandonMs = Number.isFinite(opts.abandonMs) ? opts.abandonMs : TICK_PHASE_ABANDON_MS;
12193
+ const existing = _phaseInFlight.get(label);
12194
+ if (existing) {
12195
+ const ageMs = Date.now() - existing.startedAt;
12196
+ if (ageMs < abandonMs) {
12197
+ return Promise.reject(new Error(`tick-phase-busy: ${label} skipped โ€” a previous invocation is still running (${ageMs}ms)`));
12198
+ }
12199
+ // Wedged past the abandon window: stop waiting on it. The release handler
12200
+ // below is identity-checked, so the abandoned run can never clear the
12201
+ // successor's slot when (if ever) it finally settles.
12202
+ log('warn', `[tick] abandoning wedged phase ${label} after ${ageMs}ms โ€” starting a fresh invocation`);
12203
+ _phaseInFlight.delete(label);
12204
+ }
12205
+ let started;
12206
+ try {
12207
+ started = Promise.resolve(fn());
12208
+ } catch (e) {
12209
+ return Promise.reject(e);
12210
+ }
12211
+ const entry = { promise: started, startedAt: Date.now() };
12212
+ _phaseInFlight.set(label, entry);
12213
+ // Release the slot only when the UNDERLYING work truly settles โ€” not when the
12214
+ // race below rejects. Identity check so a stale settle can't clear a successor.
12215
+ const release = () => { if (_phaseInFlight.get(label) === entry) _phaseInFlight.delete(label); };
12216
+ started.then(release, release);
12217
+ let timer = null;
12162
12218
  return Promise.race([
12163
- fn(),
12164
- new Promise((_, reject) =>
12165
- setTimeout(() => reject(new Error(`tick-timeout: ${label} exceeded ${ms}ms`)), ms)
12166
- ),
12167
- ]);
12219
+ started,
12220
+ new Promise((_, reject) => {
12221
+ timer = setTimeout(() => reject(new Error(`tick-timeout: ${label} exceeded ${ms}ms`)), ms);
12222
+ }),
12223
+ ]).finally(() => { if (timer) clearTimeout(timer); });
12168
12224
  }
12169
12225
 
12170
12226
  // P-c2e5a1d9-a โ€” Generation counter that is incremented on every tick start
@@ -12496,9 +12552,14 @@ async function tickInner() {
12496
12552
  // P-a1f3c2d4: engine.pollingPaused is a hard-stop master switch โ€” it overrides
12497
12553
  // both per-provider toggles. When ON, adoPollEnabled and ghPollEnabled are
12498
12554
  // forced to false so the status-poll and human-comment-poll gates below
12499
- // short-circuit without editing every per-call gate. Reconciliation (the
12500
- // final block below) is NOT gated โ€” it's a recovery sweep, not a
12501
- // convenience poll. The same gate is mirrored inside discoverFromPrs.
12555
+ // short-circuit without editing every per-call gate. The same gate is mirrored
12556
+ // inside discoverFromPrs.
12557
+ // W-ms5eljc60090f0d5: reconciliation is now gated too. It was previously exempt
12558
+ // ("recovery sweep, not a convenience poll"), but reconcilePrs / ghReconcilePrs
12559
+ // walk every tracked PR and issue provider API calls, so the documented
12560
+ // emergency stop (POST /api/engine/polling/pause) did not actually stop
12561
+ // provider network traffic. The per-provider *PrReconcileEnabled flags stay
12562
+ // independent whenever the master switch is OFF.
12502
12563
  const pollingPaused = config.engine?.pollingPaused === true;
12503
12564
  if (pollingPaused && _shouldLogPollingPausedOnce()) {
12504
12565
  log('info', '[engine] PR polling paused โ€” engine.pollingPaused=true overrides adoPollEnabled/ghPollEnabled');
@@ -12519,8 +12580,8 @@ async function tickInner() {
12519
12580
  const ghStatusPollEnabled = !pollingPaused && resolvePollFlag(config.engine, 'ghPrStatusPollEnabled', 'ghPollEnabled');
12520
12581
  const adoCommentsPollEnabled = !pollingPaused && resolvePollFlag(config.engine, 'adoPrCommentsPollEnabled', 'adoPollEnabled');
12521
12582
  const ghCommentsPollEnabled = !pollingPaused && resolvePollFlag(config.engine, 'ghPrCommentsPollEnabled', 'ghPollEnabled');
12522
- const adoReconcileEnabled = resolvePollFlag(config.engine, 'adoPrReconcileEnabled', 'adoPollEnabled');
12523
- const ghReconcileEnabled = resolvePollFlag(config.engine, 'ghPrReconcileEnabled', 'ghPollEnabled');
12583
+ const adoReconcileEnabled = !pollingPaused && resolvePollFlag(config.engine, 'adoPrReconcileEnabled', 'adoPollEnabled');
12584
+ const ghReconcileEnabled = !pollingPaused && resolvePollFlag(config.engine, 'ghPrReconcileEnabled', 'ghPollEnabled');
12524
12585
  const rebaseProcessorEnabled = resolvePollFlag(config.engine, 'processPendingRebasesEnabled', null);
12525
12586
  const prPollStatusEvery = Math.max(
12526
12587
  1,
@@ -12619,11 +12680,13 @@ async function tickInner() {
12619
12680
  }
12620
12681
  if (commentPolls.length) await Promise.allSettled(commentPolls);
12621
12682
  if (_isTickStale(myGeneration)) return;
12622
- // P-c4d8e1a3: reconcile gated by *PrReconcileEnabled (default true) โ€” ungated
12623
- // by ADO/GH throttle trackers (recovery-sweep contract intact). Legacy
12624
- // adoPollEnabled/ghPollEnabled=false now also silences reconcile via
12625
- // resolvePollFlag's legacy-macro fallback (documented breaking change).
12626
- // ADO and GitHub reconciliation are independent and run in parallel.
12683
+ // P-c4d8e1a3: legacy adoPollEnabled/ghPollEnabled=false also silences
12684
+ // reconcile via resolvePollFlag's legacy-macro fallback (documented
12685
+ // breaking change). ADO and GitHub reconcilers are independent and run in
12686
+ // parallel; both are gated by *PrReconcileEnabled (default true) and by the
12687
+ // engine.pollingPaused master kill-switch (W-ms5eljc60090f0d5, so "pause
12688
+ // polling" means zero provider network traffic), but stay ungated by the
12689
+ // ADO/GH throttle trackers (recovery-sweep contract intact).
12627
12690
  const reconcilePolls = [];
12628
12691
  if (adoReconcileEnabled) {
12629
12692
  reconcilePolls.push(withTickTimeout(() => reconcilePrs(config), 'reconcilePrs', tickOpTimeoutMs).catch(err => { log('warn', `ADO PR reconciliation error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
@@ -12662,12 +12725,16 @@ async function tickInner() {
12662
12725
  // but is untracked / under-linked, so the dashboard renders the single
12663
12726
  // aggregate PR instead of N phantom per-item rows. Host-agnostic and fully
12664
12727
  // self-guarding (per-PRD/per-project try/catch, never creates a remote PR),
12665
- // so it runs unconditionally in the reconcile phase like the other recovery
12666
- // sweeps โ€” independent of the ADO/GitHub poll + throttle gates.
12667
- try {
12668
- await withTickTimeout(() => reconcileSharedBranchPrs(config), 'reconcileSharedBranchPrs', tickOpTimeoutMs);
12669
- } catch (err) {
12670
- log('warn', `[shared-branch-reconcile] sweep error: ${err?.message || err}`);
12728
+ // so it runs independently of the ADO/GitHub per-provider poll + throttle
12729
+ // gates. W-ms5eljc60090f0d5: it DOES query the remote for an open PR, so the
12730
+ // pollingPaused master kill-switch suppresses it too โ€” "pause polling" must
12731
+ // mean zero provider network traffic.
12732
+ if (!pollingPaused) {
12733
+ try {
12734
+ await withTickTimeout(() => reconcileSharedBranchPrs(config), 'reconcileSharedBranchPrs', tickOpTimeoutMs);
12735
+ } catch (err) {
12736
+ log('warn', `[shared-branch-reconcile] sweep error: ${err?.message || err}`);
12737
+ }
12671
12738
  }
12672
12739
  if (_isTickStale(myGeneration)) return;
12673
12740
  }
@@ -13618,6 +13685,8 @@ module.exports = {
13618
13685
  _isTickStale,
13619
13686
  // #421 โ€” exported for testing the per-tick hard timeout path
13620
13687
  _resolveTickHardTimeoutMs,
13688
+ // W-ms5eljc60090f0d5 โ€” exported for testing the phase-in-flight re-entry guard
13689
+ withTickTimeout, isTickPhaseInFlight, _resetTickPhasesForTest,
13621
13690
  _runScheduleBootstrapOnce, // exported for testing
13622
13691
  get _scheduleBootstrapDone() { return _scheduleBootstrapDone; },
13623
13692
  set _scheduleBootstrapDone(v) { _scheduleBootstrapDone = v; },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2441",
3
+ "version": "0.1.2443",
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"