@yemi33/minions 0.1.2379 → 0.1.2380

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) 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/architecture-review-2026-07-09.md +2 -4
  7. package/docs/auto-discovery.md +36 -49
  8. package/docs/blog-first-successful-dispatch.md +1 -1
  9. package/docs/branch-derivation.md +2 -2
  10. package/docs/command-center.md +1 -1
  11. package/docs/completion-reports.md +14 -4
  12. package/docs/constellation-bridge.md +59 -10
  13. package/docs/constellation-style-telemetry.md +6 -6
  14. package/docs/cooldown-merge-semantics.md +4 -0
  15. package/docs/copilot-cli-schema.md +3 -3
  16. package/docs/cross-repo-plans.md +17 -17
  17. package/docs/deprecated.json +2 -2
  18. package/docs/design-state-storage.md +1 -1
  19. package/docs/documentation-audit-2026-07-09.md +2 -2
  20. package/docs/engine-restart.md +20 -8
  21. package/docs/harness-mode.md +1 -1
  22. package/docs/managed-spawn.md +4 -4
  23. package/docs/onboarding.md +1 -2
  24. package/docs/pr-comment-followup.md +3 -3
  25. package/docs/pr-review-fix-loop.md +1 -1
  26. package/docs/proposals/repo-pool-for-live-checkout.md +1 -2
  27. package/docs/qa-runbook-lifecycle.md +4 -4
  28. package/docs/qa-runbooks.md +2 -2
  29. package/docs/rfc-completion-json.md +4 -1
  30. package/docs/runtime-adapters.md +1 -1
  31. package/docs/self-improvement.md +4 -5
  32. package/docs/shared-lifecycle-module-map.md +3 -1
  33. package/docs/slim-ux/architecture-suggestions.md +5 -6
  34. package/docs/slim-ux/concepts.md +23 -25
  35. package/docs/watches.md +7 -7
  36. package/docs/workspace-manifests.md +1 -1
  37. package/docs/worktree-lifecycle.md +1 -1
  38. package/engine/abandoned-pr-reconciliation.js +4 -5
  39. package/engine/ado-status.js +5 -5
  40. package/engine/ado.js +20 -25
  41. package/engine/agent-worker-pool.js +58 -1
  42. package/engine/bridge.js +260 -5
  43. package/engine/cleanup.js +48 -131
  44. package/engine/cli.js +125 -83
  45. package/engine/cooldown.js +9 -16
  46. package/engine/db/index.js +22 -9
  47. package/engine/db/migrations/009-qa.js +1 -1
  48. package/engine/db/migrations/020-qa-session-scopes.js +23 -0
  49. package/engine/db/migrations/021-archived-work-items.js +72 -0
  50. package/engine/db/migrations/022-global-cc-session.js +31 -0
  51. package/engine/db/migrations/023-engine-state.js +30 -0
  52. package/engine/db/migrations/024-prd-ghost-collisions.js +53 -0
  53. package/engine/db/migrations/025-malformed-work-item-phantoms.js +33 -0
  54. package/engine/dispatch-store.js +2 -7
  55. package/engine/dispatch.js +34 -41
  56. package/engine/github.js +20 -27
  57. package/engine/lifecycle.js +221 -283
  58. package/engine/llm.js +1 -1
  59. package/engine/logs-store.js +2 -2
  60. package/engine/managed-spawn.js +2 -23
  61. package/engine/meeting.js +6 -6
  62. package/engine/metrics-store.js +2 -2
  63. package/engine/note-link-backfill.js +6 -11
  64. package/engine/pipeline.js +18 -36
  65. package/engine/playbook.js +1 -1
  66. package/engine/prd-store.js +73 -54
  67. package/engine/preflight.js +2 -5
  68. package/engine/projects.js +15 -62
  69. package/engine/pull-requests-store.js +0 -17
  70. package/engine/qa-runbooks.js +2 -2
  71. package/engine/qa-runs.js +1 -8
  72. package/engine/qa-sessions.js +41 -64
  73. package/engine/queries.js +120 -219
  74. package/engine/routing.js +3 -5
  75. package/engine/scheduler.js +0 -4
  76. package/engine/shared-branch-pr-reconcile.js +2 -3
  77. package/engine/shared.js +132 -637
  78. package/engine/small-state-store.js +89 -10
  79. package/engine/state-operations.js +16 -4
  80. package/engine/stdio-timestamps.js +1 -1
  81. package/engine/timeout.js +5 -12
  82. package/engine/watch-actions.js +20 -22
  83. package/engine/watches-store.js +1 -1
  84. package/engine/watches.js +6 -10
  85. package/engine/work-item-validation.js +52 -0
  86. package/engine/work-items-store.js +127 -29
  87. package/engine/worktree-gc.js +2 -2
  88. package/engine/worktree-pool.js +8 -18
  89. package/engine.js +147 -343
  90. package/minions.js +2 -2
  91. package/package.json +1 -1
  92. package/playbooks/plan-to-prd.md +2 -2
  93. package/playbooks/shared-rules.md +3 -3
  94. package/playbooks/templates/followup-dispatch.md +1 -1
  95. package/playbooks/verify.md +1 -1
  96. package/prompts/cc-system.md +2 -2
