@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.
Files changed (100) hide show
  1. package/bin/minions.js +19 -9
  2. package/dashboard/js/refresh.js +2 -3
  3. package/dashboard/js/render-prd.js +1 -1
  4. package/dashboard/js/render-work-items.js +13 -6
  5. package/dashboard.js +393 -721
  6. package/docs/README.md +1 -0
  7. package/docs/architecture-review-2026-07-09.md +2 -4
  8. package/docs/auto-discovery.md +36 -49
  9. package/docs/blog-first-successful-dispatch.md +1 -1
  10. package/docs/branch-derivation.md +2 -2
  11. package/docs/command-center.md +1 -1
  12. package/docs/completion-reports.md +14 -4
  13. package/docs/constellation-bridge.md +59 -10
  14. package/docs/constellation-style-telemetry.md +6 -6
  15. package/docs/cooldown-merge-semantics.md +4 -0
  16. package/docs/copilot-cli-schema.md +3 -3
  17. package/docs/cross-repo-plans.md +17 -17
  18. package/docs/deprecated.json +2 -2
  19. package/docs/design-inbox-entries-schema.md +31 -39
  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/managed-spawn.md +4 -4
  25. package/docs/onboarding.md +1 -2
  26. package/docs/pr-comment-followup.md +3 -3
  27. package/docs/pr-review-fix-loop.md +1 -1
  28. package/docs/proposals/repo-pool-for-live-checkout.md +1 -2
  29. package/docs/qa-runbook-lifecycle.md +4 -4
  30. package/docs/qa-runbooks.md +2 -2
  31. package/docs/rfc-completion-json.md +4 -1
  32. package/docs/runtime-adapters.md +1 -1
  33. package/docs/self-improvement.md +4 -5
  34. package/docs/shared-lifecycle-module-map.md +3 -1
  35. package/docs/slim-ux/architecture-suggestions.md +5 -6
  36. package/docs/slim-ux/concepts.md +23 -25
  37. package/docs/watches.md +7 -7
  38. package/docs/workspace-manifests.md +1 -1
  39. package/docs/worktree-lifecycle.md +1 -1
  40. package/engine/abandoned-pr-reconciliation.js +4 -5
  41. package/engine/acp-transport.js +49 -8
  42. package/engine/ado-status.js +5 -5
  43. package/engine/ado.js +20 -25
  44. package/engine/agent-worker-pool.js +124 -15
  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 +34 -41
  59. package/engine/github.js +20 -27
  60. package/engine/lifecycle.js +221 -283
  61. package/engine/llm.js +1 -1
  62. package/engine/logs-store.js +2 -2
  63. package/engine/managed-spawn.js +2 -23
  64. package/engine/meeting.js +6 -6
  65. package/engine/metrics-store.js +2 -2
  66. package/engine/note-link-backfill.js +6 -11
  67. package/engine/pipeline.js +18 -36
  68. package/engine/playbook.js +1 -1
  69. package/engine/pooled-agent-process.js +46 -26
  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 +3 -5
  79. package/engine/scheduler.js +0 -4
  80. package/engine/shared-branch-pr-reconcile.js +2 -3
  81. package/engine/shared.js +132 -637
  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 +167 -349
  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 +2 -2
package/engine/cleanup.js CHANGED
@@ -7,16 +7,16 @@ const fs = require('fs');
7
7
  const path = require('path');
8
8
  const shared = require('./shared');
9
9
  const queries = require('./queries');
10
+ const smallStateStore = require('./small-state-store');
10
11
 
11
12
  const { exec, execAsync, execSilent, log, ts, ENGINE_DEFAULTS } = shared;
