@yemi33/minions 0.1.2249 → 0.1.2251

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/minions.js CHANGED
@@ -734,9 +734,11 @@ if (devMode) process.env.MINIONS_DASHBOARD_PORT = String(devPort);
734
734
  const DASHBOARD_PORT = resolveDashboardPort(rest).port;
735
735
  // Propagate the port to every child (engine, dashboard, delegated subcommands).
736
736
  // engine/cli.js status reads MINIONS_PORT; dashboard.js reads PORT. We export
737
- // MINIONS_PORT for the engine + watch-actions / pipeline / managed-spawn
738
- // loopback callers that already honor it, and pass PORT explicitly when
739
- // spawning dashboard.js.
737
+ // MINIONS_PORT as the *requested* port for legacy consumers, but loopback callers
738
+ // (watch-actions / pipeline / managed-spawn) must read the *actual* bound port
739
+ // from the dashboard-port.json beacon via shared.readDashboardPortFile() — the
740
+ // dashboard may bind to a fallback port (7332+) on EADDRINUSE, and MINIONS_PORT
741
+ // stays frozen at the originally-requested value in that case.
740
742
  process.env.MINIONS_PORT = String(DASHBOARD_PORT);
741
743
  const POST_UPDATE_INIT_TIMEOUT_MS = 120000;
742
744
  // W-mqpx0tpi — the post-update `minions restart` runs as a child of `minions
@@ -2050,7 +2050,7 @@ async function ccExecuteAction(action, targetTabId, opts) {
2050
2050
  break;
2051
2051
  }
