@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 +5 -3
- package/dashboard/js/command-center.js +1 -1
- package/dashboard/js/refresh.js +9 -3
- package/dashboard/js/render-prs.js +11 -5
- package/dashboard/js/utils.js +2 -1
- package/dashboard/slim/js/link-pr.js +1 -1
- package/dashboard.js +41 -1
- package/docs/deprecated.json +10 -35
- package/docs/pr-auto-fix-dispatch.md +2 -0
- package/engine/ado-git-auth.js +2 -1
- package/engine/ado.js +14 -8
- package/engine/check-status.js +11 -3
- package/engine/cli.js +0 -15
- package/engine/dispatch.js +25 -5
- package/engine/github.js +19 -1
- package/engine/lifecycle.js +47 -5
- package/engine/pipeline.js +5 -1
- package/engine/queries.js +44 -26
- package/engine/scheduler.js +8 -8
- package/engine/shared.js +65 -190
- package/engine/timeout.js +2 -0
- package/engine/watch-actions.js +2 -2
- package/engine/worktree-gc.js +45 -31
- package/engine.js +3 -3
- package/package.json +1 -1
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
|
|
738
|
-
//
|
|
739
|
-
//
|
|
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 || '',
|
|
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 = '✓ PR linked: <strong>' + escHtml(action.url) + '</strong>' +
|
package/dashboard/js/refresh.js
CHANGED
|
@@ -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
|
-
//
|
|
874
|
-
//
|
|
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
|
|
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
|
|
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)
|
|
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) {
|
package/dashboard/js/utils.js
CHANGED
|
@@ -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
|
-
|
|
540
|
+
var safeHref = href.replace(/"/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,
|
|
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.
|
package/docs/deprecated.json
CHANGED
|
@@ -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.
|
package/engine/ado-git-auth.js
CHANGED
|
@@ -210,7 +210,8 @@ function isAdoAuthFailure(err) {
|
|
|
210
210
|
|
|
211
211
|
function _redactBearer(s) {
|
|
212
212
|
if (typeof s !== 'string') return s;
|
|
213
|
-
|
|
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
|
|
1211
|
-
//
|
|
1212
|
-
// project
|
|
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
|
-
|
|
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
|
|
package/engine/check-status.js
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
package/engine/dispatch.js
CHANGED
|
@@ -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
|
|
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 `
|
|
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 (
|
|
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
|
-
|
|
641
|
+
safeReason ? `**Reason:** ${safeReason}` : '',
|
|
624
642
|
'',
|
|
625
|
-
|
|
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
|
-
|
|
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
|
package/engine/lifecycle.js
CHANGED
|
@@ -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
|
-
|
|
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 `
|
|
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
|
-
|
|
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
|
}
|
package/engine/pipeline.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
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 || '') ||
|
package/engine/scheduler.js
CHANGED
|
@@ -259,9 +259,9 @@ function parseCronExpr(expr) {
|
|
|
259
259
|
|
|
260
260
|
return {
|
|
261
261
|
matches(date) {
|
|
262
|
-
return minuteMatcher(date.
|
|
263
|
-
hourMatcher(date.
|
|
264
|
-
dowMatcher(date.
|
|
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.
|
|
302
|
-
last.
|
|
303
|
-
last.
|
|
304
|
-
last.
|
|
305
|
-
last.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
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
|
-
//
|
|
6783
|
-
//
|
|
6784
|
-
//
|
|
6785
|
-
//
|
|
6786
|
-
// contamination while still treating old context-only rows as reference-only.
|
|
6807
|
+
// Canonical `contextOnly` gate — reads 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
|
-
|
|
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
|
-
|
|
6947
|
-
|
|
6948
|
-
|
|
6949
|
-
|
|
6950
|
-
|
|
6951
|
-
|
|
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
|
-
|
|
6989
|
-
|
|
6990
|
-
|
|
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
|
};
|
package/engine/watch-actions.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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;
|
package/engine/worktree-gc.js
CHANGED
|
@@ -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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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('
|
|
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
|
-
|
|
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.
|
|
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"
|