@@ -0,0 +1,72 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+
4
+ function _resolveMinionsDir() {
5
+ const envHome = process.env.MINIONS_TEST_DIR || process.env.MINIONS_HOME;
6
+ if (envHome) return envHome;
7
+ try { return require('../../shared').MINIONS_DIR; } catch { return null; }
8
+ }
9
+
10
+ function _toMs(value) {
11
+ if (value == null) return null;
12
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
13
+ const parsed = Date.parse(value);
14
+ return Number.isFinite(parsed) ? parsed : null;
15
+ }
16
+
17
+ function _readArray(filePath) {
18
+ try {
19
+ const value = JSON.parse(fs.readFileSync(filePath, 'utf8'));
20
+ return Array.isArray(value) ? value : [];
21
+ } catch {
22
+ return [];
23
+ }
24
+ }
25
+
26
+ module.exports = {
27
+ version: 21,
28
+ description: 'store archived work items in SQLite',
29
+ up(db) {
30
+ db.exec(`
31
+ ALTER TABLE work_items ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
32
+ CREATE INDEX idx_wi_scope_archived ON work_items(scope, archived);
33
+ `);
34
+
35
+ const minionsDir = _resolveMinionsDir();
36
+ if (!minionsDir) return;
37
+ const insert = db.prepare(`
38
+ INSERT OR IGNORE INTO work_items
39
+ (id, scope, status, type, agent, parent_id, created_at, completed_at, data, updated_at, archived)
40
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
41
+ `);
42
+ const now = Date.now();
43
+ const importFile = (scope, filePath) => {
44
+ for (const raw of _readArray(filePath)) {
45
+ if (!raw || typeof raw !== 'object' || !raw.id) continue;
46
+ const item = { ...raw, _archived: true };
47
+ item.archivedAt = item.archivedAt || new Date(now).toISOString();
48
+ insert.run(
49
+ String(item.id),
50
+ scope,
51
+ String(item.status || 'done'),
52
+ item.type || null,
53
+ item.dispatched_to || item.agent || null,
54
+ item.parent_id || null,
55
+ _toMs(item.created_at || item.created),
56
+ _toMs(item.completed_at || item.completedAt),
57
+ JSON.stringify(item),
58
+ now,
59
+ );
60
+ }
61
+ };
62
+
63
+ importFile('central', path.join(minionsDir, 'work-items-archive.json'));
64
+ let projects = [];
65
+ try { projects = fs.readdirSync(path.join(minionsDir, 'projects'), { withFileTypes: true }); }
66
+ catch { /* optional */ }
67
+ for (const project of projects) {
68
+ if (!project.isDirectory() || project.name === '.archived') continue;
69
+ importFile(project.name, path.join(minionsDir, 'projects', project.name, 'work-items-archive.json'));
70
+ }
71
+ },
72
+ };
@@ -0,0 +1,31 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const CC_GLOBAL_SESSION_ID = '__global__';
5
+
6
+ function _resolveMinionsDir() {
7
+ const envHome = process.env.MINIONS_TEST_DIR || process.env.MINIONS_HOME;
8
+ if (envHome) return envHome;
9
+ try { return require('../../shared').MINIONS_DIR; } catch { return null; }
10
+ }
11
+
12
+ module.exports = {
13
+ version: 22,
14
+ description: 'global Command Center session',
15
+ up(db) {
16
+ const minionsDir = _resolveMinionsDir();
17
+ if (!minionsDir) return;
18
+ const legacyPath = path.join(minionsDir, 'engine', 'cc-session.json');
19
+ let session;
20
+ try {
21
+ session = JSON.parse(fs.readFileSync(legacyPath, 'utf8'));
22
+ } catch {
23
+ return;
24
+ }
25
+ if (!session || typeof session !== 'object' || Array.isArray(session) || !session.sessionId) return;
26
+ db.prepare(`
27
+ INSERT OR IGNORE INTO cc_sessions (id, data, updated_at)
28
+ VALUES (?, ?, ?)
29
+ `).run(CC_GLOBAL_SESSION_ID, JSON.stringify(session), Date.now());
30
+ },
31
+ };
@@ -0,0 +1,30 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const ENGINE_STATE_MARKER = 'engine_state';
5
+
6
+ function _resolveMinionsDir() {
7
+ const envHome = process.env.MINIONS_TEST_DIR || process.env.MINIONS_HOME;
8
+ if (envHome) return envHome;
9
+ try { return require('../../shared').MINIONS_DIR; } catch { return null; }
10
+ }
11
+
12
+ module.exports = {
13
+ version: 23,
14
+ description: 'engine state marker',
15
+ up(db) {
16
+ const minionsDir = _resolveMinionsDir();
17
+ if (!minionsDir) return;
18
+ let state;
19
+ try {
20
+ state = JSON.parse(fs.readFileSync(path.join(minionsDir, 'engine', 'state.json'), 'utf8'));
21
+ } catch {
22
+ return;
23
+ }
24
+ if (!state || typeof state !== 'object' || Array.isArray(state)) return;
25
+ db.prepare(`
26
+ INSERT OR IGNORE INTO state_markers (key, value, updated_at)
27
+ VALUES (?, ?, ?)
28
+ `).run(ENGINE_STATE_MARKER, JSON.stringify(state), Date.now());
29
+ },
30
+ };
@@ -0,0 +1,53 @@
1
+ function _parseObject(raw) {
2
+ try {
3
+ const value = JSON.parse(raw);
4
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
5
+ } catch {
6
+ return null;
7
+ }
8
+ }
9
+
10
+ function _isArchivedEcho(live, archived) {
11
+ if (!live || !archived) return true;
12
+ const liveSource = live.source_plan || live.sourcePlan;
13
+ const archivedSource = archived.source_plan || archived.sourcePlan;
14
+ if (liveSource && archivedSource && liveSource !== archivedSource) return false;
15
+ const archivedIds = new Set(
16
+ (archived.missing_features || []).map(feature => feature && feature.id).filter(Boolean),
17
+ );
18
+ return !(live.missing_features || []).some(
19
+ feature => feature && feature.id && !archivedIds.has(feature.id),
20
+ );
21
+ }
22
+
23
+ function _deletePrdRow(db, prdId) {
24
+ db.prepare(`
25
+ UPDATE work_items SET prd_item_id=NULL
26
+ WHERE prd_item_id IN (SELECT id FROM prd_items WHERE prd_id=?)
27
+ `).run(prdId);
28
+ db.prepare('DELETE FROM prd_items WHERE prd_id=?').run(prdId);
29
+ db.prepare('DELETE FROM prd_verify_prs WHERE prd_id=?').run(prdId);
30
+ db.prepare('DELETE FROM prds WHERE id=?').run(prdId);
31
+ }
32
+
33
+ module.exports = {
34
+ version: 24,
35
+ description: 'remove duplicate live PRD echoes of archived imports',
36
+ up(db) {
37
+ const rows = db.prepare(`
38
+ SELECT id, filename, archived, data
39
+ FROM prds
40
+ WHERE filename IN (SELECT filename FROM prds GROUP BY filename HAVING COUNT(DISTINCT archived) > 1)
41
+ ORDER BY filename, archived, id
42
+ `).all();
43
+ const archivedByFilename = new Map();
44
+ for (const row of rows) {
45
+ if (row.archived) archivedByFilename.set(row.filename, _parseObject(row.data));
46
+ }
47
+ for (const row of rows) {
48
+ if (row.archived) continue;
49
+ const archived = archivedByFilename.get(row.filename);
50
+ if (_isArchivedEcho(_parseObject(row.data), archived)) _deletePrdRow(db, row.id);
51
+ }
52
+ },
53
+ };
@@ -0,0 +1,33 @@
1
+ const { isMalformedPhantomWorkItem } = require('../../work-item-validation');
2
+
3
+ module.exports = {
4
+ version: 25,
5
+ description: 'archive failed contentless work-item phantoms',
6
+ up(db) {
7
+ const rows = db.prepare(`
8
+ SELECT id, scope, data
9
+ FROM work_items
10
+ WHERE archived=0 AND status='failed'
11
+ `).all();
12
+ const update = db.prepare(`
13
+ UPDATE work_items
14
+ SET archived=1, data=?, updated_at=?
15
+ WHERE scope=? AND id=? AND archived=0
16
+ `);
17
+ const reconciledAt = new Date().toISOString();
18
+ for (const row of rows) {
19
+ let item;
20
+ try { item = JSON.parse(row.data); } catch { continue; }
21
+ if (!isMalformedPhantomWorkItem(item)) continue;
22
+ const preservedFields = Object.keys(item).sort();
23
+ item._archived = true;
24
+ item.archivedAt = item.archivedAt || reconciledAt;
25
+ item._corruption = {
26
+ kind: 'malformed-phantom',
27
+ reconciledAt,
28
+ preservedFields,
29
+ };
30
+ update.run(JSON.stringify(item), Date.now(), row.scope, row.id);
31
+ }
32
+ },
33
+ };
@@ -1,6 +1,4 @@
1
- // engine/dispatch-store.js — SQL-backed implementation of the
2
- // section-shaped dispatch object. Same external contract as the legacy
3
- // dispatch.json reader:
1
+ // engine/dispatch-store.js — SQL-backed section-shaped dispatch state:
4
2
  //
