@yemi33/minions 0.1.2378 → 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/README.md +1 -0
- 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-inbox-entries-schema.md +31 -39
- 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/acp-transport.js +49 -8
- package/engine/ado-status.js +5 -5
- package/engine/ado.js +20 -25
- package/engine/agent-worker-pool.js +124 -15
- 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/pooled-agent-process.js +46 -26
- 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 +167 -349
- 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/cli.js
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const shared = require('./shared');
|
|
9
|
-
const { safeRead, safeJson, safeJsonArr, safeWrite, mutateControl, mutateWorkItems, ts, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, REVIEW_STATUS, DISPATCH_RESULT } = shared;
|
|
9
|
+
const { safeRead, safeJson, safeJsonArr, safeWrite, readWorkItems, readPullRequests, mutateControl, mutateWorkItems, ts, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, REVIEW_STATUS, DISPATCH_RESULT, FAILURE_CLASS } = shared;
|
|
10
10
|
const queries = require('./queries');
|
|
11
|
+
const agentWorkerPool = require('./agent-worker-pool');
|
|
11
12
|
const { getConfig, getControl, getDispatch, getAgentStatus,
|
|
12
|
-
MINIONS_DIR, ENGINE_DIR, AGENTS_DIR, PLANS_DIR, PRD_DIR, CONTROL_PATH
|
|
13
|
+
MINIONS_DIR, ENGINE_DIR, AGENTS_DIR, PLANS_DIR, PRD_DIR, CONTROL_PATH } = queries;
|
|
13
14
|
|
|
14
15
|
// Lazy require — only for engine-specific functions (log, ts, tick, addToDispatch, etc.)
|
|
15
16
|
let _engine = null;
|
|
@@ -242,7 +243,7 @@ const CLI_COMMAND_DOCS = Object.freeze({
|
|
|
242
243
|
discover: { args: '', summary: 'Dry-run work discovery' },
|
|
243
244
|
dispatch: { args: '', summary: 'Force a dispatch cycle' },
|
|
244
245
|
spawn: { args: '<agent> <prompt>', summary: 'Manually spawn an agent with prompt' },
|
|
245
|
-
work: { args: '<title> [opts]', summary: 'Add to work-
|
|
246
|
+
work: { args: '<title> [opts]', summary: 'Add to the work-item queue' },
|
|
246
247
|
plan: { args: '<file|text> [project]', summary: 'Generate PRD from a plan (file or text)' },
|
|
247
248
|
kill: { args: '', summary: 'Kill all active agents, reset to pending' },
|
|
248
249
|
complete: { args: '<dispatch-id>', summary: 'Mark a dispatch as done' },
|
|
@@ -540,7 +541,7 @@ const commands = {
|
|
|
540
541
|
// auto-archived back then left the WI pill pointing at the gone
|
|
541
542
|
// notes/inbox/<name> path; this repoints them to the archived/KB location.
|
|
542
543
|
// Idempotent (only touches provably-broken + resolvable links) and routed
|
|
543
|
-
// through mutateWorkItems so
|
|
544
|
+
// through mutateWorkItems so the repair is transactional — a no-op
|
|
544
545
|
// once healed, so it's safe to run on every boot.
|
|
545
546
|
try {
|
|
546
547
|
const res = require('./note-link-backfill').runNoteLinkBackfill({ config });
|
|
@@ -596,7 +597,7 @@ const commands = {
|
|
|
596
597
|
console.log(` WARNING: ${p.name} path not found: ${p.localPath}`);
|
|
597
598
|
} else {
|
|
598
599
|
try {
|
|
599
|
-
shared.
|
|
600
|
+
shared.initializeProjectState(p);
|
|
600
601
|
} catch (err) {
|
|
601
602
|
e.log('warn', `Project state setup failed for "${p.name}": ${err.message}`);
|
|
602
603
|
}
|
|
@@ -683,8 +684,7 @@ const commands = {
|
|
|
683
684
|
// Sync work item status to dispatched — atomic write to avoid lifecycle lazy init issues
|
|
684
685
|
if (item.meta?.item?.id && item.meta?.project?.localPath) {
|
|
685
686
|
try {
|
|
686
|
-
|
|
687
|
-
mutateWorkItems(wiPath, items => {
|
|
687
|
+
mutateWorkItems(item.meta.project.name, items => {
|
|
688
688
|
const wi = items.find(w => w.id === item.meta.item.id);
|
|
689
689
|
if (wi && wi.status !== WI_STATUS.DISPATCHED) {
|
|
690
690
|
wi.status = WI_STATUS.DISPATCHED;
|
|
@@ -756,13 +756,12 @@ const commands = {
|
|
|
756
756
|
isSuccess = true;
|
|
757
757
|
}
|
|
758
758
|
|
|
759
|
-
// Fallback: check
|
|
759
|
+
// Fallback: check tracked PR state for a matching work item ID.
|
|
760
760
|
if (!isSuccess && item.meta?.item?.id) {
|
|
761
761
|
try {
|
|
762
762
|
const projName = item.meta.project?.name;
|
|
763
763
|
if (projName) {
|
|
764
|
-
const
|
|
765
|
-
const prs = safeJsonArr(prPath);
|
|
764
|
+
const prs = readPullRequests(projName);
|
|
766
765
|
const matchingPr = prs.find(pr =>
|
|
767
766
|
(pr.prdItems || []).includes(item.meta.item.id) &&
|
|
768
767
|
pr.status !== 'abandoned' && pr.status !== 'closed'
|
|
@@ -788,8 +787,7 @@ const commands = {
|
|
|
788
787
|
try {
|
|
789
788
|
const projName = item.meta.project?.name;
|
|
790
789
|
if (projName) {
|
|
791
|
-
|
|
792
|
-
mutateWorkItems(wiPath, items => {
|
|
790
|
+
mutateWorkItems(projName, items => {
|
|
793
791
|
const wi = items.find(w => w.id === item.meta.item.id);
|
|
794
792
|
if (wi) {
|
|
795
793
|
wi.status = status;
|
|
@@ -855,13 +853,10 @@ const commands = {
|
|
|
855
853
|
let fixes = 0;
|
|
856
854
|
|
|
857
855
|
const activeIds = new Set((dispatch.active || []).map(d => d.meta?.item?.id).filter(Boolean));
|
|
858
|
-
const
|
|
859
|
-
for (const
|
|
860
|
-
allWiPaths.push(path.join(MINIONS_DIR, "projects", p.name, "work-items.json"));
|
|
861
|
-
}
|
|
862
|
-
for (const wiPath of allWiPaths) {
|
|
856
|
+
const workItemScopes = ['central', ...projects];
|
|
857
|
+
for (const scope of workItemScopes) {
|
|
863
858
|
try {
|
|
864
|
-
mutateWorkItems(
|
|
859
|
+
mutateWorkItems(scope, items => {
|
|
865
860
|
for (const item of items) {
|
|
866
861
|
if (item.status === WI_STATUS.DISPATCHED && !activeIds.has(item.id)) {
|
|
867
862
|
item.status = WI_STATUS.PENDING;
|
|
@@ -887,7 +882,7 @@ const commands = {
|
|
|
887
882
|
// fix prevents future false-flips, this pass un-flips PRs that were
|
|
888
883
|
// already wrongly marked `abandoned` before the hardening shipped (e.g.
|
|
889
884
|
// the 16 PRs hit by the 2026-05-14 gh-auth-flip incident). Version-gated
|
|
890
|
-
//
|
|
885
|
+
// in SQL so it runs once per ENGINE_DEFAULTS.abandonedReconciliationVersion
|
|
891
886
|
// bump and is otherwise a no-op. Fire-and-forget — boot must not block on
|
|
892
887
|
// network calls; the pass operates on `abandoned` PRs while pollPrStatus
|
|
893
888
|
// operates on `active`/`linked`, so there is no per-record race.
|
|
@@ -925,7 +920,7 @@ const commands = {
|
|
|
925
920
|
})();
|
|
926
921
|
|
|
927
922
|
// P-8a4d6f29 — Boot reconcile for managed-spawn: drop dead-PID rows from
|
|
928
|
-
//
|
|
923
|
+
// managed-process state, kill TTL-expired specs, re-probe
|
|
929
924
|
// survivors once. Wrapped in Promise.race against
|
|
930
925
|
// ENGINE_DEFAULTS.managedSpawn.bootReconcileMaxMs (2s default) so a
|
|
931
926
|
// malformed or oversized state file cannot block engine boot. Async +
|
|
@@ -1101,6 +1096,9 @@ const commands = {
|
|
|
1101
1096
|
});
|
|
1102
1097
|
e.tick();
|
|
1103
1098
|
}
|
|
1099
|
+
if (ctrl._shutdownRequestedAt) {
|
|
1100
|
+
gracefulShutdown('shutdown request');
|
|
1101
|
+
}
|
|
1104
1102
|
}, 1000);
|
|
1105
1103
|
|
|
1106
1104
|
console.log(`Tick interval: ${interval / 1000}s | Max concurrent: ${config.engine?.maxConcurrent || 5}`);
|
|
@@ -1140,7 +1138,8 @@ const commands = {
|
|
|
1140
1138
|
catch (err) { console.error(`[shutdown] flushLogs failed: ${err.message}`); }
|
|
1141
1139
|
}
|
|
1142
1140
|
|
|
1143
|
-
// Graceful shutdown
|
|
1141
|
+
// Graceful shutdown drains non-reattachable pooled leases. Detached cold
|
|
1142
|
+
// agents intentionally survive for normal restart reattachment.
|
|
1144
1143
|
let shuttingDown = false;
|
|
1145
1144
|
function gracefulShutdown(signal) {
|
|
1146
1145
|
if (shuttingDown) return;
|
|
@@ -1157,43 +1156,77 @@ const commands = {
|
|
|
1157
1156
|
}
|
|
1158
1157
|
e.log('info', `Graceful shutdown initiated (${signal})`);
|
|
1159
1158
|
|
|
1160
|
-
|
|
1159
|
+
agentWorkerPool.beginDrain();
|
|
1160
|
+
|
|
1161
|
+
function getPooledDispatchIds() {
|
|
1162
|
+
return [...e.activeProcesses.entries()]
|
|
1163
|
+
.filter(([, procInfo]) => procInfo?._pooled)
|
|
1164
|
+
.map(([id]) => id);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const drainingPooledIds = new Set([
|
|
1168
|
+
...getPooledDispatchIds(),
|
|
1169
|
+
...(getDispatch().active || []).filter(item => item.pooled).map(item => item.id),
|
|
1170
|
+
]);
|
|
1171
|
+
|
|
1172
|
+
function getOutstandingPooledIds() {
|
|
1173
|
+
const persistedActiveIds = new Set((getDispatch().active || []).map(item => item.id));
|
|
1174
|
+
const outstanding = new Set(getPooledDispatchIds());
|
|
1175
|
+
for (const id of drainingPooledIds) {
|
|
1176
|
+
if (persistedActiveIds.has(id)) outstanding.add(id);
|
|
1177
|
+
}
|
|
1178
|
+
return [...outstanding];
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function finishShutdown(message, exitCode = 0) {
|
|
1182
|
+
agentWorkerPool.shutdown();
|
|
1161
1183
|
const stoppedWrite = markControlStoppedForOwner(controlOwner, e.ts());
|
|
1162
1184
|
if (!stoppedWrite.changed) {
|
|
1163
1185
|
e.log('warn', 'Graceful shutdown skipped control.json stopped transition; control file is owned by a different engine process');
|
|
1164
1186
|
}
|
|
1165
|
-
e.log(
|
|
1187
|
+
e.log(exitCode === 0 ? 'info' : 'warn', message);
|
|
1166
1188
|
drainBuffers();
|
|
1167
|
-
console.log(
|
|
1168
|
-
process.exit(
|
|
1189
|
+
console.log(message);
|
|
1190
|
+
process.exit(exitCode);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
let pooledIds = getOutstandingPooledIds();
|
|
1194
|
+
if (pooledIds.length === 0 && agentWorkerPool.isDrained()) {
|
|
1195
|
+
finishShutdown('No active pooled agents — stopped.');
|
|
1196
|
+
return;
|
|
1169
1197
|
}
|
|
1170
1198
|
|
|
1171
|
-
console.log(`Waiting for ${
|
|
1199
|
+
console.log(`Waiting for ${pooledIds.length || 1} active pooled agent(s) to finish...`);
|
|
1172
1200
|
const timeout = config.engine?.shutdownTimeout || shared.ENGINE_DEFAULTS.shutdownTimeout;
|
|
1173
1201
|
const deadline = Date.now() + timeout;
|
|
1174
1202
|
|
|
1175
1203
|
const poll = setInterval(() => {
|
|
1176
|
-
|
|
1204
|
+
pooledIds = getOutstandingPooledIds();
|
|
1205
|
+
if (pooledIds.length === 0 && agentWorkerPool.isDrained()) {
|
|
1177
1206
|
clearInterval(poll);
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
e.log('warn', 'Graceful shutdown skipped control.json stopped transition; control file is owned by a different engine process');
|
|
1181
|
-
}
|
|
1182
|
-
e.log('info', 'Graceful shutdown complete (all agents finished)');
|
|
1183
|
-
drainBuffers();
|
|
1184
|
-
console.log('All agents finished — stopped.');
|
|
1185
|
-
process.exit(0);
|
|
1207
|
+
finishShutdown('All pooled agents finished — stopped.');
|
|
1208
|
+
return;
|
|
1186
1209
|
}
|
|
1187
1210
|
if (Date.now() >= deadline) {
|
|
1188
1211
|
clearInterval(poll);
|
|
1189
|
-
const
|
|
1190
|
-
|
|
1191
|
-
|
|
1212
|
+
for (const id of pooledIds) {
|
|
1213
|
+
const procInfo = e.activeProcesses.get(id);
|
|
1214
|
+
if (procInfo?._pooled) {
|
|
1215
|
+
try { procInfo.proc.kill(); } catch (err) {
|
|
1216
|
+
e.log('warn', `Failed to cancel pooled dispatch ${id} during shutdown: ${err.message}`);
|
|
1217
|
+
}
|
|
1218
|
+
e.activeProcesses.delete(id);
|
|
1219
|
+
e.realActivityMap.delete(id);
|
|
1220
|
+
}
|
|
1221
|
+
e.completeDispatch(
|
|
1222
|
+
id,
|
|
1223
|
+
DISPATCH_RESULT.ERROR,
|
|
1224
|
+
'Pooled dispatch cancelled after graceful shutdown timeout',
|
|
1225
|
+
'',
|
|
1226
|
+
{ failureClass: FAILURE_CLASS.TIMEOUT, retryable: true },
|
|
1227
|
+
);
|
|
1192
1228
|
}
|
|
1193
|
-
|
|
1194
|
-
drainBuffers();
|
|
1195
|
-
console.log(`Shutdown timeout (${timeout / 1000}s) — force exiting with ${e.activeProcesses.size} agent(s) still running.`);
|
|
1196
|
-
process.exit(1);
|
|
1229
|
+
finishShutdown(`Shutdown timeout (${timeout / 1000}s) — cancelled ${pooledIds.length} pooled agent(s) for retry.`, 1);
|
|
1197
1230
|
}
|
|
1198
1231
|
}, 2000);
|
|
1199
1232
|
}
|
|
@@ -1230,8 +1263,7 @@ const commands = {
|
|
|
1230
1263
|
for (const item of active) {
|
|
1231
1264
|
console.log(` - ${item.agentName || item.agent}: ${(item.task || '').slice(0, 80)}`);
|
|
1232
1265
|
}
|
|
1233
|
-
console.log('\n
|
|
1234
|
-
console.log(' On next start, they\'ll get a 20-min grace period before being marked as orphans.');
|
|
1266
|
+
console.log('\n Detached agents will continue for restart reattachment; pooled agents will drain before exit.');
|
|
1235
1267
|
console.log(' To kill them now, run: node engine.js kill\n');
|
|
1236
1268
|
}
|
|
1237
1269
|
// Write stop-intent BEFORE killing so the external supervisor (engine/
|
|
@@ -1243,11 +1275,18 @@ const commands = {
|
|
|
1243
1275
|
e.log('warn', 'Failed to write stop-intent');
|
|
1244
1276
|
}
|
|
1245
1277
|
const control = getControl();
|
|
1246
|
-
if (control.pid && control.pid !== process.pid) {
|
|
1247
|
-
//
|
|
1248
|
-
//
|
|
1249
|
-
//
|
|
1250
|
-
|
|
1278
|
+
if (control.pid && control.pid !== process.pid && isEngineProcessAlive(control)) {
|
|
1279
|
+
// Cross-platform shutdown request. Do not use killGracefully here:
|
|
1280
|
+
// taskkill /T would terminate pooled and detached cold-agent children
|
|
1281
|
+
// before the daemon can drain/reattach them correctly.
|
|
1282
|
+
mutateControl(current => ({
|
|
1283
|
+
...current,
|
|
1284
|
+
_shutdownRequestedAt: Date.now(),
|
|
1285
|
+
_shutdownRequestedByPid: process.pid,
|
|
1286
|
+
}));
|
|
1287
|
+
e.log('info', `Engine shutdown requested (PID ${control.pid})`);
|
|
1288
|
+
console.log('Engine shutdown requested.');
|
|
1289
|
+
return;
|
|
1251
1290
|
}
|
|
1252
1291
|
mutateControl(() => ({ state: 'stopped', stopped_at: e.ts() }));
|
|
1253
1292
|
e.log('info', 'Engine stopped');
|
|
@@ -1528,7 +1567,7 @@ const commands = {
|
|
|
1528
1567
|
}
|
|
1529
1568
|
|
|
1530
1569
|
const config = getConfig();
|
|
1531
|
-
const { getProjects,
|
|
1570
|
+
const { getProjects, resolveProjectSource, uid } = require('./shared');
|
|
1532
1571
|
const projects = getProjects(config);
|
|
1533
1572
|
const target = opts.project ? resolveProjectSource(opts.project, projects, { allowCentral: false }) : null;
|
|
1534
1573
|
if (target?.error) {
|
|
@@ -1540,8 +1579,7 @@ const commands = {
|
|
|
1540
1579
|
console.log('No projects configured. Add a project before running work.');
|
|
1541
1580
|
return;
|
|
1542
1581
|
}
|
|
1543
|
-
const
|
|
1544
|
-
const archivePath = wiPath.replace(/\.json$/, '-archive.json');
|
|
1582
|
+
const scope = targetProject.name;
|
|
1545
1583
|
|
|
1546
1584
|
// Caller-supplied id: validate and pre-check live + archive for collisions
|
|
1547
1585
|
// BEFORE acquiring the work-items lock, so a rejection never mutates state.
|
|
@@ -1552,17 +1590,17 @@ const commands = {
|
|
|
1552
1590
|
process.exit(1);
|
|
1553
1591
|
}
|
|
1554
1592
|
requestedId = opts.id.trim();
|
|
1555
|
-
const liveItems =
|
|
1556
|
-
const archivedItems =
|
|
1593
|
+
const liveItems = readWorkItems(scope);
|
|
1594
|
+
const archivedItems = require('./work-items-store').readArchivedWorkItemsForScope(scope);
|
|
1557
1595
|
if (liveItems.some(w => w?.id === requestedId) || archivedItems.some(w => w?.id === requestedId)) {
|
|
1558
|
-
console.log(`Error: work item id "${requestedId}" already exists in ${
|
|
1596
|
+
console.log(`Error: work item id "${requestedId}" already exists in scope "${scope}" or its archive`);
|
|
1559
1597
|
process.exit(1);
|
|
1560
1598
|
}
|
|
1561
1599
|
}
|
|
1562
1600
|
|
|
1563
1601
|
let item;
|
|
1564
1602
|
let collision = false;
|
|
1565
|
-
mutateWorkItems(
|
|
1603
|
+
mutateWorkItems(scope, items => {
|
|
1566
1604
|
const id = requestedId || ('W-' + uid());
|
|
1567
1605
|
// Re-check inside the lock to close the TOCTOU race against other writers.
|
|
1568
1606
|
if (items.some(w => w?.id === id)) {
|
|
@@ -1731,9 +1769,17 @@ const commands = {
|
|
|
1731
1769
|
console.log(` ${name}: ${status}`);
|
|
1732
1770
|
|
|
1733
1771
|
let filePath = null;
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1772
|
+
let workItems = null;
|
|
1773
|
+
let pullRequests = null;
|
|
1774
|
+
if (name === 'workItems') {
|
|
1775
|
+
workItems = readWorkItems(project);
|
|
1776
|
+
console.log(` Store: SQLite scope ${project.name}`);
|
|
1777
|
+
} else if (name === 'pullRequests') {
|
|
1778
|
+
pullRequests = readPullRequests(project);
|
|
1779
|
+
console.log(` Store: SQLite scope ${project.name}`);
|
|
1780
|
+
} else if (src.path) {
|
|
1781
|
+
filePath = path.resolve(root, src.path);
|
|
1782
|
+
}
|
|
1737
1783
|
const exists = filePath && fs.existsSync(filePath);
|
|
1738
1784
|
if (filePath) {
|
|
1739
1785
|
console.log(` Path: ${filePath} ${exists ? '(found)' : '(NOT FOUND)'}`);
|
|
@@ -1747,15 +1793,13 @@ const commands = {
|
|
|
1747
1793
|
console.log(` Items: ${missing.length} missing features`);
|
|
1748
1794
|
}
|
|
1749
1795
|
}
|
|
1750
|
-
if (
|
|
1751
|
-
const
|
|
1752
|
-
const
|
|
1753
|
-
const needsFix = prs.filter(p => p.status === PR_STATUS.ACTIVE && p.reviewStatus === REVIEW_STATUS.CHANGES_REQUESTED);
|
|
1796
|
+
if (pullRequests) {
|
|
1797
|
+
const pending = pullRequests.filter(p => p.status === PR_STATUS.ACTIVE && (p.reviewStatus === REVIEW_STATUS.PENDING || p.reviewStatus === REVIEW_STATUS.WAITING));
|
|
1798
|
+
const needsFix = pullRequests.filter(p => p.status === PR_STATUS.ACTIVE && p.reviewStatus === REVIEW_STATUS.CHANGES_REQUESTED);
|
|
1754
1799
|
console.log(` PRs: ${pending.length} pending review, ${needsFix.length} need fixes`);
|
|
1755
1800
|
}
|
|
1756
|
-
if (
|
|
1757
|
-
const
|
|
1758
|
-
const queued = items.filter(i => i.status === WI_STATUS.QUEUED);
|
|
1801
|
+
if (workItems) {
|
|
1802
|
+
const queued = workItems.filter(i => i.status === WI_STATUS.QUEUED);
|
|
1759
1803
|
console.log(` Items: ${queued.length} queued`);
|
|
1760
1804
|
}
|
|
1761
1805
|
if (name === 'specs' || name === 'mergedDesignDocs') {
|
|
@@ -1811,19 +1855,17 @@ const commands = {
|
|
|
1811
1855
|
return dispatch;
|
|
1812
1856
|
});
|
|
1813
1857
|
|
|
1814
|
-
// Reset work items outside the dispatch
|
|
1858
|
+
// Reset work items outside the dispatch transaction.
|
|
1815
1859
|
for (const item of killed) {
|
|
1816
1860
|
if (item.meta) {
|
|
1817
1861
|
e.updateWorkItemStatus(item.meta, WI_STATUS.PENDING, '');
|
|
1818
1862
|
const itemId = item.meta.item?.id;
|
|
1819
1863
|
if (itemId) {
|
|
1820
|
-
const
|
|
1821
|
-
?
|
|
1822
|
-
: item.meta.project?.
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
if (wiPath) {
|
|
1826
|
-
mutateWorkItems(wiPath, items => {
|
|
1864
|
+
const wiScope = (item.meta.source === 'central-work-item' || item.meta.source === 'central-work-item-fanout')
|
|
1865
|
+
? 'central'
|
|
1866
|
+
: item.meta.project?.name || null;
|
|
1867
|
+
if (wiScope) {
|
|
1868
|
+
mutateWorkItems(wiScope, items => {
|
|
1827
1869
|
const target = items.find(i => i.id === itemId);
|
|
1828
1870
|
if (target) {
|
|
1829
1871
|
target.status = WI_STATUS.PENDING;
|
|
@@ -1840,7 +1882,7 @@ const commands = {
|
|
|
1840
1882
|
console.log(`Killed dispatch: ${item.id} (${item.agent}) — work item reset to pending`);
|
|
1841
1883
|
}
|
|
1842
1884
|
|
|
1843
|
-
// Agent status
|
|
1885
|
+
// Agent status derives from dispatch state — clearing dispatch.active is sufficient.
|
|
1844
1886
|
console.log('All agents reset to idle (dispatch cleared)');
|
|
1845
1887
|
|
|
1846
1888
|
console.log(`\nDone: ${killed.length} dispatches killed, agents reset.`);
|
|
@@ -2103,14 +2145,14 @@ const commands = {
|
|
|
2103
2145
|
},
|
|
2104
2146
|
|
|
2105
2147
|
// `minions bridge <subcommand>` — Constellation read-only bridge surface.
|
|
2106
|
-
// Owns the on/off flag and the
|
|
2148
|
+
// Owns the on/off flag and the versioned metadata snapshot used by the
|
|
2107
2149
|
// Constellation agent. Bridge polling logic itself lives in the
|
|
2108
2150
|
// Constellation repo (P-wi1-bridge-readonly).
|
|
2109
2151
|
//
|
|
2110
2152
|
// Subcommands:
|
|
2111
2153
|
// status Print enabled flag + last-seen Constellation agent timestamp.
|
|
2112
|
-
// health Probe
|
|
2113
|
-
//
|
|
2154
|
+
// health Probe the versioned local snapshot endpoint and print the same
|
|
2155
|
+
// metadata summary the Constellation bridge consumes.
|
|
2114
2156
|
// enable Atomically set engine.constellationBridge.enabled = true.
|
|
2115
2157
|
// disable Atomically set engine.constellationBridge.enabled = false.
|
|
2116
2158
|
bridge(subcmd, ...rest) {
|
|
@@ -2121,7 +2163,7 @@ const commands = {
|
|
|
2121
2163
|
console.log(BRIDGE_USAGE);
|
|
2122
2164
|
console.log('');
|
|
2123
2165
|
console.log(' status Show enabled flag + last-seen Constellation agent timestamp');
|
|
2124
|
-
console.log(' health Probe
|
|
2166
|
+
console.log(' health Probe the local versioned Constellation snapshot');
|
|
2125
2167
|
console.log(' enable Set engine.constellationBridge.enabled = true');
|
|
2126
2168
|
console.log(' disable Set engine.constellationBridge.enabled = false');
|
|
2127
2169
|
return;
|
|
@@ -2164,7 +2206,7 @@ const commands = {
|
|
|
2164
2206
|
if (subcmd === 'health') {
|
|
2165
2207
|
const http = require('http');
|
|
2166
2208
|
const req = http.get(
|
|
2167
|
-
{ hostname: '127.0.0.1', port: 7331, path: '/api/
|
|
2209
|
+
{ hostname: '127.0.0.1', port: 7331, path: '/api/constellation/v1/snapshot', timeout: 5000 },
|
|
2168
2210
|
(res) => {
|
|
2169
2211
|
let body = '';
|
|
2170
2212
|
res.setEncoding('utf8');
|
|
@@ -2180,13 +2222,13 @@ const commands = {
|
|
|
2180
2222
|
console.error(`error: dashboard response was not JSON: ${e.message}`);
|
|
2181
2223
|
process.exit(1);
|
|
2182
2224
|
}
|
|
2183
|
-
const projection = bridge.
|
|
2225
|
+
const projection = bridge.projectSnapshotHealthForBridge(parsed);
|
|
2184
2226
|
if (!projection) {
|
|
2185
|
-
console.error('error: dashboard
|
|
2227
|
+
console.error('error: dashboard returned an incompatible Constellation snapshot');
|
|
2186
2228
|
process.exit(1);
|
|
2187
2229
|
}
|
|
2188
2230
|
console.log('bridge: dashboard reachable on http://127.0.0.1:7331');
|
|
2189
|
-
console.log('
|
|
2231
|
+
console.log(' snapshot (same metadata the Constellation bridge mirrors):');
|
|
2190
2232
|
for (const [k, v] of Object.entries(projection)) {
|
|
2191
2233
|
console.log(` ${k}: ${v === null ? '(unknown)' : v}`);
|
|
2192
2234
|
}
|
package/engine/cooldown.js
CHANGED
|
@@ -3,16 +3,15 @@
|
|
|
3
3
|
* Extracted from engine.js.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
const path = require('path');
|
|
7
6
|
const shared = require('./shared');
|
|
8
7
|
const queries = require('./queries');
|
|
8
|
+
const smallStateStore = require('./small-state-store');
|
|
9
9
|
|
|
10
|
-
const {
|
|
11
|
-
const { ENGINE_DIR } = queries;
|
|
10
|
+
const { mutateCooldowns, log, ENGINE_DEFAULTS } = shared;
|
|
12
11
|
|
|
13
12
|
/**
|
|
14
13
|
* Truncate any string fields on a pendingContexts entry so a single huge PR
|
|
15
|
-
* comment / build log cannot bloat
|
|
14
|
+
* comment / build log cannot bloat cooldown state (#1167).
|
|
16
15
|
* Returns a new entry object (does not mutate the caller's copy).
|
|
17
16
|
*/
|
|
18
17
|
function _truncateContextEntry(entry, maxBytes) {
|
|
@@ -35,16 +34,11 @@ function _truncateContextEntry(entry, maxBytes) {
|
|
|
35
34
|
return out;
|
|
36
35
|
}
|
|
37
36
|
|
|
38
|
-
const COOLDOWN_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
|
|
39
37
|
const dispatchCooldowns = new Map(); // key → { timestamp, failures }
|
|
40
38
|
let _lastDiskCooldownKeys = new Set();
|
|
41
39
|
|
|
42
40
|
function loadCooldowns() {
|
|
43
|
-
|
|
44
|
-
// Restoring a stale `cooldowns.json.backup` could resurrect expired entries
|
|
45
|
-
// that should already have been pruned, suppressing legitimate dispatches
|
|
46
|
-
// (P-bfa1d-safejson-no-restore). Missing/corrupt primary == "no cooldowns".
|
|
47
|
-
const saved = safeJsonNoRestore(COOLDOWN_PATH);
|
|
41
|
+
const saved = smallStateStore.readCooldowns();
|
|
48
42
|
if (!saved) return;
|
|
49
43
|
const now = Date.now();
|
|
50
44
|
for (const [k, v] of Object.entries(saved)) {
|
|
@@ -56,12 +50,12 @@ function loadCooldowns() {
|
|
|
56
50
|
}
|
|
57
51
|
}
|
|
58
52
|
_lastDiskCooldownKeys = new Set(dispatchCooldowns.keys());
|
|
59
|
-
log('info', `Loaded ${dispatchCooldowns.size} cooldowns from
|
|
53
|
+
log('info', `Loaded ${dispatchCooldowns.size} cooldowns from SQL`);
|
|
60
54
|
}
|
|
61
55
|
|
|
62
56
|
let _cooldownWriteTimer = null;
|
|
63
57
|
/**
|
|
64
|
-
* Persist the in-memory `dispatchCooldowns` Map to
|
|
58
|
+
* Persist the in-memory `dispatchCooldowns` Map to SQL.
|
|
65
59
|
*
|
|
66
60
|
* Merges the in-memory map ON TOP of the lock-acquired on-disk snapshot
|
|
67
61
|
* rather than replacing it (P-bfa3a → docs/cooldown-merge-semantics.md):
|
|
@@ -148,7 +142,7 @@ function saveCooldowns() {
|
|
|
148
142
|
return merged;
|
|
149
143
|
});
|
|
150
144
|
} catch (err) {
|
|
151
|
-
log('warn', `saveCooldowns failed
|
|
145
|
+
log('warn', `saveCooldowns failed: ${err.message}`);
|
|
152
146
|
}
|
|
153
147
|
}, 1000); // debounce — write at most once per second
|
|
154
148
|
}
|
|
@@ -214,10 +208,10 @@ function setCooldownWithContext(key, context) {
|
|
|
214
208
|
if (context) {
|
|
215
209
|
// Truncate oversized string fields per-entry BEFORE storing so a single
|
|
216
210
|
// huge payload (PR diff, build log, full repo dump) cannot bloat
|
|
217
|
-
//
|
|
211
|
+
// cooldown state regardless of array cap (#1167).
|
|
218
212
|
pendingContexts.push(_truncateContextEntry(context, ENGINE_DEFAULTS.maxPendingContextEntryBytes));
|
|
219
213
|
}
|
|
220
|
-
// Cap to
|
|
214
|
+
// Cap to the most recent N entries.
|
|
221
215
|
const cap = ENGINE_DEFAULTS.maxPendingContexts;
|
|
222
216
|
if (pendingContexts.length > cap) pendingContexts = pendingContexts.slice(-cap);
|
|
223
217
|
dispatchCooldowns.set(key, {
|
|
@@ -290,7 +284,6 @@ function isBranchActive(branch) {
|
|
|
290
284
|
}
|
|
291
285
|
|
|
292
286
|
module.exports = {
|
|
293
|
-
COOLDOWN_PATH,
|
|
294
287
|
dispatchCooldowns,
|
|
295
288
|
loadCooldowns,
|
|
296
289
|
saveCooldowns,
|
package/engine/db/index.js
CHANGED
|
@@ -23,25 +23,26 @@ let _dbInitError = null;
|
|
|
23
23
|
let _exitCheckpointRegistered = false;
|
|
24
24
|
let _cleanedDbPath = null;
|
|
25
25
|
|
|
26
|
-
function
|
|
27
|
-
if (_cleanedDbPath === dbPath) return;
|
|
28
|
-
const marker = db.prepare("SELECT value FROM state_markers WHERE key='sql_only_cutover'").get();
|
|
29
|
-
if (!marker) return;
|
|
30
|
-
const minionsDir = path.dirname(path.dirname(dbPath));
|
|
26
|
+
function _retiredJsonTargets(minionsDir) {
|
|
31
27
|
const engineDir = path.join(minionsDir, 'engine');
|
|
32
28
|
const engineFiles = [
|
|
33
|
-
'dispatch.json', 'log.json', 'metrics.json', 'watches.json',
|
|
29
|
+
'dispatch.json', 'log.json', 'metrics.json', 'watches.json', 'state.json',
|
|
34
30
|
'schedule-runs.json', 'pipeline-runs.json', 'managed-processes.json',
|
|
35
31
|
'worktree-pool.json', 'qa-runs.json', 'qa-sessions.json', 'pr-links.json',
|
|
36
|
-
'cooldowns.json', 'pending-rebases.json', 'cc-sessions.json', 'doc-sessions.json',
|
|
32
|
+
'cooldowns.json', 'pending-rebases.json', 'cc-session.json', 'cc-sessions.json', 'doc-sessions.json',
|
|
37
33
|
];
|
|
38
34
|
const targets = engineFiles.map(name => path.join(engineDir, name));
|
|
39
|
-
targets.push(
|
|
35
|
+
targets.push(
|
|
36
|
+
path.join(minionsDir, 'work-items.json'),
|
|
37
|
+
path.join(minionsDir, 'work-items-archive.json'),
|
|
38
|
+
path.join(minionsDir, 'pull-requests.json'),
|
|
39
|
+
);
|
|
40
40
|
try {
|
|
41
41
|
for (const project of fs.readdirSync(path.join(minionsDir, 'projects'), { withFileTypes: true })) {
|
|
42
42
|
if (!project.isDirectory() || project.name === '.archived') continue;
|
|
43
43
|
targets.push(
|
|
44
44
|
path.join(minionsDir, 'projects', project.name, 'work-items.json'),
|
|
45
|
+
path.join(minionsDir, 'projects', project.name, 'work-items-archive.json'),
|
|
45
46
|
path.join(minionsDir, 'projects', project.name, 'pull-requests.json'),
|
|
46
47
|
);
|
|
47
48
|
}
|
|
@@ -53,11 +54,23 @@ function _cleanupRetiredJsonFiles(db, dbPath) {
|
|
|
53
54
|
}
|
|
54
55
|
} catch { /* PRD directory is optional */ }
|
|
55
56
|
}
|
|
57
|
+
return targets;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function cleanupRetiredJsonFiles(minionsDir) {
|
|
61
|
+
const targets = _retiredJsonTargets(minionsDir);
|
|
56
62
|
for (const target of targets) {
|
|
57
63
|
for (const suffix of ['', '.backup', '.lock']) {
|
|
58
64
|
try { fs.unlinkSync(target + suffix); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
59
65
|
}
|
|
60
66
|
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function _cleanupRetiredJsonFiles(db, dbPath) {
|
|
70
|
+
if (_cleanedDbPath === dbPath) return;
|
|
71
|
+
const marker = db.prepare("SELECT value FROM state_markers WHERE key='sql_only_cutover'").get();
|
|
72
|
+
if (!marker) return;
|
|
73
|
+
cleanupRetiredJsonFiles(path.dirname(path.dirname(dbPath)));
|
|
61
74
|
_cleanedDbPath = dbPath;
|
|
62
75
|
}
|
|
63
76
|
|
|
@@ -250,4 +263,4 @@ function prepareCached(db, sql) {
|
|
|
250
263
|
return stmt;
|
|
251
264
|
}
|
|
252
265
|
|
|
253
|
-
module.exports = { getDb, closeDb, withTransaction, prepareCached };
|
|
266
|
+
module.exports = { getDb, closeDb, withTransaction, prepareCached, cleanupRetiredJsonFiles };
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Mirrors the dual-write / content-hash divergence pattern established by
|
|
9
9
|
// the watches and Phase 7 small-state stores. JSON sidecars stay in place as
|
|
10
|
-
//
|
|
10
|
+
// At release time the imported files remained for a temporary compatibility window.
|
|
11
11
|
|
|
12
12
|
const path = require('path');
|
|
13
13
|
const fs = require('fs');
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
version: 20,
|
|
3
|
+
description: 'replace QA session work-item paths with SQL scopes',
|
|
4
|
+
up(db) {
|
|
5
|
+
const rows = db.prepare('SELECT id, data FROM qa_sessions').all();
|
|
6
|
+
const update = db.prepare('UPDATE qa_sessions SET data=?, updated_at=? WHERE id=?');
|
|
7
|
+
const now = Date.now();
|
|
8
|
+
for (const row of rows) {
|
|
9
|
+
let session;
|
|
10
|
+
try { session = JSON.parse(row.data); } catch { continue; }
|
|
11
|
+
if (!session || typeof session !== 'object' || Array.isArray(session)) continue;
|
|
12
|
+
const legacyPath = session.primaryWiPath;
|
|
13
|
+
if (typeof legacyPath === 'string' && legacyPath && !session.primaryScope) {
|
|
14
|
+
const normalized = legacyPath.replace(/\\/g, '/');
|
|
15
|
+
const match = normalized.match(/(?:^|\/)projects\/([^/]+)\/work-items\.json$/);
|
|
16
|
+
session.primaryScope = match ? match[1] : 'central';
|
|
17
|
+
}
|
|
18
|
+
if (!Object.prototype.hasOwnProperty.call(session, 'primaryWiPath')) continue;
|
|
19
|
+
delete session.primaryWiPath;
|
|
20
|
+
update.run(JSON.stringify(session), now, row.id);
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
};
|