@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
@@ -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;
@@ -53,6 +53,8 @@ const NON_MUTATING_DISPATCH_TYPES = new Set([
53
53
  // PR source branch always differs and causes phantom auto-cancellation (#369).
54
54
  const PR_BRANCH_TARGETED_TYPES = new Set([
55
55
  WORK_TYPE.FIX,
56
+ WORK_TYPE.BUILD_FIX_COMPLEX,
57
+ WORK_TYPE.TEST,
56
58
  WORK_TYPE.VERIFY,
57
59
  WORK_TYPE.DECOMPOSE,
58
60
  ]);
@@ -81,7 +83,7 @@ function _snapshotPrompts(dispatch) {
81
83
 
82
84
  /**
83
85
  * 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
86
+ * Keeps dispatch state compact (#1167) without paying O(n) on every
85
87
  * unrelated mutation (e.g. status flips, completion-marking).
86
88
  */
87
89
  function _sidecarChangedPrompts(dispatch, prevSnap) {
@@ -148,7 +150,7 @@ function getPrDispatchDedupeKey(entry) {
148
150
  if (!entry?.type) return null;
149
151
  const targetKey = getPrDispatchTargetKey(entry);
150
152
  if (!targetKey) return null;
151
- const type = entry.type === WORK_TYPE.FIX ? WORK_TYPE.FIX : entry.type;
153
+ const type = shared.isFixLikeWorkType(entry.type) ? WORK_TYPE.FIX : entry.type;
152
154
  return `${targetKey}:${type}`;
153
155
  }
154
156
 
@@ -197,7 +199,7 @@ function findActivePrOrBranchLock(dispatch, item) {
197
199
  // PR-target dedup is FIX-only: a FIX shouldn't stack on top of another FIX
198
200
  // for the same PR, but a REVIEW + FIX pair targeting the same PR is the
199
201
  // normal review-then-fix flow and must not be dedup'd here.
200
- if (item?.type === WORK_TYPE.FIX) {
202
+ if (shared.isFixLikeWorkType(item?.type)) {
201
203
  const prTargetKey = getPrDispatchTargetKey(item);
202
204
  if (prTargetKey) {
203
205
  const existing = active.find(d => getPrDispatchTargetKey(d) === prTargetKey);
@@ -306,9 +308,9 @@ function _persistInvalidWorkItem(item, evaluation) {
306
308
  const meta = item?.meta;
307
309
  const itemId = meta?.item?.id;
308
310
  if (!itemId) return { stuck: false };
309
- let wiPath;
310
- try { wiPath = lifecycle().resolveWorkItemPath(meta); } catch { wiPath = null; }
311
- if (!wiPath) return { stuck: false };
311
+ let wiScope;
312
+ try { wiScope = lifecycle().resolveWorkItemScope(meta); } catch { wiScope = null; }
313
+ if (!wiScope) return { stuck: false };
312
314
  // W-mrcrh5hj0017d22f — repeat-rejection cap. Compare against the
313
315
  // description snapshot from the LAST rejection so an operator edit to the
314
316
  // WI resets the counter (it deserves a fresh evaluation), while an
@@ -319,7 +321,7 @@ function _persistInvalidWorkItem(item, evaluation) {
319
321
  const currentDescription = String((meta.item && meta.item.description) || '');
320
322
  let stuck = false;
321
323
  try {
322
- mutateWorkItems(wiPath, (items) => {
324
+ mutateWorkItems(wiScope, (items) => {
323
325
  if (!Array.isArray(items)) return items;
324
326
  const idx = items.findIndex(w => w && w.id === itemId);
325
327
  if (idx === -1) return items;
@@ -393,8 +395,7 @@ function _isPrdSourcedAndVetted(wi) {
393
395
  const sourcePlan = wi && wi.sourcePlan;
394
396
  if (!sourcePlan || typeof sourcePlan !== 'string') return false;
395
397
  try {
396
- const prdPath = path.join(queries.PRD_DIR, sourcePlan);
397
- const plan = shared.safeJsonNoRestore(prdPath);
398
+ const plan = require('./prd-store').readPrd(sourcePlan);
398
399
  if (!plan || !plan.missing_features) return false;
399
400
  const status = plan.status || shared.PLAN_STATUS.ACTIVE;
400
401
  return status === shared.PLAN_STATUS.APPROVED || status === shared.PLAN_STATUS.ACTIVE;
@@ -454,7 +455,7 @@ async function addToDispatchWithValidation(item, opts = {}) {
454
455
  : _isPrdSourcedAndVetted;
455
456
  if (isPrdVetted(wi)) {
456
457
  // Stamp the skip marker on the dispatch item so the entry that lands
457
- // in dispatch.json carries observability — operators can spot which
458
+ // in dispatch state carries observability — operators can spot which
458
459
  // items the validator skipped vs which it ran.
459
460
  item._preDispatchEvalSkipped = 'prd-sourced';
460
461
  return addToDispatch(item);
@@ -569,17 +570,17 @@ function getStalePrDispatchReason(entry, config) {
569
570
  function cancelSourceWorkItemForPrunedDispatch(entry, reason) {
570
571
  const itemId = entry?.meta?.item?.id;
571
572
  if (!itemId) return false;
572
- let wiPath;
573
- try { wiPath = lifecycle().resolveWorkItemPath(entry.meta); }
573
+ let wiScope;
574
+ try { wiScope = lifecycle().resolveWorkItemScope(entry.meta); }
574
575
  catch (e) {
575
576
  log('warn', `Failed to resolve source work item for pruned dispatch ${entry.id}: ${e.message}`);
576
577
  return false;
577
578
  }
578
- if (!wiPath) return false;
579
+ if (!wiScope) return false;
579
580
 
580
581
  let cancelled = false;
581
582
  try {
582
- mutateWorkItems(wiPath, (items) => {
583
+ mutateWorkItems(wiScope, (items) => {
583
584
  if (!Array.isArray(items)) return items;
584
585
  const wi = items.find(w => w && w.id === itemId);
585
586
  if (!wi) return items;
@@ -608,9 +609,9 @@ function _resolveWorkItemForPrunedEntry(entry) {
608
609
  const itemId = entry?.meta?.item?.id;
609
610
  if (!itemId) return null;
610
611
  try {
611
- const wiPath = lifecycle().resolveWorkItemPath(entry.meta);
612
- if (!wiPath) return null;
613
- const items = safeJsonArr(wiPath);
612
+ const wiScope = lifecycle().resolveWorkItemScope(entry.meta);
613
+ if (!wiScope) return null;
614
+ const items = readWorkItems(wiScope);
614
615
  return items.find(w => w && w.id === itemId) || null;
615
616
  } catch {
616
617
  return null;
@@ -704,7 +705,7 @@ async function pruneStalePrDispatchesAsync(config = queries.getConfig()) {
704
705
  for (const entry of pending) {
705
706
  const reason = reasonsById.get(entry.id);
706
707
  if (!reason) continue;
707
- if (entry.type !== WORK_TYPE.FIX || !_MERGED_OR_ABANDONED_REASON_RE.test(reason)) continue;
708
+ if (!shared.isFixLikeWorkType(entry.type) || !_MERGED_OR_ABANDONED_REASON_RE.test(reason)) continue;
708
709
  try {
709
710
  const wi = _resolveWorkItemForPrunedEntry(entry);
710
711
  const scopePaths = shared.extractScopeFilePathsFromWorkItem(wi);
@@ -851,9 +852,9 @@ const FORCE_DEMOTE_FAILURE_CLASSES = new Set([
851
852
  function readLiveWorkItem(meta) {
852
853
  const itemId = meta?.item?.id;
853
854
  if (!itemId) return null;
854
- const wiPath = lifecycle().resolveWorkItemPath(meta);
855
- if (!wiPath) return null;
856
- const items = safeJsonArr(wiPath);
855
+ const wiScope = lifecycle().resolveWorkItemScope(meta);
856
+ if (!wiScope) return null;
857
+ const items = readWorkItems(wiScope);
857
858
  return items.find(i => i.id === itemId) || null;
858
859
  }
859
860
 
@@ -894,11 +895,11 @@ function markCompletedSourceWorkItem(item) {
894
895
  const meta = item?.meta;
895
896
  const itemId = meta?.item?.id;
896
897
  if (!itemId) return false;
897
- const wiPath = lifecycle().resolveWorkItemPath(meta);
898
- if (!wiPath) return false;
898
+ const wiScope = lifecycle().resolveWorkItemScope(meta);
899
+ if (!wiScope) return false;
899
900
  let found = false;
900
901
  let terminal = false;
901
- mutateWorkItems(wiPath, items => {
902
+ mutateWorkItems(wiScope, items => {
902
903
  if (!Array.isArray(items)) return items;
903
904
  const wi = items.find(i => i.id === itemId);
904
905
  if (!wi) return items;
@@ -985,7 +986,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
985
986
  if (item) {
986
987
  log('info', `Completed dispatch: ${id} (${result}${reason ? ': ' + reason : ''})`);
987
988
  // W-mpob4nyk0006580e — broadcast terminal state immediately after the
988
- // `result` field is committed to dispatch.json. This is the single
989
+ // `result` field is committed to dispatch state. This is the single
989
990
  // source of truth for the live-view banner; subscribers (dashboard SSE
990
991
  // handler at handleAgentLiveStream) push it to clients so the banner
991
992
  // mirrors `dispatch.result` exactly — no log-parsing heuristics that can
@@ -1046,11 +1047,11 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
1046
1047
  const isForceDemote = failureClass && FORCE_DEMOTE_FAILURE_CLASSES.has(failureClass) && !isPrShipped;
1047
1048
  if (isForceDemote) {
1048
1049
  try {
1049
- const wiPath = lifecycle().resolveWorkItemPath(item.meta);
1050
- if (wiPath) {
1050
+ const wiScope = lifecycle().resolveWorkItemScope(item.meta);
1051
+ if (wiScope) {
1051
1052
  const classSuffix = ` [${failureClass.toUpperCase().replace(/-/g, '_')}]`;
1052
1053
  const demoteReason = `Non-retryable failure: ${reason || failureClass}${classSuffix}`;
1053
- mutateWorkItems(wiPath, items => {
1054
+ mutateWorkItems(wiScope, items => {
1054
1055
  const wi = items.find(i => i.id === item.meta.item.id);
1055
1056
  if (wi) {
1056
1057
  wi.status = WI_STATUS.FAILED;
@@ -1093,7 +1094,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
1093
1094
  } else {
1094
1095
  let retries = (item.meta.item._retryCount || 0);
1095
1096
  try {
1096
- const wi = queries.getWorkItems().find(i => i.id === item.meta.item.id);
1097
+ const wi = liveWi || readLiveWorkItem(item.meta);
1097
1098
  if (wi) retries = wi._retryCount || 0;
1098
1099
  } catch (e) { log('warn', 'read retry count: ' + e.message); }
1099
1100
  const maxRetries = ENGINE_DEFAULTS.maxRetries;
@@ -1112,9 +1113,9 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
1112
1113
  }
1113
1114
  // Increment retry counter on the source work item
1114
1115
  try {
1115
- const wiPath = lifecycle().resolveWorkItemPath(item.meta);
1116
- if (wiPath) {
1117
- mutateWorkItems(wiPath, items => {
1116
+ const wiScope = lifecycle().resolveWorkItemScope(item.meta);
1117
+ if (wiScope) {
1118
+ mutateWorkItems(wiScope, items => {
1118
1119
  const wi = items.find(i => i.id === item.meta.item.id);
1119
1120
  if (wi && wi.status !== WI_STATUS.PAUSED && wi.status !== WI_STATUS.DONE && !wi.completedAt) {
1120
1121
  wi._retryCount = retries + 1;
@@ -1229,10 +1230,9 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
1229
1230
  const isTransientPreflight = TRANSIENT_PREFLIGHT_CLASSES.has(failureClass);
1230
1231
  if (prId && project) {
1231
1232
  try {
1232
- const prsPath = projectPrPath(project);
1233
1233
  let restored = false;
1234
1234
  let gaveUp = false;
1235
- mutatePullRequests(prsPath, prs => {
1235
+ mutatePullRequests(project, prs => {
1236
1236
  const target = shared.findPrRecord(prs, { id: prId }, project);
1237
1237
  if (!target?.humanFeedback) return;
1238
1238
  if (isTransientPreflight) {
@@ -1383,12 +1383,7 @@ function cleanDispatchEntries(matchFn) {
1383
1383
  const filesToDelete = [];
1384
1384
  const dispatchDirsToRemove = [];
1385
1385
  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.
1386
+ // Keep the drain and orphan-tagging passes atomic in the dispatch store.
1392
1387
  mutateDispatch((dispatch) => {
1393
1388
  for (const queue of ['pending', 'active', 'completed']) {
1394
1389
  dispatch[queue] = Array.isArray(dispatch[queue]) ? dispatch[queue] : [];
@@ -1449,10 +1444,10 @@ function cleanDispatchEntries(matchFn) {
1449
1444
  * Cancel pending/queued work items matching a predicate. Done items pass through.
1450
1445
  * Sets status=CANCELLED + _cancelledBy=reason. Returns count cancelled.
1451
1446
  */
1452
- function cancelPendingWorkItems(wiPath, matchFn, reason) {
1447
+ function cancelPendingWorkItems(scope, matchFn, reason) {
1453
1448
  let cancelled = 0;
1454
1449
  try {
1455
- mutateWorkItems(wiPath, items => {
1450
+ mutateWorkItems(scope, items => {
1456
1451
  for (const w of items) {
1457
1452
  if (!matchFn(w)) continue;
1458
1453
  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