@yemi33/minions 0.1.2379 → 0.1.2380
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 +19 -9
- package/dashboard/js/refresh.js +2 -3
- package/dashboard/js/render-prd.js +1 -1
- package/dashboard/js/render-work-items.js +13 -6
- package/dashboard.js +393 -721
- package/docs/architecture-review-2026-07-09.md +2 -4
- package/docs/auto-discovery.md +36 -49
- package/docs/blog-first-successful-dispatch.md +1 -1
- package/docs/branch-derivation.md +2 -2
- package/docs/command-center.md +1 -1
- package/docs/completion-reports.md +14 -4
- package/docs/constellation-bridge.md +59 -10
- package/docs/constellation-style-telemetry.md +6 -6
- package/docs/cooldown-merge-semantics.md +4 -0
- package/docs/copilot-cli-schema.md +3 -3
- package/docs/cross-repo-plans.md +17 -17
- package/docs/deprecated.json +2 -2
- package/docs/design-state-storage.md +1 -1
- package/docs/documentation-audit-2026-07-09.md +2 -2
- package/docs/engine-restart.md +20 -8
- package/docs/harness-mode.md +1 -1
- package/docs/managed-spawn.md +4 -4
- package/docs/onboarding.md +1 -2
- package/docs/pr-comment-followup.md +3 -3
- package/docs/pr-review-fix-loop.md +1 -1
- package/docs/proposals/repo-pool-for-live-checkout.md +1 -2
- package/docs/qa-runbook-lifecycle.md +4 -4
- package/docs/qa-runbooks.md +2 -2
- package/docs/rfc-completion-json.md +4 -1
- package/docs/runtime-adapters.md +1 -1
- package/docs/self-improvement.md +4 -5
- package/docs/shared-lifecycle-module-map.md +3 -1
- package/docs/slim-ux/architecture-suggestions.md +5 -6
- package/docs/slim-ux/concepts.md +23 -25
- package/docs/watches.md +7 -7
- package/docs/workspace-manifests.md +1 -1
- package/docs/worktree-lifecycle.md +1 -1
- package/engine/abandoned-pr-reconciliation.js +4 -5
- package/engine/ado-status.js +5 -5
- package/engine/ado.js +20 -25
- package/engine/agent-worker-pool.js +58 -1
- package/engine/bridge.js +260 -5
- package/engine/cleanup.js +48 -131
- package/engine/cli.js +125 -83
- package/engine/cooldown.js +9 -16
- package/engine/db/index.js +22 -9
- package/engine/db/migrations/009-qa.js +1 -1
- package/engine/db/migrations/020-qa-session-scopes.js +23 -0
- package/engine/db/migrations/021-archived-work-items.js +72 -0
- package/engine/db/migrations/022-global-cc-session.js +31 -0
- package/engine/db/migrations/023-engine-state.js +30 -0
- package/engine/db/migrations/024-prd-ghost-collisions.js +53 -0
- package/engine/db/migrations/025-malformed-work-item-phantoms.js +33 -0
- package/engine/dispatch-store.js +2 -7
- package/engine/dispatch.js +34 -41
- package/engine/github.js +20 -27
- package/engine/lifecycle.js +221 -283
- package/engine/llm.js +1 -1
- package/engine/logs-store.js +2 -2
- package/engine/managed-spawn.js +2 -23
- package/engine/meeting.js +6 -6
- package/engine/metrics-store.js +2 -2
- package/engine/note-link-backfill.js +6 -11
- package/engine/pipeline.js +18 -36
- package/engine/playbook.js +1 -1
- package/engine/prd-store.js +73 -54
- package/engine/preflight.js +2 -5
- package/engine/projects.js +15 -62
- package/engine/pull-requests-store.js +0 -17
- package/engine/qa-runbooks.js +2 -2
- package/engine/qa-runs.js +1 -8
- package/engine/qa-sessions.js +41 -64
- package/engine/queries.js +120 -219
- package/engine/routing.js +3 -5
- package/engine/scheduler.js +0 -4
- package/engine/shared-branch-pr-reconcile.js +2 -3
- package/engine/shared.js +132 -637
- package/engine/small-state-store.js +89 -10
- package/engine/state-operations.js +16 -4
- package/engine/stdio-timestamps.js +1 -1
- package/engine/timeout.js +5 -12
- package/engine/watch-actions.js +20 -22
- package/engine/watches-store.js +1 -1
- package/engine/watches.js +6 -10
- package/engine/work-item-validation.js +52 -0
- package/engine/work-items-store.js +127 -29
- package/engine/worktree-gc.js +2 -2
- package/engine/worktree-pool.js +8 -18
- package/engine.js +147 -343
- package/minions.js +2 -2
- package/package.json +1 -1
- package/playbooks/plan-to-prd.md +2 -2
- package/playbooks/shared-rules.md +3 -3
- package/playbooks/templates/followup-dispatch.md +1 -1
- package/playbooks/verify.md +1 -1
- package/prompts/cc-system.md +2 -2
package/engine/ado-status.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Output: JSON to stdout. Exit 0 on success, 1 on error/not-found.
|
|
16
16
|
*
|
|
17
|
-
* Cached (default): reads
|
|
17
|
+
* Cached (default): reads engine-maintained SQL state (~3 min stale).
|
|
18
18
|
* Live (--live): makes fresh ADO API calls — use when engine isn't running or you
|
|
19
19
|
* just pushed and need up-to-the-moment build status.
|
|
20
20
|
*/
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
const path = require('path');
|
|
25
25
|
const shared = require('./shared');
|
|
26
|
-
const { safeJson,
|
|
26
|
+
const { safeJson, readPullRequests, getProjects, MINIONS_DIR } = shared;
|
|
27
27
|
|
|
28
28
|
// ── Arg parsing ──────────────────────────────────────────────────────────────
|
|
29
29
|
|
|
@@ -56,7 +56,7 @@ const prNumber = parseInt(prNumberArg, 10);
|
|
|
56
56
|
|
|
57
57
|
function findInCache(projects) {
|
|
58
58
|
for (const project of projects) {
|
|
59
|
-
const prs =
|
|
59
|
+
const prs = readPullRequests(project);
|
|
60
60
|
shared.normalizePrRecords(prs, project);
|
|
61
61
|
const pr = prs.find(p => p.prNumber === prNumber || p.id === shared.getCanonicalPrId(project, prNumber) || shared.getPrDisplayId(p) === `PR-${prNumber}`);
|
|
62
62
|
if (!pr) continue;
|
|
@@ -104,7 +104,7 @@ async function main() {
|
|
|
104
104
|
if (!live) {
|
|
105
105
|
const result = findInCache(projects);
|
|
106
106
|
if (result) { console.log(JSON.stringify(result, null, 2)); return; }
|
|
107
|
-
console.error(`PR #${prNumber} not found in cached
|
|
107
|
+
console.error(`PR #${prNumber} not found in the cached PR store`);
|
|
108
108
|
console.error('Tip: try --live for a fresh ADO API call, or check the PR number is correct.');
|
|
109
109
|
process.exit(1);
|
|
110
110
|
}
|
|
@@ -133,7 +133,7 @@ async function main() {
|
|
|
133
133
|
return;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
console.error(`PR #${prNumber} not found (tried live ADO API and cached
|
|
136
|
+
console.error(`PR #${prNumber} not found (tried live ADO API and the cached PR store).`);
|
|
137
137
|
process.exit(1);
|
|
138
138
|
}
|
|
139
139
|
|
package/engine/ado.js
CHANGED
|
@@ -279,7 +279,7 @@ function repairAdoProjectConfig(project, purpose, prs = null) {
|
|
|
279
279
|
const missingBefore = getMissingAdoProjectConfigFields(project);
|
|
280
280
|
if (missingBefore.length === 0) return false;
|
|
281
281
|
|
|
282
|
-
const trackedPrs = prs || shared.
|
|
282
|
+
const trackedPrs = prs || shared.readPullRequests(project);
|
|
283
283
|
for (const candidate of collectAdoRepairCandidates(project, trackedPrs)) {
|
|
284
284
|
const repairs = {};
|
|
285
285
|
if (!project.adoOrg && candidate.adoOrg) repairs.adoOrg = candidate.adoOrg;
|
|
@@ -1199,7 +1199,7 @@ async function forEachActivePr(config, token, callback) {
|
|
|
1199
1199
|
}
|
|
1200
1200
|
|
|
1201
1201
|
if (projectUpdated > 0) {
|
|
1202
|
-
|
|
1202
|
+
shared.mutatePullRequests(project, (currentPrs) => {
|
|
1203
1203
|
// Merge back only fields changed by callbacks; preserve concurrent disk updates.
|
|
1204
1204
|
for (const { before, after } of updatedRecords) {
|
|
1205
1205
|
const updatedPrNumber = shared.getPrNumber(after);
|
|
@@ -1244,9 +1244,9 @@ async function forEachActivePr(config, token, callback) {
|
|
|
1244
1244
|
}
|
|
1245
1245
|
}
|
|
1246
1246
|
|
|
1247
|
-
// BUG-C2 — Also poll manually
|
|
1247
|
+
// BUG-C2 — Also poll manually linked ADO PRs from the central PR store.
|
|
1248
1248
|
// Mirror of engine/github.js:540-633. Without this block, any ADO PR enrolled
|
|
1249
|
-
// into central
|
|
1249
|
+
// into the central PR store via the dashboard attach UI or
|
|
1250
1250
|
// lifecycle.enrollPrFromCanonicalId is never polled (status, build, review,
|
|
1251
1251
|
// comments, abandoned-reconciliation all freeze permanently).
|
|
1252
1252
|
//
|
|
@@ -1272,8 +1272,7 @@ async function forEachActivePr(config, token, callback) {
|
|
|
1272
1272
|
// in that project's per-project file, so the project loop will really see
|
|
1273
1273
|
// them. Blindly skipping all project-compatible IDs caused a permanent
|
|
1274
1274
|
// orphan when the record lived in the central file (#328).
|
|
1275
|
-
const
|
|
1276
|
-
const centralPrs = shared.safeJsonArr(centralPath);
|
|
1275
|
+
const centralPrs = shared.readPullRequests('central');
|
|
1277
1276
|
const configuredAdoProjects = projects.filter(p => !isGitHubProject(p) && p.adoOrg && p.adoProject);
|
|
1278
1277
|
|
|
1279
1278
|
const activeCentral = centralPrs.filter(pr => {
|
|
@@ -1287,7 +1286,7 @@ async function forEachActivePr(config, token, callback) {
|
|
|
1287
1286
|
// polled (fix for #328).
|
|
1288
1287
|
const owningProject = configuredAdoProjects.find(p => shared.isPrCompatibleWithProject(p, { id: pr.id }, ''));
|
|
1289
1288
|
if (owningProject) {
|
|
1290
|
-
const projectPrs = shared.
|
|
1289
|
+
const projectPrs = shared.readPullRequests(owningProject);
|
|
1291
1290
|
if (projectPrs.some(p => p.id === pr.id)) return false; // present in per-project file → project loop handles it
|
|
1292
1291
|
}
|
|
1293
1292
|
return true;
|
|
@@ -1353,7 +1352,7 @@ async function forEachActivePr(config, token, callback) {
|
|
|
1353
1352
|
}
|
|
1354
1353
|
|
|
1355
1354
|
if (centralUpdated > 0) {
|
|
1356
|
-
|
|
1355
|
+
shared.mutatePullRequests('central', (currentPrs) => {
|
|
1357
1356
|
for (const { before, after } of updatedCentralRecords) {
|
|
1358
1357
|
const idx = currentPrs.findIndex(p => p.id === after.id);
|
|
1359
1358
|
if (idx >= 0) {
|
|
@@ -1457,7 +1456,7 @@ async function pollPrStatus(config) {
|
|
|
1457
1456
|
}
|
|
1458
1457
|
// F6 (P-f6commentedit): drop the comment-edit dedup map on merge/abandon
|
|
1459
1458
|
// — no further re-dispatch path can fire from this PR, so keeping it
|
|
1460
|
-
// around would only bloat
|
|
1459
|
+
// around would only bloat the PR store.
|
|
1461
1460
|
if (pr.humanFeedback && pr.humanFeedback.editsSeen) {
|
|
1462
1461
|
delete pr.humanFeedback.editsSeen;
|
|
1463
1462
|
}
|
|
@@ -1911,7 +1910,7 @@ async function pollPrStatus(config) {
|
|
|
1911
1910
|
// review status flipped, etc.) is dropped via clearPrNoOpFixAttempt. The
|
|
1912
1911
|
// dispatch gate already ignores these stale records (isPrNoOpFixCausePaused
|
|
1913
1912
|
// re-validates the fingerprint), but without this sweep they linger in
|
|
1914
|
-
//
|
|
1913
|
+
// PR store forever — bloating the registry and confusing the
|
|
1915
1914
|
// dashboard `pr-paused-chip`. Runs AFTER all live-field updates so the
|
|
1916
1915
|
// fingerprint comparison uses post-poll PR state.
|
|
1917
1916
|
try {
|
|
@@ -2160,7 +2159,7 @@ async function pollPrHumanComments(config) {
|
|
|
2160
2159
|
|
|
2161
2160
|
/**
|
|
2162
2161
|
* Reconcile PRs: find active ADO PRs created by the minions that aren't tracked
|
|
2163
|
-
* in
|
|
2162
|
+
* in the PR store, and add them. Matches PRs to work items by branch name.
|
|
2164
2163
|
*/
|
|
2165
2164
|
async function reconcilePrs(config) {
|
|
2166
2165
|
// #3758 — inert when no project actually uses ADO.
|
|
@@ -2207,18 +2206,15 @@ async function reconcilePrs(config) {
|
|
|
2207
2206
|
|
|
2208
2207
|
if (adoPrs.length === 0) continue;
|
|
2209
2208
|
|
|
2210
|
-
const
|
|
2211
|
-
const existingPrs = shared.safeJsonArr(prPath);
|
|
2209
|
+
const existingPrs = shared.readPullRequests(project);
|
|
2212
2210
|
shared.normalizePrRecords(existingPrs, project);
|
|
2213
2211
|
const existingIds = new Set(existingPrs.map(p => p.id));
|
|
2214
2212
|
let projectAdded = 0;
|
|
2215
2213
|
let metadataUpdated = 0;
|
|
2216
2214
|
|
|
2217
2215
|
// Load work items to match branches to work item IDs
|
|
2218
|
-
const
|
|
2219
|
-
const
|
|
2220
|
-
const centralWiPath = path.join(shared.MINIONS_DIR, 'work-items.json');
|
|
2221
|
-
const centralItems = shared.safeJsonArr(centralWiPath);
|
|
2216
|
+
const workItems = shared.readWorkItems(project);
|
|
2217
|
+
const centralItems = shared.readWorkItems('central');
|
|
2222
2218
|
const allItems = [...workItems, ...centralItems];
|
|
2223
2219
|
|
|
2224
2220
|
let projectUpdated = 0;
|
|
@@ -2251,9 +2247,9 @@ async function reconcilePrs(config) {
|
|
|
2251
2247
|
log('warn', `ADO: invalid sourceRefName "${branch.slice(0, 64)}" for PR ${prId}: ${refErr.message}`);
|
|
2252
2248
|
}
|
|
2253
2249
|
}
|
|
2254
|
-
// PR already tracked — write
|
|
2250
|
+
// PR already tracked — write a PR link if we can extract an ID
|
|
2255
2251
|
if (confirmedItemId) {
|
|
2256
|
-
shared.upsertPullRequestRecord(
|
|
2252
|
+
shared.upsertPullRequestRecord(project, existing || {
|
|
2257
2253
|
id: prId,
|
|
2258
2254
|
prNumber: adoPr.pullRequestId,
|
|
2259
2255
|
title: (adoPr.title || `PR #${adoPr.pullRequestId}`).slice(0, 120),
|
|
@@ -2312,7 +2308,7 @@ async function reconcilePrs(config) {
|
|
|
2312
2308
|
sourcePlan: linkedItem?.sourcePlan || '',
|
|
2313
2309
|
itemType: linkedItem?.itemType || '',
|
|
2314
2310
|
};
|
|
2315
|
-
const upserted = shared.upsertPullRequestRecord(
|
|
2311
|
+
const upserted = shared.upsertPullRequestRecord(project, entry, { project, itemId: confirmedItemId });
|
|
2316
2312
|
existingPrs.push(upserted.record || entry);
|
|
2317
2313
|
existingIds.add(prId);
|
|
2318
2314
|
projectAdded++;
|
|
@@ -2331,7 +2327,7 @@ async function reconcilePrs(config) {
|
|
|
2331
2327
|
const backfilled = shared.backfillPrPrdItems(existingPrs, shared.getPrLinks());
|
|
2332
2328
|
|
|
2333
2329
|
if (projectAdded > 0 || projectUpdated > 0 || backfilled > 0 || metadataUpdated > 0) {
|
|
2334
|
-
|
|
2330
|
+
shared.mutatePullRequests(project, (currentPrs) => {
|
|
2335
2331
|
// Merge reconciled PRs into the locked copy by ID
|
|
2336
2332
|
for (const pr of existingPrs) {
|
|
2337
2333
|
const idx = currentPrs.findIndex(p => p.id === pr.id);
|
|
@@ -2905,7 +2901,7 @@ async function reconcileAbandonedPrs(config) {
|
|
|
2905
2901
|
// log line is honest about what we left behind.
|
|
2906
2902
|
let pending = 0;
|
|
2907
2903
|
for (const project of projects) {
|
|
2908
|
-
const prs = shared.
|
|
2904
|
+
const prs = shared.readPullRequests(project);
|
|
2909
2905
|
pending += prs.filter(pr =>
|
|
2910
2906
|
pr.status === shared.PR_STATUS.ABANDONED && !pr._reconciliation404Confirmed
|
|
2911
2907
|
).length;
|
|
@@ -2926,8 +2922,7 @@ async function reconcileAbandonedPrs(config) {
|
|
|
2926
2922
|
continue;
|
|
2927
2923
|
}
|
|
2928
2924
|
|
|
2929
|
-
const
|
|
2930
|
-
const prs = shared.safeJsonArr(prPath);
|
|
2925
|
+
const prs = shared.readPullRequests(project);
|
|
2931
2926
|
const abandonedPrs = prs.filter(pr =>
|
|
2932
2927
|
pr.status === shared.PR_STATUS.ABANDONED
|
|
2933
2928
|
&& !pr._reconciliation404Confirmed
|
|
@@ -3010,7 +3005,7 @@ async function reconcileAbandonedPrs(config) {
|
|
|
3010
3005
|
|
|
3011
3006
|
if (updates.length > 0) {
|
|
3012
3007
|
const reconciledAt = ts();
|
|
3013
|
-
shared.mutatePullRequests(
|
|
3008
|
+
shared.mutatePullRequests(project, (currentPrs) => {
|
|
3014
3009
|
for (const upd of updates) {
|
|
3015
3010
|
const pr = currentPrs.find(p => shared.getPrNumber(p) === upd.prNumber);
|
|
3016
3011
|
if (!pr) continue;
|
|
@@ -112,6 +112,7 @@ const _leaseContexts = new Map(); // dispatchId → mutable session-only context
|
|
|
112
112
|
// toward `_poolSize` so a burst of concurrent acquireWorker calls can't
|
|
113
113
|
// overshoot the configured pool size while spawns are still in flight.
|
|
114
114
|
let _reserved = 0;
|
|
115
|
+
let _draining = false;
|
|
115
116
|
|
|
116
117
|
function _occupied() {
|
|
117
118
|
return _free.length + _busy.size + _reserved;
|
|
@@ -169,6 +170,11 @@ async function _reuseWorker(worker, req) {
|
|
|
169
170
|
await worker.newSession({ mcpServers: req.mcpServers, model: req.model, effort: req.effort, cwd: req.cwd });
|
|
170
171
|
_trace(`dispatch=${req.dispatchId} reuse: worker=${worker.id} sessionId=${worker.sessionId}`);
|
|
171
172
|
_reserved--;
|
|
173
|
+
if (_draining) {
|
|
174
|
+
try { worker.close(); } catch { /* already torn down */ }
|
|
175
|
+
req.reject(new Error('agent-worker-pool: draining'));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
172
178
|
req.resolve(worker);
|
|
173
179
|
} catch (err) {
|
|
174
180
|
_reserved--;
|
|
@@ -185,6 +191,11 @@ async function _respawnWorker(stale, req) {
|
|
|
185
191
|
try {
|
|
186
192
|
const worker = await _bootWorker(req);
|
|
187
193
|
_reserved--;
|
|
194
|
+
if (_draining) {
|
|
195
|
+
try { worker.close(); } catch { /* already torn down */ }
|
|
196
|
+
req.reject(new Error('agent-worker-pool: draining'));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
188
199
|
req.resolve(worker);
|
|
189
200
|
} catch (err) {
|
|
190
201
|
_reserved--;
|
|
@@ -197,6 +208,11 @@ async function _spawnFreshWorker(req) {
|
|
|
197
208
|
try {
|
|
198
209
|
const worker = await _bootWorker(req);
|
|
199
210
|
_reserved--;
|
|
211
|
+
if (_draining) {
|
|
212
|
+
try { worker.close(); } catch { /* already torn down */ }
|
|
213
|
+
req.reject(new Error('agent-worker-pool: draining'));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
200
216
|
req.resolve(worker);
|
|
201
217
|
} catch (err) {
|
|
202
218
|
_reserved--;
|
|
@@ -299,6 +315,7 @@ async function acquireWorker({
|
|
|
299
315
|
dispatchId, cwd, model, effort, mcpServers, hermeticDirs, processEnv, sessionContext,
|
|
300
316
|
} = {}) {
|
|
301
317
|
if (!dispatchId) throw new Error('agent-worker-pool.acquireWorker: dispatchId is required');
|
|
318
|
+
if (_draining) throw new Error('agent-worker-pool: draining');
|
|
302
319
|
if (_busy.has(dispatchId) || _leaseContexts.has(dispatchId)) {
|
|
303
320
|
throw new Error(
|
|
304
321
|
`agent-worker-pool.acquireWorker: dispatchId ${dispatchId} already has an active worker ` +
|
|
@@ -329,13 +346,20 @@ async function acquireWorker({
|
|
|
329
346
|
_pump();
|
|
330
347
|
});
|
|
331
348
|
|
|
349
|
+
if (_draining) throw new Error('agent-worker-pool: draining');
|
|
350
|
+
_busy.set(dispatchId, worker);
|
|
332
351
|
await _applyHermeticDirs(worker, hermeticDirs);
|
|
333
352
|
if (worker.killed) throw new Error('agent-worker-pool: worker died during acquisition');
|
|
353
|
+
if (_draining) throw new Error('agent-worker-pool: draining');
|
|
334
354
|
} catch (err) {
|
|
355
|
+
if (_busy.get(dispatchId) === worker) _busy.delete(dispatchId);
|
|
335
356
|
_clearLeaseContext(dispatchId);
|
|
357
|
+
if (worker) {
|
|
358
|
+
try { worker.close(); } catch { /* already torn down */ }
|
|
359
|
+
_pump();
|
|
360
|
+
}
|
|
336
361
|
throw err;
|
|
337
362
|
}
|
|
338
|
-
_busy.set(dispatchId, worker);
|
|
339
363
|
|
|
340
364
|
return {
|
|
341
365
|
dispatchId,
|
|
@@ -381,13 +405,42 @@ function release(dispatchId) {
|
|
|
381
405
|
_pump();
|
|
382
406
|
return true;
|
|
383
407
|
}
|
|
408
|
+
if (_draining) {
|
|
409
|
+
try { worker.close(); } catch { /* already torn down */ }
|
|
410
|
+
_trace(`dispatch=${dispatchId} release: draining, worker=${worker.id} closed`);
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
384
413
|
_free.push(worker);
|
|
385
414
|
_trace(`dispatch=${dispatchId} release: worker=${worker.id} returned to free set`);
|
|
386
415
|
_pump();
|
|
387
416
|
return true;
|
|
388
417
|
}
|
|
389
418
|
|
|
419
|
+
function beginDrain() {
|
|
420
|
+
if (_draining) return;
|
|
421
|
+
_draining = true;
|
|
422
|
+
for (const req of _queue.splice(0)) {
|
|
423
|
+
try { req.reject(new Error('agent-worker-pool: draining')); } catch { /* already settled */ }
|
|
424
|
+
}
|
|
425
|
+
for (const worker of _free.splice(0)) {
|
|
426
|
+
try { worker.close(); } catch { /* already torn down */ }
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function isDraining() {
|
|
431
|
+
return _draining;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function isDrained() {
|
|
435
|
+
return _busy.size === 0 && _reserved === 0;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function getActiveLeaseIds() {
|
|
439
|
+
return [..._busy.keys()];
|
|
440
|
+
}
|
|
441
|
+
|
|
390
442
|
function shutdown() {
|
|
443
|
+
_draining = true;
|
|
391
444
|
for (const req of _queue.splice(0)) {
|
|
392
445
|
_clearLeaseContext(req.dispatchId);
|
|
393
446
|
try { req.reject(new Error('agent-worker-pool: shutdown')); } catch { /* swallow */ }
|
|
@@ -408,6 +461,10 @@ module.exports = {
|
|
|
408
461
|
release,
|
|
409
462
|
setPoolSize,
|
|
410
463
|
getPoolSize,
|
|
464
|
+
beginDrain,
|
|
465
|
+
isDraining,
|
|
466
|
+
isDrained,
|
|
467
|
+
getActiveLeaseIds,
|
|
411
468
|
shutdown,
|
|
412
469
|
DEFAULT_POOL_SIZE,
|
|
413
470
|
// Exposed for unit tests; engine code MUST go through the public API.
|
package/engine/bridge.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* engine/bridge.js — Constellation-bridge config + marker accessors.
|
|
3
3
|
*
|
|
4
|
-
* Pure helpers for the `minions bridge ...` subcommand and
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* marker-file contract, and atomic config writes for the toggle.
|
|
4
|
+
* Pure helpers for the `minions bridge ...` subcommand and the versioned
|
|
5
|
+
* metadata snapshot consumed by Constellation. Minions remains authoritative:
|
|
6
|
+
* the Constellation agent polls the local dashboard and mirrors only the
|
|
7
|
+
* allowlisted projection returned by buildBridgeSnapshot().
|
|
9
8
|
*
|
|
10
9
|
* Strict semantics: only the literal boolean `true` enables the bridge.
|
|
11
10
|
* Mirror this check on the Constellation reader to avoid truthy coercion
|
|
@@ -24,6 +23,258 @@ const {
|
|
|
24
23
|
} = shared;
|
|
25
24
|
|
|
26
25
|
const CONFIG_PATH = path.join(MINIONS_DIR, 'config.json');
|
|
26
|
+
const BRIDGE_SNAPSHOT_PROTOCOL = 'minions.constellation.snapshot';
|
|
27
|
+
const BRIDGE_SNAPSHOT_PROTOCOL_VERSION = 1;
|
|
28
|
+
|
|
29
|
+
const SNAPSHOT_CAPS = Object.freeze({
|
|
30
|
+
id: 128,
|
|
31
|
+
status: 128,
|
|
32
|
+
agent: 128,
|
|
33
|
+
title: 512,
|
|
34
|
+
project: 128,
|
|
35
|
+
url: 2048,
|
|
36
|
+
timestamp: 64,
|
|
37
|
+
shortLabel: 128,
|
|
38
|
+
branch: 255,
|
|
39
|
+
name: 128,
|
|
40
|
+
});
|
|
41
|
+
const INACTIVE_AGENT_STATUSES = new Set(['idle', 'available', 'sleeping']);
|
|
42
|
+
|
|
43
|
+
function _string(value, max, fallback = null) {
|
|
44
|
+
if (typeof value !== 'string') return fallback;
|
|
45
|
+
const trimmed = value.trim();
|
|
46
|
+
if (!trimmed) return fallback;
|
|
47
|
+
return trimmed.slice(0, max);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function _isoTime(value) {
|
|
51
|
+
if (value === null || value === undefined || value === '') return undefined;
|
|
52
|
+
const ms = typeof value === 'number' ? value : Date.parse(String(value));
|
|
53
|
+
if (!Number.isFinite(ms)) return undefined;
|
|
54
|
+
return new Date(ms).toISOString().slice(0, SNAPSHOT_CAPS.timestamp);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function _defined(obj) {
|
|
58
|
+
return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function _projectList(items, projector) {
|
|
62
|
+
if (!Array.isArray(items)) return [];
|
|
63
|
+
const projected = [];
|
|
64
|
+
for (const item of items) {
|
|
65
|
+
if (!item || typeof item !== 'object') continue;
|
|
66
|
+
const value = projector(item);
|
|
67
|
+
if (value) projected.push(value);
|
|
68
|
+
}
|
|
69
|
+
return projected;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function _projectWorkItem(item) {
|
|
73
|
+
const id = _string(item.id, SNAPSHOT_CAPS.id);
|
|
74
|
+
if (!id) return null;
|
|
75
|
+
return _defined({
|
|
76
|
+
id,
|
|
77
|
+
title: _string(item.title, SNAPSHOT_CAPS.title, id),
|
|
78
|
+
type: _string(item.type, SNAPSHOT_CAPS.shortLabel) || undefined,
|
|
79
|
+
priority: _string(item.priority, SNAPSHOT_CAPS.shortLabel) || undefined,
|
|
80
|
+
status: _string(item.status, SNAPSHOT_CAPS.status, 'unknown'),
|
|
81
|
+
project: _string(item.project, SNAPSHOT_CAPS.project) || undefined,
|
|
82
|
+
agent: _string(item.agent, SNAPSHOT_CAPS.agent) || undefined,
|
|
83
|
+
createdBy: _string(item.createdBy, SNAPSHOT_CAPS.shortLabel) || undefined,
|
|
84
|
+
created: _isoTime(item.created),
|
|
85
|
+
dispatched_to: _string(item.dispatched_to, SNAPSHOT_CAPS.agent) || undefined,
|
|
86
|
+
dispatched_at: _isoTime(item.dispatched_at),
|
|
87
|
+
completedAt: _isoTime(item.completedAt || item.completed_at),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function _projectPullRequest(pr) {
|
|
92
|
+
const id = _string(pr.id, SNAPSHOT_CAPS.id);
|
|
93
|
+
const prNumber = Number(pr.prNumber);
|
|
94
|
+
const url = _string(pr.url, SNAPSHOT_CAPS.url);
|
|
95
|
+
if (!id || !url || !Number.isInteger(prNumber) || prNumber < 0) return null;
|
|
96
|
+
return _defined({
|
|
97
|
+
id,
|
|
98
|
+
prNumber,
|
|
99
|
+
title: _string(pr.title, SNAPSHOT_CAPS.title, id),
|
|
100
|
+
agent: _string(pr.agent, SNAPSHOT_CAPS.agent) || undefined,
|
|
101
|
+
branch: _string(pr.branch, SNAPSHOT_CAPS.branch) || undefined,
|
|
102
|
+
reviewStatus: _string(pr.reviewStatus, SNAPSHOT_CAPS.status, 'unknown'),
|
|
103
|
+
status: _string(pr.status, SNAPSHOT_CAPS.status, 'unknown'),
|
|
104
|
+
url,
|
|
105
|
+
created: _isoTime(pr.created),
|
|
106
|
+
lastReviewedAt: _isoTime(pr.lastReviewedAt),
|
|
107
|
+
lastPushedAt: _isoTime(pr.lastPushedAt),
|
|
108
|
+
lastDispatchedAt: _isoTime(pr.lastDispatchedAt),
|
|
109
|
+
mergeStatus: _string(pr.mergeStatus || pr.mergeStateStatus, SNAPSHOT_CAPS.shortLabel) || undefined,
|
|
110
|
+
mergeable: typeof pr.mergeable === 'boolean' ? pr.mergeable : undefined,
|
|
111
|
+
isDraft: typeof pr.isDraft === 'boolean' ? pr.isDraft : undefined,
|
|
112
|
+
behindBy: Number.isInteger(pr.behindBy) && pr.behindBy >= 0 ? pr.behindBy : undefined,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function _projectPlan(plan) {
|
|
117
|
+
const id = _string(plan.id || plan.file, SNAPSHOT_CAPS.id);
|
|
118
|
+
if (!id) return null;
|
|
119
|
+
return _defined({
|
|
120
|
+
id,
|
|
121
|
+
title: _string(plan.title || plan.summary || plan.plan_summary, SNAPSHOT_CAPS.title, id),
|
|
122
|
+
status: _string(plan.status, SNAPSHOT_CAPS.status, plan.archived ? 'archived' : 'unknown'),
|
|
123
|
+
agent: _string(plan.agent || plan.generatedBy || plan.generated_by, SNAPSHOT_CAPS.agent) || undefined,
|
|
124
|
+
project: _string(plan.project, SNAPSHOT_CAPS.project) || undefined,
|
|
125
|
+
createdAt: _isoTime(plan.createdAt || plan.generatedAt || plan.generated_at),
|
|
126
|
+
updatedAt: _isoTime(plan.updatedAt),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function _projectSchedule(schedule) {
|
|
131
|
+
const id = _string(schedule.id, SNAPSHOT_CAPS.id);
|
|
132
|
+
if (!id) return null;
|
|
133
|
+
return _defined({
|
|
134
|
+
id,
|
|
135
|
+
title: _string(schedule.title, SNAPSHOT_CAPS.title, id),
|
|
136
|
+
status: _string(schedule.status, SNAPSHOT_CAPS.status,
|
|
137
|
+
schedule.enabled === false ? 'disabled' : 'enabled'),
|
|
138
|
+
type: _string(schedule.type, SNAPSHOT_CAPS.shortLabel, 'schedule'),
|
|
139
|
+
project: _string(schedule.project, SNAPSHOT_CAPS.project) || undefined,
|
|
140
|
+
agent: _string(schedule.agent, SNAPSHOT_CAPS.agent) || undefined,
|
|
141
|
+
nextRun: _isoTime(schedule.nextRun),
|
|
142
|
+
lastRun: _isoTime(schedule.lastRun || schedule._lastRun || schedule._lastCompletedAt),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function _projectDispatch(entry, status) {
|
|
147
|
+
const id = _string(entry.id, SNAPSHOT_CAPS.id);
|
|
148
|
+
if (!id) return null;
|
|
149
|
+
const meta = entry.meta && typeof entry.meta === 'object' ? entry.meta : {};
|
|
150
|
+
const metaItem = meta.item && typeof meta.item === 'object' ? meta.item : {};
|
|
151
|
+
const metaProject = meta.project && typeof meta.project === 'object' ? meta.project : {};
|
|
152
|
+
return _defined({
|
|
153
|
+
id,
|
|
154
|
+
agent: _string(entry.agent, SNAPSHOT_CAPS.agent, 'unassigned'),
|
|
155
|
+
status: _string(entry.status, SNAPSHOT_CAPS.status, status),
|
|
156
|
+
type: _string(entry.type, SNAPSHOT_CAPS.shortLabel) || undefined,
|
|
157
|
+
project: _string(entry.project || metaProject.name, SNAPSHOT_CAPS.project) || undefined,
|
|
158
|
+
workItemId: _string(entry.workItemId || metaItem.id, SNAPSHOT_CAPS.id) || undefined,
|
|
159
|
+
prId: _string(entry.prId || entry.pr_id, SNAPSHOT_CAPS.id) || undefined,
|
|
160
|
+
startedAt: _isoTime(entry.startedAt || entry.started_at),
|
|
161
|
+
completedAt: _isoTime(entry.completedAt || entry.completed_at),
|
|
162
|
+
outcome: _string(entry.outcome || entry.result, SNAPSHOT_CAPS.shortLabel) || undefined,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function _projectDispatchQueue(dispatch) {
|
|
167
|
+
if (!dispatch || typeof dispatch !== 'object') return [];
|
|
168
|
+
const projected = [];
|
|
169
|
+
for (const [bucket, status] of [
|
|
170
|
+
['pending', 'queued'],
|
|
171
|
+
['active', 'active'],
|
|
172
|
+
['review', 'review'],
|
|
173
|
+
['completed', 'completed'],
|
|
174
|
+
]) {
|
|
175
|
+
const entries = bucket === 'completed' && Array.isArray(dispatch[bucket])
|
|
176
|
+
? dispatch[bucket].slice(-20)
|
|
177
|
+
: dispatch[bucket];
|
|
178
|
+
projected.push(..._projectList(entries, entry => _projectDispatch(entry, status)));
|
|
179
|
+
}
|
|
180
|
+
return projected;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function _projectAgent(agent) {
|
|
184
|
+
const id = _string(agent.id || agent.agent, SNAPSHOT_CAPS.agent);
|
|
185
|
+
if (!id) return null;
|
|
186
|
+
return _defined({
|
|
187
|
+
agent: id,
|
|
188
|
+
status: _string(agent.status, SNAPSHOT_CAPS.status, 'unknown'),
|
|
189
|
+
project: _string(agent.project, SNAPSHOT_CAPS.project) || undefined,
|
|
190
|
+
currentTaskId: _string(agent.currentTaskId || agent.currentDispatchId, SNAPSHOT_CAPS.id) || undefined,
|
|
191
|
+
currentTaskTitle: _string(agent.currentTaskTitle || agent.currentTask, SNAPSHOT_CAPS.title) || undefined,
|
|
192
|
+
updatedAt: _isoTime(agent.updatedAt || agent.lastActionAt),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Build the complete metadata-only state projection consumed by Constellation.
|
|
198
|
+
* Every field is copied explicitly so free-text task descriptions, prompts,
|
|
199
|
+
* completion reports, diffs, comments, paths, logs, and credentials cannot
|
|
200
|
+
* cross the bridge accidentally.
|
|
201
|
+
*/
|
|
202
|
+
function buildBridgeSnapshot(state = {}) {
|
|
203
|
+
const generatedAt = _isoTime(state.generatedAt) || new Date().toISOString();
|
|
204
|
+
const engine = state.control && typeof state.control === 'object' ? state.control : {};
|
|
205
|
+
const workItems = _projectList(state.workItems, _projectWorkItem);
|
|
206
|
+
const pullRequests = _projectList(state.pullRequests, _projectPullRequest);
|
|
207
|
+
const plans = _projectList(state.plans, _projectPlan);
|
|
208
|
+
const schedules = _projectList(state.schedules, _projectSchedule);
|
|
209
|
+
const agents = _projectList(state.agents, _projectAgent);
|
|
210
|
+
const dispatches = _projectDispatchQueue(state.dispatch);
|
|
211
|
+
const activeAgents = agents.filter(agent => !INACTIVE_AGENT_STATUSES.has(agent.status.toLowerCase())).length;
|
|
212
|
+
const minionsVersion = _string(state.minionsVersion, SNAPSHOT_CAPS.shortLabel);
|
|
213
|
+
const engineState = _string(engine.state, SNAPSHOT_CAPS.status, 'unknown');
|
|
214
|
+
const enginePid = Number.isInteger(engine.pid) && engine.pid > 0 ? engine.pid : null;
|
|
215
|
+
const projects = Array.isArray(state.projects) ? state.projects : [];
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
protocol: BRIDGE_SNAPSHOT_PROTOCOL,
|
|
219
|
+
protocolVersion: BRIDGE_SNAPSHOT_PROTOCOL_VERSION,
|
|
220
|
+
complete: true,
|
|
221
|
+
generatedAt,
|
|
222
|
+
minionsVersion,
|
|
223
|
+
engine: {
|
|
224
|
+
state: engineState,
|
|
225
|
+
pid: enginePid,
|
|
226
|
+
},
|
|
227
|
+
entities: {
|
|
228
|
+
work_item: workItems,
|
|
229
|
+
pr: pullRequests,
|
|
230
|
+
plan: plans,
|
|
231
|
+
schedule: schedules,
|
|
232
|
+
dispatch: dispatches,
|
|
233
|
+
agent_status: agents,
|
|
234
|
+
device_summary: [{
|
|
235
|
+
name: 'Minions',
|
|
236
|
+
status: engineState,
|
|
237
|
+
lastSeen: generatedAt,
|
|
238
|
+
agentCount: agents.length,
|
|
239
|
+
activeAgentCount: activeAgents,
|
|
240
|
+
workItemCount: workItems.length,
|
|
241
|
+
pullRequestCount: pullRequests.length,
|
|
242
|
+
planCount: plans.length,
|
|
243
|
+
scheduleCount: schedules.length,
|
|
244
|
+
dispatchCount: dispatches.length,
|
|
245
|
+
projectCount: projects.length,
|
|
246
|
+
protocolVersion: BRIDGE_SNAPSHOT_PROTOCOL_VERSION,
|
|
247
|
+
minionsVersion,
|
|
248
|
+
snapshotGeneratedAt: generatedAt,
|
|
249
|
+
enginePid,
|
|
250
|
+
}],
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function projectSnapshotHealthForBridge(snapshot) {
|
|
256
|
+
if (!snapshot || typeof snapshot !== 'object') return null;
|
|
257
|
+
if (snapshot.protocol !== BRIDGE_SNAPSHOT_PROTOCOL) return null;
|
|
258
|
+
if (snapshot.protocolVersion !== BRIDGE_SNAPSHOT_PROTOCOL_VERSION) return null;
|
|
259
|
+
if (snapshot.complete !== true || !snapshot.entities || typeof snapshot.entities !== 'object') return null;
|
|
260
|
+
const entities = snapshot.entities;
|
|
261
|
+
const summary = Array.isArray(entities.device_summary) ? entities.device_summary[0] || {} : {};
|
|
262
|
+
const count = key => (Array.isArray(entities[key]) ? entities[key].length : 0);
|
|
263
|
+
return {
|
|
264
|
+
protocolVersion: snapshot.protocolVersion,
|
|
265
|
+
minionsVersion: snapshot.minionsVersion ?? null,
|
|
266
|
+
engineState: snapshot.engine?.state ?? null,
|
|
267
|
+
enginePid: snapshot.engine?.pid ?? null,
|
|
268
|
+
agentCount: count('agent_status'),
|
|
269
|
+
activeAgentCount: summary.activeAgentCount ?? null,
|
|
270
|
+
workItemCount: count('work_item'),
|
|
271
|
+
pullRequestCount: count('pr'),
|
|
272
|
+
planCount: count('plan'),
|
|
273
|
+
scheduleCount: count('schedule'),
|
|
274
|
+
dispatchCount: count('dispatch'),
|
|
275
|
+
projectCount: summary.projectCount ?? null,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
27
278
|
|
|
28
279
|
/**
|
|
29
280
|
* Strict check: `true` ⇔ bridge enabled. Any other shape (missing field,
|
|
@@ -118,7 +369,11 @@ module.exports = {
|
|
|
118
369
|
readBridgeMarker,
|
|
119
370
|
setBridgeEnabled,
|
|
120
371
|
formatRelativeAge,
|
|
372
|
+
buildBridgeSnapshot,
|
|
373
|
+
projectSnapshotHealthForBridge,
|
|
121
374
|
projectStatusForBridge,
|
|
375
|
+
BRIDGE_SNAPSHOT_PROTOCOL,
|
|
376
|
+
BRIDGE_SNAPSHOT_PROTOCOL_VERSION,
|
|
122
377
|
CONSTELLATION_BRIDGE_MARKER_PATH,
|
|
123
378
|
CONSTELLATION_BRIDGE_MARKER_SCHEMA_VERSION,
|
|
124
379
|
};
|