2052
2052
  case 'link-pr': {
2053
- var prLinkRes = await _ccFetch('/api/pull-requests/link', { url: action.url, title: action.title || '', project: action.project || '', autoObserve: action.autoObserve !== false });
2053
+ var prLinkRes = await _ccFetch('/api/pull-requests/link', { url: action.url, title: action.title || '', project: action.project || '', contextOnly: action.autoObserve === false });
2054
2054
  var prLinkData = await prLinkRes.json().catch(function() { return {}; });
2055
2055
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; PR URL and server message wrapped in escHtml() (fields: action.url, prLinkData.message)
2056
2056
  status.innerHTML = '&#10003; PR linked: <strong>' + escHtml(action.url) + '</strong>' +
@@ -869,10 +869,16 @@ function _processStatusUpdate(data, opts) {
869
869
  _safeRender('commands', function() { renderCommands(data.commands || []); });
870
870
  _changed('mcpServers', data.mcpServers);
871
871
  _safeRender('mcpServers', function() { renderMcpServers(data.mcpServers || []); });
872
- // Harness propagation diagnostic comes from /api/harness/diagnostics
873
- // (rare-change, fetched on each /api/status refresh so the tools page is
874
- // always up to date when an operator drops a project-local skill).
872
+ // Harness propagation diagnostic comes from /api/harness/diagnostics.
873
+ // HEAVY ENDPOINT: the handler runs a synchronous `git status` per linked
874
+ // project (execFileSync, 10s timeout) which hard-blocks the dashboard event
875
+ // loop — a single WSL/UNC-path project freezes the whole dashboard for up to
876
+ // 10s on every poll. It is also "rare-change" and its output is ONLY rendered
877
+ // on the Tools page (`#harness-diag`), with no cross-page consumer of
878
+ // window._lastHarnessDiag. So only poll it while the Tools page is open;
879
+ // every other page skips the fetch entirely. (Load-reduction audit 2026-06-24.)
875
880
  _safeRender('harnessDiag', function() {
881
+ if (typeof currentPage !== 'undefined' && currentPage !== 'tools') return;
876
882
  fetch('/api/harness/diagnostics')
877
883
  .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
878
884
  .then(function (fresh) {
@@ -59,9 +59,13 @@ async function fetchPullRequestsFromDisk(projects) {
59
59
  return out;
60
60
  }
61
61
 
62
+ // Terminal statuses — follow-up WIs in these states are historical, not active.
63
+ var _WI_TERMINAL = new Set(['done', 'cancelled', 'failed', 'decomposed', 'in-pr', 'implemented', 'complete']);
64
+
62
65
  function _countPrFollowups(pr) {
63
- // PR follow-up chip (W-mpej3cox00099466) — counts WIs whose
64
- // meta.pr_followup.parent_pr_url or parent_pr_id matches this PR.
66
+ // PR follow-up chip (W-mpej3cox00099466) — counts ACTIVE (non-terminal) WIs
67
+ // whose meta.pr_followup.parent_pr_url or parent_pr_id matches this PR.
68
+ // Terminal WIs (done/cancelled/failed) are excluded to avoid stale noise.
65
69
  if (!pr) return 0;
66
70
  var wis = (window._lastWorkItems) || [];
67
71
  if (!wis.length) return 0;
@@ -69,7 +73,9 @@ function _countPrFollowups(pr) {
69
73
  var prId = pr.id || '';
70
74
  var n = 0;
71
75
  for (var i = 0; i < wis.length; i++) {
72
- var f = wis[i] && wis[i].meta && wis[i].meta.pr_followup;
76
+ var wi = wis[i];
77
+ if (!wi || _WI_TERMINAL.has(wi.status)) continue;
78
+ var f = wi.meta && wi.meta.pr_followup;
73
79
  if (!f) continue;
74
80
  if (prUrl && f.parent_pr_url === prUrl) { n++; continue; }
75
81
  if (prId && f.parent_pr_id === prId) { n++; }
@@ -114,7 +120,7 @@ function prRow(pr) {
114
120
  : '';
115
121
  var followupCount = _countPrFollowups(pr);
116
122
  var followupChip = followupCount > 0
117
- ? ' <span class="pr-badge draft" style="font-size:var(--text-xs)" title="' + followupCount + ' follow-up work item(s) dispatched from comments on this PR">+' + followupCount + ' follow-up' + (followupCount === 1 ? '' : 's') + '</span>'
123
+ ? ' <span class="pr-badge draft" style="font-size:var(--text-xs)" title="' + followupCount + ' active follow-up work item(s) in progress from comments on this PR">+' + followupCount + ' follow-up' + (followupCount === 1 ? '' : 's') + '</span>'
118
124
  : '';
119
125
  // Issue #2969 — paused-cause chip(s). Prefer the API-enriched _pausedCauses
120
126
  // field; fall back to deriving from _noOpFixes for the /state/ disk-fetch
@@ -542,7 +548,7 @@ async function _submitLinkPr(e) {
542
548
  try {
543
549
  const res = await fetch('/api/pull-requests/link', {
544
550
  method: 'POST', headers: { 'Content-Type': 'application/json' },
545
- body: JSON.stringify({ url, title, project, context, autoObserve })
551
+ body: JSON.stringify({ url, title, project, context, contextOnly: !autoObserve })
546
552
  });
547
553
  const data = await res.json();
548
554
  if (res.ok) {
@@ -537,7 +537,8 @@ function _renderMdCore(s) {
537
537
  html = html.replace(/~~(.+?)~~/g, '<s>$1</s>');
538
538
  html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(_, text, href) {
539
539
  if (/^(javascript|data|vbscript):/i.test(href)) return text;
540
- return '<a href="' + href + '" target="_blank" rel="noopener" style="color:var(--blue)">' + text + '</a>';
540
+ var safeHref = href.replace(/&quot;/g, '%22');
541
+ return '<a href="' + safeHref + '" target="_blank" rel="noopener" style="color:var(--blue)">' + text + '</a>';
541
542
  });
542
543
 
543
544
  // 3. Block-level processing (line by line)
@@ -46,7 +46,7 @@
46
46
  var res = await fetch('/api/pull-requests/link', {
47
47
  method: 'POST',
48
48
  headers: { 'Content-Type': 'application/json' },
49
- body: JSON.stringify({ url: url, title: title, project: project, context: context, autoObserve: autoObserve }),
49
+ body: JSON.stringify({ url: url, title: title, project: project, context: context, contextOnly: !autoObserve }),
50
50
  });
51
51
  var data = await res.json().catch(function() { return {}; });
52
52
  if (res.ok) {
package/dashboard.js CHANGED
@@ -5,6 +5,16 @@
5
5
  * Opens: http://localhost:7331
6
6
  */
7
7
 
8
+ // Enlarge the libuv thread pool (default 4) BEFORE any async fs/crypto op
9
+ // initializes it. The dashboard fans out many concurrent filesystem reads —
10
+ // the worst being the knowledge-base scan (~thousands of files) — and with
11
+ // only 4 pool threads a single fs-heavy burst monopolizes the pool and starves
12
+ // every other fs-dependent request (including the /api/status poll that drives
13
+ // the "Dashboard unreachable — stale" banner). 16 gives headroom without
14
+ // meaningful memory cost. Respect an operator override if already set.
15
+ // (Load-reduction audit 2026-06-24.)
16
+ if (!process.env.UV_THREADPOOL_SIZE) process.env.UV_THREADPOOL_SIZE = '16';
17
+
8
18
  // Install ISO timestamp prefixes on console.{log,info,warn,error} so the
9
19
  // post-mortem in engine/dashboard-stdio.log is diagnosable to the second.
10
20
  // Must run BEFORE any other require that might log during module init.
@@ -1335,6 +1345,15 @@ function _scanProjectLocalHarnessFootgun(project) {
1335
1345
  footgunWarning: _PROJECT_LOCAL_FOOTGUN_WARNING,
1336
1346
  };
1337
1347
  if (!project || !project.localPath) return out;
1348
+ // Skip the synchronous `git status` for UNC / WSL-from-Windows paths
1349
+ // (\\wsl.localhost\..., \\wsl$\..., or any \\server\share). git/stat over a
1350
+ // WSL UNC mount can hang for minutes; even capped at the 10s timeout below it
1351
+ // hard-blocks the dashboard event loop on every poll, freezing the whole
1352
+ // dashboard. Such projects can't carry uncommitted project-local harness
1353
+ // assets the engine would propagate anyway, so returning the empty shell here
1354
+ // is correct, not lossy. (Load-reduction audit 2026-06-24.)
1355
+ const _lp = String(project.localPath);
1356
+ if (_lp.startsWith('\\\\') || _lp.startsWith('//')) return out;
1338
1357
  let raw = '';
1339
1358
  try {
1340
1359
  const { execFileSync } = require('child_process');
@@ -1397,7 +1416,28 @@ function _scanProjectLocalHarnessFootgun(project) {
1397
1416
  return out;
1398
1417
  }
1399
1418
 
1419
+ // TTL cache for the harness diagnostic. The build runs a synchronous `git
1420
+ // status` + directory walks per project; the Tools page polls it while open,
1421
+ // so without a cache it re-runs the whole sweep every 4s and blocks the event
1422
+ // loop each time. The diagnostic is rare-change, so a short TTL is ample.
1423
+ // Bypassed whenever explicit opts are supplied (unit tests pass homeDir/
1424
+ // projects/engineConfig) so test determinism is unaffected.
1425
+ // (Load-reduction audit 2026-06-24.)
1426
+ let _harnessDiagCache = null;
1427
+ let _harnessDiagCacheTs = 0;
1428
+ const _HARNESS_DIAG_TTL_MS = 20000;
1429
+
1400
1430
  function _buildHarnessDiagnostics(opts = {}) {
1431
+ const _useCache = !opts || Object.keys(opts).length === 0;
1432
+ if (_useCache && _harnessDiagCache && (Date.now() - _harnessDiagCacheTs) < _HARNESS_DIAG_TTL_MS) {
1433
+ return _harnessDiagCache;
1434
+ }
1435
+ const out = _buildHarnessDiagnosticsUncached(opts);
1436
+ if (_useCache) { _harnessDiagCache = out; _harnessDiagCacheTs = Date.now(); }
1437
+ return out;
1438
+ }
1439
+
1440
+ function _buildHarnessDiagnosticsUncached(opts = {}) {
1401
1441
  const homeDir = opts.homeDir || os.homedir();
1402
1442
  const engineConfig = opts.engineConfig || (CONFIG && CONFIG.engine) || {};
1403
1443
  const projects = (opts.projects || PROJECTS || []).filter(
@@ -7502,7 +7542,7 @@ const server = http.createServer(async (req, res) => {
7502
7542
  if (isJson) {
7503
7543
  try {
7504
7544
  const plan = JSON.parse(content);
7505
- const status = plan.status || 'active';
7545
+ const status = plan.status || (plan.requires_approval && !plan.approvedAt ? 'awaiting-approval' : 'active');
7506
7546
  // W-mqacrzis0003df4a Bug 2 — fresh staleness from plans/*.md mtime.
7507
7547
  // plan.planStale lags by an engine tick; stat the source plan here
7508
7548
  // so the Plans tab does not silently mirror a stale-but-cached false.
@@ -110,41 +110,6 @@
110
110
  "targetRemovalDate": "2026-08-03",
111
111
  "notes": "Introduced by W-mq03l6zh0006f0a1 (Per-org ADO throttle isolation). 60-day window (2 release cycles + buffer) gives the in-flight per-org migration time to land + observe per-org keys on the diagnostics endpoint. Observable live at GET /api/diagnostics/ado-throttle — the endpoint reports a single `global` key while the arg-less shim is still load-bearing, and per-org `<orgBase>` keys once isolation is complete; that key shape is the human-readable signal for whether this shim can retire."
112
112
  },
113
- {
114
- "id": "pr-record-contextOnly-underscore-alias",
115
- "description": "Legacy `pr._contextOnly` field on PR records (engine/<scope>/pull-requests.json + SQL). Renamed to canonical `pr.contextOnly` by the W-mq5s5ttx000j7ab8 PR-gate consolidation. The write side is already gone: normalizePrRecord DELETES `_contextOnly` once canonical `contextOnly` is present, and dashboard.js no longer references the alias at all. What survives is a READ-BRIDGE ONLY — code still reads `_contextOnly === true` as a fallback for pre-migration / raw on-disk records, and a one-shot boot migration copies the alias onto canonical `contextOnly` and deletes it.",
116
- "code": [
117
- { "file": "engine/shared.js", "note": "normalizePrRecord deletes `_contextOnly` when canonical `contextOnly` is present (engine/shared.js:6502-6507); the linkPr path deletes it after reading (:6716); the boot migration migratePrGateFlags deletes it (:6868-6870). The only surviving reads are legacy fallbacks: `pr._contextOnly === true` at :6555, :6713, :6759." },
118
- { "file": "dashboard.js", "note": "Zero `_contextOnly` references — neither GET /api/pull-requests nor any observe/link handler writes or shapes the alias." },
119
- { "file": "engine.js", "note": "discoverFromPrs reads canonical `pr.contextOnly` only." }
120
- ],
121
- "deprecated": "2026-06-08",
122
- "targetRemovalDate": "2026-06-25",
123
- "notes": "The write side already shipped out — there is no dual-write to retire. What remains is the read-fallback (`_contextOnly === true`) plus the one-shot boot migration migratePrGateFlags (engine/shared.js:6833) that copies + deletes the alias from any pre-migration on-disk record. Pair this entry's removal with `pr-record-autoObserve-underscore-alias`, `pr-record-manual-underscore-alias`, and `pr-link-autoObserve-body-param` — they share the same boot migration. Removal scope (DEFERRED — gated on a positive signal that every live pull-requests.json has been swept of underscore keys, not on the expired calendar date): delete the `_contextOnly` read-fallback in engine/shared.js and the migratePrGateFlags handling of it. On-disk sweep confirmed CLEAN on 2026-06-18: grep of every live projects/*/pull-requests.json (budget-test-project, constellation, minions-opg, office-bohemia) for the `_contextOnly`/`_autoObserve`/`_manual` record keys returned zero matches, so the deferral gate is now cleared and a deliberate, human-reviewed code removal can be scheduled (this calendar bump 2026-06-16 → 2026-06-25 is the docs-only consistency half; the MEDIUM/>3-file code removal stays a separate PR)."
124
- },
125
- {
126
- "id": "pr-record-autoObserve-underscore-alias",
127
- "description": "Legacy `pr._autoObserve` boolean on PR records. Was one of the clauses in the old `isAutoManagedPrRecord` check; replaced by the canonical `pr.contextOnly` field (foreign-author PRs linked with `autoObserve: true` now land with `contextOnly: false` directly). The write side is already gone — normalizePrRecord and the boot migration DELETE `_autoObserve`, and dashboard.js never writes it. What survives is a READ-BRIDGE ONLY: `_prRecordIsLegacyManaged` still reads `record._autoObserve === true` as a fallback when canonical `contextOnly` is absent on a pre-migration record.",
128
- "code": [
129
- { "file": "engine/shared.js", "note": "normalizePrRecord deletes `_autoObserve` (engine/shared.js:6502-6507); the linkPr path deletes it (:6764); migratePrGateFlags deletes it (:6868). The only surviving read is the legacy-managed fallback `record._autoObserve === true` in _prRecordIsLegacyManaged (:6823)." },
130
- { "file": "dashboard.js", "note": "Zero `_autoObserve` references — link/observe handlers accept the canonical `contextOnly` body param and write no underscore alias onto the record." }
131
- ],
132
- "deprecated": "2026-06-08",
133
- "targetRemovalDate": "2026-06-25",
134
- "notes": "The write side already shipped out — there is no dual-write to retire. Removal is paired with `pr-record-contextOnly-underscore-alias` — same boot migration, same read-bridge. Removal scope (DEFERRED — gated on a sweep confirming no live record retains `_autoObserve`, not on the expired calendar date): delete the `_autoObserve === true` read in _prRecordIsLegacyManaged (engine/shared.js:6823) and the migratePrGateFlags handling of it. On-disk sweep confirmed CLEAN on 2026-06-18: grep of every live projects/*/pull-requests.json (budget-test-project, constellation, minions-opg, office-bohemia) for the `_contextOnly`/`_autoObserve`/`_manual` record keys returned zero matches, so the deferral gate is now cleared and a deliberate, human-reviewed code removal can be scheduled (this calendar bump 2026-06-16 → 2026-06-25 is the docs-only consistency half; the MEDIUM/>3-file code removal stays a separate PR)."
135
- },
136
- {
137
- "id": "pr-record-manual-underscore-alias",
138
- "description": "Legacy `pr._manual` boolean on PR records (set by older `POST /api/pull-requests/link` writes to mark manually-linked PRs). Was the third clause in the old `isAutoManagedPrRecord` check (`pr._manual && !pr._contextOnly` → auto-managed). Post-consolidation, the dispatch gate reads only `pr.contextOnly`. The write side is already gone — normalizePrRecord and the boot migration DELETE `_manual`, and dashboard.js never writes it. The alias survives only as data on pre-migration on-disk records that the boot migration sweeps away.",
139
- "code": [
140
- { "file": "engine.js", "note": "discoverFromPrs reads canonical `pr.contextOnly` only — `_manual` is no longer read." },
141
- { "file": "engine/shared.js", "note": "normalizePrRecord deletes `_manual` (engine/shared.js:6502-6507); the linkPr path deletes it (:6765); migratePrGateFlags deletes it (:6869). No code reads `_manual` for dispatch decisions." },
142
- { "file": "dashboard.js", "note": "Zero `_manual` references — the link handler no longer writes the provenance alias onto the record." }
143
- ],
144
- "deprecated": "2026-06-08",
145
- "targetRemovalDate": "2026-06-25",
146
- "notes": "Disambiguation: this entry covers ONLY the PR-record `_manual` flag (engine/<scope>/pull-requests.json). Other `_manual`-named flags elsewhere in the engine (work items, dispatch records, etc.) are NOT covered by this entry and remain in active use. The write side already shipped out — there is no write site or dashboard badge left to remove. Removal scope (DEFERRED — gated on a sweep confirming no live record retains `_manual`, not on the expired calendar date): drop the `_manual` handling in normalizePrRecord and migratePrGateFlags (engine/shared.js) once the on-disk sweep is confirmed. On-disk sweep confirmed CLEAN on 2026-06-18: grep of every live projects/*/pull-requests.json (budget-test-project, constellation, minions-opg, office-bohemia) for the `_contextOnly`/`_autoObserve`/`_manual` record keys returned zero matches, so the deferral gate is now cleared and a deliberate, human-reviewed code removal can be scheduled (this calendar bump 2026-06-16 → 2026-06-25 is the docs-only consistency half; the MEDIUM/>3-file code removal stays a separate PR)."
147
- },
148
113
  {
149
114
  "id": "pr-link-autoObserve-body-param",
150
115
  "description": "Legacy `autoObserve` body parameter on `POST /api/pull-requests/link`. Replaced by canonical `contextOnly` body param (inverse boolean: `autoObserve: false` ⇔ `contextOnly: true`). This is a READ-BRIDGE on the input side only — the handler reads `body.contextOnly` first and falls back to `!body.autoObserve` for callers not yet migrated. No underscore alias is written onto the record (the record-field aliases already shipped out — see the three paired entries); the only thing kept alive is the input fallback.",
@@ -176,5 +141,15 @@
176
141
  "deprecated": "2026-06-08",
177
142
  "targetRemovalDate": null,
178
143
  "notes": "targetRemovalDate intentionally null — unlike the record-field aliases (`_contextOnly`, `_autoObserve`, `_manual`) which carry a 7-day clock, the `observe` body param is documented as a longer-lived back-compat alias. Set targetRemovalDate to a concrete future date once the dashboard UI + any client scripts are confirmed to POST `contextOnly` exclusively. Removal scope when the date is set: drop the `body.observe` fallback in dashboard.js, drop `observe` from the route registry params, and update any client still POSTing `observe`."
144
+ },
145
+ {
146
+ "id": "discover-review-skills-shim",
147
+ "description": "engine/discover-review-skills.js is a 25-line re-export shim for engine/discover-project-skills.js. Zero production callers; only test files (discover-review-skills.test.js, discover-project-skills.test.js:706) reference it.",
148
+ "addedDate": "2026-06-24",
149
+ "targetRemovalDate": "2026-09-01",
150
+ "removalGate": "No external callers import discover-review-skills.js; test files updated to import from discover-project-skills.js directly.",
151
+ "removalScope": "Delete engine/discover-review-skills.js, delete test/unit/discover-review-skills.test.js, update test/unit/discover-project-skills.test.js:706 to import from discover-project-skills.js.",
152
+ "autoRemoveSafe": false,
153
+ "notes": "gate: zero test references to discover-review-skills; must update tests to point to discover-project-skills.js before file can be deleted"
179
154
  }
180
155
  ]
@@ -24,6 +24,8 @@ Each PR carries a `repoHost` (`github` / `ado`). Before evaluating any of the si
24
24
  - GitHub PRs honor `ghPollEnabled` (legacy macro) and the new granular `ghPrStatusPollEnabled` / `ghPrCommentsPollEnabled` / `ghPrReconcileEnabled`.
25
25
  - ADO PRs honor `adoPollEnabled` and `adoPrStatusPollEnabled` / `adoPrCommentsPollEnabled` / `adoPrReconcileEnabled`.
26
26
 
27
+ > **UI-saved configs always carry the granular keys.** The dashboard Settings save (`dashboard/js/settings.js`) persists all seven granular poll keys on every save, so any `config.json` written through the UI always includes them. The `resolvePollFlag` legacy-macro fallback (which falls back to `{ado,gh}PollEnabled` when a granular key is absent) therefore applies only to hand-edited or legacy configs that omit the granular keys.
28
+
27
29
  When the provider's status poll is off, the cached `buildStatus` / `reviewStatus` / `_mergeConflict` won't refresh — but `discoverFromPrs` still runs against the **last known cache** unless `pollingPaused` is also set, which forces `pollEnabled=false` and makes every per-PR auto-dispatch gate inert.
28
30
 
29
31
  See [`docs/auto-discovery.md`](auto-discovery.md) → "Granular per-poller flags" for the full polling-side table and the resolution order.
@@ -210,7 +210,8 @@ function isAdoAuthFailure(err) {
210
210
 
211
211
  function _redactBearer(s) {
212
212
  if (typeof s !== 'string') return s;
213
- return s.replace(/Authorization:\s*Bearer\s+[A-Za-z0-9._\-]+/gi, 'Authorization: Bearer [REDACTED]');
213
+ // W-mqrb8okm — include +/= so base64-padded ADO tokens are fully redacted
214
+ return s.replace(/Authorization:\s*Bearer\s+[A-Za-z0-9+/=._\-]+/gi, 'Authorization: Bearer [REDACTED]');
214
215
  }
215
216
 
216
217
  function _redactErrorInPlace(err) {
package/engine/ado.js CHANGED
@@ -1207,22 +1207,28 @@ async function forEachActivePr(config, token, callback) {
1207
1207
  // 3. The repo-host filter is explicit (repoHost === 'ado') so a future
1208
1208
  // central poll that loops over both forks doesn't accidentally treat
1209
1209
  // github PRs as ADO records.
1210
- // 4. Already-configured PRs are skipped (isPrCompatibleWithProject against
1211
- // any configured ADO project) so we never double-poll PRs that the
1212
- // project loop above already handled.
1210
+ // 4. Already-configured PRs are skipped only when they are actually present
1211
+ // in that project's per-project file, so the project loop will really see
1212
+ // them. Blindly skipping all project-compatible IDs caused a permanent
1213
+ // orphan when the record lived in the central file (#328).
1213
1214
  const centralPath = path.join(shared.MINIONS_DIR, 'pull-requests.json');
1214
1215
  const centralPrs = shared.safeJsonArr(centralPath);
1215
1216
  const configuredAdoProjects = projects.filter(p => !isGitHubProject(p) && p.adoOrg && p.adoProject);
1216
- const isConfiguredAdoCanonical = (canonicalId) => {
1217
- if (!canonicalId) return false;
1218
- return configuredAdoProjects.some(p => shared.isPrCompatibleWithProject(p, { id: canonicalId }, ''));
1219
- };
1220
1217
 
1221
1218
  const activeCentral = centralPrs.filter(pr => {
1222
1219
  if (!shared.PR_POLLABLE_STATUSES.has(pr.status)) return false;
1223
1220
  if (String(pr.repoHost || 'ado').toLowerCase() !== 'ado') return false;
1224
1221
  if (!parseCanonicalAdoPrId(pr.id, 'central ADO PR poll')) return false;
1225
- if (isConfiguredAdoCanonical(pr.id)) return false;
1222
+ // Only defer to the project loop when the PR is ACTUALLY present in that
1223
+ // project's per-project file. Unconditionally skipping all project-compatible
1224
+ // PRs caused a permanent orphan: the project loop never read the central file,
1225
+ // so a PR stored centrally but compatible with a configured project was never
1226
+ // polled (fix for #328).
1227
+ const owningProject = configuredAdoProjects.find(p => shared.isPrCompatibleWithProject(p, { id: pr.id }, ''));
1228
+ if (owningProject) {
1229
+ const projectPrs = shared.safeJsonArr(shared.projectPrPath(owningProject));
1230
+ if (projectPrs.some(p => p.id === pr.id)) return false; // present in per-project file → project loop handles it
1231
+ }
1226
1232
  return true;
1227
1233
  });
1228
1234
 
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const dir = path.resolve(__dirname, '..');
4
+ const { safeJson, MINIONS_DIR } = require('./shared');
4
5
 
5
6
  console.log('=== Work Items (non-done) ===');
6
7
  let items = [];
@@ -11,9 +12,16 @@ items.filter(i => i.status !== 'done').forEach(i => {
11
12
 
12
13
  console.log('\n=== Agent Status (derived from dispatch) ===');
13
14
  const { getAgentStatus } = require('./queries');
14
- for (const a of ['ripley', 'dallas', 'lambert', 'rebecca', 'ralph']) {
15
- const s = getAgentStatus(a);
16
- console.log(a.padEnd(10), s.status.padEnd(10), (s.task || '-').slice(0, 60));
15
+ let config = {};
16
+ try { config = safeJson(path.join(MINIONS_DIR, 'config.json')) || {}; } catch {}
17
+ const agents = Object.keys(config.agents || {});
18
+ if (agents.length === 0) {
19
+ console.log('(no agents configured)');
20
+ } else {
21
+ for (const a of agents) {
22
+ const s = getAgentStatus(a);
23
+ console.log(a.padEnd(10), s.status.padEnd(10), (s.task || '-').slice(0, 60));
24
+ }
17
25
  }
18
26
 
19
27
  console.log('\n=== Inbox ===');
package/engine/cli.js CHANGED
@@ -609,21 +609,6 @@ const commands = {
609
609
  try { shared.applyLegacyCcModelMigration(config, { logger: e.log }); }
610
610
  catch (err) { e.log('warn', `legacy ccModel migration failed: ${err.message}`); }
611
611
 
612
- // W-mq5s5ttx000j7ab8-a — One-shot canonical-gate migration. Project the
613
- // legacy `_contextOnly` / `_autoObserve` / `_manual` keys onto the
614
- // canonical `contextOnly` field on every `projects/<name>/pull-
615
- // requests.json` record BEFORE the first tick fires. `isAutoManagedPrRecord`
616
- // now reads `contextOnly` only, so any record the migration doesn't reach
617
- // before tick #1 would flip its auto-managed verdict. Idempotent.
618
- try {
619
- const projectsRoot = path.join(shared.MINIONS_DIR, 'projects');
620
- // W-mqil3jtw — pass the configured agent roster so the migration
621
- // cross-checks `agent` against real agent ids instead of a shape regex;
622
- // a GitHub author login (e.g. `calebt_microsoft`) must not be mistaken
623
- // for a managed persona and flip a context-only PR's observe toggle.
624
- shared.migratePrGateFlags(projectsRoot, { config });
625
- } catch (err) { e.log('warn', `pr-gate-migration failed: ${err.message}`); }
626
-
627
612
  // One-time force-on of the CC worker pool. The pool has been the resolved
628
613
  // default for copilot CC since PR #2492, but configs still carrying an
629
614
  // explicit `ccUseWorkerPool: false` (set before the default flipped) stay
@@ -19,10 +19,10 @@ const { getConfig, INBOX_DIR } = queries;
19
19
  const MINIONS_DIR = shared.MINIONS_DIR;
20
20
 
21
21
  // Dispatch types that do not push code to a PR branch. These remain dispatchable
22
- // against `_contextOnly: true` PRs because casting a review vote, posting a
22
+ // against context-only PRs because casting a review vote, posting a
23
23
  // comment, asking a question, or running a read-only explore never mutates the
24
24
  // PR's source branch. Auto-discovery (engine.js#discoverFromPrs) is still
25
- // gated on `_contextOnly` separately — only explicit WIs (dashboard, watches,
25
+ // gated on `contextOnly` separately — only explicit WIs (dashboard, watches,
26
26
  // CC) can target a context-only PR, and only via these types.
27
27
  const NON_MUTATING_DISPATCH_TYPES = new Set([
28
28
  WORK_TYPE.REVIEW,
@@ -30,6 +30,19 @@ const NON_MUTATING_DISPATCH_TYPES = new Set([
30
30
  WORK_TYPE.EXPLORE,
31
31
  ]);
32
32
 
33
+ // Dispatch types that push code directly onto the PR's source branch. Only for
34
+ // these does a branch mismatch (dispatch queued against old branch; PR since
35
+ // rebased) signal a genuinely stale entry.
36
+ //
37
+ // implement / implement_large attach a targetPr for read-only context while
38
+ // working on their own new branch. Comparing that work branch to the tracked
39
+ // PR source branch always differs and causes phantom auto-cancellation (#369).
40
+ const PR_BRANCH_TARGETED_TYPES = new Set([
41
+ WORK_TYPE.FIX,
42
+ WORK_TYPE.VERIFY,
43
+ WORK_TYPE.DECOMPOSE,
44
+ ]);
45
+
33
46
  // Lazy require to break circular dependency with engine.js
34
47
  let _lifecycle = null;
35
48
  function lifecycle() { if (!_lifecycle) _lifecycle = require('./lifecycle'); return _lifecycle; }
@@ -438,7 +451,9 @@ function getStalePrDispatchReason(entry, config) {
438
451
 
439
452
  const queuedBranch = entry.meta.branch || entry.meta.pr?.branch || '';
440
453
  const trackedBranch = tracked.branch || '';
441
- if (queuedBranch && trackedBranch && shared.sanitizeBranch(queuedBranch) !== shared.sanitizeBranch(trackedBranch)) {
454
+ if (PR_BRANCH_TARGETED_TYPES.has(entry.type)
455
+ && queuedBranch && trackedBranch
456
+ && shared.sanitizeBranch(queuedBranch) !== shared.sanitizeBranch(trackedBranch)) {
442
457
  return `PR ${tracked.id || prLabel} branch changed from ${queuedBranch} to ${trackedBranch}`;
443
458
  }
444
459
 
@@ -602,6 +617,9 @@ function writeFailedAgentReport(item, reason, resultSummary, failureClass) {
602
617
  log('warn', 'Cannot write failed agent report without dispatch id');
603
618
  return;
604
619
  }
620
+ // W-mqrb8okm — redact bearer tokens before writing to inbox notes
621
+ const safeReason = reason ? shared.redactSecrets(reason) : reason;
622
+ const safeSummary = resultSummary ? shared.redactSecrets(resultSummary) : resultSummary;
605
623
  const agentId = item.agent || 'engine';
606
624
  const itemId = item.meta?.item?.id || '';
607
625
  const title = item.meta?.item?.title || item.task || item.id;
@@ -620,9 +638,9 @@ function writeFailedAgentReport(item, reason, resultSummary, failureClass) {
620
638
  `**Type:** ${item.type || 'unknown'}`,
621
639
  `**Result:** ${DISPATCH_RESULT.ERROR}`,
622
640
  failureClass ? `**Failure Class:** ${failureClass}` : '',
623
- reason ? `**Reason:** ${reason}` : '',
641
+ safeReason ? `**Reason:** ${safeReason}` : '',
624
642
  '',
625
- resultSummary ? `## Summary\n${resultSummary}` : '## Summary\n(no agent summary captured)',
643
+ safeSummary ? `## Summary\n${safeSummary}` : '## Summary\n(no agent summary captured)',
626
644
  ].filter(Boolean).join('\n');
627
645
  shared.writeToInbox(agentId, `agent-failure-${item.id}`, content, null, metadata);
628
646
  }
@@ -1164,4 +1182,6 @@ module.exports = {
1164
1182
  isCompletedWorkItemForFailure,
1165
1183
  _isPrdSourcedAndVetted,
1166
1184
  NON_MUTATING_DISPATCH_TYPES,
1185
+ PR_BRANCH_TARGETED_TYPES,
1186
+ writeFailedAgentReport,
1167
1187
  };
package/engine/github.js CHANGED
@@ -594,7 +594,25 @@ async function forEachActiveGhPr(config, callback) {
594
594
  // Also poll manually-linked PRs from central pull-requests.json (extract slug from URL)
595
595
  const centralPath = path.join(MINIONS_DIR, 'pull-requests.json');
596
596
  const centralPrs = safeJsonArr(centralPath);
597
- const activeCentral = centralPrs.filter(pr => PR_POLLABLE_STATUSES.has(pr.status) && pr.url);
597
+ // Build a slug→project map for configured GitHub projects so we can detect
598
+ // PRs that are actually owned by the project loop (present in per-project file).
599
+ const configuredGhProjectsBySlug = new Map(projects.map(p => [getRepoSlug(p), p]));
600
+ const activeCentral = centralPrs.filter(pr => {
601
+ if (!PR_POLLABLE_STATUSES.has(pr.status)) return false;
602
+ if (!pr.url) return false;
603
+ // Symmetric fix for #328: only defer to the project loop when the PR is
604
+ // ACTUALLY present in that project's per-project file. If it's absent there,
605
+ // the project loop will never see it — the central branch must poll it.
606
+ const ghMatch = pr.url.match(/github\.com\/([^/]+\/[^/]+)\/pull\/\d+/);
607
+ if (ghMatch) {
608
+ const owningProject = configuredGhProjectsBySlug.get(ghMatch[1]);
609
+ if (owningProject) {
610
+ const projectPrs = safeJsonArr(projectPrPath(owningProject));
611
+ if (projectPrs.some(p => p.id === pr.id)) return false; // present → project loop handles it
612
+ }
613
+ }
614
+ return true;
615
+ });
598
616
 
599
617
  // W-mp5trwh60008386d: probe each unique slug in the central list ONCE before iterating PRs.
600
618
  // Without this gate, central PRs would inherit the same per-PR 404 trapdoor that project-local
@@ -720,7 +720,8 @@ function updateWorkItemStatus(meta, status, reason) {
720
720
  delete target._noopReason;
721
721
  }
722
722
  } else if (status === WI_STATUS.FAILED) {
723
- if (reason) target.failReason = reason;
723
+ // W-mqrb8okm — redact bearer tokens from reason before persisting as failReason
724
+ if (reason) target.failReason = shared.redactSecrets(reason);
724
725
  target.failedAt = ts();
725
726
  }
726
727
  }
@@ -1122,7 +1123,7 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
1122
1123
  //
1123
1124
  // `enrollPrFromCanonicalId` is the write-side fix: given a canonical PR id and
1124
1125
  // the owning project, fetch live state from GitHub (or ADO, best-effort) and
1125
- // insert a `_contextOnly: true` row into the project's `pull-requests.json`.
1126
+ // insert a `contextOnly: true` row into the project's `pull-requests.json`.
1126
1127
  // Idempotent — no-op if a record already exists. Always marks the record as
1127
1128
  // context-only so the engine doesn't try to re-dispatch fix/review loops on
1128
1129
  // an already-merged PR (matches the #1772 enrollment-flag semantics).
@@ -1704,12 +1705,47 @@ function markMissingPrAttachment(meta, agentId, reason, resultSummary, severity,
1704
1705
  let phantomRetryDeferred = false;
1705
1706
  let phantomRetryExhausted = false;
1706
1707
  let phantomRetryCount = 0;
1708
+ let systematicPhantomFired = false;
1707
1709
  if (isHard && isPhantom && noPrWiPath) {
1708
1710
  mutateJsonFileLocked(noPrWiPath, data => {
1709
1711
  if (!Array.isArray(data)) return data;
1710
1712
  const w = data.find(i => i.id === meta.item.id);
1711
1713
  if (!w) return data;
1712
1714
  const phantomRetries = w._phantomRetryCount || 0;
1715
+
1716
+ // W-mqsh4d95000fb753 — mirror the _phantomAgents tracking that
1717
+ // _deferRetryWithCounter performs. Without this, the systematic phantom
1718
+ // detector (≥2 distinct agents all phantom-complete the same item) never
1719
+ // fires through this path because _phantomAgents stays empty {}.
1720
+ if (!w._phantomAgents || typeof w._phantomAgents !== 'object' || Array.isArray(w._phantomAgents)) {
1721
+ w._phantomAgents = {};
1722
+ }
1723
+ if (agentId) {
1724
+ w._phantomAgents[agentId] = (w._phantomAgents[agentId] || 0) + 1;
1725
+ }
1726
+ const distinctPhantomAgents = Object.keys(w._phantomAgents);
1727
+ if (distinctPhantomAgents.length >= 2 && phantomRetries >= 1) {
1728
+ w.status = WI_STATUS.FAILED;
1729
+ w.failReason = `Systematic phantom completion across ${distinctPhantomAgents.length} agents ` +
1730
+ `(${distinctPhantomAgents.join(', ')}) — likely a content filter triggered by the task description ` +
1731
+ `or a malformed prompt. Manual intervention required.`;
1732
+ w._failureClass = 'phantom-all-agents';
1733
+ w.failedAt = ts();
1734
+ delete w.completedAt;
1735
+ delete w.dispatched_at;
1736
+ delete w.dispatched_to;
1737
+ delete w._pendingReason;
1738
+ delete w._phantomCompletion;
1739
+ delete w._phantomBranch;
1740
+ delete w._missingPrAttachment;
1741
+ phantomRetryExhausted = true;
1742
+ phantomRetryCount = phantomRetries;
1743
+ systematicPhantomFired = true;
1744
+ log('warn', `Work item ${meta.item.id} systematically phantoms across ${distinctPhantomAgents.length} agents ` +
1745
+ `(${distinctPhantomAgents.join(', ')}) — marking non-retryable (phantom-all-agents)`);
1746
+ return data;
1747
+ }
1748
+
1713
1749
  if (phantomRetries < ENGINE_DEFAULTS.maxPhantomRetries) {
1714
1750
  w.status = WI_STATUS.PENDING;
1715
1751
  w._phantomRetryCount = phantomRetries + 1;
@@ -1758,6 +1794,14 @@ function markMissingPrAttachment(meta, agentId, reason, resultSummary, severity,
1758
1794
  }
1759
1795
  return;
1760
1796
  }
1797
+ if (systematicPhantomFired) {
1798
+ // Already hard-failed with phantom-all-agents inside mutateJsonFileLocked;
1799
+ // skip the generic hard-fail path below to preserve the correct failReason.
1800
+ if (meta.item?.sourcePlan) {
1801
+ try { syncPrdItemStatus(meta.item.id, WI_STATUS.FAILED, meta.item.sourcePlan); } catch (e) { log('warn', 'phantom-all-agents PRD sync: ' + e.message); }
1802
+ }
1803
+ return;
1804
+ }
1761
1805
  if (phantomRetryExhausted) {
1762
1806
  // Fall through to the regular hard-fail path with augmented reason so
1763
1807
  // operators see "phantom retries exhausted" instead of the generic msg.
@@ -3401,9 +3445,7 @@ function createReviewFeedbackForAuthor(reviewerAgentId, pr, config, opts = {}) {
3401
3445
  function updateMetrics(agentId, dispatchItem, result, taskUsage, prsCreatedCount, model) {
3402
3446
  if (!agentId || agentId.startsWith('temp-') || agentId === 'agent1' || agentId === 'reviewer' || agentId.startsWith('_test')) return;
3403
3447
 
3404
- const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
3405
- mutateJsonFileLocked(metricsPath, metrics => {
3406
- metrics = metrics || {};
3448
+ shared.mutateMetrics(metrics => {
3407
3449
  if (!metrics[agentId]) {
3408
3450
  metrics[agentId] = { ...DEFAULT_AGENT_METRICS };
3409
3451
  }
@@ -714,12 +714,16 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
714
714
  // `<endpoint>: <reason>` so updateRunStage records a terminal failure.
715
715
  async function executeApiStage(stage, stageState, run) {
716
716
  const calls = stage.calls || [{ endpoint: stage.endpoint, method: stage.method || 'POST', body: stage.body }];
717
+ if (!calls.length) {
718
+ return { status: PIPELINE_STATUS.FAILED, error: 'calls array is empty', completedAt: ts() };
719
+ }
717
720
  const maxAttempts = ENGINE_DEFAULTS.pipelineApiRetries;
718
721
  const retryDelay = ENGINE_DEFAULTS.pipelineApiRetryDelay;
719
722
  const timeoutMs = ENGINE_DEFAULTS.pipelineApiTimeoutMs;
720
723
 
721
724
  for (const call of calls) {
722
- const url = `http://localhost:${process.env.MINIONS_PORT || 7331}${call.endpoint}`;
725
+ const port = shared.readDashboardPortFile(MINIONS_DIR)?.port || (process.env.MINIONS_PORT && parseInt(process.env.MINIONS_PORT, 10)) || 7331;
726
+ const url = `http://localhost:${port}${call.endpoint}`;
723
727
  const body = typeof call.body === 'string' ? call.body : JSON.stringify(call.body || {});
724
728
 
725
729
  const attemptOnce = (attempt) => new Promise((resolve) => {
package/engine/queries.js CHANGED
@@ -1684,35 +1684,53 @@ async function _scanKnowledgeBase() {
1684
1684
  // ~30 large KB entries. `_flat()` materialises a fresh flat string via
1685
1685
  // Buffer round-trip so the cached entry no longer pins the parent file.
1686
1686
  const _flat = (s) => Buffer.from(String(s || ''), 'utf8').toString('utf8');
1687
- const entries = [];
1687
+ // Build the full (category, file) work list first, THEN process it with a
1688
+ // bounded concurrency window. The KB tree holds thousands of files; the old
1689
+ // unbounded `Promise.all(files.map(...))` per category fired thousands of
1690
+ // concurrent readFile+stat ops at once, flooding the (default-4, now 16)
1691
+ // libuv thread pool and starving every other fs-dependent dashboard request
1692
+ // — including the /api/status poll — for tens of seconds, which is what
1693
+ // tripped the "stale / unreachable" banner. Capping in-flight reads keeps
1694
+ // the scan from monopolizing the pool. (Load-reduction audit 2026-06-24.)
1695
+ const work = [];
1688
1696
  for (const cat of KB_CATEGORIES) {
1689
1697
  const catDir = path.join(KNOWLEDGE_DIR, cat);
1690
1698
  const files = (await fsp.readdir(catDir).catch(() => [])).filter(f => f.endsWith('.md'));
1691
- const fileResults = await Promise.all(files.map(async f => {
1692
- const filePath = path.join(catDir, f);
1693
- const [content, stat] = await Promise.all([
1694
- fsp.readFile(filePath, 'utf8').catch(() => ''),
1695
- fsp.stat(filePath).catch(() => null),
1696
- ]);
1697
- const titleMatch = content.match(/^#\s+(.+)/m);
1698
- const title = _flat(titleMatch ? titleMatch[1].trim() : f.replace(/\.md$/, ''));
1699
- const agentMatch = f.match(/^\d{4}-\d{2}-\d{2}-(\w+)-/);
1700
- const dateMatch = f.match(/^(\d{4}-\d{2}-\d{2})/) || content.match(/^date:\s*(\d{4}-\d{2}-\d{2})$/m);
1701
- const sourceMatch = content.match(/^source:\s*(.+)/m);
1702
- const sortTs = (stat && stat.mtimeMs) || 0;
1703
- const displayDate = dateMatch ? _flat(dateMatch[1]) : (sortTs ? new Date(sortTs).toISOString().slice(0, 10) : '');
1704
- return {
1705
- cat, file: f, title,
1706
- agent: agentMatch ? agentMatch[1] : '',
1707
- date: displayDate,
1708
- sortTs,
1709
- source: _flat(sourceMatch ? sourceMatch[1].trim() : ''),
1710
- preview: _flat(content.slice(0, 200)),
1711
- size: content.length,
1712
- };
1713
- }));
1714
- entries.push(...fileResults);
1715
- }
1699
+ for (const f of files) work.push({ cat, catDir, f });
1700
+ }
1701
+ const scanOne = async ({ cat, catDir, f }) => {
1702
+ const filePath = path.join(catDir, f);
1703
+ const [content, stat] = await Promise.all([
1704
+ fsp.readFile(filePath, 'utf8').catch(() => ''),
1705
+ fsp.stat(filePath).catch(() => null),
1706
+ ]);
1707
+ const titleMatch = content.match(/^#\s+(.+)/m);
1708
+ const title = _flat(titleMatch ? titleMatch[1].trim() : f.replace(/\.md$/, ''));
1709
+ const agentMatch = f.match(/^\d{4}-\d{2}-\d{2}-(\w+)-/);
1710
+ const dateMatch = f.match(/^(\d{4}-\d{2}-\d{2})/) || content.match(/^date:\s*(\d{4}-\d{2}-\d{2})$/m);
1711
+ const sourceMatch = content.match(/^source:\s*(.+)/m);
1712
+ const sortTs = (stat && stat.mtimeMs) || 0;
1713
+ const displayDate = dateMatch ? _flat(dateMatch[1]) : (sortTs ? new Date(sortTs).toISOString().slice(0, 10) : '');
1714
+ return {
1715
+ cat, file: f, title,
1716
+ agent: agentMatch ? agentMatch[1] : '',
1717
+ date: displayDate,
1718
+ sortTs,
1719
+ source: _flat(sourceMatch ? sourceMatch[1].trim() : ''),
1720
+ preview: _flat(content.slice(0, 200)),
1721
+ size: content.length,
1722
+ };
1723
+ };
1724
+ const entries = [];
1725
+ const KB_SCAN_CONCURRENCY = 16;
1726
+ let _next = 0;
1727
+ const worker = async () => {
1728
+ while (_next < work.length) {
1729
+ const idx = _next++;
1730
+ entries.push(await scanOne(work[idx]));
1731
+ }
1732
+ };
1733
+ await Promise.all(Array.from({ length: Math.min(KB_SCAN_CONCURRENCY, work.length) }, worker));
1716
1734
  entries.sort((a, b) =>
1717
1735
  (b.sortTs || 0) - (a.sortTs || 0) ||
1718
1736
  (b.date || '').localeCompare(a.date || '') ||
@@ -259,9 +259,9 @@ function parseCronExpr(expr) {
259
259
 
260
260
  return {
261
261
  matches(date) {
262
- return minuteMatcher(date.getMinutes()) &&
263
- hourMatcher(date.getHours()) &&
264
- dowMatcher(date.getDay());
262
+ return minuteMatcher(date.getUTCMinutes()) &&
263
+ hourMatcher(date.getUTCHours()) &&
264
+ dowMatcher(date.getUTCDay());
265
265
  }
266
266
  };
267
267
  }
@@ -298,11 +298,11 @@ function shouldRunNow(schedule, lastRunAt) {
298
298
  if (lastRunAt) {
299
299
  const last = new Date(lastRunAt);
300
300
  if (!isNaN(last.getTime()) &&
301
- last.getFullYear() === now.getFullYear() &&
302
- last.getMonth() === now.getMonth() &&
303
- last.getDate() === now.getDate() &&
304
- last.getHours() === now.getHours() &&
305
- last.getMinutes() === now.getMinutes()) {
301
+ last.getUTCFullYear() === now.getUTCFullYear() &&
302
+ last.getUTCMonth() === now.getUTCMonth() &&
303
+ last.getUTCDate() === now.getUTCDate() &&
304
+ last.getUTCHours() === now.getUTCHours() &&
305
+ last.getUTCMinutes() === now.getUTCMinutes()) {
306
306
  return false;
307
307
  }
308
308
  }
package/engine/shared.js CHANGED
@@ -403,6 +403,19 @@ function _redactString(s, options = {}) {
403
403
  .replace(_JWT_RE, '[REDACTED_JWT]');
404
404
  }
405
405
 
406
+ // W-mqrb8okm — Redact bearer tokens from git error.message / stdout / stderr
407
+ // in-place. Called from shellSafeGit / shellSafeGitSync catch paths so that ADO
408
+ // -c http.extraHeader=Authorization: Bearer TOKEN args embedded in the
409
+ // "Command failed: git ..." error message are sanitised before the error
410
+ // propagates to callers (who may store it as failReason / inbox notes).
411
+ function _redactGitError(err) {
412
+ if (!err || typeof err !== 'object') return err;
413
+ if (err.message) err.message = _redactString(err.message);
414
+ if (typeof err.stdout === 'string') err.stdout = _redactString(err.stdout);
415
+ if (typeof err.stderr === 'string') err.stderr = _redactString(err.stderr);
416
+ return err;
417
+ }
418
+
406
419
  function redactSecrets(value, options = {}) {
407
420
  if (value == null) return value;
408
421
  if (typeof value === 'string') return _redactString(value, options);
@@ -2342,17 +2355,27 @@ function shellSafeGit(args, opts = {}) {
2342
2355
  // can inject per-invocation `-c key=value` flags (e.g. ADO bearer-token
2343
2356
  // auth header) without rewriting every shellSafeGit call site. Strip the
2344
2357
  // key before delegating so it never reaches Node's execFile options.
2345
- const { timeout, gitExtraArgs, ...rest } = opts;
2358
+ // W-mqrb8okm `_execFileAsync` injection point for unit tests; production
2359
+ // callers leave it unset so the module-level promisified execFile is used.
2360
+ const { timeout, gitExtraArgs, _execFileAsync: _execFileOverride, ...rest } = opts;
2346
2361
  const finalArgs = (Array.isArray(gitExtraArgs) && gitExtraArgs.length > 0)
2347
2362
  ? [...gitExtraArgs, ...args]
2348
2363
  : args;
2349
- return _execFileAsync('git', finalArgs, {
2364
+ const execFn = _execFileOverride || _execFileAsync;
2365
+ return execFn('git', finalArgs, {
2350
2366
  windowsHide: true,
2351
2367
  encoding: 'utf8',
2352
2368
  shell: false,
2353
2369
  ...rest,
2354
2370
  timeout: timeout || 30000,
2355
- }).then(({ stdout }) => stdout);
2371
+ }).then(({ stdout }) => stdout).catch(err => {
2372
+ // W-mqrb8okm — Redact bearer tokens from git errors before they propagate.
2373
+ // The "Command failed: git ..." message produced by Node's execFile includes
2374
+ // every argv element, so -c http.extraHeader=Authorization: Bearer TOKEN
2375
+ // would end up verbatim in failReason / inbox notes without this guard.
2376
+ _redactGitError(err);
2377
+ throw err;
2378
+ });
2356
2379
  }
2357
2380
 
2358
2381
  // Sync argv-form helper for callers that aren't async (e.g. plan
@@ -2368,14 +2391,19 @@ function shellSafeGitSync(args, opts = {}) {
2368
2391
  ? [...gitExtraArgs, ...args]
2369
2392
  : args;
2370
2393
  const { execFileSync: _execFileSync } = require('child_process');
2371
- return _execFileSync('git', finalArgs, {
2372
- windowsHide: true,
2373
- encoding: 'utf8',
2374
- shell: false,
2375
- stdio: 'pipe',
2376
- ...rest,
2377
- timeout: timeout || 30000,
2378
- });
2394
+ try {
2395
+ return _execFileSync('git', finalArgs, {
2396
+ windowsHide: true,
2397
+ encoding: 'utf8',
2398
+ shell: false,
2399
+ stdio: 'pipe',
2400
+ ...rest,
2401
+ timeout: timeout || 30000,
2402
+ });
2403
+ } catch (err) {
2404
+ // W-mqrb8okm — same bearer-token redaction as the async variant.
2405
+ throw _redactGitError(err);
2406
+ }
2379
2407
  }
2380
2408
 
2381
2409
  /**
@@ -3236,6 +3264,12 @@ function resolvePollFlag(engineCfg, granularKey, legacyMacroKey) {
3236
3264
  if (engineCfg && Object.prototype.hasOwnProperty.call(engineCfg, granularKey)) {
3237
3265
  return engineCfg[granularKey] !== false;
3238
3266
  }
3267
+ // Legacy-macro path: reachable ONLY for hand-edited configs or pre-P-c4d8e1a3
3268
+ // configs that set the provider-level macro (adoPollEnabled / ghPollEnabled)
3269
+ // to false WITHOUT setting the granular key. The dashboard Settings save
3270
+ // (dashboard/js/settings.js) always persists all seven granular keys, so
3271
+ // UI-saved configs never reach this branch — but operator-edited config.json
3272
+ // files that use the legacy macro still must work. Do NOT delete this branch.
3239
3273
  if (legacyMacroKey && engineCfg && engineCfg[legacyMacroKey] === false) {
3240
3274
  return false;
3241
3275
  }
@@ -6732,15 +6766,6 @@ function applyPrFieldDelta(target, before, after) {
6732
6766
  function normalizePrRecord(pr, project = null) {
6733
6767
  if (!pr || typeof pr !== 'object') return false;
6734
6768
  let changed = false;
6735
- const hasCanonicalContextOnly = Object.prototype.hasOwnProperty.call(pr, 'contextOnly');
6736
- if (hasCanonicalContextOnly) {
6737
- for (const legacyKey of ['_contextOnly', '_autoObserve', '_manual']) {
6738
- if (Object.prototype.hasOwnProperty.call(pr, legacyKey)) {
6739
- delete pr[legacyKey];
6740
- changed = true;
6741
- }
6742
- }
6743
- }
6744
6769
  const prNumber = getPrNumber(pr.prNumber ?? pr.id ?? pr.url);
6745
6770
  if (prNumber != null && pr.prNumber !== prNumber) {
6746
6771
  pr.prNumber = prNumber;
@@ -6779,15 +6804,13 @@ function normalizePrLinkItems(value) {
6779
6804
  return [...new Set(items.filter(item => typeof item === 'string' && item))];
6780
6805
  }
6781
6806
 
6782
- // W-mq5s5ttx000j7ab8-a / W-mqerisvz000n3901 — canonical `contextOnly` gate
6783
- // with a legacy bridge. Canonical `contextOnly` wins whenever it exists; legacy
6784
- // `_contextOnly` is only a fallback for pre-migration/raw records. This keeps
6785
- // observe toggles (`contextOnly:false`) from being overridden by stale legacy
6786
- // contamination while still treating old context-only rows as reference-only.
6807
+ // Canonical `contextOnly` gatereads only the canonical field.
6808
+ // All records are expected to carry `contextOnly` after the 2026-06-24
6809
+ // on-disk sweep confirmed no live `_contextOnly`/`_autoObserve`/`_manual`
6810
+ // keys remain (and the boot migration has been removed).
6787
6811
  function isContextOnlyPrRecord(pr) {
6788
6812
  if (!pr || typeof pr !== 'object') return false;
6789
- if (typeof pr.contextOnly === 'boolean') return pr.contextOnly;
6790
- return pr._contextOnly === true;
6813
+ return pr.contextOnly === true;
6791
6814
  }
6792
6815
 
6793
6816
  function isAutoManagedPrRecord(pr) {
@@ -6943,12 +6966,17 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
6943
6966
  prNumber: prNumber ?? entry.prNumber ?? null,
6944
6967
  prdItems: linkedItemIds,
6945
6968
  };
6946
- const normalizedEntryContextOnly = normalizedEntry.contextOnly != null
6947
- ? normalizedEntry.contextOnly
6948
- : normalizedEntry._contextOnly;
6949
- if (normalizedEntryContextOnly != null) {
6950
- normalizedEntry.contextOnly = normalizedEntryContextOnly === true;
6951
- delete normalizedEntry._contextOnly;
6969
+ // Issue #348: backfill `project` from the project option when the entry
6970
+ // doesn't carry one. The file path already encodes the project name (e.g.
6971
+ // projects/<name>/pull-requests.json), so it is always deterministic.
6972
+ // Without this, PRs linked via /api/pull-requests/link had project:null on
6973
+ // the record, which broke any downstream path that reads pr.project to
6974
+ // resolve the owning project.
6975
+ if (project?.name != null && normalizedEntry.project == null) {
6976
+ normalizedEntry.project = project.name;
6977
+ }
6978
+ if (normalizedEntry.contextOnly != null) {
6979
+ normalizedEntry.contextOnly = normalizedEntry.contextOnly === true;
6952
6980
  }
6953
6981
 
6954
6982
  let created = false;
@@ -6970,34 +6998,18 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
6970
6998
  } else {
6971
6999
  target.id = canonicalId;
6972
7000
  if (prNumber != null) target.prNumber = prNumber;
6973
- const targetWasAutoManaged = isAutoManagedPrRecord(target)
6974
- || (target.contextOnly == null && target._contextOnly === true && _prRecordIsLegacyManaged(target));
7001
+ const targetWasAutoManaged = isAutoManagedPrRecord(target);
6975
7002
  for (const key of ['url', 'title', 'description', 'agent', 'branch', 'reviewStatus', 'status', 'created', 'sourcePlan', 'itemType']) {
6976
7003
  if (normalizedEntry[key] != null && normalizedEntry[key] !== '' && (target[key] == null || target[key] === '')) {
6977
7004
  target[key] = normalizedEntry[key];
6978
7005
  }
6979
7006
  }
6980
- // W-mq5s5ttx000j7ab8-a — `_manual` and `_autoObserve` are no longer
6981
- // copied through; the engine reads gate state from the canonical
6982
- // `contextOnly` field (see isAutoManagedPrRecord above + the boot
6983
- // migration migratePrGateFlags). `_context`/`_projectResolution` are
6984
- // unrelated breadcrumbs and stay.
6985
7007
  for (const key of ['_context', '_projectResolution']) {
6986
7008
  if (normalizedEntry[key] != null) target[key] = normalizedEntry[key];
6987
7009
  }
6988
- // Accept either the canonical `contextOnly` (preferred) or the legacy
6989
- // `_contextOnly` from callers that haven't been migrated yet
6990
- // (dashboard.js manual-link path, lifecycle.js oneShot tagging items
6991
- // (b)/(c) of the decomposition). Persist as canonical `contextOnly`.
6992
- const incomingContextOnly = normalizedEntry.contextOnly != null
6993
- ? normalizedEntry.contextOnly
6994
- : normalizedEntry._contextOnly;
6995
- if (incomingContextOnly != null) {
6996
- const wouldDemoteManagedPr = incomingContextOnly === true && targetWasAutoManaged;
6997
- target.contextOnly = wouldDemoteManagedPr ? false : incomingContextOnly === true;
6998
- delete target._contextOnly;
6999
- delete target._autoObserve;
7000
- delete target._manual;
7010
+ if (normalizedEntry.contextOnly != null) {
7011
+ const wouldDemoteManagedPr = normalizedEntry.contextOnly === true && targetWasAutoManaged;
7012
+ target.contextOnly = wouldDemoteManagedPr ? false : normalizedEntry.contextOnly === true;
7001
7013
  }
7002
7014
  }
7003
7015
  target.prdItems = normalizePrLinkItems(target.prdItems || []);
@@ -7020,142 +7032,6 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
7020
7032
  return { id: canonicalId, prNumber, created, linked, skipped, record };
7021
7033
  }
7022
7034
 
7023
- // ─── PR Gate Migration (W-mq5s5ttx000j7ab8-a) ───────────────────────────────
7024
- //
7025
- // One-shot boot migration that projects the legacy gate signals
7026
- // (`_contextOnly`, `_autoObserve`, `_manual`) onto the canonical
7027
- // `contextOnly` field on every `projects/<name>/pull-requests.json` record.
7028
- // Wired from `engine/cli.js#start()` before the first tick fires so the new
7029
- // `isAutoManagedPrRecord` (which only reads `contextOnly`) returns the same
7030
- // verdict as the legacy 6-clause helper for pre-existing data.
7031
- //
7032
- // Per-record decision:
7033
- // 1. contextOnly := (_contextOnly === true)
7034
- // 2. legacyManaged := _autoObserve === true || sourcePlan || itemType ||
7035
- // (agent is a *configured* Minions persona)
7036
- // "Configured Minions persona" = `agent` matches an id in
7037
- // `config.agents` (case-insensitive). The github poller writes the PR
7038
- // author's login into `agent` (from `prData.user.login`); human logins
7039
- // like `calebt_microsoft` are kebab/snake-shaped and previously passed a
7040
- // naive shape regex, mis-classifying a human PR as managed and flipping
7041
- // its observe toggle ON on the next boot (W-mqil3jtw). Cross-checking the
7042
- // real roster instead of a shape heuristic fixes that. A login that is
7043
- // not a configured agent id is treated as a human author.
7044
- // NOTE: `prdItems.length > 0` is intentionally NOT a managed signal in
7045
- // this migration. A watch can link a PR context-only AND spin off a
7046
- // courtesy-review WI whose id is stamped into `prdItems`; that stamp must
7047
- // not override the explicit context-only intent. Genuine managed PRs
7048
- // always carry a real signal above (configured agent / sourcePlan /
7049
- // itemType / _autoObserve), so dropping prdItems-alone is safe here.
7050
- // 3. If !contextOnly && !legacyManaged, set contextOnly = true and stamp
7051
- // `_migrationNote: 'auto-set-contextOnly-by-pr-gate-simplification'`.
7052
- // 4. Persist `contextOnly`; delete `_autoObserve`, `_manual`, `_contextOnly`.
7053
- //
7054
- // Idempotent: records that already have `contextOnly` and none of the legacy
7055
- // keys are skipped (no rewrite, no log line, JSON mtime unchanged).
7056
- const _PR_GATE_MIGRATION_NOTE = 'auto-set-contextOnly-by-pr-gate-simplification';
7057
- const _AGENT_PERSONA_RE = /^[a-z0-9_-]+$/;
7058
-
7059
- function _prRecordHasLegacyGateKey(record) {
7060
- return Object.prototype.hasOwnProperty.call(record, '_contextOnly')
7061
- || Object.prototype.hasOwnProperty.call(record, '_autoObserve')
7062
- || Object.prototype.hasOwnProperty.call(record, '_manual');
7063
- }
7064
-
7065
- // Resolve the configured agent roster (lower-cased Set) once per migration.
7066
- // Accepts either an explicit `agentIds` (Set / array) or a `config` object
7067
- // whose `agents` keys are the roster. Returns an empty Set when nothing is
7068
- // supplied (no record can match a managed persona).
7069
- // (Deliberately NOT queries.getAgents(config): that lives in queries.js — which
7070
- // requires shared.js, so importing it here is circular — and it also reads
7071
- // dispatch/inbox state. We only need the bare id set.)
7072
- function _resolveConfiguredAgentIds(opts = {}) {
7073
- let ids = opts.agentIds;
7074
- if (ids == null && opts.config && opts.config.agents && typeof opts.config.agents === 'object') {
7075
- ids = Object.keys(opts.config.agents);
7076
- }
7077
- const source = ids instanceof Set ? [...ids] : (Array.isArray(ids) ? ids : []);
7078
- return new Set(source.map(id => String(id || '').trim().toLowerCase()).filter(Boolean));
7079
- }
7080
-
7081
- // `agentIds` (a Set, supplied by the boot migration) switches this helper into
7082
- // "roster mode": the `agent` field only counts as a managed signal when it is
7083
- // an actually-configured agent id, and prdItems-alone is dropped (see the
7084
- // migration header). Callers that can't cheaply resolve the roster (the
7085
- // runtime upsert/link demotion-guard at the `_prRecordIsLegacyManaged(target)`
7086
- // call site) pass no agentIds and keep the legacy shape-heuristic behavior.
7087
- function _prRecordIsLegacyManaged(record, agentIds) {
7088
- const rosterMode = agentIds instanceof Set;
7089
- // prdItems-alone is a managed signal only in the legacy runtime path, not the
7090
- // boot migration (see header: courtesy-review watch contamination).
7091
- if (!rosterMode && Array.isArray(record.prdItems) && record.prdItems.length > 0) return true;
7092
- if (record._autoObserve === true) return true;
7093
- if (record.sourcePlan) return true;
7094
- if (record.itemType) return true;
7095
- if (typeof record.agent === 'string') {
7096
- const agent = record.agent.trim().toLowerCase();
7097
- if (agent && agent !== 'human') {
7098
- if (rosterMode ? agentIds.has(agent) : _AGENT_PERSONA_RE.test(agent)) return true;
7099
- }
7100
- }
7101
- return false;
7102
- }
7103
-
7104
- function migratePrGateFlags(projectsRoot, opts = {}) {
7105
- const summary = { projectsScanned: 0, projectsMigrated: 0, totalRecords: 0, totalMigrated: 0 };
7106
- if (!projectsRoot || typeof projectsRoot !== 'string') return summary;
7107
- const agentIds = _resolveConfiguredAgentIds(opts);
7108
- let entries;
7109
- try {
7110
- entries = fs.readdirSync(projectsRoot, { withFileTypes: true });
7111
- } catch {
7112
- return summary;
7113
- }
7114
- for (const entry of entries) {
7115
- if (!entry.isDirectory()) continue;
7116
- const projectName = entry.name;
7117
- const prPath = path.join(projectsRoot, projectName, 'pull-requests.json');
7118
- if (!fs.existsSync(prPath)) continue;
7119
- summary.projectsScanned++;
7120
-
7121
- let migrated = 0;
7122
- let stamped = 0;
7123
- let alreadyMigrated = 0;
7124
-
7125
- mutatePullRequests(prPath, (prs) => {
7126
- if (!Array.isArray(prs)) return prs;
7127
- for (const record of prs) {
7128
- if (!record || typeof record !== 'object') continue;
7129
- const hasLegacy = _prRecordHasLegacyGateKey(record);
7130
- const hasCanonical = Object.prototype.hasOwnProperty.call(record, 'contextOnly');
7131
- if (hasCanonical && !hasLegacy) { alreadyMigrated++; continue; }
7132
-
7133
- let contextOnly = (record._contextOnly === true);
7134
- if (!contextOnly && !_prRecordIsLegacyManaged(record, agentIds)) {
7135
- contextOnly = true;
7136
- record._migrationNote = _PR_GATE_MIGRATION_NOTE;
7137
- stamped++;
7138
- }
7139
- record.contextOnly = contextOnly;
7140
- delete record._autoObserve;
7141
- delete record._manual;
7142
- delete record._contextOnly;
7143
- migrated++;
7144
- }
7145
- return prs;
7146
- });
7147
-
7148
- summary.totalRecords += (migrated + alreadyMigrated);
7149
- summary.totalMigrated += migrated;
7150
- if (migrated > 0) {
7151
- summary.projectsMigrated++;
7152
- // One line per project that actually moved. Keep idempotent runs silent.
7153
- console.log(`[pr-gate-migration] ${projectName}: migrated ${migrated} records (${stamped} stamped contextOnly, ${alreadyMigrated} already-migrated)`);
7154
- }
7155
- }
7156
- return summary;
7157
- }
7158
-
7159
7035
  // ─── PR Reference → URL Derivation ───────────────────────────────────────────
7160
7036
  //
7161
7037
  // W-mq5wfh1v000e0da9 — Given a PR ref (URL, canonical `host:scope#N` id, or
@@ -9012,7 +8888,6 @@ module.exports = {
9012
8888
  isContextOnlyPrRecord,
9013
8889
  upsertPullRequestRecord,
9014
8890
  isAutoManagedPrRecord, // W-mq5s5ttx000j7ab8-a — exported for engine + watch-plugin gate consolidation
9015
- migratePrGateFlags, // W-mq5s5ttx000j7ab8-a — boot migration wired from engine/cli.js
9016
8891
  autoEnrollPrFromFixWorkItem,
9017
8892
  deriveUrlForPrRef, // exported for testing
9018
8893
  classifyPrRefForVerification, // issue #246 — host routing for loose PR-ref verification
package/engine/timeout.js CHANGED
@@ -970,4 +970,6 @@ module.exports = {
970
970
  // exported for testing
971
971
  rememberDeferredSteering, checkDeferredStranded, _runSteeringKillLadder, _collectDescendantPids,
972
972
  _resetSteeringStoreCacheForTest, _setSteeringStoreForTest,
973
+ // exported for testing — steering clamp helpers and deferred checkpoint
974
+ _clampSteeringDeferredMaxMs, _clampSteeringMaxKillRetries, _appendLiveOutputLine, deferSteeringUntilCheckpoint,
973
975
  };
@@ -604,7 +604,7 @@ registerActionType(WATCH_ACTION_TYPE.MINIONS_API, {
604
604
  if (!_ALLOWED_API_METHODS.has(method)) {
605
605
  return { ok: false, summary: `minions-api: unsupported method ${method} (allowed: ${[..._ALLOWED_API_METHODS].join(', ')})` };
606
606
  }
607
- const port = process.env.MINIONS_PORT || 7331;
607
+ const port = shared.readDashboardPortFile(shared.MINIONS_DIR)?.port || 7331;
608
608
  const headers = {
609
609
  'User-Agent': 'minions-watch/1.0',
610
610
  'X-Minions-Internal': '1',
@@ -933,7 +933,7 @@ registerActionType(WATCH_ACTION_TYPE.CC_TRIAGE, {
933
933
 
934
934
  const watchId = String(watch?.id || 'unknown');
935
935
  const triggerCount = Number(watch?.triggerCount || 0);
936
- const port = process.env.MINIONS_PORT || 7331;
936
+ const port = shared.readDashboardPortFile(shared.MINIONS_DIR)?.port || 7331;
937
937
  const timeoutMs = Number.isFinite(p.timeoutMs) && p.timeoutMs > 0
938
938
  ? Math.min(Number(p.timeoutMs), CC_TRIAGE_MAX_TIMEOUT_MS)
939
939
  : CC_TRIAGE_DEFAULT_TIMEOUT_MS;
@@ -309,42 +309,56 @@ function _resetStuckPathsForTesting() { _stuckPaths.clear(); }
309
309
  // `_markStuckSuccess` with the reaped-pid attribution.
310
310
  function _postReapRetry(wtPath, gitRoot, parentDir, resolvedPath, _removeWorktree, opts, projStats, result, log, reapedPids) {
311
311
  try { shared.clearWorktreeFailureCache(resolvedPath); } catch { /* optional */ }
312
- // Brief settle window for OS to release file handles from killed processes.
313
- try {
314
- const sleepFn = typeof opts.sleepSyncFn === 'function'
315
- ? opts.sleepSyncFn
316
- : (ms) => {
317
- try {
318
- require('child_process').execFileSync(process.execPath, ['-e', `setTimeout(()=>process.exit(0), ${Number(ms) || 0})`], { stdio: 'ignore' });
319
- } catch { /* timeout / spawn fail — proceed anyway */ }
320
- };
321
- sleepFn(2000);
322
- } catch { /* sleep failures should not block the retry */ }
323
312
 
324
- try {
325
- const removed = _removeWorktree(wtPath, gitRoot, parentDir);
326
- if (removed) {
327
- // Down-count the failure we recorded just before; this dispatch
328
- // ultimately succeeded after the auto-reap.
329
- if (projStats) { projStats.failed = Math.max(0, projStats.failed - 1); projStats.evicted++; }
330
- if (result) { result.failed = Math.max(0, result.failed - 1); result.evicted++; }
331
- _markStuckSuccess(resolvedPath, {
332
- writeToInbox: opts.writeToInbox,
333
- recoveryReason: 'holder-reap',
334
- reapedPids,
335
- });
336
- try { shared.bumpWorktreeGcMetric('recoveredViaHolderReap'); } catch { /* optional */ }
313
+ // The actual removal attempt, run after the settle window.
314
+ function _doRetry() {
315
+ try {
316
+ const removed = _removeWorktree(wtPath, gitRoot, parentDir);
317
+ if (removed) {
318
+ // Down-count the failure we recorded just before; this dispatch
319
+ // ultimately succeeded after the auto-reap.
320
+ if (projStats) { projStats.failed = Math.max(0, projStats.failed - 1); projStats.evicted++; }
321
+ if (result) { result.failed = Math.max(0, result.failed - 1); result.evicted++; }
322
+ _markStuckSuccess(resolvedPath, {
323
+ writeToInbox: opts.writeToInbox,
324
+ recoveryReason: 'holder-reap',
325
+ reapedPids,
326
+ });
327
+ try { shared.bumpWorktreeGcMetric('recoveredViaHolderReap'); } catch { /* optional */ }
328
+ if (typeof log === 'function') {
329
+ log('info', `worktree-gc: removed ${wtPath} after auto-reaping holder(s) ${reapedPids.join(', ')}`);
330
+ }
331
+ return true;
332
+ }
333
+ } catch (retryErr) {
337
334
  if (typeof log === 'function') {
338
- log('info', `worktree-gc: removed ${wtPath} after auto-reaping holder(s) ${reapedPids.join(', ')}`);
335
+ log('warn', `worktree-gc: post-reap retry threw for ${wtPath}: ${retryErr && retryErr.message}`);
339
336
  }
340
- return true;
341
- }
342
- } catch (retryErr) {
343
- if (typeof log === 'function') {
344
- log('warn', `worktree-gc: post-reap retry threw for ${wtPath}: ${retryErr && retryErr.message}`);
345
337
  }
338
+ return false;
346
339
  }
347
- return false;
340
+
341
+ // Brief settle window for OS to release file handles from killed processes.
342
+ // When sleepSyncFn returns a Promise (or is absent — the production default
343
+ // uses Promise-based setTimeout), the retry fires asynchronously so the
344
+ // event loop is never blocked. When sleepSyncFn is a sync no-op (test
345
+ // injection), the retry fires immediately so test assertions remain sync.
346
+ // The production timer is .unref()'d so it never keeps a test process alive.
347
+ const sleepFn = typeof opts.sleepSyncFn === 'function'
348
+ ? opts.sleepSyncFn
349
+ : (ms) => new Promise(r => { const t = setTimeout(r, ms); if (t && typeof t.unref === 'function') t.unref(); });
350
+
351
+ let settled;
352
+ try { settled = sleepFn(2000); } catch { /* sleep init failure — fire retry anyway */ }
353
+
354
+ if (settled && typeof settled.then === 'function') {
355
+ // Async settle window — fire-and-forget; never blocks the event loop.
356
+ settled.then(() => { try { _doRetry(); } catch { /* ignore */ } }).catch(() => {});
357
+ return;
358
+ }
359
+
360
+ // Sync settle window (test injection) — fire retry immediately.
361
+ try { _doRetry(); } catch { /* ignore */ }
348
362
  }
349
363
 
350
364
  /**
package/engine.js CHANGED
@@ -8422,7 +8422,7 @@ function discoverCentralWorkItems(config) {
8422
8422
  item_id: item.id,
8423
8423
  item_name: item.title || item.id,
8424
8424
  item_priority: item.priority || 'medium',
8425
- item_description: item.description || '',
8425
+ item_description: shared.redactPromptDescription(item.description || ''),
8426
8426
  work_type: workType,
8427
8427
  additional_context: item.prompt ? `## Additional Context\n\n${item.prompt}` : '',
8428
8428
  scope_section: buildProjectContext(dispatchProjects, assignedProject, true, agent.name, agent.role),
@@ -8615,7 +8615,7 @@ function discoverCentralWorkItems(config) {
8615
8615
  // Ensure plans directory exists before agent tries to write
8616
8616
  if (!fs.existsSync(PLANS_DIR)) fs.mkdirSync(PLANS_DIR, { recursive: true });
8617
8617
  const planFileName = `plan-${item.id.toLowerCase()}-${dateStamp()}.md`;
8618
- vars.plan_content = item.title + (item.description ? '\n\n' + item.description : '');
8618
+ vars.plan_content = shared.redactPromptDescription(item.title + (item.description ? '\n\n' + item.description : ''));
8619
8619
  vars.plan_title = item.title;
8620
8620
  vars.plan_file = planFileName;
8621
8621
  vars.task_description = item.title;
@@ -8642,7 +8642,7 @@ function discoverCentralWorkItems(config) {
8642
8642
  vars.plan_content = planFileContent;
8643
8643
  } else {
8644
8644
  if (planReadError) log('warn', `plan-to-prd: could not read plan file ${item.planFile} for ${item.id}: ${planReadError.message}`);
8645
- vars.plan_content = item.description || '';
8645
+ vars.plan_content = shared.redactPromptDescription(item.description || '');
8646
8646
  }
8647
8647
  vars.plan_summary = (item.title || item.planFile).substring(0, 80);
8648
8648
  vars.plan_file = item.planFile || '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2249",
3
+ "version": "0.1.2251",
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"