5
3
  // readDispatchSectioned() -> { pending, active, completed, review }
6
4
  // applyDispatchMutation(fn) -> runs fn against the section object,
@@ -38,8 +36,7 @@ function readDispatchSectioned() {
38
36
  const { getDb } = require('./db');
39
37
  const db = getDb();
40
38
 
41
- // All rows, all statuses. Matches the legacy dispatch.json reader's
42
- // semantics — returns the complete dispatch state regardless of how old
39
+ // Return the complete dispatch state regardless of how old
43
40
  // completed entries are. Cross-reference consumers like state-integrity
44
41
  // need the full completed list to match against work-items, and the
45
42
  // dashboard's slim slice handles its own payload sizing downstream.
@@ -200,8 +197,6 @@ function applyDispatchMutation(mutator) {
200
197
  }
201
198
  const diff = _computeDispatchDiff(beforeSnapshot, after);
202
199
  const wrote = _applyDispatchDiff(db, diff);
203
- // First-touch guard: record that SQL was written this process so a later
204
- // empty read won't resurrect a stale dispatch.json (see readDispatchSectioned).
205
200
  return { wrote, result: after };
206
201
  });
207
202
  }
@@ -24,8 +24,8 @@ const queries = require('./queries');
24
24
  const { setCooldown, setCooldownFailure } = require('./cooldown');