12
- const { safeJson, safeJsonArr, safeJsonNoRestore, safeWrite, safeReadDir, mutateCooldowns, mutateWorkItems, mutateJsonFileLocked, getProjects, projectWorkItemsPath, projectPrPath,
13
+ const { safeJson, safeJsonArr, safeJsonNoRestore, safeWrite, safeReadDir, readWorkItems, readPullRequests, mutateCooldowns, mutateMetrics, mutateWorkItems, getProjects,
13
14
  sanitizeBranch, KB_CATEGORIES } = shared;
14
15
  const { getDispatch, getAgentStatus } = queries;
15
16
 
16
17
  const MINIONS_DIR = shared.MINIONS_DIR;
17
18
  const AGENTS_DIR = queries.AGENTS_DIR;
18
19
  const ENGINE_DIR = queries.ENGINE_DIR;
19
- const PRD_DIR = queries.PRD_DIR;
20
20
  const PLANS_DIR = queries.PLANS_DIR;
21
21
 
22
22
  // Lazy require to break circular dependency with engine.js
@@ -98,7 +98,7 @@ function localBranchWorktreeInUse(root, branch) {
98
98
  function collectPhantomBranchesForProject(project) {
99
99
  const branches = new Set();
100
100
  try {
101
- const items = safeJsonArr(projectWorkItemsPath(project));
101
+ const items = readWorkItems(project);
102
102
  if (!Array.isArray(items)) return branches;
103
103
  for (const w of items) {
104
104
  if (w && w._phantomCompletion === true && w._phantomBranch) {
@@ -187,7 +187,7 @@ function cleanupMergedPrLocalBranch(root, project, pr) {
187
187
  * env var is unset, or that fail before cleanup, leave `.json` + `.json.backup`
188
188
  * sidecars behind). The engine's tick loop then re-discovers these fixtures
189
189
  * every tick, fails playbook validation ("missing required template variables:
190
- * agenda"), and spams log.json with the same error.
190
+ * agenda"), and spams the engine log with the same error.
191
191
  *
192
192
  * Filter is conservative: only files whose ID begins with `TEST-` AND whose
193
193
  * JSON either lacks an `agenda` or has an empty/whitespace-only one. Both the
@@ -206,41 +206,14 @@ function cleanupMergedPrLocalBranch(root, project, pr) {
206
206
  // `.json` — and only when SQLite is healthy so backups remain the legacy
207
207
  // fallback for users on Node < 22.5.
208
208
  //
209
- // SQL_BACKED maps every engine-dir state file with a known SQL backing
210
- // table to that table; safe-by-age files have no SQL backing but their
211
- // .backup sidecars are equally useless once SQLite is the authority.
212
-
213
- const ENGINE_SQL_BACKED_BACKUPS = [
214
- { file: 'dispatch.json', table: 'dispatches' },
215
- { file: 'metrics.json', table: 'metrics' },
216
- { file: 'watches.json', table: 'watches' },
217
- { file: 'schedule-runs.json', table: 'schedule_runs' },
218
- { file: 'pipeline-runs.json', table: 'pipeline_runs' },
219
- { file: 'managed-processes.json', table: 'managed_processes' },
220
- { file: 'log.json', table: 'logs' },
221
- ];
222
-
223
- const ENGINE_SAFE_BY_AGE_BACKUPS = [
224
- 'cc-sessions.json',
209
+ const ENGINE_FILE_BACKED_BACKUPS = [
225
210
  'control.json',
226
- 'cooldowns.json',
227
211
  'dashboard-browser.json',
228
- 'doc-sessions.json',
229
212
  'kb-pins.json',
230
- 'pending-rebases.json',
231
- 'pr-links.json',
232
- 'qa-sessions.json',
233
213
  ];
234
214
 
235
215
  const MEETINGS_BACKUP_FRESH_WINDOW_MS = 24 * 60 * 60 * 1000;
236
216
 
237
- function _tableHasRow(db, table) {
238
- try {
239
- const row = db.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get();
240
- return !!row;
241
- } catch { return false; }
242
- }
243
-
244
217
  function _tryPruneBackup(liveFile, pruned) {
245
218
  const backupPath = liveFile + '.backup';
246
219
  if (!fs.existsSync(backupPath)) return;
@@ -253,32 +226,7 @@ function _tryPruneBackup(liveFile, pruned) {
253
226
 
254
227
  function pruneStaleBackupSidecars(config) {
255
228
  const pruned = { count: 0 };
256
- let db;
257
- try { db = require('./db').getDb(); }
258
- catch { return pruned; } // SQLite unavailable — keep backups as the legacy fallback
259
-
260
- // (1a) SQL-backed engine state files — require the table to be populated.
261
- for (const { file, table } of ENGINE_SQL_BACKED_BACKUPS) {
262
- if (!_tableHasRow(db, table)) continue;
263
- _tryPruneBackup(path.join(ENGINE_DIR, file), pruned);
264
- }
265
-
266
- // (1b) Per-project work-items.json + pull-requests.json — both share
267
- // global SQL tables, so populate-check those tables once.
268
- const wiHasRow = _tableHasRow(db, 'work_items');
269
- const prHasRow = _tableHasRow(db, 'pull_requests');
270
- if (wiHasRow || prHasRow) {
271
- for (const project of getProjects(config)) {
272
- try {
273
- if (wiHasRow) _tryPruneBackup(projectWorkItemsPath(project), pruned);
274
- if (prHasRow) _tryPruneBackup(projectPrPath(project), pruned);
275
- } catch { /* malformed project entry — skip */ }
276
- }
277
- }
278
-
279
- // (1c) Safe-by-age set — no SQL table backing, but the .backup sidecars
280
- // are equally useless once SQLite is healthy.
281
- for (const file of ENGINE_SAFE_BY_AGE_BACKUPS) {
229
+ for (const file of ENGINE_FILE_BACKED_BACKUPS) {
282
230
  _tryPruneBackup(path.join(ENGINE_DIR, file), pruned);
283
231
  }
284
232
 
@@ -528,7 +476,7 @@ async function runCleanup(config, verbose = false) {
528
476
  if (e?.tmpDir) activeDispatchTmpDirs.add(path.resolve(e.tmpDir));
529
477
  }
530
478
  }
531
- } catch { /* dispatch.json may be empty */ }
479
+ } catch { /* dispatch state may be empty */ }
532
480
  for (const dir of scanDirs) {
533
481
  // Each directory gets its own try-catch so one failure doesn't abort other directories (Bug #27)
534
482
  let dirEntries;
@@ -600,11 +548,11 @@ async function runCleanup(config, verbose = false) {
600
548
  }
601
549
 
602
550
  // 2c. Reap orphan agents/temp-* dirs whose dispatch is no longer referenced
603
- // anywhere in dispatch.json. Temp-agent dirs are created by the engine for
551
+ // anywhere in dispatch state. Temp-agent dirs are created by the engine for
604
552
  // ephemeral temp-<uid> agents; once the dispatch ages out of dispatch
605
553
  // history they're never touched again, accumulating MB of live-output.log
606
554
  // tails over weeks. 1h mtime gate prevents reaping a still-spawning temp
607
- // agent that races dispatch.json visibility.
555
+ // agent that races dispatch-state visibility.
608
556
  cleaned.orphanTempAgentDirs = 0;
609
557
  try {
610
558
  const dispatch = getDispatch();
@@ -766,6 +714,12 @@ async function runCleanup(config, verbose = false) {
766
714
  // 3. Clean git worktrees for merged/abandoned PRs
767
715
  const _attemptedWorktreePaths = new Set(); // dedup across projects sharing a worktreeRoot
768
716
  const _reapedScratchRoots = new Set(); // reap each worktree root's .agent-temp once per cleanup
717
+ let activeSharedBranchPlans = [];
718
+ try {
719
+ activeSharedBranchPlans = require('./prd-store').listPrdRows()
720
+ .map(row => row.plan)
721
+ .filter(plan => plan?.branch_strategy === 'shared-branch' && plan.feature_branch && plan.status !== 'completed');
722
+ } catch { /* optional */ }
769
723
  cleaned.agentScratch = 0;
770
724
  for (const project of projects) {
771
725
  const root = project.localPath ? path.resolve(project.localPath) : null;
@@ -788,12 +742,7 @@ async function runCleanup(config, verbose = false) {
788
742
  try { cleaned.agentScratch += reapAgentScratch(worktreeRoot); } catch (_e) { /* best-effort */ }
789
743
  }
790
744
 
791
- // Get PRs for this project
792
- // Array.isArray guard: safeJson(p) || [] only substitutes [] for falsy values;
793
- // a truthy non-array (e.g. {}, 42, true) bypasses the fallback and causes a
794
- // TypeError in the for-of loop below. Array.isArray covers that case.
795
- const _prsRaw = safeJson(projectPrPath(project));
796
- const prs = Array.isArray(_prsRaw) ? _prsRaw : [];
745
+ const prs = readPullRequests(project);
797
746
  const mergedBranches = new Set();
798
747
  for (const pr of prs) {
799
748
  if (pr.status === shared.PR_STATUS.MERGED || pr.status === shared.PR_STATUS.ABANDONED) {
@@ -856,7 +805,7 @@ async function runCleanup(config, verbose = false) {
856
805
  // `cwd` inside the dispatch's worktree; the agent is gone so no
857
806
  // active dispatch references the branch and the >2h age sweep would
858
807
  // reap the worktree from under the live services. Build a list of
859
- // normalized cwds from engine/managed-processes.json and protect any
808
+ // normalized cwd values from managed-process state and protect any
860
809
  // worktree dir that contains one. Cwd is optional per the schema, so
861
810
  // entries without cwd contribute nothing.
862
811
  const _managedSpawnCwds = [];
@@ -970,27 +919,18 @@ async function runCleanup(config, verbose = false) {
970
919
  } catch { /* optional */ }
971
920
  }
972
921
 
973
- // Skip worktrees for active shared-branch plans (check both prd/ and plans/ for .json PRDs)
922
+ // Skip worktrees for active shared-branch plans.
974
923
  if (shouldClean || !isProtected) {
975
- try {
976
- for (const checkDir of [PRD_DIR, path.join(MINIONS_DIR, 'plans')]) {
977
- if (!fs.existsSync(checkDir)) continue;
978
- for (const pf of fs.readdirSync(checkDir).filter(f => f.endsWith('.json'))) {
979
- const plan = safeJson(path.join(checkDir, pf));
980
- if (plan?.branch_strategy === 'shared-branch' && plan?.feature_branch && plan?.status !== 'completed') {
981
- if (worktreeMatchesBranch(dirLower, plan.feature_branch, actualBranch)) {
982
- isProtected = true;
983
- if (shouldClean) {
984
- shouldClean = false;
985
- if (verbose) console.log(` Skipping worktree ${dir}: active shared-branch plan`);
986
- }
987
- break;
988
- }
989
- }
924
+ for (const plan of activeSharedBranchPlans) {
925
+ if (worktreeMatchesBranch(dirLower, plan.feature_branch, actualBranch)) {
926
+ isProtected = true;
927
+ if (shouldClean) {
928
+ shouldClean = false;
929
+ if (verbose) console.log(` Skipping worktree ${dir}: active shared-branch plan`);
990
930
  }
991
- if (isProtected) break;
931
+ break;
992
932
  }
993
- } catch (e) { log('warn', 'check shared-branch protection: ' + e.message); }
933
+ }
994
934
  }
995
935
 
996
936
  wtEntries.push({ dir, wtPath, mtime, shouldClean, isProtected, actualBranch, matchedMergedBranch });
@@ -1011,10 +951,7 @@ async function runCleanup(config, verbose = false) {
1011
951
  // Remove all marked worktrees
1012
952
  // Re-read PR status immediately before deletion — a PR can be reopened between
1013
953
  // the initial status check and the actual deletion (Bug #15: TOCTOU race)
1014
- // Array.isArray guard: same as the prs read above — non-array truthy JSON
1015
- // bypasses || [] and would throw inside the try-catch, silently skipping deletions.
1016
- const _freshPrsRaw = safeJson(projectPrPath(project));
1017
- const freshPrs = Array.isArray(_freshPrsRaw) ? _freshPrsRaw : [];
954
+ const freshPrs = readPullRequests(project);
1018
955
  const freshMergedBranches = new Set();
1019
956
  const freshMergedPrByBranch = new Map();
1020
957
  for (const pr of freshPrs) {
@@ -1280,9 +1217,8 @@ async function runCleanup(config, verbose = false) {
1280
1217
  // race conditions with concurrent engine/dashboard/lifecycle writers.
1281
1218
  for (const project of projects) {
1282
1219
  try {
1283
- const wiPath = projectWorkItemsPath(project);
1284
1220
  let reconciled = 0;
1285
- mutateWorkItems(wiPath, items => {
1221
+ mutateWorkItems(project, items => {
1286
1222
  for (const item of items) {
1287
1223
  if (item.status === shared.WI_STATUS.FAILED && item._pr) {
1288
1224
  item.status = shared.WI_STATUS.DONE;
@@ -1323,10 +1259,10 @@ async function runCleanup(config, verbose = false) {
1323
1259
  }
1324
1260
  return false;
1325
1261
  }
1326
- function _migrateLegacyItemsAt(wiPath, label) {
1262
+ function _migrateLegacyItemsAt(scope, label) {
1327
1263
  try {
1328
1264
  let migrated = 0;
1329
- mutateWorkItems(wiPath, items => {
1265
+ mutateWorkItems(scope, items => {
1330
1266
  for (const item of items) if (_migrateLegacyItem(item)) migrated++;
1331
1267
  });
1332
1268
  if (migrated > 0) {
@@ -1335,12 +1271,9 @@ async function runCleanup(config, verbose = false) {
1335
1271
  // gate ("legacy-done-aliases" removal after 30 consecutive days zero
1336
1272
  // migrations) measurable by bumping a rolling-daily counter under
1337
1273
  // `_engine.legacyStatusMigrations[YYYY-MM-DD]`.
1338
- // Inline mutateJsonFileLocked by design — promote to a shared
1339
- // `mutateMetrics(key, date)` helper if more N-day-silence gates land.
1340
1274
  try {
1341
- const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
1342
1275
  const dateKey = new Date().toISOString().slice(0, 10);
1343
- mutateJsonFileLocked(metricsPath, (metrics) => {
1276
+ mutateMetrics((metrics) => {
1344
1277
  metrics = metrics || {};
1345
1278
  if (!metrics._engine) metrics._engine = {};
1346
1279
  if (!metrics._engine.legacyStatusMigrations) metrics._engine.legacyStatusMigrations = {};
@@ -1354,15 +1287,14 @@ async function runCleanup(config, verbose = false) {
1354
1287
  }
1355
1288
  } catch (e) { log('warn', `migrate legacy statuses (${label}): ${e.message}`); }
1356
1289
  }
1357
- for (const project of projects) _migrateLegacyItemsAt(projectWorkItemsPath(project), `${project.name} work items`);
1358
- _migrateLegacyItemsAt(path.join(MINIONS_DIR, 'work-items.json'), 'central work items');
1290
+ for (const project of projects) _migrateLegacyItemsAt(project, `${project.name} work items`);
1291
+ _migrateLegacyItemsAt('central', 'central work items');
1359
1292
 
1360
1293
  // 6c. Strip stale retry metadata from completed work items
1361
1294
  cleaned.doneRetryCounts = 0;
1362
1295
  for (const project of projects) {
1363
1296
  try {
1364
- const wiPath = projectWorkItemsPath(project);
1365
- mutateWorkItems(wiPath, items => {
1297
+ mutateWorkItems(project, items => {
1366
1298
  for (const item of items) {
1367
1299
  if (item.status === shared.WI_STATUS.DONE && item._retryCount !== undefined) {
1368
1300
  delete item._retryCount;
@@ -1373,8 +1305,7 @@ async function runCleanup(config, verbose = false) {
1373
1305
  } catch (e) { log('warn', 'cleanup done retry metadata: ' + e.message); }
1374
1306
  }
1375
1307
  try {
1376
- const centralPath = path.join(MINIONS_DIR, 'work-items.json');
1377
- mutateWorkItems(centralPath, items => {
1308
+ mutateWorkItems('central', items => {
1378
1309
  for (const item of items) {
1379
1310
  if (item.status === shared.WI_STATUS.DONE && item._retryCount !== undefined) {
1380
1311
  delete item._retryCount;
@@ -1396,15 +1327,14 @@ async function runCleanup(config, verbose = false) {
1396
1327
  const prdStore = require('./prd-store');
1397
1328
  const prdFiles = prdStore.listPrdRows().filter(row => !row.archived).map(row => row.filename);
1398
1329
  for (const pf of prdFiles) {
1399
- const prdPath = path.join(PRD_DIR, pf);
1400
1330
  // Cheap out-of-transaction peek to skip PRDs that clearly need no work;
1401
1331
  // the authoritative decision (and the write) happens inside mutatePrd.
1402
- const peek = prdStore.readPrd(prdPath);
1332
+ const peek = prdStore.readPrd(pf);
1403
1333
  const needsMigration = peek?.missing_features?.some(feat =>
1404
1334
  LEGACY_DONE_ALIASES.has(feat.status) || feat.status === LEGACY_NEEDS_REVIEW_STATUS);
1405
1335
  if (!needsMigration) continue;
1406
1336
  let migrated = 0;
1407
- prdStore.mutatePrd(prdPath, (prd) => {
1337
+ prdStore.mutatePrd(pf, (prd) => {
1408
1338
  if (prd?.missing_features) {
1409
1339
  for (const feat of prd.missing_features) {
1410
1340
  if (LEGACY_DONE_ALIASES.has(feat.status)) {
@@ -1436,11 +1366,10 @@ async function runCleanup(config, verbose = false) {
1436
1366
  const prdStore = require('./prd-store');
1437
1367
  const orphanPrdEntries = prdStore.listPrdRows().filter(row => !row.archived).map(row => row.filename);
1438
1368
  for (const pf of orphanPrdEntries) {
1439
- const prdPath = path.join(PRD_DIR, pf);
1440
- const peek = prdStore.readPrd(prdPath);
1369
+ const peek = prdStore.readPrd(pf);
1441
1370
  if (!peek?.missing_features) continue;
1442
1371
  let reset = 0;
1443
- prdStore.mutatePrd(prdPath, (prd) => {
1372
+ prdStore.mutatePrd(pf, (prd) => {
1444
1373
  if (prd?.missing_features) {
1445
1374
  for (const feat of prd.missing_features) {
1446
1375
  if ((feat.status === shared.WI_STATUS.DISPATCHED || feat.status === shared.WI_STATUS.FAILED) && !wiIds.has(feat.id)) {
@@ -1458,39 +1387,28 @@ async function runCleanup(config, verbose = false) {
1458
1387
  }
1459
1388
  } catch (e) { log('warn', 'orphan PRD status reset: ' + e.message); }
1460
1389
 
1461
- // 10. CC tab sessions are non-expiring by design they persist until the
1462
- // user explicitly closes the tab (which fires DELETE /api/cc-sessions/:id).
1463
- // Cleanup intentionally does NOT prune cc-sessions.json; doing so would
1464
- // silently invalidate live chat tabs the user expects to keep.
1390
+ // 10. CC tab sessions persist until the user explicitly closes a tab.
1465
1391
  cleaned.ccSessions = 0;
1466
1392
 
1467
1393
  // 10b. Prune doc-chat sessions — cap at 100 entries, remove oldest beyond cap.
1468
- // P-c2sess-1d8e: read+sort+write must run atomically under the file lock so a
1469
- // concurrent dashboard persistDocSessions can't race the cap-trim.
1470
1394
  cleaned.docSessions = 0;
1471
1395
  try {
1472
- const docSessionsPath = path.join(ENGINE_DIR, 'doc-sessions.json');
1473
1396
  const DOC_SESSIONS_CAP = 100;
1474
- mutateJsonFileLocked(docSessionsPath, (docSessions) => {
1397
+ smallStateStore.applyDocSessionsMutation((docSessions) => {
1475
1398
  if (!docSessions || typeof docSessions !== 'object' || Array.isArray(docSessions)) return docSessions;
1476
1399
  const entries = Object.entries(docSessions);
1477
1400
  if (entries.length <= DOC_SESSIONS_CAP) return docSessions;
1478
1401
  entries.sort((a, b) => new Date(b.lastActiveAt || 0) - new Date(a.lastActiveAt || 0));
1479
1402
  cleaned.docSessions = entries.length - DOC_SESSIONS_CAP;
1480
1403
  return Object.fromEntries(entries.slice(0, DOC_SESSIONS_CAP));
1481
- }, { defaultValue: {}, skipWriteIfUnchanged: true });
1482
- } catch (e) { log('warn', 'prune doc-sessions: ' + e.message); }
1404
+ });
1405
+ } catch (e) { log('warn', 'prune doc sessions: ' + e.message); }
1483
1406
 
1484
- // 11. Cap cooldowns.json keep at most 500 entries (on top of 24h TTL in cooldown.js)
1485
- // Also trim pendingContexts arrays to ENGINE_DEFAULTS.maxPendingContexts to prevent bloat.
1407
+ // 11. Cap cooldown state and pending context arrays.
1486
1408
  cleaned.cooldowns = 0;
1487
1409
  cleaned.pendingContextsTrimmed = 0;
1488
1410
  try {
1489
- const cooldownPath = path.join(ENGINE_DIR, 'cooldowns.json');
1490
- // safeJsonNoRestore — same rationale as engine/cooldown.js loadCooldowns:
1491
- // resurrecting a stale .backup at cleanup time could re-introduce expired
1492
- // entries the active code already pruned (P-bfa1d-safejson-no-restore).
1493
- const cooldowns = safeJsonNoRestore(cooldownPath);
1411
+ const cooldowns = smallStateStore.readCooldowns();
1494
1412
  if (cooldowns && typeof cooldowns === 'object') {
1495
1413
  let dirty = false;
1496
1414
  // Trim oversized pendingContexts arrays (one-time migration + ongoing cap)
@@ -1562,7 +1480,7 @@ async function runCleanup(config, verbose = false) {
1562
1480
  }
1563
1481
  } catch { /* optional — file may not exist */ }
1564
1482
 
1565
- // 14. Scrub stale temp agent keys from metrics.json
1483
+ // 14. Scrub stale temp agent keys from metrics state
1566
1484
  try { scrubStaleMetrics(); } catch { /* best-effort cleanup */ }
1567
1485
 
1568
1486
  // 15. Evict old completion reports — keep reports durable beyond the capped
@@ -1660,12 +1578,11 @@ async function runCleanup(config, verbose = false) {
1660
1578
 
1661
1579
  // ─── Metrics Scrub ──────────────────────────────────────────────────────────
1662
1580
 
1663
- /** Remove stale temp-*, agent1, and _test-* keys from metrics.json.
1581
+ /** Remove stale temp-*, agent1, and _test-* keys from metrics state.
1664
1582
  * Mirrors the guard in lifecycle.js updateMetrics() — cleans up keys
1665
1583
  * that were written before that guard existed. */
1666
1584
  function scrubStaleMetrics() {
1667
- const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
1668
- mutateJsonFileLocked(metricsPath, metrics => {
1585
+ mutateMetrics(metrics => {
1669
1586
  for (const key of Object.keys(metrics)) {
1670
1587
  if (key.startsWith('temp-') || key === 'agent1' || key.startsWith('_test')) {
1671
1588
  delete metrics[key];