@yemi33/minions 0.1.2142 → 0.1.2144
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/cli-api-client.js +41 -4
- package/bin/minions.js +130 -25
- package/dashboard/js/refresh.js +1 -1
- package/dashboard/js/render-prs.js +9 -7
- package/dashboard.js +101 -33
- package/docs/deprecated.json +55 -0
- package/engine/cleanup.js +54 -0
- package/engine/cli.js +35 -6
- package/engine/dispatch.js +1 -1
- package/engine/lifecycle.js +266 -0
- package/engine/queries.js +75 -1
- package/engine/shared.js +246 -1
- package/engine/supervisor.js +22 -4
- package/engine/worktree-gc.js +377 -6
- package/engine.js +53 -6
- package/package.json +1 -1
package/docs/deprecated.json
CHANGED
|
@@ -109,5 +109,60 @@
|
|
|
109
109
|
"removalGate": "Two conditions must hold simultaneously: (a) grep `engine/ado.js` for `isAdoThrottled\\s*\\(\\s*\\)` and confirm zero arg-less call sites remain across the engine — every caller passes a concrete `orgBase` resolved via `shared.getAdoOrgBase(project)`; (b) `GET /api/diagnostics/ado-throttle` on a live engine has been observed for >=2 consecutive weeks reporting per-org keys (proves the per-org Map is populated under load and the global-OR isn't masking a regression). Once both hold, removal deletes the arg-less branch in isAdoThrottled and the global-OR fold; callers that still pass no argument become an immediate, surfaced bug rather than a silent over-throttle.",
|
|
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
|
+
},
|
|
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 foundation sub-WI adds canonical `contextOnly` + a one-shot boot migration that copies `_contextOnly` → `contextOnly` for existing records). The underscore-prefixed alias is preserved on writes for one release so any out-of-process readers (dashboards, ops scripts) that still inspect `_contextOnly` continue to work.",
|
|
116
|
+
"code": [
|
|
117
|
+
{ "file": "engine/shared.js", "note": "isAutoManagedPrRecord + normalizePrRecords write both `contextOnly` and `_contextOnly` until removal." },
|
|
118
|
+
{ "file": "dashboard.js", "note": "GET /api/pull-requests responses include `_contextOnly` alias; POST /api/pull-requests/observe writes both fields until removal." },
|
|
119
|
+
{ "file": "engine.js", "note": "discoverFromPrs reads `pr.contextOnly` only — `_contextOnly` is write-only legacy alias post-consolidation." }
|
|
120
|
+
],
|
|
121
|
+
"deprecated": "2026-06-08",
|
|
122
|
+
"targetRemovalDate": "2026-06-16",
|
|
123
|
+
"notes": "Cleanup-date is 7 days from PR merge per the W-mq5s5ttx000j7ab8 docs sub-WI. 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 + dual-write window. Removal scope: delete the `_contextOnly` write site in `normalizePrRecords` (engine/shared.js), drop the `_contextOnly` field from `GET /api/pull-requests` shaping in dashboard.js, and remove the boot migration in engine/shared.js once telemetry confirms no record-with-`_contextOnly`-only on disk."
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"id": "pr-record-autoObserve-underscore-alias",
|
|
127
|
+
"description": "Legacy `pr._autoObserve` boolean on PR records. Was one of 6 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, so the dispatch gate no longer needs to inspect `_autoObserve`).",
|
|
128
|
+
"code": [
|
|
129
|
+
{ "file": "engine/shared.js", "note": "isAutoManagedPrRecord no longer reads `_autoObserve`; normalizePrRecords still writes it for one release as a backward-compat alias." },
|
|
130
|
+
{ "file": "dashboard.js", "note": "POST /api/pull-requests/link + POST /api/pull-requests/observe accept `contextOnly` as the canonical body param; `_autoObserve` is mirrored on the record for legacy readers." }
|
|
131
|
+
],
|
|
132
|
+
"deprecated": "2026-06-08",
|
|
133
|
+
"targetRemovalDate": "2026-06-16",
|
|
134
|
+
"notes": "Cleanup-date is 7 days from PR merge per the W-mq5s5ttx000j7ab8 docs sub-WI. Removal is paired with `pr-record-contextOnly-underscore-alias` — same dual-write window, same boot migration. After removal, only `pr.contextOnly` exists on records; any external reader that still reads `_autoObserve` will get `undefined` and must migrate."
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"id": "pr-record-manual-underscore-alias",
|
|
138
|
+
"description": "Legacy `pr._manual` boolean on PR records (set by `POST /api/pull-requests/link` 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 `_manual` provenance flag is preserved on records for dashboard display + audit but is no longer load-bearing for dispatch decisions.",
|
|
139
|
+
"code": [
|
|
140
|
+
{ "file": "engine.js", "note": "discoverFromPrs no longer reads `_manual` post-consolidation (was at the inline `isAgentPr` check, now removed)." },
|
|
141
|
+
{ "file": "engine/shared.js", "note": "isAutoManagedPrRecord no longer reads `_manual`; normalizePrRecords still writes it for one release as a provenance/UI alias." },
|
|
142
|
+
{ "file": "dashboard.js", "note": "POST /api/pull-requests/link continues to set `_manual: true` for UI/audit; remove once dashboards stop displaying the badge." }
|
|
143
|
+
],
|
|
144
|
+
"deprecated": "2026-06-08",
|
|
145
|
+
"targetRemovalDate": "2026-06-16",
|
|
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. Cleanup-date is 7 days from PR merge per the W-mq5s5ttx000j7ab8 docs sub-WI; remove the write site in dashboard.js (POST /api/pull-requests/link record-build) and any remaining `_manual` reads in render-prs.js when dashboard UI no longer displays a 'manual link' badge."
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
"id": "pr-link-autoObserve-body-param",
|
|
150
|
+
"description": "Legacy `autoObserve` body parameter on `POST /api/pull-requests/link`. Replaced by canonical `contextOnly` body param (inverse boolean: `autoObserve: false` ⇔ `contextOnly: true`). Endpoint accepts both for one release: if `contextOnly` is present it wins; otherwise the handler maps `autoObserve` → `contextOnly = !autoObserve` and proceeds.",
|
|
151
|
+
"code": [
|
|
152
|
+
{ "file": "dashboard.js", "note": "POST /api/pull-requests/link handler reads `body.contextOnly` first, then falls back to `!body.autoObserve` for backwards compat. Route registry params string still lists `autoObserve?` for one release." }
|
|
153
|
+
],
|
|
154
|
+
"deprecated": "2026-06-08",
|
|
155
|
+
"targetRemovalDate": "2026-06-16",
|
|
156
|
+
"notes": "Cleanup-date is 7 days from PR merge per the W-mq5s5ttx000j7ab8 docs sub-WI. After removal, callers that still send `autoObserve` will see their value silently ignored (the record's `contextOnly` will fall back to whatever default the endpoint applies). Removal scope: drop the `body.autoObserve` fallback in the link handler in dashboard.js, drop `autoObserve?` from the route registry `params` string, and update any client (dashboard JS, ops scripts) that still POSTs `autoObserve`."
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"id": "pr-observe-observe-body-param",
|
|
160
|
+
"description": "Legacy `observe` body parameter on `POST /api/pull-requests/observe`. The W-mq5s5ttx000j7ab8 endpoint sub-WI introduces canonical `contextOnly` as the inverse (`observe: false` ⇔ `contextOnly: true`) and keeps `observe` accepted for backward compat. Registering the deprecation here so the alias has a documented removal path; the WI explicitly notes this entry is the implementer's call (it is kept for backward compat and may live longer than the underscore-prefixed record fields).",
|
|
161
|
+
"code": [
|
|
162
|
+
{ "file": "dashboard.js", "note": "POST /api/pull-requests/observe handler reads `body.contextOnly` first, then falls back to `!body.observe` for backwards compat." }
|
|
163
|
+
],
|
|
164
|
+
"deprecated": "2026-06-08",
|
|
165
|
+
"targetRemovalDate": null,
|
|
166
|
+
"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`."
|
|
112
167
|
}
|
|
113
168
|
]
|
package/engine/cleanup.js
CHANGED
|
@@ -1519,10 +1519,64 @@ function scrubStaleMetrics() {
|
|
|
1519
1519
|
});
|
|
1520
1520
|
}
|
|
1521
1521
|
|
|
1522
|
+
// ─── Periodic worktree GC sweep (W-mq5o6bvy000x7191 Layer 2 + 3) ────────────
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* Periodic per-tick worktree sweep. Aggregates the two pruners in
|
|
1526
|
+
* `engine/worktree-gc.js` so a long-lived engine catches orphans the
|
|
1527
|
+
* boot-only reconcile would miss:
|
|
1528
|
+
*
|
|
1529
|
+
* - `pruneOrphanWorktrees`: walks `<localPath>/<worktreeRoot>` and reaps
|
|
1530
|
+
* orphan W-* dirs with no matching dispatch.
|
|
1531
|
+
* - `pruneOrphanWorktreesFromGitRegistry`: walks `git worktree list` per
|
|
1532
|
+
* project and reaps registered worktrees outside the configured
|
|
1533
|
+
* `worktreeRoot` (covers `D:/tmp-*`, `D:/squad-worktrees/*`, etc.).
|
|
1534
|
+
*
|
|
1535
|
+
* Both pruners share the SAME `dispatchSnap` from `queries.getDispatch()`
|
|
1536
|
+
* so live work isn't yanked between scans. Returns aggregate stats for
|
|
1537
|
+
* tick-level logging.
|
|
1538
|
+
*/
|
|
1539
|
+
function runPeriodicWorktreeSweep(config) {
|
|
1540
|
+
const worktreeGc = require('./worktree-gc');
|
|
1541
|
+
const projects = getProjects(config);
|
|
1542
|
+
if (projects.length === 0) {
|
|
1543
|
+
return { scanned: 0, kept: 0, evicted: 0, failed: 0, outOfRootEvicted: 0, prunedRegistry: 0 };
|
|
1544
|
+
}
|
|
1545
|
+
const dispatchSnap = getDispatch();
|
|
1546
|
+
const worktreeRootRel = config?.engine?.worktreeRoot || ENGINE_DEFAULTS.worktreeRoot;
|
|
1547
|
+
const _log = (lvl, msg) => log(lvl, msg);
|
|
1548
|
+
|
|
1549
|
+
let scanned = 0, kept = 0, evicted = 0, failed = 0, outOfRootEvicted = 0, prunedRegistry = 0;
|
|
1550
|
+
const _writeToInbox = (a, s, c) => { try { return shared.writeToInbox(a, s, c); } catch (_e) { return false; } };
|
|
1551
|
+
try {
|
|
1552
|
+
const r1 = worktreeGc.pruneOrphanWorktrees({
|
|
1553
|
+
projects, dispatchSnap, worktreeRootRel, log: _log, config, writeToInbox: _writeToInbox,
|
|
1554
|
+
});
|
|
1555
|
+
scanned += r1.scanned || 0;
|
|
1556
|
+
kept += r1.kept || 0;
|
|
1557
|
+
evicted += r1.evicted || 0;
|
|
1558
|
+
failed += r1.failed || 0;
|
|
1559
|
+
} catch (e) { log('warn', `worktree-gc periodic in-root: ${e.message}`); }
|
|
1560
|
+
|
|
1561
|
+
try {
|
|
1562
|
+
const r2 = worktreeGc.pruneOrphanWorktreesFromGitRegistry({
|
|
1563
|
+
projects, dispatchSnap, worktreeRootRel, log: _log, config, writeToInbox: _writeToInbox,
|
|
1564
|
+
});
|
|
1565
|
+
scanned += r2.scanned || 0;
|
|
1566
|
+
kept += r2.kept || 0;
|
|
1567
|
+
outOfRootEvicted += r2.evicted || 0;
|
|
1568
|
+
failed += r2.failed || 0;
|
|
1569
|
+
prunedRegistry += r2.prunedRegistry || 0;
|
|
1570
|
+
} catch (e) { log('warn', `worktree-gc periodic out-of-root: ${e.message}`); }
|
|
1571
|
+
|
|
1572
|
+
return { scanned, kept, evicted, failed, outOfRootEvicted, prunedRegistry };
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1522
1575
|
// ─── Exports ─────────────────────────────────────────────────────────────────
|
|
1523
1576
|
|
|
1524
1577
|
module.exports = {
|
|
1525
1578
|
runCleanup,
|
|
1579
|
+
runPeriodicWorktreeSweep, // W-mq5o6bvy000x7191 — exported for engine.js tick wiring + testing
|
|
1526
1580
|
scrubStaleMetrics,
|
|
1527
1581
|
sweepLeakedTestMeetings, // exported for testing
|
|
1528
1582
|
pruneStaleBackupSidecars, // P-c91d4f76 — exported for testing
|
package/engine/cli.js
CHANGED
|
@@ -192,15 +192,25 @@ function isHelpToken(arg) {
|
|
|
192
192
|
return arg === '--help' || arg === '-h' || arg === 'help';
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
// W-mq07mjzi000s1cc9: Stricter guard for command first-positionals.
|
|
195
|
+
// W-mq07mjzi000s1cc9 + W-mq5hk4u200081043: Stricter guard for command first-positionals.
|
|
196
196
|
//
|
|
197
197
|
// Real work-item titles, agent ids, plan source paths, and dispatch ids never
|
|
198
|
-
// start with
|
|
199
|
-
// reported (`--help`) plus typos
|
|
200
|
-
//
|
|
198
|
+
// start with `-` and are never bare `?`/`help`. Rejecting these catches the
|
|
199
|
+
// exact bug reported (`--help`) plus single-dash typos (`-X`, `-quiet`),
|
|
200
|
+
// question-mark probing (`?`), case-variant help (`Help`, `HELP`), and
|
|
201
|
+
// whitespace-only arguments that would otherwise create ghost WIs.
|
|
202
|
+
//
|
|
203
|
+
// handleCommand's narrow `isHelpToken` interceptor still handles the canonical
|
|
204
|
+
// `--help`/`-h`/`help` triplet by printing the global command list; anything
|
|
205
|
+
// else falls through to this defensive per-command guard which prints
|
|
206
|
+
// command-specific usage and exits 2.
|
|
201
207
|
function looksLikeFlagOrHelp(arg) {
|
|
202
|
-
if (
|
|
203
|
-
|
|
208
|
+
if (typeof arg !== 'string') return false;
|
|
209
|
+
const t = arg.trim();
|
|
210
|
+
if (!t) return true;
|
|
211
|
+
if (t.startsWith('-')) return true;
|
|
212
|
+
if (/^(help|\?)$/i.test(t)) return true;
|
|
213
|
+
return false;
|
|
204
214
|
}
|
|
205
215
|
|
|
206
216
|
// SoT for engine-CLI metadata: drives handleCommand's help text and the
|
|
@@ -920,10 +930,29 @@ const commands = {
|
|
|
920
930
|
dispatchSnap,
|
|
921
931
|
worktreeRootRel,
|
|
922
932
|
log: (lvl, msg) => e.log(lvl, msg),
|
|
933
|
+
config: cfg,
|
|
934
|
+
writeToInbox: (a, s, c) => { try { return sharedMod.writeToInbox(a, s, c); } catch (_e) { return false; } },
|
|
923
935
|
});
|
|
924
936
|
if (result.evicted > 0 || result.failed > 0) {
|
|
925
937
|
console.log(` Worktree boot reconcile: evicted ${result.evicted} orphan worktree(s)${result.failed ? `, ${result.failed} failed` : ''} (scanned ${result.scanned}, kept ${result.kept})`);
|
|
926
938
|
}
|
|
939
|
+
// W-mq5o6bvy000x7191 (Layer 3): out-of-root scan — catches worktrees
|
|
940
|
+
// registered in `git worktree list` but living outside the configured
|
|
941
|
+
// `worktreeRoot` (e.g. ad-hoc `D:/tmp-*/` dirs left by previous
|
|
942
|
+
// experiments). Same dispatch snapshot so branch protection is
|
|
943
|
+
// consistent. Also runs `git worktree prune --expire=now` per project
|
|
944
|
+
// so manually-removed dirs drop out of the registry immediately.
|
|
945
|
+
const regResult = worktreeGc.pruneOrphanWorktreesFromGitRegistry({
|
|
946
|
+
projects,
|
|
947
|
+
dispatchSnap,
|
|
948
|
+
worktreeRootRel,
|
|
949
|
+
log: (lvl, msg) => e.log(lvl, msg),
|
|
950
|
+
config: cfg,
|
|
951
|
+
writeToInbox: (a, s, c) => { try { return sharedMod.writeToInbox(a, s, c); } catch (_e) { return false; } },
|
|
952
|
+
});
|
|
953
|
+
if (regResult.evicted > 0 || regResult.failed > 0 || regResult.prunedRegistry > 0) {
|
|
954
|
+
console.log(` Worktree registry reconcile: scanned ${regResult.scanned}, evicted ${regResult.evicted}, kept ${regResult.kept}, failed ${regResult.failed}, registry-pruned ${regResult.prunedRegistry}`);
|
|
955
|
+
}
|
|
927
956
|
} catch (err) {
|
|
928
957
|
e.log('warn', `Worktree boot reconcile failed: ${err.message}`);
|
|
929
958
|
}
|
package/engine/dispatch.js
CHANGED
|
@@ -358,7 +358,7 @@ function getStalePrDispatchReason(entry, config) {
|
|
|
358
358
|
const prLabel = entry.meta.pr?.id || entry.meta.pr?.url || entry.id;
|
|
359
359
|
if (!tracked) return `PR ${prLabel} is no longer tracked`;
|
|
360
360
|
if (tracked.status !== PR_STATUS.ACTIVE) return `PR ${tracked.id || prLabel} is ${tracked.status || 'missing status'}`;
|
|
361
|
-
if (tracked._contextOnly) return `PR ${tracked.id || prLabel} is context-only`;
|
|
361
|
+
if (tracked._contextOnly && entry.meta?.source !== 'work-item') return `PR ${tracked.id || prLabel} is context-only`;
|
|
362
362
|
|
|
363
363
|
const queuedBranch = entry.meta.branch || entry.meta.pr?.branch || '';
|
|
364
364
|
const trackedBranch = tracked.branch || '';
|
package/engine/lifecycle.js
CHANGED
|
@@ -987,6 +987,258 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
|
|
|
987
987
|
return added;
|
|
988
988
|
}
|
|
989
989
|
|
|
990
|
+
// ─── Post-hoc PR enrollment (W-mq5uzmc6001d708f) ─────────────────────────────
|
|
991
|
+
//
|
|
992
|
+
// When a work item has `_pr` set (e.g. via reconcileItemsWithPrs stamping from
|
|
993
|
+
// pr-links, or via an old commit referencing the PR URL) but the `pull_requests`
|
|
994
|
+
// SQL table has no matching row, the dashboard's PRD-progress synthesis falls
|
|
995
|
+
// back to PR_STATUS.ACTIVE forever — the PR rings blue ○ even after it's
|
|
996
|
+
// merged. This happens to PRs that were created AND merged inside one open-PR
|
|
997
|
+
// poller gap (the engine never observed them as `open`, so it never enrolled
|
|
998
|
+
// them).
|
|
999
|
+
//
|
|
1000
|
+
// `enrollPrFromCanonicalId` is the write-side fix: given a canonical PR id and
|
|
1001
|
+
// the owning project, fetch live state from GitHub (or ADO, best-effort) and
|
|
1002
|
+
// insert a `_contextOnly: true` row into the project's `pull-requests.json`.
|
|
1003
|
+
// Idempotent — no-op if a record already exists. Always marks the record as
|
|
1004
|
+
// context-only so the engine doesn't try to re-dispatch fix/review loops on
|
|
1005
|
+
// an already-merged PR (matches the #1772 enrollment-flag semantics).
|
|
1006
|
+
|
|
1007
|
+
let _enrollmentGhRunner = null;
|
|
1008
|
+
function _setEnrollmentGhRunnerForTest(fn) {
|
|
1009
|
+
_enrollmentGhRunner = (typeof fn === 'function') ? fn : null;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
async function _fetchGitHubPrStateForEnrollment(slug, prNumber) {
|
|
1013
|
+
if (_enrollmentGhRunner) {
|
|
1014
|
+
try {
|
|
1015
|
+
return await _enrollmentGhRunner(slug, prNumber);
|
|
1016
|
+
} catch (err) {
|
|
1017
|
+
log('warn', `enrollPrFromCanonicalId: test runner failed for ${slug}#${prNumber}: ${err.message}`);
|
|
1018
|
+
return null;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
let token = null;
|
|
1022
|
+
try {
|
|
1023
|
+
const ghToken = require('./gh-token');
|
|
1024
|
+
if (typeof ghToken.resolveTokenForSlug === 'function') {
|
|
1025
|
+
token = ghToken.resolveTokenForSlug(slug);
|
|
1026
|
+
}
|
|
1027
|
+
} catch { /* gh-token optional in some test contexts */ }
|
|
1028
|
+
const env = token ? { ...process.env, GH_TOKEN: token } : undefined;
|
|
1029
|
+
try {
|
|
1030
|
+
const validatedSlug = shared.validateGhSlug(slug);
|
|
1031
|
+
const args = ['pr', 'view', String(prNumber),
|
|
1032
|
+
'--repo', validatedSlug,
|
|
1033
|
+
'--json', 'state,mergedAt,closedAt,title,headRefName,baseRefName,url,number,isDraft'];
|
|
1034
|
+
const stdout = await shared.shellSafeGh(args, { timeout: 15000, env });
|
|
1035
|
+
if (!stdout || !stdout.trim()) return null;
|
|
1036
|
+
return JSON.parse(stdout);
|
|
1037
|
+
} catch (err) {
|
|
1038
|
+
log('warn', `enrollPrFromCanonicalId: gh pr view ${slug}#${prNumber} failed: ${err.message}`);
|
|
1039
|
+
return null;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function _liveGitHubStateToPrStatus(liveState) {
|
|
1044
|
+
if (!liveState) return PR_STATUS.ACTIVE;
|
|
1045
|
+
const state = String(liveState.state || '').toUpperCase();
|
|
1046
|
+
if (state === 'MERGED' || liveState.mergedAt) return PR_STATUS.MERGED;
|
|
1047
|
+
if (state === 'CLOSED') return PR_STATUS.ABANDONED;
|
|
1048
|
+
return PR_STATUS.ACTIVE;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function _existingPrRecordForCanonicalId(canonicalPrId) {
|
|
1052
|
+
try {
|
|
1053
|
+
const store = require('./pull-requests-store');
|
|
1054
|
+
if (typeof store.readAllPullRequests !== 'function') return null;
|
|
1055
|
+
const all = store.readAllPullRequests() || [];
|
|
1056
|
+
return all.find(pr => pr && pr.id === canonicalPrId) || null;
|
|
1057
|
+
} catch {
|
|
1058
|
+
return null;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Enroll a PR (identified by canonical id, e.g. `github:owner/repo#123`) into
|
|
1064
|
+
* the engine's pull-requests tracker if no record exists yet. Used to back-fill
|
|
1065
|
+
* orphan `wi._pr` pointers from the legacy / pre-tracker era so the dashboard
|
|
1066
|
+
* can render the real merged/abandoned status instead of a perpetual blue ○.
|
|
1067
|
+
*
|
|
1068
|
+
* Idempotent: returns `{ enrolled: false, reason: 'already_tracked' }` if a row
|
|
1069
|
+
* already exists. Marks new records with `_contextOnly: true` so the engine
|
|
1070
|
+
* doesn't try to manage them (no re-dispatch of fix/review loops). Live state
|
|
1071
|
+
* is fetched via `gh pr view` for GitHub; ADO enrollment is best-effort with
|
|
1072
|
+
* a conservative ACTIVE default.
|
|
1073
|
+
*
|
|
1074
|
+
* @param {string} canonicalPrId - canonical id like `github:owner/repo#42`
|
|
1075
|
+
* @param {object|null} project - resolved project config; null routes to central
|
|
1076
|
+
* @param {object} [opts]
|
|
1077
|
+
* @param {string} [opts.itemId] - work-item id to link to the enrolled record
|
|
1078
|
+
* @returns {Promise<{enrolled: boolean, status?: string, reason?: string, record?: object}>}
|
|
1079
|
+
*/
|
|
1080
|
+
async function enrollPrFromCanonicalId(canonicalPrId, project, opts = {}) {
|
|
1081
|
+
if (!canonicalPrId || typeof canonicalPrId !== 'string') {
|
|
1082
|
+
return { enrolled: false, reason: 'no_id' };
|
|
1083
|
+
}
|
|
1084
|
+
// Allow callers to pass a bare display id (e.g. `PR-3082`) plus project; the
|
|
1085
|
+
// upsert helper will re-derive a canonical id from project + display id.
|
|
1086
|
+
let parsed = shared.parseCanonicalPrId(canonicalPrId);
|
|
1087
|
+
let scope;
|
|
1088
|
+
let prNumber;
|
|
1089
|
+
if (parsed) {
|
|
1090
|
+
scope = parsed.scope;
|
|
1091
|
+
prNumber = parsed.prNumber;
|
|
1092
|
+
} else {
|
|
1093
|
+
prNumber = shared.getPrNumber(canonicalPrId);
|
|
1094
|
+
if (prNumber == null) return { enrolled: false, reason: 'unparseable_id' };
|
|
1095
|
+
scope = shared.getProjectPrScope(project);
|
|
1096
|
+
if (!scope) return { enrolled: false, reason: 'no_scope' };
|
|
1097
|
+
}
|
|
1098
|
+
// Normalize canonical id so an inbound `pr-3082` + project resolves to
|
|
1099
|
+
// `github:owner/repo#3082` for the existence check below.
|
|
1100
|
+
const normalizedCanonicalId = `${scope}#${prNumber}`;
|
|
1101
|
+
|
|
1102
|
+
const existing = _existingPrRecordForCanonicalId(normalizedCanonicalId);
|
|
1103
|
+
if (existing) {
|
|
1104
|
+
// Even when already tracked, opportunistically link the work item so
|
|
1105
|
+
// pr-links stays consistent (#779 pattern).
|
|
1106
|
+
if (opts.itemId && Array.isArray(existing.prdItems) && !existing.prdItems.includes(opts.itemId)) {
|
|
1107
|
+
try {
|
|
1108
|
+
const targetPath = project ? shared.projectPrPath(project) : path.join(MINIONS_DIR, 'pull-requests.json');
|
|
1109
|
+
shared.upsertPullRequestRecord(targetPath, {
|
|
1110
|
+
id: normalizedCanonicalId,
|
|
1111
|
+
prNumber,
|
|
1112
|
+
url: existing.url || '',
|
|
1113
|
+
title: existing.title || '',
|
|
1114
|
+
agent: existing.agent || 'engine',
|
|
1115
|
+
status: existing.status || PR_STATUS.ACTIVE,
|
|
1116
|
+
}, { project, itemId: opts.itemId });
|
|
1117
|
+
} catch { /* best-effort link */ }
|
|
1118
|
+
}
|
|
1119
|
+
return { enrolled: false, reason: 'already_tracked', status: existing.status, record: existing };
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
let liveState = null;
|
|
1123
|
+
let url = '';
|
|
1124
|
+
let title = '';
|
|
1125
|
+
let branch = '';
|
|
1126
|
+
let baseBranch = '';
|
|
1127
|
+
let status = PR_STATUS.ACTIVE;
|
|
1128
|
+
let isDraft = false;
|
|
1129
|
+
|
|
1130
|
+
if (scope.startsWith('github:')) {
|
|
1131
|
+
const slug = scope.slice('github:'.length);
|
|
1132
|
+
liveState = await _fetchGitHubPrStateForEnrollment(slug, prNumber);
|
|
1133
|
+
if (liveState) {
|
|
1134
|
+
url = liveState.url || '';
|
|
1135
|
+
title = String(liveState.title || '').slice(0, 200);
|
|
1136
|
+
branch = liveState.headRefName || '';
|
|
1137
|
+
baseBranch = liveState.baseRefName || '';
|
|
1138
|
+
isDraft = !!liveState.isDraft;
|
|
1139
|
+
status = _liveGitHubStateToPrStatus(liveState);
|
|
1140
|
+
}
|
|
1141
|
+
if (!url) {
|
|
1142
|
+
url = `https://github.com/${slug}/pull/${prNumber}`;
|
|
1143
|
+
}
|
|
1144
|
+
} else if (scope.startsWith('ado:')) {
|
|
1145
|
+
// ADO best-effort: fetchAdoPrMetadata returns title/branch but not status.
|
|
1146
|
+
// Record the canonical id so the next ADO poll cycle can refresh status.
|
|
1147
|
+
try {
|
|
1148
|
+
const adoParts = scope.slice('ado:'.length).split('/');
|
|
1149
|
+
if (adoParts.length === 3) {
|
|
1150
|
+
const ado = require('./ado');
|
|
1151
|
+
if (typeof ado.fetchAdoPrMetadata === 'function') {
|
|
1152
|
+
const meta = await ado.fetchAdoPrMetadata(prNumber, adoParts[0], adoParts[1], adoParts[2]);
|
|
1153
|
+
if (meta) {
|
|
1154
|
+
title = String(meta.title || '').slice(0, 200);
|
|
1155
|
+
branch = meta.branch || '';
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
} catch (err) {
|
|
1160
|
+
log('warn', `enrollPrFromCanonicalId: ADO metadata fetch for ${normalizedCanonicalId} failed: ${err.message}`);
|
|
1161
|
+
}
|
|
1162
|
+
if (!url && project && project.prUrlBase) url = String(project.prUrlBase) + prNumber;
|
|
1163
|
+
} else {
|
|
1164
|
+
return { enrolled: false, reason: 'unsupported_scope' };
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const prPath = project ? shared.projectPrPath(project) : path.join(MINIONS_DIR, 'pull-requests.json');
|
|
1168
|
+
if (!prPath) return { enrolled: false, reason: 'no_pr_path' };
|
|
1169
|
+
|
|
1170
|
+
const entry = {
|
|
1171
|
+
id: normalizedCanonicalId,
|
|
1172
|
+
prNumber,
|
|
1173
|
+
title: title || `PR #${prNumber}`,
|
|
1174
|
+
agent: 'engine',
|
|
1175
|
+
branch,
|
|
1176
|
+
baseBranch,
|
|
1177
|
+
reviewStatus: REVIEW_STATUS.PENDING,
|
|
1178
|
+
status,
|
|
1179
|
+
isDraft,
|
|
1180
|
+
url,
|
|
1181
|
+
prdItems: opts.itemId ? [opts.itemId] : [],
|
|
1182
|
+
_attachedAt: ts(),
|
|
1183
|
+
_contextOnly: true,
|
|
1184
|
+
_enrolledBy: 'enrollPrFromCanonicalId',
|
|
1185
|
+
};
|
|
1186
|
+
if (liveState && liveState.mergedAt) entry.mergedAt = liveState.mergedAt;
|
|
1187
|
+
if (liveState && liveState.closedAt && !liveState.mergedAt) entry.closedAt = liveState.closedAt;
|
|
1188
|
+
|
|
1189
|
+
try {
|
|
1190
|
+
const result = shared.upsertPullRequestRecord(prPath, entry, {
|
|
1191
|
+
project,
|
|
1192
|
+
itemId: opts.itemId || null,
|
|
1193
|
+
});
|
|
1194
|
+
if (result.created) {
|
|
1195
|
+
log('info', `enrollPrFromCanonicalId: enrolled ${normalizedCanonicalId} (status=${status}) into ${project?.name || 'central'}/pull-requests.json`);
|
|
1196
|
+
}
|
|
1197
|
+
return {
|
|
1198
|
+
enrolled: !!(result.created || result.linked),
|
|
1199
|
+
status,
|
|
1200
|
+
reason: result.created ? 'created' : (result.linked ? 'linked' : 'noop'),
|
|
1201
|
+
record: result.record,
|
|
1202
|
+
};
|
|
1203
|
+
} catch (err) {
|
|
1204
|
+
log('warn', `enrollPrFromCanonicalId: upsert failed for ${normalizedCanonicalId}: ${err.message}`);
|
|
1205
|
+
return { enrolled: false, reason: 'upsert_failed', error: err.message };
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// Internal helper: after `syncPrsFromOutput` finishes the regex-evidence pass,
|
|
1210
|
+
// some work items still have `meta.item._pr` set without a tracker row (e.g.
|
|
1211
|
+
// the agent attached the PR via the structured-completion sidecar before the
|
|
1212
|
+
// poller could observe it as `open`, then it merged seconds later). Run a
|
|
1213
|
+
// best-effort enrollment so the PRD progress view sees the real status.
|
|
1214
|
+
async function _ensurePrEnrollmentForCompletedItem(meta, config) {
|
|
1215
|
+
const itemPr = meta && meta.item && meta.item._pr;
|
|
1216
|
+
if (!itemPr || typeof itemPr !== 'string') return null;
|
|
1217
|
+
let project = null;
|
|
1218
|
+
try {
|
|
1219
|
+
const projectsList = (config && Array.isArray(config.projects)) ? config.projects : shared.getProjects(config);
|
|
1220
|
+
const resolutionSource = meta.project?.name || meta.item?.project || meta.item?._source;
|
|
1221
|
+
if (resolutionSource && Array.isArray(projectsList)) {
|
|
1222
|
+
const resolution = shared.resolveProjectSource(resolutionSource, projectsList, { allowCentral: true });
|
|
1223
|
+
project = resolution?.project || null;
|
|
1224
|
+
}
|
|
1225
|
+
} catch (err) {
|
|
1226
|
+
log('warn', `_ensurePrEnrollmentForCompletedItem: project resolution failed: ${err.message}`);
|
|
1227
|
+
}
|
|
1228
|
+
let canonicalPrId = itemPr;
|
|
1229
|
+
if (!shared.parseCanonicalPrId(itemPr) && project) {
|
|
1230
|
+
canonicalPrId = shared.getCanonicalPrId(project, itemPr) || itemPr;
|
|
1231
|
+
}
|
|
1232
|
+
if (!shared.parseCanonicalPrId(canonicalPrId)) return null;
|
|
1233
|
+
if (_existingPrRecordForCanonicalId(canonicalPrId)) return null;
|
|
1234
|
+
try {
|
|
1235
|
+
return await enrollPrFromCanonicalId(canonicalPrId, project, { itemId: meta.item.id });
|
|
1236
|
+
} catch (err) {
|
|
1237
|
+
log('warn', `_ensurePrEnrollmentForCompletedItem: enrollment failed for ${canonicalPrId}: ${err.message}`);
|
|
1238
|
+
return null;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
990
1242
|
function isPrAttachmentRequired(type, item, meta = {}) {
|
|
991
1243
|
if (!item?.id || item.skipPr) return false;
|
|
992
1244
|
// SETUP (W-mpbi6f2q00104957) is implicitly PR-exempt — the type itself
|
|
@@ -4393,6 +4645,17 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
4393
4645
|
prsCreatedCount = syncPrsFromOutput(stdout, agentId, meta, config, { structuredCompletion }) || 0;
|
|
4394
4646
|
} catch (err) { log('warn', `PR sync from output: ${err.message}`); }
|
|
4395
4647
|
|
|
4648
|
+
// W-mq5uzmc6001d708f — back-fill enrollment for orphan `_pr` pointers.
|
|
4649
|
+
// syncPrsFromOutput only enrolls PRs whose URL appeared in this dispatch's
|
|
4650
|
+
// output. When meta.item._pr was stamped from an earlier dispatch (e.g. via
|
|
4651
|
+
// reconcileItemsWithPrs against pr-links) but the canonical row never made
|
|
4652
|
+
// it into pull-requests.json, the dashboard's PRD-progress synthesis
|
|
4653
|
+
// defaults to ACTIVE forever. This catches those cases at the end of every
|
|
4654
|
+
// post-completion run.
|
|
4655
|
+
try {
|
|
4656
|
+
await _ensurePrEnrollmentForCompletedItem(meta, config);
|
|
4657
|
+
} catch (err) { log('warn', `PR enrollment back-fill: ${err.message}`); }
|
|
4658
|
+
|
|
4396
4659
|
// Structured completion may report PR even when regex didn't find it
|
|
4397
4660
|
const scHasPr = structuredCompletion && structuredCompletion.pr && structuredCompletion.pr !== 'N/A';
|
|
4398
4661
|
if (scHasPr && prsCreatedCount === 0) {
|
|
@@ -5311,4 +5574,7 @@ module.exports = {
|
|
|
5311
5574
|
// Issue #2969 — exported for direct unit testing of the pause-flip alert
|
|
5312
5575
|
// path without going through updatePrAfterFix's many guards.
|
|
5313
5576
|
recordPrNoOpFixAttempt,
|
|
5577
|
+
// W-mq5uzmc6001d708f — post-hoc PR enrollment for orphan `_pr` pointers.
|
|
5578
|
+
enrollPrFromCanonicalId,
|
|
5579
|
+
_setEnrollmentGhRunnerForTest,
|
|
5314
5580
|
};
|
package/engine/queries.js
CHANGED
|
@@ -15,6 +15,63 @@ const { safeRead, safeReadDir, safeJson, safeWrite, getProjects, mutateJsonFileL
|
|
|
15
15
|
projectWorkItemsPath, projectPrPath, parseSkillFrontmatter, KB_CATEGORIES,
|
|
16
16
|
WI_STATUS, DONE_STATUSES, PRD_ITEM_STATUS, PR_STATUS, ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS } = shared;
|
|
17
17
|
|
|
18
|
+
// ─── Defensive PR enrichment cache (W-mq5uzmc6001d708f) ──────────────────────
|
|
19
|
+
//
|
|
20
|
+
// buildPrdProgress runs on every /api/status poll. When a work item carries
|
|
21
|
+
// `_pr` but no canonical row exists in pull-requests.json, the synthesis
|
|
22
|
+
// fallback below used to default to PR_STATUS.ACTIVE forever (blue ○). Now
|
|
23
|
+
// we cache the resolved status per canonical id and kick off a best-effort
|
|
24
|
+
// fire-and-forget enrollment so the next poll cycle sees the real status.
|
|
25
|
+
// Lifecycle is lazy-required to avoid the circular dep (lifecycle.js requires
|
|
26
|
+
// queries at top-level).
|
|
27
|
+
|
|
28
|
+
const PR_ENRICHMENT_TTL_MS = 5 * 60 * 1000;
|
|
29
|
+
const _prEnrichmentCache = new Map(); // canonicalId -> { status, expiresAt }
|
|
30
|
+
const _prEnrichmentInFlight = new Set();
|
|
31
|
+
let _lifecycleModule = null;
|
|
32
|
+
function _lifecycle() {
|
|
33
|
+
if (!_lifecycleModule) {
|
|
34
|
+
try { _lifecycleModule = require('./lifecycle'); }
|
|
35
|
+
catch { _lifecycleModule = null; }
|
|
36
|
+
}
|
|
37
|
+
return _lifecycleModule;
|
|
38
|
+
}
|
|
39
|
+
function _getCachedPrEnrichment(id) {
|
|
40
|
+
if (!id) return null;
|
|
41
|
+
const cached = _prEnrichmentCache.get(id);
|
|
42
|
+
if (!cached) return null;
|
|
43
|
+
if (Date.now() > cached.expiresAt) {
|
|
44
|
+
_prEnrichmentCache.delete(id);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return cached.status;
|
|
48
|
+
}
|
|
49
|
+
function _setCachedPrEnrichment(id, status) {
|
|
50
|
+
if (!id || !status) return;
|
|
51
|
+
_prEnrichmentCache.set(id, { status, expiresAt: Date.now() + PR_ENRICHMENT_TTL_MS });
|
|
52
|
+
}
|
|
53
|
+
function _scheduleAsyncPrEnrollment(canonicalPrId, project, itemId) {
|
|
54
|
+
if (!canonicalPrId) return;
|
|
55
|
+
if (_prEnrichmentInFlight.has(canonicalPrId)) return;
|
|
56
|
+
const lc = _lifecycle();
|
|
57
|
+
if (!lc || typeof lc.enrollPrFromCanonicalId !== 'function') return;
|
|
58
|
+
_prEnrichmentInFlight.add(canonicalPrId);
|
|
59
|
+
setImmediate(() => {
|
|
60
|
+
Promise.resolve()
|
|
61
|
+
.then(() => lc.enrollPrFromCanonicalId(canonicalPrId, project, { itemId }))
|
|
62
|
+
.then((result) => {
|
|
63
|
+
if (result && result.status) _setCachedPrEnrichment(canonicalPrId, result.status);
|
|
64
|
+
})
|
|
65
|
+
.catch(() => { /* best-effort enrollment — caller is the dashboard hot path */ })
|
|
66
|
+
.then(() => { _prEnrichmentInFlight.delete(canonicalPrId); });
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// Exposed for tests so suites that toggle PR-enrollment state can reset.
|
|
70
|
+
function _resetPrEnrichmentCacheForTest() {
|
|
71
|
+
_prEnrichmentCache.clear();
|
|
72
|
+
_prEnrichmentInFlight.clear();
|
|
73
|
+
}
|
|
74
|
+
|
|
18
75
|
/**
|
|
19
76
|
* Read the first `bytes` and last `bytes` of a file efficiently using byte offsets.
|
|
20
77
|
* For files <= 2*bytes, reads the whole file. Returns { head, tail } strings.
|
|
@@ -1625,7 +1682,21 @@ function getPrdInfo(config) {
|
|
|
1625
1682
|
const displayMatches = exactPr ? [] : Object.values(prById).filter(candidate => shared.getPrDisplayId(candidate) === shared.getPrDisplayId(wi._pr));
|
|
1626
1683
|
const pr = exactPr || (displayMatches.length === 1 ? displayMatches[0] : null);
|
|
1627
1684
|
const url = buildPrUrlFromId(canonicalPrId || wi._pr, pr, projects);
|
|
1628
|
-
|
|
1685
|
+
// W-mq5uzmc6001d708f — read-side defensive enrichment. When no record
|
|
1686
|
+
// exists (orphan _pr pointer), use a cached status from a prior async
|
|
1687
|
+
// enrollment if available, else synthesize ACTIVE and schedule a
|
|
1688
|
+
// fire-and-forget enrollment so the next status poll renders correctly.
|
|
1689
|
+
let synthesizedStatus = pr?.status;
|
|
1690
|
+
if (!pr && canonicalPrId) {
|
|
1691
|
+
const cachedStatus = _getCachedPrEnrichment(canonicalPrId);
|
|
1692
|
+
if (cachedStatus) {
|
|
1693
|
+
synthesizedStatus = cachedStatus;
|
|
1694
|
+
} else {
|
|
1695
|
+
synthesizedStatus = PR_STATUS.ACTIVE;
|
|
1696
|
+
_scheduleAsyncPrEnrollment(canonicalPrId, project, wi.id);
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
prdToPr[wi.id] = [{ id: pr?.id || canonicalPrId || wi._pr, url, title: pr?.title || '', status: synthesizedStatus || PR_STATUS.ACTIVE, _project: project?.name || '' }];
|
|
1629
1700
|
}
|
|
1630
1701
|
// Aggregate sub-task PRs to decomposed parent (sub-tasks aren't PRD items but their PRs should show)
|
|
1631
1702
|
for (const pr of allPrs) {
|
|
@@ -2501,4 +2572,7 @@ module.exports = {
|
|
|
2501
2572
|
|
|
2502
2573
|
// Work items & PRD
|
|
2503
2574
|
getWorkItems, invalidateWorkItemsCache, getPrdInfo,
|
|
2575
|
+
|
|
2576
|
+
// W-mq5uzmc6001d708f — test hooks for the defensive PR enrichment cache.
|
|
2577
|
+
_resetPrEnrichmentCacheForTest,
|
|
2504
2578
|
};
|