25
25
  const dispatchEvents = require('./dispatch-events');
26
26
 
27
- const { safeJsonArr, mutateWorkItems,
28
- mutatePullRequests, getProjects, projectPrPath, log, ts, dateStamp,
27
+ const { readWorkItems, mutateWorkItems,
28
+ mutatePullRequests, getProjects, log, ts, dateStamp,
29
29
  sidecarDispatchPrompt, deleteDispatchPromptSidecar, DONE_STATUSES,
30
30
  WI_STATUS, WORK_TYPE, DISPATCH_RESULT, ENGINE_DEFAULTS, AGENT_STATUS, FAILURE_CLASS, PR_STATUS } = shared;
31
31
  const { getConfig, INBOX_DIR } = queries;
@@ -81,7 +81,7 @@ function _snapshotPrompts(dispatch) {
81
81
 
82
82
  /**
83
83
  * Sidecar oversized prompts only for items the mutator added or modified.
84
- * Keeps dispatch.json safe from bloat (#1167) without paying O(n) on every
84
+ * Keeps dispatch state compact (#1167) without paying O(n) on every
85
85
  * unrelated mutation (e.g. status flips, completion-marking).
86
86
  */
87
87
  function _sidecarChangedPrompts(dispatch, prevSnap) {
@@ -306,9 +306,9 @@ function _persistInvalidWorkItem(item, evaluation) {
306
306
  const meta = item?.meta;
307
307
  const itemId = meta?.item?.id;
308
308
  if (!itemId) return { stuck: false };
309
- let wiPath;
310
- try { wiPath = lifecycle().resolveWorkItemPath(meta); } catch { wiPath = null; }
311
- if (!wiPath) return { stuck: false };
309
+ let wiScope;
310
+ try { wiScope = lifecycle().resolveWorkItemScope(meta); } catch { wiScope = null; }
311
+ if (!wiScope) return { stuck: false };
312
312
  // W-mrcrh5hj0017d22f — repeat-rejection cap. Compare against the
313
313
  // description snapshot from the LAST rejection so an operator edit to the
314
314
  // WI resets the counter (it deserves a fresh evaluation), while an
@@ -319,7 +319,7 @@ function _persistInvalidWorkItem(item, evaluation) {
319
319
  const currentDescription = String((meta.item && meta.item.description) || '');
320
320
  let stuck = false;
321
321
  try {
322
- mutateWorkItems(wiPath, (items) => {
322
+ mutateWorkItems(wiScope, (items) => {
323
323
  if (!Array.isArray(items)) return items;
324
324
  const idx = items.findIndex(w => w && w.id === itemId);
325
325
  if (idx === -1) return items;
@@ -393,8 +393,7 @@ function _isPrdSourcedAndVetted(wi) {
393
393
  const sourcePlan = wi && wi.sourcePlan;
394
394
  if (!sourcePlan || typeof sourcePlan !== 'string') return false;
395
395
  try {
396
- const prdPath = path.join(queries.PRD_DIR, sourcePlan);
397
- const plan = shared.safeJsonNoRestore(prdPath);
396
+ const plan = require('./prd-store').readPrd(sourcePlan);
398
397
  if (!plan || !plan.missing_features) return false;
399
398
  const status = plan.status || shared.PLAN_STATUS.ACTIVE;
400
399
  return status === shared.PLAN_STATUS.APPROVED || status === shared.PLAN_STATUS.ACTIVE;
@@ -454,7 +453,7 @@ async function addToDispatchWithValidation(item, opts = {}) {
454
453
  : _isPrdSourcedAndVetted;
455
454
  if (isPrdVetted(wi)) {
456
455
  // Stamp the skip marker on the dispatch item so the entry that lands
457
- // in dispatch.json carries observability — operators can spot which
456
+ // in dispatch state carries observability — operators can spot which
458
457
  // items the validator skipped vs which it ran.
459
458
  item._preDispatchEvalSkipped = 'prd-sourced';
460
459
  return addToDispatch(item);
@@ -569,17 +568,17 @@ function getStalePrDispatchReason(entry, config) {
569
568
  function cancelSourceWorkItemForPrunedDispatch(entry, reason) {
570
569
  const itemId = entry?.meta?.item?.id;
571
570
  if (!itemId) return false;
572
- let wiPath;
573
- try { wiPath = lifecycle().resolveWorkItemPath(entry.meta); }
571
+ let wiScope;
572
+ try { wiScope = lifecycle().resolveWorkItemScope(entry.meta); }
574
573
  catch (e) {
575
574
  log('warn', `Failed to resolve source work item for pruned dispatch ${entry.id}: ${e.message}`);
576
575
  return false;
577
576
  }
578
- if (!wiPath) return false;
577
+ if (!wiScope) return false;
579
578
 
580
579
  let cancelled = false;
581
580
  try {
582
- mutateWorkItems(wiPath, (items) => {
581
+ mutateWorkItems(wiScope, (items) => {
583
582
  if (!Array.isArray(items)) return items;
584
583
  const wi = items.find(w => w && w.id === itemId);
585
584
  if (!wi) return items;
@@ -608,9 +607,9 @@ function _resolveWorkItemForPrunedEntry(entry) {
608
607
  const itemId = entry?.meta?.item?.id;
609
608
  if (!itemId) return null;
610
609
  try {
611
- const wiPath = lifecycle().resolveWorkItemPath(entry.meta);
612
- if (!wiPath) return null;
613
- const items = safeJsonArr(wiPath);
610
+ const wiScope = lifecycle().resolveWorkItemScope(entry.meta);
611
+ if (!wiScope) return null;
612
+ const items = readWorkItems(wiScope);
614
613
  return items.find(w => w && w.id === itemId) || null;
615
614
  } catch {
616
615
  return null;
@@ -851,9 +850,9 @@ const FORCE_DEMOTE_FAILURE_CLASSES = new Set([
851
850
  function readLiveWorkItem(meta) {
852
851
  const itemId = meta?.item?.id;
853
852
  if (!itemId) return null;
854
- const wiPath = lifecycle().resolveWorkItemPath(meta);
855
- if (!wiPath) return null;
856
- const items = safeJsonArr(wiPath);
853
+ const wiScope = lifecycle().resolveWorkItemScope(meta);
854
+ if (!wiScope) return null;
855
+ const items = readWorkItems(wiScope);
857
856
  return items.find(i => i.id === itemId) || null;
858
857
  }
859
858
 
@@ -894,11 +893,11 @@ function markCompletedSourceWorkItem(item) {
894
893
  const meta = item?.meta;
895
894
  const itemId = meta?.item?.id;
896
895
  if (!itemId) return false;
897
- const wiPath = lifecycle().resolveWorkItemPath(meta);
898
- if (!wiPath) return false;
896
+ const wiScope = lifecycle().resolveWorkItemScope(meta);
897
+ if (!wiScope) return false;
899
898
  let found = false;
900
899
  let terminal = false;
901
- mutateWorkItems(wiPath, items => {
900
+ mutateWorkItems(wiScope, items => {
902
901
  if (!Array.isArray(items)) return items;
903
902
  const wi = items.find(i => i.id === itemId);
904
903
  if (!wi) return items;
@@ -985,7 +984,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
985
984
  if (item) {
986
985
  log('info', `Completed dispatch: ${id} (${result}${reason ? ': ' + reason : ''})`);
987
986
  // W-mpob4nyk0006580e — broadcast terminal state immediately after the
988
- // `result` field is committed to dispatch.json. This is the single
987
+ // `result` field is committed to dispatch state. This is the single
989
988
  // source of truth for the live-view banner; subscribers (dashboard SSE
990
989
  // handler at handleAgentLiveStream) push it to clients so the banner
991
990
  // mirrors `dispatch.result` exactly — no log-parsing heuristics that can
@@ -1046,11 +1045,11 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
1046
1045
  const isForceDemote = failureClass && FORCE_DEMOTE_FAILURE_CLASSES.has(failureClass) && !isPrShipped;
1047
1046
  if (isForceDemote) {
1048
1047
  try {
1049
- const wiPath = lifecycle().resolveWorkItemPath(item.meta);
1050
- if (wiPath) {
1048
+ const wiScope = lifecycle().resolveWorkItemScope(item.meta);
1049
+ if (wiScope) {
1051
1050
  const classSuffix = ` [${failureClass.toUpperCase().replace(/-/g, '_')}]`;
1052
1051
  const demoteReason = `Non-retryable failure: ${reason || failureClass}${classSuffix}`;
1053
- mutateWorkItems(wiPath, items => {
1052
+ mutateWorkItems(wiScope, items => {
1054
1053
  const wi = items.find(i => i.id === item.meta.item.id);
1055
1054
  if (wi) {
1056
1055
  wi.status = WI_STATUS.FAILED;
@@ -1093,7 +1092,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
1093
1092
  } else {
1094
1093
  let retries = (item.meta.item._retryCount || 0);
1095
1094
  try {
1096
- const wi = queries.getWorkItems().find(i => i.id === item.meta.item.id);
1095
+ const wi = liveWi || readLiveWorkItem(item.meta);
1097
1096
  if (wi) retries = wi._retryCount || 0;
1098
1097
  } catch (e) { log('warn', 'read retry count: ' + e.message); }
1099
1098
  const maxRetries = ENGINE_DEFAULTS.maxRetries;
@@ -1112,9 +1111,9 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
1112
1111
  }
1113
1112
  // Increment retry counter on the source work item
1114
1113
  try {
1115
- const wiPath = lifecycle().resolveWorkItemPath(item.meta);
1116
- if (wiPath) {
1117
- mutateWorkItems(wiPath, items => {
1114
+ const wiScope = lifecycle().resolveWorkItemScope(item.meta);
1115
+ if (wiScope) {
1116
+ mutateWorkItems(wiScope, items => {
1118
1117
  const wi = items.find(i => i.id === item.meta.item.id);
1119
1118
  if (wi && wi.status !== WI_STATUS.PAUSED && wi.status !== WI_STATUS.DONE && !wi.completedAt) {
1120
1119
  wi._retryCount = retries + 1;
@@ -1229,10 +1228,9 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
1229
1228
  const isTransientPreflight = TRANSIENT_PREFLIGHT_CLASSES.has(failureClass);
1230
1229
  if (prId && project) {
1231
1230
  try {
1232
- const prsPath = projectPrPath(project);
1233
1231
  let restored = false;
1234
1232
  let gaveUp = false;
1235
- mutatePullRequests(prsPath, prs => {
1233
+ mutatePullRequests(project, prs => {
1236
1234
  const target = shared.findPrRecord(prs, { id: prId }, project);
1237
1235
  if (!target?.humanFeedback) return;
1238
1236
  if (isTransientPreflight) {
@@ -1383,12 +1381,7 @@ function cleanDispatchEntries(matchFn) {
1383
1381
  const filesToDelete = [];
1384
1382
  const dispatchDirsToRemove = [];
1385
1383
  try {
1386
- // Route through mutateDispatch so the SQL-backed store (Phase 1) and the
1387
- // dispatch.json mirror stay coherent. Writing dispatch.json directly with
1388
- // mutateJsonFileLocked here would leave stale rows in the `dispatches`
1389
- // table; the very next mutateDispatch call (e.g. removeProject's
1390
- // step 7.5 orphan-tagging pass) would then mirror SQL back to JSON and
1391
- // resurrect the entries we just drained.
1384
+ // Keep the drain and orphan-tagging passes atomic in the dispatch store.
1392
1385
  mutateDispatch((dispatch) => {
1393
1386
  for (const queue of ['pending', 'active', 'completed']) {
1394
1387
  dispatch[queue] = Array.isArray(dispatch[queue]) ? dispatch[queue] : [];
@@ -1449,10 +1442,10 @@ function cleanDispatchEntries(matchFn) {
1449
1442
  * Cancel pending/queued work items matching a predicate. Done items pass through.
1450
1443
  * Sets status=CANCELLED + _cancelledBy=reason. Returns count cancelled.
1451
1444
  */
1452
- function cancelPendingWorkItems(wiPath, matchFn, reason) {
1445
+ function cancelPendingWorkItems(scope, matchFn, reason) {
1453
1446
  let cancelled = 0;
1454
1447
  try {
1455
- mutateWorkItems(wiPath, items => {
1448
+ mutateWorkItems(scope, items => {
1456
1449
  for (const w of items) {
1457
1450
  if (!matchFn(w)) continue;
1458
1451
  if (w.status !== WI_STATUS.PENDING && w.status !== WI_STATUS.QUEUED) continue;
package/engine/github.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  const shared = require('./shared');
8
- const { exec, execAsync, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeJsonArr, safeWrite, mutateJsonFileLocked, mutatePullRequests, MINIONS_DIR, getPrLinks, backfillPrPrdItems, log, ts, dateStamp, PR_STATUS, PR_POLLABLE_STATUSES, BUILD_STATUS, REVIEW_STATUS, FETCH_TIMEOUT_MS, ENGINE_DEFAULTS, createThrottleTracker, getProjectOrg } = shared;
8
+ const { exec, execAsync, getProjects, readWorkItems, readPullRequests, safeJson, safeJsonArr, safeWrite, mutateMetrics, mutatePullRequests, MINIONS_DIR, getPrLinks, backfillPrPrdItems, log, ts, dateStamp, PR_STATUS, PR_POLLABLE_STATUSES, BUILD_STATUS, REVIEW_STATUS, FETCH_TIMEOUT_MS, ENGINE_DEFAULTS, createThrottleTracker, getProjectOrg } = shared;
9
9
  const { getPrs } = require('./queries');
10
10
  const { isPreviewStatusBody, hasMinionsMarker, hasVerdictPrefix } = require('./comment-classifier');
11
11
  const { wrapUntrusted, buildSource } = require('./untrusted-fence');
@@ -291,8 +291,7 @@ async function _resolveViewerLogin(slug) {
291
291
  // engine metrics aggregation (`getMetrics()` in `engine/queries.js`).
292
292
  function _incrementViewerLoginResolutionFailures() {
293
293
  try {
294
- const metricsPath = path.join(MINIONS_DIR, 'engine', 'metrics.json');
295
- mutateJsonFileLocked(metricsPath, (metrics) => {
294
+ mutateMetrics((metrics) => {
296
295
  metrics = metrics || {};
297
296
  if (!metrics._engine) metrics._engine = {};
298
297
  metrics._engine.viewerLoginResolutionFailures =
@@ -590,7 +589,7 @@ async function forEachActiveGhPr(config, callback) {
590
589
  }
591
590
 
592
591
  if (projectUpdated > 0) {
593
- mutateJsonFileLocked(projectPrPath(project), (currentPrs) => {
592
+ mutatePullRequests(project, (currentPrs) => {
594
593
  // Merge back only fields changed by callbacks; preserve concurrent disk updates.
595
594
  for (const { before, after } of updatedRecords) {
596
595
  const idx = currentPrs.findIndex(p => p.id === after.id);
@@ -601,7 +600,7 @@ async function forEachActiveGhPr(config, callback) {
601
600
  }
602
601
  // W-mp5trwh60008386d: never downgrade `status: merged` — terminal state. A stale
603
602
  // 404 reaching `prAbandonConfirmCount` could otherwise overwrite a concurrent
604
- // success/merge poll. Keeps the central pull-requests.json status invariants
603
+ // success/merge poll. Keeps the central PR status invariants
605
604
  // intact even under multi-writer races.
606
605
  if (currentPrs[idx].status === PR_STATUS.MERGED && after.status !== PR_STATUS.MERGED) {
607
606
  after.status = PR_STATUS.MERGED;
@@ -629,9 +628,8 @@ async function forEachActiveGhPr(config, callback) {
629
628
  }
630
629
  }
631
630
 
632
- // Also poll manually-linked PRs from central pull-requests.json (extract slug from URL)
633
- const centralPath = path.join(MINIONS_DIR, 'pull-requests.json');
634
- const centralPrs = safeJsonArr(centralPath);
631
+ // Also poll manually-linked PRs from central state (extract slug from URL).
632
+ const centralPrs = readPullRequests('central');
635
633
  // Build a slug→project map for configured GitHub projects so we can detect
636
634
  // PRs that are actually owned by the project loop (present in per-project file).
637
635
  const configuredGhProjectsBySlug = new Map();
@@ -648,7 +646,7 @@ async function forEachActiveGhPr(config, callback) {
648
646
  if (ghMatch) {
649
647
  const owningProject = configuredGhProjectsBySlug.get(ghMatch[1].toLowerCase());
650
648
  if (owningProject) {
651
- const projectPrs = safeJsonArr(projectPrPath(owningProject));
649
+ const projectPrs = readPullRequests(owningProject);
652
650
  if (projectPrs.some(p => p.id === pr.id)) return false; // present → project loop handles it
653
651
  }
654
652
  }
@@ -726,7 +724,7 @@ async function forEachActiveGhPr(config, callback) {
726
724
  }
727
725
  }
728
726
  if (centralUpdated > 0) {
729
- mutateJsonFileLocked(centralPath, (currentPrs) => {
727
+ mutatePullRequests('central', (currentPrs) => {
730
728
  // Only merge back central PRs that the callback actually modified
731
729
  for (const { before, after } of updatedCentralRecords) {
732
730
  const idx = currentPrs.findIndex(p => p.id === after.id);
@@ -1004,7 +1002,7 @@ async function pollPrStatus(config) {
1004
1002
  }
1005
1003
  // F6 (P-f6commentedit): drop the comment-edit dedup map on merge/abandon
1006
1004
  // — no further re-dispatch path can fire from this PR, so keeping it
1007
- // around would only bloat pull-requests.json.
1005
+ // around would only bloat the PR store.
1008
1006
  if (pr.humanFeedback && pr.humanFeedback.editsSeen) {
1009
1007
  delete pr.humanFeedback.editsSeen;
1010
1008
  }
@@ -1204,7 +1202,7 @@ async function pollPrStatus(config) {
1204
1202
  // review status flipped, etc.) is dropped via clearPrNoOpFixAttempt. The
1205
1203
  // dispatch gate already ignores these stale records (isPrNoOpFixCausePaused
1206
1204
  // re-validates the fingerprint), but without this sweep they linger in
1207
- // pull-requests.json forever — bloating the file and confusing the
1205
+ // PR store forever — bloating the registry and confusing the
1208
1206
  // dashboard `pr-paused-chip`. Runs AFTER all live-field updates so the
1209
1207
  // fingerprint comparison uses post-poll PR state.
1210
1208
  try {
@@ -1454,8 +1452,7 @@ async function reconcilePrs(config) {
1454
1452
  const existingPrs = getPrs(project);
1455
1453
  if (existingPrs.length === 0) {
1456
1454
  try {
1457
- const wiPath = projectWorkItemsPath(project);
1458
- const wis = safeJsonArr(wiPath);
1455
+ const wis = readWorkItems(project);
1459
1456
  if (wis.length === 0) continue;
1460
1457
  } catch { continue; }
1461
1458
  }
@@ -1478,18 +1475,15 @@ async function reconcilePrs(config) {
1478
1475
 
1479
1476
  if (ghPrs.length === 0) continue;
1480
1477
 
1481
- const prPath = projectPrPath(project);
1482
- const currentPrs = safeJsonArr(prPath);
1478
+ const currentPrs = readPullRequests(project);
1483
1479
  shared.normalizePrRecords(currentPrs, project);
1484
1480
  const existingIds = new Set(currentPrs.map(p => p.id));
1485
1481
  let metadataUpdated = 0;
1486
1482
  let projectAdded = 0;
1487
1483
 
1488
1484
  // Load work items to match branches
1489
- const wiPath = projectWorkItemsPath(project);
1490
- const workItems = safeJsonArr(wiPath);
1491
- const centralWiPath = path.join(MINIONS_DIR, 'work-items.json');
1492
- const centralItems = safeJsonArr(centralWiPath);
1485
+ const workItems = readWorkItems(project);
1486
+ const centralItems = readWorkItems('central');
1493
1487
  const allItems = [...workItems, ...centralItems];
1494
1488
 
1495
1489
  for (const { pr: ghPr, slug } of ghPrs) {
@@ -1528,7 +1522,7 @@ async function reconcilePrs(config) {
1528
1522
  metadataUpdated++;
1529
1523
  }
1530
1524
  if (confirmedItemId) {
1531
- shared.upsertPullRequestRecord(prPath, existing || {
1525
+ shared.upsertPullRequestRecord(project, existing || {
1532
1526
  id: prId,
1533
1527
  prNumber: ghPr.number,
1534
1528
  title: (ghPr.title || `PR #${ghPr.number}`).slice(0, 120),
@@ -1587,7 +1581,7 @@ async function reconcilePrs(config) {
1587
1581
  sourcePlan: linkedItem?.sourcePlan || '',
1588
1582
  itemType: linkedItem?.itemType || '',
1589
1583
  };
1590
- const upserted = shared.upsertPullRequestRecord(prPath, entry, { project, itemId: confirmedItemId });
1584
+ const upserted = shared.upsertPullRequestRecord(project, entry, { project, itemId: confirmedItemId });
1591
1585
  currentPrs.push(upserted.record || entry);
1592
1586
  existingIds.add(prId);
1593
1587
  projectAdded++;
@@ -1607,14 +1601,14 @@ async function reconcilePrs(config) {
1607
1601
  const backfilled = backfillPrPrdItems(currentPrs, getPrLinks());
1608
1602
 
1609
1603
  if (projectAdded > 0 || backfilled > 0 || metadataUpdated > 0) {
1610
- mutateJsonFileLocked(prPath, (lockedPrs) => {
1604
+ mutatePullRequests(project, (lockedPrs) => {
1611
1605
  for (const pr of currentPrs) {
1612
1606
  const idx = lockedPrs.findIndex(p => p.id === pr.id);
1613
1607
  if (idx >= 0) lockedPrs[idx] = pr;
1614
1608
  else lockedPrs.push(pr);
1615
1609
  }
1616
1610
  return lockedPrs;
1617
- }, { defaultValue: [] });
1611
+ });
1618
1612
  totalAdded += projectAdded;
1619
1613
  }
1620
1614
  }
@@ -1872,8 +1866,7 @@ async function reconcileAbandonedPrs(config) {
1872
1866
  const slugProbeCache = new Map(); // slug → 'ok' | 'fail'
1873
1867
 
1874
1868
  for (const project of projects) {
1875
- const prPath = projectPrPath(project);
1876
- const prs = safeJsonArr(prPath);
1869
+ const prs = readPullRequests(project);
1877
1870
  // Filter: only abandoned PRs that don't already have the confirmed-404
1878
1871
  // marker from a previous reconciliation pass. Marker means "we already
1879
1872
  // verified this PR is genuinely deleted on the server" — no point
@@ -1958,7 +1951,7 @@ async function reconcileAbandonedPrs(config) {
1958
1951
 
1959
1952
  if (updates.length > 0) {
1960
1953
  const reconciledAt = ts();
1961
- mutatePullRequests(prPath, (currentPrs) => {
1954
+ mutatePullRequests(project, (currentPrs) => {
1962
1955
  for (const upd of updates) {
1963
1956
  const pr = currentPrs.find(p =>
1964
1957
  shared.getCanonicalPrId(project, p, p.url || '') === upd.prId