@yemi33/minions 0.1.2250 → 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) {
@@ -548,7 +548,7 @@ async function _submitLinkPr(e) {
548
548
  try {
549
549
  const res = await fetch('/api/pull-requests/link', {
550
550
  method: 'POST', headers: { 'Content-Type': 'application/json' },
551
- body: JSON.stringify({ url, title, project, context, autoObserve })
551
+ body: JSON.stringify({ url, title, project, context, contextOnly: !autoObserve })
552
552
  });
553
553
  const data = await res.json();
554
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.
@@ -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
 
@@ -1167,5 +1182,6 @@ module.exports = {
1167
1182
  isCompletedWorkItemForFailure,
1168
1183
  _isPrdSourcedAndVetted,
1169
1184
  NON_MUTATING_DISPATCH_TYPES,
1185
+ PR_BRANCH_TARGETED_TYPES,
1170
1186
  writeFailedAgentReport,
1171
1187
  };
@@ -1123,7 +1123,7 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
1123
1123
  //
1124
1124
  // `enrollPrFromCanonicalId` is the write-side fix: given a canonical PR id and
1125
1125
  // the owning project, fetch live state from GitHub (or ADO, best-effort) and
1126
- // 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`.
1127
1127
  // Idempotent — no-op if a record already exists. Always marks the record as
1128
1128
  // context-only so the engine doesn't try to re-dispatch fix/review loops on
1129
1129
  // an already-merged PR (matches the #1772 enrollment-flag semantics).
@@ -1705,12 +1705,47 @@ function markMissingPrAttachment(meta, agentId, reason, resultSummary, severity,
1705
1705
  let phantomRetryDeferred = false;
1706
1706
  let phantomRetryExhausted = false;
1707
1707
  let phantomRetryCount = 0;
1708
+ let systematicPhantomFired = false;
1708
1709
  if (isHard && isPhantom && noPrWiPath) {
1709
1710
  mutateJsonFileLocked(noPrWiPath, data => {
1710
1711
  if (!Array.isArray(data)) return data;
1711
1712
  const w = data.find(i => i.id === meta.item.id);
1712
1713
  if (!w) return data;
1713
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
+
1714
1749
  if (phantomRetries < ENGINE_DEFAULTS.maxPhantomRetries) {
1715
1750
  w.status = WI_STATUS.PENDING;
1716
1751
  w._phantomRetryCount = phantomRetries + 1;
@@ -1759,6 +1794,14 @@ function markMissingPrAttachment(meta, agentId, reason, resultSummary, severity,
1759
1794
  }
1760
1795
  return;
1761
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
+ }
1762
1805
  if (phantomRetryExhausted) {
1763
1806
  // Fall through to the regular hard-fail path with augmented reason so
1764
1807
  // operators see "phantom retries exhausted" instead of the generic msg.
@@ -3402,9 +3445,7 @@ function createReviewFeedbackForAuthor(reviewerAgentId, pr, config, opts = {}) {
3402
3445
  function updateMetrics(agentId, dispatchItem, result, taskUsage, prsCreatedCount, model) {
3403
3446
  if (!agentId || agentId.startsWith('temp-') || agentId === 'agent1' || agentId === 'reviewer' || agentId.startsWith('_test')) return;
3404
3447
 
3405
- const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
3406
- mutateJsonFileLocked(metricsPath, metrics => {
3407
- metrics = metrics || {};
3448
+ shared.mutateMetrics(metrics => {
3408
3449
  if (!metrics[agentId]) {
3409
3450
  metrics[agentId] = { ...DEFAULT_AGENT_METRICS };
3410
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
@@ -3264,6 +3264,12 @@ function resolvePollFlag(engineCfg, granularKey, legacyMacroKey) {
3264
3264
  if (engineCfg && Object.prototype.hasOwnProperty.call(engineCfg, granularKey)) {
3265
3265
  return engineCfg[granularKey] !== false;
3266
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.
3267
3273
  if (legacyMacroKey && engineCfg && engineCfg[legacyMacroKey] === false) {
3268
3274
  return false;
3269
3275
  }
@@ -6760,15 +6766,6 @@ function applyPrFieldDelta(target, before, after) {
6760
6766
  function normalizePrRecord(pr, project = null) {
6761
6767
  if (!pr || typeof pr !== 'object') return false;
6762
6768
  let changed = false;
6763
- const hasCanonicalContextOnly = Object.prototype.hasOwnProperty.call(pr, 'contextOnly');
6764
- if (hasCanonicalContextOnly) {
6765
- for (const legacyKey of ['_contextOnly', '_autoObserve', '_manual']) {
6766
- if (Object.prototype.hasOwnProperty.call(pr, legacyKey)) {
6767
- delete pr[legacyKey];
6768
- changed = true;
6769
- }
6770
- }
6771
- }
6772
6769
  const prNumber = getPrNumber(pr.prNumber ?? pr.id ?? pr.url);
6773
6770
  if (prNumber != null && pr.prNumber !== prNumber) {
6774
6771
  pr.prNumber = prNumber;
@@ -6807,15 +6804,13 @@ function normalizePrLinkItems(value) {
6807
6804
  return [...new Set(items.filter(item => typeof item === 'string' && item))];
6808
6805
  }
6809
6806
 
6810
- // W-mq5s5ttx000j7ab8-a / W-mqerisvz000n3901 — canonical `contextOnly` gate
6811
- // with a legacy bridge. Canonical `contextOnly` wins whenever it exists; legacy
6812
- // `_contextOnly` is only a fallback for pre-migration/raw records. This keeps
6813
- // observe toggles (`contextOnly:false`) from being overridden by stale legacy
6814
- // 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).
6815
6811
  function isContextOnlyPrRecord(pr) {
6816
6812
  if (!pr || typeof pr !== 'object') return false;
6817
- if (typeof pr.contextOnly === 'boolean') return pr.contextOnly;
6818
- return pr._contextOnly === true;
6813
+ return pr.contextOnly === true;
6819
6814
  }
6820
6815
 
6821
6816
  function isAutoManagedPrRecord(pr) {
@@ -6971,12 +6966,17 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
6971
6966
  prNumber: prNumber ?? entry.prNumber ?? null,
6972
6967
  prdItems: linkedItemIds,
6973
6968
  };
6974
- const normalizedEntryContextOnly = normalizedEntry.contextOnly != null
6975
- ? normalizedEntry.contextOnly
6976
- : normalizedEntry._contextOnly;
6977
- if (normalizedEntryContextOnly != null) {
6978
- normalizedEntry.contextOnly = normalizedEntryContextOnly === true;
6979
- 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;
6980
6980
  }
6981
6981
 
6982
6982
  let created = false;
@@ -6998,34 +6998,18 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
6998
6998
  } else {
6999
6999
  target.id = canonicalId;
7000
7000
  if (prNumber != null) target.prNumber = prNumber;
7001
- const targetWasAutoManaged = isAutoManagedPrRecord(target)
7002
- || (target.contextOnly == null && target._contextOnly === true && _prRecordIsLegacyManaged(target));
7001
+ const targetWasAutoManaged = isAutoManagedPrRecord(target);
7003
7002
  for (const key of ['url', 'title', 'description', 'agent', 'branch', 'reviewStatus', 'status', 'created', 'sourcePlan', 'itemType']) {
7004
7003
  if (normalizedEntry[key] != null && normalizedEntry[key] !== '' && (target[key] == null || target[key] === '')) {
7005
7004
  target[key] = normalizedEntry[key];
7006
7005
  }
7007
7006
  }
7008
- // W-mq5s5ttx000j7ab8-a — `_manual` and `_autoObserve` are no longer
7009
- // copied through; the engine reads gate state from the canonical
7010
- // `contextOnly` field (see isAutoManagedPrRecord above + the boot
7011
- // migration migratePrGateFlags). `_context`/`_projectResolution` are
7012
- // unrelated breadcrumbs and stay.
7013
7007
  for (const key of ['_context', '_projectResolution']) {
7014
7008
  if (normalizedEntry[key] != null) target[key] = normalizedEntry[key];
7015
7009
  }
7016
- // Accept either the canonical `contextOnly` (preferred) or the legacy
7017
- // `_contextOnly` from callers that haven't been migrated yet
7018
- // (dashboard.js manual-link path, lifecycle.js oneShot tagging items
7019
- // (b)/(c) of the decomposition). Persist as canonical `contextOnly`.
7020
- const incomingContextOnly = normalizedEntry.contextOnly != null
7021
- ? normalizedEntry.contextOnly
7022
- : normalizedEntry._contextOnly;
7023
- if (incomingContextOnly != null) {
7024
- const wouldDemoteManagedPr = incomingContextOnly === true && targetWasAutoManaged;
7025
- target.contextOnly = wouldDemoteManagedPr ? false : incomingContextOnly === true;
7026
- delete target._contextOnly;
7027
- delete target._autoObserve;
7028
- delete target._manual;
7010
+ if (normalizedEntry.contextOnly != null) {
7011
+ const wouldDemoteManagedPr = normalizedEntry.contextOnly === true && targetWasAutoManaged;
7012
+ target.contextOnly = wouldDemoteManagedPr ? false : normalizedEntry.contextOnly === true;
7029
7013
  }
7030
7014
  }
7031
7015
  target.prdItems = normalizePrLinkItems(target.prdItems || []);
@@ -7048,142 +7032,6 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
7048
7032
  return { id: canonicalId, prNumber, created, linked, skipped, record };
7049
7033
  }
7050
7034
 
7051
- // ─── PR Gate Migration (W-mq5s5ttx000j7ab8-a) ───────────────────────────────
7052
- //
7053
- // One-shot boot migration that projects the legacy gate signals
7054
- // (`_contextOnly`, `_autoObserve`, `_manual`) onto the canonical
7055
- // `contextOnly` field on every `projects/<name>/pull-requests.json` record.
7056
- // Wired from `engine/cli.js#start()` before the first tick fires so the new
7057
- // `isAutoManagedPrRecord` (which only reads `contextOnly`) returns the same
7058
- // verdict as the legacy 6-clause helper for pre-existing data.
7059
- //
7060
- // Per-record decision:
7061
- // 1. contextOnly := (_contextOnly === true)
7062
- // 2. legacyManaged := _autoObserve === true || sourcePlan || itemType ||
7063
- // (agent is a *configured* Minions persona)
7064
- // "Configured Minions persona" = `agent` matches an id in
7065
- // `config.agents` (case-insensitive). The github poller writes the PR
7066
- // author's login into `agent` (from `prData.user.login`); human logins
7067
- // like `calebt_microsoft` are kebab/snake-shaped and previously passed a
7068
- // naive shape regex, mis-classifying a human PR as managed and flipping
7069
- // its observe toggle ON on the next boot (W-mqil3jtw). Cross-checking the
7070
- // real roster instead of a shape heuristic fixes that. A login that is
7071
- // not a configured agent id is treated as a human author.
7072
- // NOTE: `prdItems.length > 0` is intentionally NOT a managed signal in
7073
- // this migration. A watch can link a PR context-only AND spin off a
7074
- // courtesy-review WI whose id is stamped into `prdItems`; that stamp must
7075
- // not override the explicit context-only intent. Genuine managed PRs
7076
- // always carry a real signal above (configured agent / sourcePlan /
7077
- // itemType / _autoObserve), so dropping prdItems-alone is safe here.
7078
- // 3. If !contextOnly && !legacyManaged, set contextOnly = true and stamp
7079
- // `_migrationNote: 'auto-set-contextOnly-by-pr-gate-simplification'`.
7080
- // 4. Persist `contextOnly`; delete `_autoObserve`, `_manual`, `_contextOnly`.
7081
- //
7082
- // Idempotent: records that already have `contextOnly` and none of the legacy
7083
- // keys are skipped (no rewrite, no log line, JSON mtime unchanged).
7084
- const _PR_GATE_MIGRATION_NOTE = 'auto-set-contextOnly-by-pr-gate-simplification';
7085
- const _AGENT_PERSONA_RE = /^[a-z0-9_-]+$/;
7086
-
7087
- function _prRecordHasLegacyGateKey(record) {
7088
- return Object.prototype.hasOwnProperty.call(record, '_contextOnly')
7089
- || Object.prototype.hasOwnProperty.call(record, '_autoObserve')
7090
- || Object.prototype.hasOwnProperty.call(record, '_manual');
7091
- }
7092
-
7093
- // Resolve the configured agent roster (lower-cased Set) once per migration.
7094
- // Accepts either an explicit `agentIds` (Set / array) or a `config` object
7095
- // whose `agents` keys are the roster. Returns an empty Set when nothing is
7096
- // supplied (no record can match a managed persona).
7097
- // (Deliberately NOT queries.getAgents(config): that lives in queries.js — which
7098
- // requires shared.js, so importing it here is circular — and it also reads
7099
- // dispatch/inbox state. We only need the bare id set.)
7100
- function _resolveConfiguredAgentIds(opts = {}) {
7101
- let ids = opts.agentIds;
7102
- if (ids == null && opts.config && opts.config.agents && typeof opts.config.agents === 'object') {
7103
- ids = Object.keys(opts.config.agents);
7104
- }
7105
- const source = ids instanceof Set ? [...ids] : (Array.isArray(ids) ? ids : []);
7106
- return new Set(source.map(id => String(id || '').trim().toLowerCase()).filter(Boolean));
7107
- }
7108
-
7109
- // `agentIds` (a Set, supplied by the boot migration) switches this helper into
7110
- // "roster mode": the `agent` field only counts as a managed signal when it is
7111
- // an actually-configured agent id, and prdItems-alone is dropped (see the
7112
- // migration header). Callers that can't cheaply resolve the roster (the
7113
- // runtime upsert/link demotion-guard at the `_prRecordIsLegacyManaged(target)`
7114
- // call site) pass no agentIds and keep the legacy shape-heuristic behavior.
7115
- function _prRecordIsLegacyManaged(record, agentIds) {
7116
- const rosterMode = agentIds instanceof Set;
7117
- // prdItems-alone is a managed signal only in the legacy runtime path, not the
7118
- // boot migration (see header: courtesy-review watch contamination).
7119
- if (!rosterMode && Array.isArray(record.prdItems) && record.prdItems.length > 0) return true;
7120
- if (record._autoObserve === true) return true;
7121
- if (record.sourcePlan) return true;
7122
- if (record.itemType) return true;
7123
- if (typeof record.agent === 'string') {
7124
- const agent = record.agent.trim().toLowerCase();
7125
- if (agent && agent !== 'human') {
7126
- if (rosterMode ? agentIds.has(agent) : _AGENT_PERSONA_RE.test(agent)) return true;
7127
- }
7128
- }
7129
- return false;
7130
- }
7131
-
7132
- function migratePrGateFlags(projectsRoot, opts = {}) {
7133
- const summary = { projectsScanned: 0, projectsMigrated: 0, totalRecords: 0, totalMigrated: 0 };
7134
- if (!projectsRoot || typeof projectsRoot !== 'string') return summary;
7135
- const agentIds = _resolveConfiguredAgentIds(opts);
7136
- let entries;
7137
- try {
7138
- entries = fs.readdirSync(projectsRoot, { withFileTypes: true });
7139
- } catch {
7140
- return summary;
7141
- }
7142
- for (const entry of entries) {
7143
- if (!entry.isDirectory()) continue;
7144
- const projectName = entry.name;
7145
- const prPath = path.join(projectsRoot, projectName, 'pull-requests.json');
7146
- if (!fs.existsSync(prPath)) continue;
7147
- summary.projectsScanned++;
7148
-
7149
- let migrated = 0;
7150
- let stamped = 0;
7151
- let alreadyMigrated = 0;
7152
-
7153
- mutatePullRequests(prPath, (prs) => {
7154
- if (!Array.isArray(prs)) return prs;
7155
- for (const record of prs) {
7156
- if (!record || typeof record !== 'object') continue;
7157
- const hasLegacy = _prRecordHasLegacyGateKey(record);
7158
- const hasCanonical = Object.prototype.hasOwnProperty.call(record, 'contextOnly');
7159
- if (hasCanonical && !hasLegacy) { alreadyMigrated++; continue; }
7160
-
7161
- let contextOnly = (record._contextOnly === true);
7162
- if (!contextOnly && !_prRecordIsLegacyManaged(record, agentIds)) {
7163
- contextOnly = true;
7164
- record._migrationNote = _PR_GATE_MIGRATION_NOTE;
7165
- stamped++;
7166
- }
7167
- record.contextOnly = contextOnly;
7168
- delete record._autoObserve;
7169
- delete record._manual;
7170
- delete record._contextOnly;
7171
- migrated++;
7172
- }
7173
- return prs;
7174
- });
7175
-
7176
- summary.totalRecords += (migrated + alreadyMigrated);
7177
- summary.totalMigrated += migrated;
7178
- if (migrated > 0) {
7179
- summary.projectsMigrated++;
7180
- // One line per project that actually moved. Keep idempotent runs silent.
7181
- console.log(`[pr-gate-migration] ${projectName}: migrated ${migrated} records (${stamped} stamped contextOnly, ${alreadyMigrated} already-migrated)`);
7182
- }
7183
- }
7184
- return summary;
7185
- }
7186
-
7187
7035
  // ─── PR Reference → URL Derivation ───────────────────────────────────────────
7188
7036
  //
7189
7037
  // W-mq5wfh1v000e0da9 — Given a PR ref (URL, canonical `host:scope#N` id, or
@@ -9040,7 +8888,6 @@ module.exports = {
9040
8888
  isContextOnlyPrRecord,
9041
8889
  upsertPullRequestRecord,
9042
8890
  isAutoManagedPrRecord, // W-mq5s5ttx000j7ab8-a — exported for engine + watch-plugin gate consolidation
9043
- migratePrGateFlags, // W-mq5s5ttx000j7ab8-a — boot migration wired from engine/cli.js
9044
8891
  autoEnrollPrFromFixWorkItem,
9045
8892
  deriveUrlForPrRef, // exported for testing
9046
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
@@ -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.2250",
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"