@yemi33/minions 0.1.2143 → 0.1.2145
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 +85 -0
- package/bin/minions.js.rej +16 -0
- package/dashboard/js/refresh.js +14 -0
- package/dashboard/js/render-pinned.js +119 -3
- package/dashboard/js/utils.js +20 -0
- package/dashboard/layout.html +1 -0
- package/dashboard/slim/body.html +113 -1
- package/dashboard/slim/body.html.rej +11 -0
- package/dashboard/slim/js/command-send.js.rej +12 -0
- package/dashboard/slim/js/helpers.js +9 -0
- package/dashboard/slim/js/history.js +153 -88
- package/dashboard/slim/js/history.js.rej +26 -0
- package/dashboard/slim/js/modals-tiles.js +8 -2
- package/dashboard/slim/js/pinned.js +182 -0
- package/dashboard/slim/js/settings.js +126 -6
- package/dashboard/slim/js/status.js +9 -6
- package/dashboard/slim/layout.html +1 -0
- package/dashboard/slim/styles.css +77 -2
- package/dashboard/slim/styles.css.rej +124 -0
- package/dashboard/styles.css +19 -0
- package/dashboard-build.js +9 -2
- package/dashboard.js +44 -1
- package/docs/README.md.rej +9 -0
- package/docs/auto-discovery.md +2 -2
- package/docs/constellation-style-telemetry.md +161 -0
- package/docs/engine-restart.md +1 -1
- package/docs/kb-sweep.md +2 -2
- package/docs/managed-spawn.md +1 -1
- package/docs/watches.md +11 -11
- package/engine/cli.js +57 -12
- package/engine/features.js +11 -0
- package/engine/lifecycle.js +266 -0
- package/engine/queries.js +75 -1
- package/engine/shared.js +173 -36
- package/engine/watchdog.js +458 -0
- package/package.json +1 -1
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
|
};
|
package/engine/shared.js
CHANGED
|
@@ -2906,6 +2906,51 @@ function _resetLegacyCcModelMigrationFlag() {
|
|
|
2906
2906
|
_legacyCcModelMigrationLogged = false;
|
|
2907
2907
|
}
|
|
2908
2908
|
|
|
2909
|
+
/**
|
|
2910
|
+
* One-time force-on for the CC worker pool.
|
|
2911
|
+
*
|
|
2912
|
+
* The pool has been the resolved default for copilot CC since PR #2492
|
|
2913
|
+
* (`resolveCcUseWorkerPool` returns true when the config has no explicit
|
|
2914
|
+
* value). But configs that carried an explicit `ccUseWorkerPool: false` —
|
|
2915
|
+
* set before the default flipped, or copied from an old template — stay
|
|
2916
|
+
* opted out forever. This migration flips those explicit-false opt-outs to
|
|
2917
|
+
* `true` ONCE, on both surfaces the value lives in (`engine.ccUseWorkerPool`
|
|
2918
|
+
* which the resolver reads, and `features.ccUseWorkerPool` which drives the
|
|
2919
|
+
* Settings toggle / `isFeatureOn`), so the two stay consistent.
|
|
2920
|
+
*
|
|
2921
|
+
* Idempotency: records `engine._ccPoolForcedOnV1` after running. Once that
|
|
2922
|
+
* marker is set the migration never touches the value again — so an operator
|
|
2923
|
+
* who *deliberately* turns the pool off afterward is never re-forced. The
|
|
2924
|
+
* marker is persisted even when nothing needed flipping, which is what lets a
|
|
2925
|
+
* later opt-out be distinguished from a never-migrated config.
|
|
2926
|
+
*
|
|
2927
|
+
* Pure + idempotent: safe to call in-memory then re-apply to the on-disk copy
|
|
2928
|
+
* under a lock (mirrors backfillProjectWorkSourceDefaults). Returns
|
|
2929
|
+
* `{ changed, flipped }` — `changed` true means the config was mutated (marker
|
|
2930
|
+
* set and/or values flipped) and should be persisted; `flipped` lists which
|
|
2931
|
+
* surfaces ('engine'/'features') were actually turned on.
|
|
2932
|
+
*/
|
|
2933
|
+
function applyCcWorkerPoolForceOnMigration(config) {
|
|
2934
|
+
const result = { changed: false, flipped: [] };
|
|
2935
|
+
if (!config || typeof config !== 'object') return result;
|
|
2936
|
+
const engine = (config.engine && typeof config.engine === 'object') ? config.engine : null;
|
|
2937
|
+
if (!engine) return result; // no engine section — retry next start, harmless
|
|
2938
|
+
if (engine._ccPoolForcedOnV1) return result; // already forced once; respect later opt-out
|
|
2939
|
+
|
|
2940
|
+
if (engine.ccUseWorkerPool === false) {
|
|
2941
|
+
engine.ccUseWorkerPool = true;
|
|
2942
|
+
result.flipped.push('engine');
|
|
2943
|
+
}
|
|
2944
|
+
const features = (config.features && typeof config.features === 'object') ? config.features : null;
|
|
2945
|
+
if (features && features.ccUseWorkerPool === false) {
|
|
2946
|
+
features.ccUseWorkerPool = true;
|
|
2947
|
+
result.flipped.push('features');
|
|
2948
|
+
}
|
|
2949
|
+
engine._ccPoolForcedOnV1 = true; // marker — never force again
|
|
2950
|
+
result.changed = true; // marker always needs persisting on first run
|
|
2951
|
+
return result;
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2909
2954
|
// ─── Runtime Config Preflight Warnings ──────────────────────────────────────
|
|
2910
2955
|
//
|
|
2911
2956
|
// Emit non-fatal warnings about runtime/CLI configuration drift. Consumed by
|
|
@@ -5547,39 +5592,17 @@ function normalizePrLinkItems(value) {
|
|
|
5547
5592
|
return [...new Set(items.filter(item => typeof item === 'string' && item))];
|
|
5548
5593
|
}
|
|
5549
5594
|
|
|
5550
|
-
|
|
5551
|
-
|
|
5552
|
-
|
|
5553
|
-
|
|
5554
|
-
|
|
5555
|
-
|
|
5556
|
-
|
|
5557
|
-
|
|
5558
|
-
|
|
5559
|
-
* sourcePlan/itemType cases. See W-mq5rs2eq000da8a9 for the bug repro.
|
|
5560
|
-
*
|
|
5561
|
-
* A PR is auto-managed when ANY of the following hold (and `_contextOnly` is
|
|
5562
|
-
* not explicitly true — context-only always wins):
|
|
5563
|
-
* - It carries one or more `prdItems` (linked to work items)
|
|
5564
|
-
* - It was explicitly opted-in via `_autoObserve: true` (manual link with
|
|
5565
|
-
* autoObserve, or per-row toggle via POST /api/pull-requests/observe)
|
|
5566
|
-
* - It was created from a plan or has an itemType (`sourcePlan`/`itemType`)
|
|
5567
|
-
* - It has a non-empty agent author other than the literal string `'human'`
|
|
5568
|
-
* (the helper deliberately does NOT require the agent be in
|
|
5569
|
-
* `config.agents` — once `_autoObserve` is the explicit opt-in for
|
|
5570
|
-
* non-agent authors, the loosened agent-string check has no operational
|
|
5571
|
-
* downside for managed flows and avoids the silent-skip bug.)
|
|
5572
|
-
*
|
|
5573
|
-
* @param {object} pr Pull request record
|
|
5574
|
-
* @returns {boolean} true when the engine should drive review/fix for this PR
|
|
5575
|
-
*/
|
|
5595
|
+
// W-mq5s5ttx000j7ab8-a — canonical `contextOnly` gate. The 6-clause legacy
|
|
5596
|
+
// body (prdItems / _autoObserve / sourcePlan / itemType / agent-heuristic) is
|
|
5597
|
+
// replaced by a single read of the canonical field. The one-shot boot
|
|
5598
|
+
// migration (migratePrGateFlags) projects the legacy signals onto
|
|
5599
|
+
// `contextOnly` so every on-disk record has the canonical value before any
|
|
5600
|
+
// tick fires. Anything new written via upsertPullRequestRecord (and the
|
|
5601
|
+
// dashboard link/observe paths consolidated in PR #3137 / `-c`) also lands
|
|
5602
|
+
// on `contextOnly` directly. See W-mq5rs2eq000da8a9 for the bug repro that
|
|
5603
|
+
// motivated consolidating onto a single helper.
|
|
5576
5604
|
function isAutoManagedPrRecord(pr) {
|
|
5577
|
-
|
|
5578
|
-
if (normalizePrLinkItems(pr.prdItems).length > 0) return true;
|
|
5579
|
-
if (pr._autoObserve === true) return true;
|
|
5580
|
-
if (pr.sourcePlan || pr.itemType) return true;
|
|
5581
|
-
const agent = String(pr.agent || '').trim().toLowerCase();
|
|
5582
|
-
return !!agent && agent !== 'human';
|
|
5605
|
+
return !!pr && typeof pr === 'object' && pr.contextOnly !== true;
|
|
5583
5606
|
}
|
|
5584
5607
|
|
|
5585
5608
|
function mergePrLinkItems(links, prId, itemIds) {
|
|
@@ -5757,12 +5780,24 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
|
|
|
5757
5780
|
target[key] = normalizedEntry[key];
|
|
5758
5781
|
}
|
|
5759
5782
|
}
|
|
5760
|
-
|
|
5783
|
+
// W-mq5s5ttx000j7ab8-a — `_manual` and `_autoObserve` are no longer
|
|
5784
|
+
// copied through; the engine reads gate state from the canonical
|
|
5785
|
+
// `contextOnly` field (see isAutoManagedPrRecord above + the boot
|
|
5786
|
+
// migration migratePrGateFlags). `_context`/`_projectResolution` are
|
|
5787
|
+
// unrelated breadcrumbs and stay.
|
|
5788
|
+
for (const key of ['_context', '_projectResolution']) {
|
|
5761
5789
|
if (normalizedEntry[key] != null) target[key] = normalizedEntry[key];
|
|
5762
5790
|
}
|
|
5763
|
-
|
|
5764
|
-
|
|
5765
|
-
|
|
5791
|
+
// Accept either the canonical `contextOnly` (preferred) or the legacy
|
|
5792
|
+
// `_contextOnly` from callers that haven't been migrated yet
|
|
5793
|
+
// (dashboard.js manual-link path, lifecycle.js oneShot tagging — items
|
|
5794
|
+
// (b)/(c) of the decomposition). Persist as canonical `contextOnly`.
|
|
5795
|
+
const incomingContextOnly = normalizedEntry.contextOnly != null
|
|
5796
|
+
? normalizedEntry.contextOnly
|
|
5797
|
+
: normalizedEntry._contextOnly;
|
|
5798
|
+
if (incomingContextOnly != null) {
|
|
5799
|
+
const wouldDemoteManagedPr = incomingContextOnly === true && targetWasAutoManaged;
|
|
5800
|
+
if (!wouldDemoteManagedPr) target.contextOnly = incomingContextOnly === true;
|
|
5766
5801
|
}
|
|
5767
5802
|
}
|
|
5768
5803
|
target.prdItems = normalizePrLinkItems(target.prdItems || []);
|
|
@@ -5785,6 +5820,105 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
|
|
|
5785
5820
|
return { id: canonicalId, prNumber, created, linked, skipped, record };
|
|
5786
5821
|
}
|
|
5787
5822
|
|
|
5823
|
+
// ─── PR Gate Migration (W-mq5s5ttx000j7ab8-a) ───────────────────────────────
|
|
5824
|
+
//
|
|
5825
|
+
// One-shot boot migration that projects the legacy gate signals
|
|
5826
|
+
// (`_contextOnly`, `_autoObserve`, `_manual`) onto the canonical
|
|
5827
|
+
// `contextOnly` field on every `projects/<name>/pull-requests.json` record.
|
|
5828
|
+
// Wired from `engine/cli.js#start()` before the first tick fires so the new
|
|
5829
|
+
// `isAutoManagedPrRecord` (which only reads `contextOnly`) returns the same
|
|
5830
|
+
// verdict as the legacy 6-clause helper for pre-existing data.
|
|
5831
|
+
//
|
|
5832
|
+
// Per-record decision:
|
|
5833
|
+
// 1. contextOnly := (_contextOnly === true)
|
|
5834
|
+
// 2. legacyManaged := prdItems.length > 0 || _autoObserve === true ||
|
|
5835
|
+
// sourcePlan || itemType || (agent is a Minions persona)
|
|
5836
|
+
// "Minions persona" = non-empty kebab-case identifier other than 'human'.
|
|
5837
|
+
// Anything with whitespace (human display names like "yemi shin", which
|
|
5838
|
+
// github poller writes into `agent` from `prData.user.login`) is treated
|
|
5839
|
+
// as a human author, not a managed agent.
|
|
5840
|
+
// 3. If !contextOnly && !legacyManaged, set contextOnly = true and stamp
|
|
5841
|
+
// `_migrationNote: 'auto-set-contextOnly-by-pr-gate-simplification'`.
|
|
5842
|
+
// 4. Persist `contextOnly`; delete `_autoObserve`, `_manual`, `_contextOnly`.
|
|
5843
|
+
//
|
|
5844
|
+
// Idempotent: records that already have `contextOnly` and none of the legacy
|
|
5845
|
+
// keys are skipped (no rewrite, no log line, JSON mtime unchanged).
|
|
5846
|
+
const _PR_GATE_MIGRATION_NOTE = 'auto-set-contextOnly-by-pr-gate-simplification';
|
|
5847
|
+
const _AGENT_PERSONA_RE = /^[a-z0-9_-]+$/;
|
|
5848
|
+
|
|
5849
|
+
function _prRecordHasLegacyGateKey(record) {
|
|
5850
|
+
return Object.prototype.hasOwnProperty.call(record, '_contextOnly')
|
|
5851
|
+
|| Object.prototype.hasOwnProperty.call(record, '_autoObserve')
|
|
5852
|
+
|| Object.prototype.hasOwnProperty.call(record, '_manual');
|
|
5853
|
+
}
|
|
5854
|
+
|
|
5855
|
+
function _prRecordIsLegacyManaged(record) {
|
|
5856
|
+
const prdItemsCount = Array.isArray(record.prdItems) ? record.prdItems.length : 0;
|
|
5857
|
+
if (prdItemsCount > 0) return true;
|
|
5858
|
+
if (record._autoObserve === true) return true;
|
|
5859
|
+
if (record.sourcePlan) return true;
|
|
5860
|
+
if (record.itemType) return true;
|
|
5861
|
+
if (typeof record.agent === 'string') {
|
|
5862
|
+
const agent = record.agent.trim().toLowerCase();
|
|
5863
|
+
if (agent && agent !== 'human' && _AGENT_PERSONA_RE.test(agent)) return true;
|
|
5864
|
+
}
|
|
5865
|
+
return false;
|
|
5866
|
+
}
|
|
5867
|
+
|
|
5868
|
+
function migratePrGateFlags(projectsRoot) {
|
|
5869
|
+
const summary = { projectsScanned: 0, projectsMigrated: 0, totalRecords: 0, totalMigrated: 0 };
|
|
5870
|
+
if (!projectsRoot || typeof projectsRoot !== 'string') return summary;
|
|
5871
|
+
let entries;
|
|
5872
|
+
try {
|
|
5873
|
+
entries = fs.readdirSync(projectsRoot, { withFileTypes: true });
|
|
5874
|
+
} catch {
|
|
5875
|
+
return summary;
|
|
5876
|
+
}
|
|
5877
|
+
for (const entry of entries) {
|
|
5878
|
+
if (!entry.isDirectory()) continue;
|
|
5879
|
+
const projectName = entry.name;
|
|
5880
|
+
const prPath = path.join(projectsRoot, projectName, 'pull-requests.json');
|
|
5881
|
+
if (!fs.existsSync(prPath)) continue;
|
|
5882
|
+
summary.projectsScanned++;
|
|
5883
|
+
|
|
5884
|
+
let migrated = 0;
|
|
5885
|
+
let stamped = 0;
|
|
5886
|
+
let alreadyMigrated = 0;
|
|
5887
|
+
|
|
5888
|
+
mutatePullRequests(prPath, (prs) => {
|
|
5889
|
+
if (!Array.isArray(prs)) return prs;
|
|
5890
|
+
for (const record of prs) {
|
|
5891
|
+
if (!record || typeof record !== 'object') continue;
|
|
5892
|
+
const hasLegacy = _prRecordHasLegacyGateKey(record);
|
|
5893
|
+
const hasCanonical = Object.prototype.hasOwnProperty.call(record, 'contextOnly');
|
|
5894
|
+
if (hasCanonical && !hasLegacy) { alreadyMigrated++; continue; }
|
|
5895
|
+
|
|
5896
|
+
let contextOnly = (record._contextOnly === true);
|
|
5897
|
+
if (!contextOnly && !_prRecordIsLegacyManaged(record)) {
|
|
5898
|
+
contextOnly = true;
|
|
5899
|
+
record._migrationNote = _PR_GATE_MIGRATION_NOTE;
|
|
5900
|
+
stamped++;
|
|
5901
|
+
}
|
|
5902
|
+
record.contextOnly = contextOnly;
|
|
5903
|
+
delete record._autoObserve;
|
|
5904
|
+
delete record._manual;
|
|
5905
|
+
delete record._contextOnly;
|
|
5906
|
+
migrated++;
|
|
5907
|
+
}
|
|
5908
|
+
return prs;
|
|
5909
|
+
});
|
|
5910
|
+
|
|
5911
|
+
summary.totalRecords += (migrated + alreadyMigrated);
|
|
5912
|
+
summary.totalMigrated += migrated;
|
|
5913
|
+
if (migrated > 0) {
|
|
5914
|
+
summary.projectsMigrated++;
|
|
5915
|
+
// One line per project that actually moved. Keep idempotent runs silent.
|
|
5916
|
+
console.log(`[pr-gate-migration] ${projectName}: migrated ${migrated} records (${stamped} stamped contextOnly, ${alreadyMigrated} already-migrated)`);
|
|
5917
|
+
}
|
|
5918
|
+
}
|
|
5919
|
+
return summary;
|
|
5920
|
+
}
|
|
5921
|
+
|
|
5788
5922
|
// ─── Cross-Platform Process Kill Helpers ─────────────────────────────────────
|
|
5789
5923
|
|
|
5790
5924
|
function normalizeKillPid(proc) {
|
|
@@ -6772,6 +6906,7 @@ module.exports = {
|
|
|
6772
6906
|
resolveAgentCli, resolveCcCli, resolveCcUseWorkerPool, resolveAgentModel, resolveCcModel,
|
|
6773
6907
|
resolveAgentMaxBudget, resolveAgentBareMode,
|
|
6774
6908
|
applyLegacyCcModelMigration, _resetLegacyCcModelMigrationFlag,
|
|
6909
|
+
applyCcWorkerPoolForceOnMigration,
|
|
6775
6910
|
runtimeConfigWarnings,
|
|
6776
6911
|
projectWorkSourceWarnings,
|
|
6777
6912
|
backfillProjectWorkSourceDefaults,
|
|
@@ -6841,6 +6976,8 @@ module.exports = {
|
|
|
6841
6976
|
mergePrLinkItems, // exported for testing
|
|
6842
6977
|
isAutoManagedPrRecord,
|
|
6843
6978
|
upsertPullRequestRecord,
|
|
6979
|
+
isAutoManagedPrRecord, // W-mq5s5ttx000j7ab8-a — exported for engine + watch-plugin gate consolidation
|
|
6980
|
+
migratePrGateFlags, // W-mq5s5ttx000j7ab8-a — boot migration wired from engine/cli.js
|
|
6844
6981
|
nextWorkItemId,
|
|
6845
6982
|
getProjectOrg,
|
|
6846
6983
|
getAdoOrgBase,
|