@yemi33/minions 0.1.2379 → 0.1.2381

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.
Files changed (100) hide show
  1. package/bin/minions.js +19 -9
  2. package/dashboard/js/refresh.js +3 -4
  3. package/dashboard/js/render-other.js +43 -23
  4. package/dashboard/js/render-prd.js +1 -1
  5. package/dashboard/js/render-work-items.js +13 -6
  6. package/dashboard/js/settings.js +40 -17
  7. package/dashboard.js +435 -768
  8. package/docs/architecture-review-2026-07-09.md +2 -4
  9. package/docs/auto-discovery.md +36 -49
  10. package/docs/blog-first-successful-dispatch.md +1 -1
  11. package/docs/branch-derivation.md +2 -2
  12. package/docs/command-center.md +1 -1
  13. package/docs/completion-reports.md +14 -4
  14. package/docs/constellation-bridge.md +59 -10
  15. package/docs/constellation-style-telemetry.md +6 -6
  16. package/docs/cooldown-merge-semantics.md +4 -0
  17. package/docs/copilot-cli-schema.md +3 -3
  18. package/docs/cross-repo-plans.md +17 -17
  19. package/docs/deprecated.json +2 -2
  20. package/docs/design-state-storage.md +1 -1
  21. package/docs/documentation-audit-2026-07-09.md +2 -2
  22. package/docs/engine-restart.md +20 -8
  23. package/docs/harness-mode.md +1 -1
  24. package/docs/live-checkout-mode.md +45 -26
  25. package/docs/managed-spawn.md +4 -4
  26. package/docs/onboarding.md +1 -2
  27. package/docs/pr-comment-followup.md +3 -3
  28. package/docs/pr-review-fix-loop.md +1 -1
  29. package/docs/proposals/repo-pool-for-live-checkout.md +1 -2
  30. package/docs/qa-runbook-lifecycle.md +4 -4
  31. package/docs/qa-runbooks.md +2 -2
  32. package/docs/rfc-completion-json.md +4 -1
  33. package/docs/runtime-adapters.md +1 -1
  34. package/docs/self-improvement.md +4 -5
  35. package/docs/shared-lifecycle-module-map.md +3 -1
  36. package/docs/slim-ux/architecture-suggestions.md +5 -6
  37. package/docs/slim-ux/concepts.md +23 -25
  38. package/docs/watches.md +7 -7
  39. package/docs/workspace-manifests.md +1 -1
  40. package/docs/worktree-lifecycle.md +1 -1
  41. package/engine/abandoned-pr-reconciliation.js +4 -5
  42. package/engine/ado-status.js +5 -5
  43. package/engine/ado.js +20 -25
  44. package/engine/agent-worker-pool.js +58 -1
  45. package/engine/bridge.js +260 -5
  46. package/engine/cleanup.js +48 -131
  47. package/engine/cli.js +125 -83
  48. package/engine/cooldown.js +9 -16
  49. package/engine/db/index.js +22 -9
  50. package/engine/db/migrations/009-qa.js +1 -1
  51. package/engine/db/migrations/020-qa-session-scopes.js +23 -0
  52. package/engine/db/migrations/021-archived-work-items.js +72 -0
  53. package/engine/db/migrations/022-global-cc-session.js +31 -0
  54. package/engine/db/migrations/023-engine-state.js +30 -0
  55. package/engine/db/migrations/024-prd-ghost-collisions.js +53 -0
  56. package/engine/db/migrations/025-malformed-work-item-phantoms.js +33 -0
  57. package/engine/dispatch-store.js +2 -7
  58. package/engine/dispatch.js +39 -44
  59. package/engine/github.js +20 -27
  60. package/engine/lifecycle.js +279 -354
  61. package/engine/live-checkout.js +193 -149
  62. package/engine/llm.js +1 -1
  63. package/engine/logs-store.js +2 -2
  64. package/engine/managed-spawn.js +2 -23
  65. package/engine/meeting.js +6 -6
  66. package/engine/metrics-store.js +2 -2
  67. package/engine/note-link-backfill.js +6 -11
  68. package/engine/pipeline.js +18 -36
  69. package/engine/playbook.js +13 -16
  70. package/engine/prd-store.js +73 -54
  71. package/engine/preflight.js +2 -5
  72. package/engine/projects.js +15 -62
  73. package/engine/pull-requests-store.js +0 -17
  74. package/engine/qa-runbooks.js +2 -2
  75. package/engine/qa-runs.js +1 -8
  76. package/engine/qa-sessions.js +41 -64
  77. package/engine/queries.js +120 -219
  78. package/engine/routing.js +4 -6
  79. package/engine/scheduler.js +0 -4
  80. package/engine/shared-branch-pr-reconcile.js +2 -3
  81. package/engine/shared.js +268 -699
  82. package/engine/small-state-store.js +89 -10
  83. package/engine/state-operations.js +16 -4
  84. package/engine/stdio-timestamps.js +1 -1
  85. package/engine/timeout.js +5 -12
  86. package/engine/watch-actions.js +20 -22
  87. package/engine/watches-store.js +1 -1
  88. package/engine/watches.js +6 -10
  89. package/engine/work-item-validation.js +52 -0
  90. package/engine/work-items-store.js +127 -29
  91. package/engine/worktree-gc.js +2 -2
  92. package/engine/worktree-pool.js +8 -18
  93. package/engine.js +197 -358
  94. package/minions.js +2 -2
  95. package/package.json +1 -1
  96. package/playbooks/plan-to-prd.md +2 -2
  97. package/playbooks/shared-rules.md +3 -3
  98. package/playbooks/templates/followup-dispatch.md +1 -1
  99. package/playbooks/verify.md +1 -1
  100. package/prompts/cc-system.md +9 -9
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, DISPATCH_PATH } = queries;
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-items.json queue' },
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 SQL + JSON mirror stay consistent — a no-op
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.ensureProjectStateFiles(p);
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
- const wiPath = path.join(MINIONS_DIR, "projects", item.meta.project.name, "work-items.json");
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 pull-requests.json for a matching PR by work item ID
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 prPath = path.join(MINIONS_DIR, 'projects', projName, 'pull-requests.json');
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
- const wiPath = path.join(MINIONS_DIR, 'projects', projName, 'work-items.json');
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 allWiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
859
- for (const p of projects) {
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(wiPath, items => {
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
- // via engine/state.json so it runs once per ENGINE_DEFAULTS.abandonedReconciliationVersion
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
- // engine/managed-processes.json, kill TTL-expired specs, re-probe
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 wait for active agents before exiting
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
- if (e.activeProcesses.size === 0) {
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('info', 'Graceful shutdown complete (no active agents)');
1187
+ e.log(exitCode === 0 ? 'info' : 'warn', message);
1166
1188
  drainBuffers();
1167
- console.log('No active agents — stopped.');
1168
- process.exit(0);
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 ${e.activeProcesses.size} active agent(s) to finish...`);
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
- if (e.activeProcesses.size === 0) {
1204
+ pooledIds = getOutstandingPooledIds();
1205
+ if (pooledIds.length === 0 && agentWorkerPool.isDrained()) {
1177
1206
  clearInterval(poll);
1178
- const stoppedWrite = markControlStoppedForOwner(controlOwner, e.ts());
1179
- if (!stoppedWrite.changed) {
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 stoppedWrite = markControlStoppedForOwner(controlOwner, e.ts());
1190
- if (!stoppedWrite.changed) {
1191
- e.log('warn', 'Graceful shutdown skipped control.json stopped transition; control file is owned by a different engine process');
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
- e.log('warn', `Graceful shutdown timed out after ${timeout / 1000}s with ${e.activeProcesses.size} agent(s) still active`);
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 These agents will continue running but the engine won\'t monitor them.');
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
- // killGracefully so the engine's SIGTERM graceful-shutdown handler runs
1248
- // and recurses into the child tree (bare process.kill defaults to a
1249
- // non-recursing SIGTERM that is Windows-broken CLAUDE.md Footgun #4).
1250
- shared.killGracefully({ pid: control.pid });
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, projectWorkItemsPath, resolveProjectSource, uid, safeJsonArr } = require('./shared');
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 wiPath = projectWorkItemsPath(targetProject);
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 = safeJsonArr(wiPath);
1556
- const archivedItems = safeJsonArr(archivePath);
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 ${path.basename(wiPath)} or its archive`);
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(wiPath, items => {
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
- if (name === 'workItems') filePath = shared.projectWorkItemsPath(project);
1735
- else if (name === 'pullRequests') filePath = shared.projectPrPath(project);
1736
- else if (src.path) filePath = path.resolve(root, src.path);
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 (exists && name === 'pullRequests') {
1751
- const prs = safeJsonArr(filePath);
1752
- const pending = prs.filter(p => p.status === PR_STATUS.ACTIVE && (p.reviewStatus === REVIEW_STATUS.PENDING || p.reviewStatus === REVIEW_STATUS.WAITING));
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 (exists && name === 'workItems') {
1757
- const items = safeJsonArr(filePath);
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 lock (work-items.json has its own lock)
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 wiPath = (item.meta.source === 'central-work-item' || item.meta.source === 'central-work-item-fanout')
1821
- ? path.join(MINIONS_DIR, 'work-items.json')
1822
- : item.meta.project?.localPath
1823
- ? shared.projectWorkItemsPath(shared.resolveProjectSource(item.meta.project, config.projects || [], { allowCentral: false }).project || item.meta.project)
1824
- : null;
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 derived from dispatch.json — clearing dispatch.active is sufficient.
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 marker-file projection used by 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 http://127.0.0.1:7331/api/status and print the same
2113
- // curated projection the Constellation bridge would consume.
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 http://127.0.0.1:7331/api/status and print bridge projection');
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/status', timeout: 5000 },
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.projectStatusForBridge(parsed);
2225
+ const projection = bridge.projectSnapshotHealthForBridge(parsed);
2184
2226
  if (!projection) {
2185
- console.error('error: dashboard /api/status returned an unexpected shape');
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(' projection (same fields the Constellation bridge would read):');
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
  }
@@ -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 { safeJson, safeJsonNoRestore, mutateCooldowns, log, ENGINE_DEFAULTS } = shared;
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 cooldowns.json to hundreds of MB (#1167).
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
- // safeJsonNoRestore cooldowns are time-bounded ephemeral state (24h TTL).
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 disk`);
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 disk.
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 writing ${COOLDOWN_PATH}: ${err.message}`);
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
- // cooldowns.json regardless of array cap (#1167).
211
+ // cooldown state regardless of array cap (#1167).
218
212
  pendingContexts.push(_truncateContextEntry(context, ENGINE_DEFAULTS.maxPendingContextEntryBytes));
219
213
  }
220
- // Cap to last N entries to prevent unbounded growth (cooldowns.json bloat)
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,
@@ -23,25 +23,26 @@ let _dbInitError = null;
23
23
  let _exitCheckpointRegistered = false;
24
24
  let _cleanedDbPath = null;
25
25
 
26
- function _cleanupRetiredJsonFiles(db, dbPath) {
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(path.join(minionsDir, 'work-items.json'), path.join(minionsDir, 'pull-requests.json'));
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
- // dual-write mirrors for one release cycle (gated by `engine.qaDualWriteJson`).
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
+ };