@yemi33/minions 0.1.2143 → 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.
@@ -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
- prdToPr[wi.id] = [{ id: pr?.id || canonicalPrId || wi._pr, url, title: pr?.title || '', status: pr?.status || PR_STATUS.ACTIVE, _project: project?.name || '' }];
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2143",
3
+ "version": "0.1.2144",
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"