@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
@@ -33,7 +33,7 @@ Watches, etc. It is the primary human-to-fleet interface.
33
33
 
34
34
  **Where it lives.**
35
35
  - System prompt: `prompts/cc-system.md`
36
- - Per-tab session state: `engine/cc-sessions.json` (also legacy `engine/cc-session.json`).
36
+ - Per-tab and global session state: `engine/state.db` (`cc_sessions`).
37
37
  - Direct CLI invocation: `engine/llm.js` `callLLM({ direct: true })` — bypasses
38
38
  `spawn-agent.js` and runs the runtime CLI itself.
39
39
 
@@ -70,10 +70,8 @@ Plan, Note, Schedule, Watch, Knowledge entry, Pinned note). Per-call cap is
70
70
  the home page eventually reduce to a Work Item.
71
71
 
72
72
  **Where it lives.**
73
- - Central queue: `work-items.json` (root).
74
- - Per-project queue: `projects/<name>/work-items.json` (`projectWorkItemsPath`,
75
- `engine/shared.js` line ~1530).
76
- - In-flight copy: `engine/dispatch.json` (`pending[] / active[] / completed[]`).
73
+ - Durable records: `engine/state.db` `work_items`, keyed by central/project scope.
74
+ - In-flight dispatch rows: `engine/state.db` `dispatches`.
77
75
 
78
76
  **Backing classes / functions.**
79
77
  - Status constants: `engine/shared.js` line 1348 (`WI_STATUS`).
@@ -327,7 +325,7 @@ engine drops a Work Item into the queue.
327
325
 
328
326
  **Where it lives.**
329
327
  - Definitions: `config.json` `schedules: []` (3-field cron `min hour dow`).
330
- - Last-run history: `engine/schedule-runs.json`.
328
+ - Last-run history: `engine/state.db` `schedule_runs`.
331
329
 
332
330
  **Backing classes / functions.**
333
331
  - Cron parser: `engine/scheduler.js`
@@ -361,7 +359,7 @@ analogous to **Watch** (event-driven instead of time-driven).
361
359
  (merged, build passes, build fails, status changes, new comments, vote changes). Watches are
362
360
  event-driven, schedules are time-driven.
363
361
 
364
- **Where it lives.** `engine/watches.json` (`engine/watches.js` line 17 `_watchesPath`).
362
+ **Where it lives.** `engine/state.db` `watches`.
365
363
 
366
364
  **Backing classes / functions.**
367
365
  - Status constants: `engine/shared.js` line 1380 (`WATCH_STATUS`).
@@ -389,11 +387,11 @@ A merged-PR watch is the canonical "tell me when X ships" primitive.
389
387
 
390
388
  **What it is.** A multi-stage workflow. Stages can be a Work Item (`task`), a `meeting`, or a
391
389
  `plan`. Stages can declare dependencies on previous stages. Pipelines run via triggers (manual,
392
- schedule, watch) and persist their state in `pipeline-runs.json`.
390
+ schedule, watch) and persist run state in SQLite.
393
391
 
394
392
  **Where it lives.**
395
393
  - Definitions: `pipelines/*.json` (one file per pipeline).
396
- - Run state: `engine/pipeline-runs.json`.
394
+ - Run state: `engine/state.db` `pipeline_runs`.
397
395
 
398
396
  **Backing classes / functions.**
399
397
  - Dirs: `engine/pipeline.js` lines 19–24 (`PIPELINES_DIR`, `PIPELINE_RUNS_PATH`, `PLANS_DIR`,
@@ -427,7 +425,7 @@ unrelated to ADO/GitHub Actions pipelines, even though the word collides.
427
425
  dispatch holds the agent's PID, prompt sidecar path, worktree path, and current status until the
428
426
  process exits.
429
427
 
430
- **Where it lives.** Inside `engine/dispatch.json`'s `active[]` array.
428
+ **Where it lives.** Active rows in `engine/state.db` `dispatches`.
431
429
 
432
430
  **Backing classes / functions.**
433
431
  - `mutateDispatch`: `engine/dispatch.js` line 60 (the only safe RMW path).
@@ -450,7 +448,7 @@ tick. Read shape via `/api/status` (line 6838).
450
448
 
451
449
  ## 13. Dispatch Queue
452
450
 
453
- **What it is.** The three-bucket structure inside `engine/dispatch.json`:
451
+ **What it is.** The sectioned view reconstructed from SQL dispatch rows:
454
452
 
455
453
  - `pending[]` — admitted Work Items waiting on agent / dependency / cooldown / budget.
456
454
  - `active[]` — currently spawned.
@@ -460,7 +458,7 @@ The queue's invariants are: max concurrent (default 5), per-PR / per-branch lock
460
458
  agents fighting on the same branch, dependency-aware spawning (`depends_on` IDs must be done
461
459
  first), retry on failure up to `ENGINE_DEFAULTS.maxRetries`.
462
460
 
463
- **Where it lives.** `engine/dispatch.json`.
461
+ **Where it lives.** `engine/state.db` `dispatches`.
464
462
 
465
463
  **Backing classes / functions.** All in `engine/dispatch.js`. Mutation only via `mutateDispatch`
466
464
  line 60. Queue dedupe keys: `getDispatchProjectKey` line 80, `getPrDispatchTargetKey` line 85,
@@ -502,12 +500,11 @@ record). It also feeds **Pull Request** sync (`pr` field).
502
500
 
503
501
  ## 15. Pull Request (as tracked in minions)
504
502
 
505
- **What it is.** A row in `projects/<name>/pull-requests.json`. Tracks status, vote tally, build
503
+ **What it is.** A project-scoped row in the SQL PR store. Tracks status, vote tally, build
506
504
  state, review verdict, and the work item that created it. *Not* the GitHub/ADO PR itself —
507
505
  minions polls the host and writes a sanitized snapshot here.
508
506
 
509
- **Where it lives.** `projects/<name>/pull-requests.json` (`projectPrPath`, `engine/shared.js`
510
- line 1546). Cross-project links also tracked in `engine/pr-links.json`.
507
+ **Where it lives.** `engine/state.db` `pull_requests`; cross-project links use `pr_links`.
511
508
 
512
509
  **Backing classes / functions.**
513
510
  - Status / pollable constants: `engine/shared.js` line 1372 (`PR_STATUS`,
@@ -541,7 +538,7 @@ Examples: Ripley (architect / explorer), Dallas (engineer), Lambert (analyst), R
541
538
  - Default roster: `engine/shared.js` line 1486 (`DEFAULT_AGENTS`).
542
539
  - Charter: `agents/<id>/charter.md` — read by `getAgentCharter` (`engine/queries.js` line 505).
543
540
  - Per-agent config (cli, model, budget, skills): `config.json` `agents.<id>`.
544
- - Metrics: `engine/metrics.json` (per-agent token / cost / quality / runtime).
541
+ - Metrics: `engine/state.db` `metrics` (per-agent token / cost / quality / runtime).
545
542
 
546
543
  **Backing classes / functions.**
547
544
  - Charter loader: `engine/queries.js` line 505.
@@ -569,14 +566,14 @@ gets which work type.
569
566
 
570
567
  **Where it lives.**
571
568
  - Definitions: `config.json` `projects: []`.
572
- - State: `projects/<name>/{work-items.json, pull-requests.json}`.
569
+ - Runtime records: project-scoped rows in `engine/state.db`.
573
570
  - Removal artifact: `projects/.archived/<name>-YYYYMMDD/`.
574
571
 
575
572
  **Backing classes / functions.**
576
573
  - Helpers: `engine/shared.js`
577
- - `getProjects` (~line 1502)
578
- - `projectStateDir`, `projectWorkItemsPath` (~line 1530)
579
- - `projectPrPath` (line 1546).
574
+ - `getProjects`
575
+ - `stateScope`, `readWorkItems`, `readPullRequests`
576
+ - `projectStateDir` for file-backed project definitions/artifacts.
580
577
  - Removal: `engine/projects.js` `removeProject` (canonical teardown — never edit `config.json`
581
578
  directly).
582
579
 
@@ -658,13 +655,14 @@ consolidates the **Inbox**, and so on. It's also the gatekeeper for control stat
658
655
 
659
656
  **Where it lives.**
660
657
  - Process: `engine.js` (root).
661
- - State: `engine/control.json` (mode), `engine/log.json` (audit ring buffer, max 2500 trimmed
662
- to 2000), `engine/metrics.json` (counters), `engine/dispatch.json` (queue).
658
+ - State: `engine/state.db` (logs, metrics, queues, watches, schedules, PRDs) plus
659
+ `engine/control.json` for process coordination.
663
660
 
664
661
  **Backing classes / functions.**
665
662
  - Tick: `engine.js` (top-level `tick()`).
666
663
  - Defaults: `engine/shared.js` line 860 (`ENGINE_DEFAULTS`).
667
- - Concurrency caps: `mutateJsonFileLocked` for all shared-JSON RMW.
664
+ - Concurrency: SQLite transactions for runtime records; file locks only for intentionally
665
+ file-backed definitions and coordination sidecars.
668
666
  - Restart re-attach: PID files in `engine/tmp/pid-<id>.pid` + `live-output.log` mtimes,
669
667
  20-min grace.
670
668
 
@@ -839,6 +837,6 @@ Pipeline (stages: task / meeting / plan) ──► spawns Work Items / Meetings
839
837
  Schedule (cron) ──► spawns Work Items
840
838
  Watch (event) ──► spawns notifications / Work Items / pipeline-continuations
841
839
 
842
- Engine reads: routing.md, config.json, all state JSON
843
- Engine writes: control.json, log.json, metrics.json, dispatch.json, project state files
840
+ Engine reads: routing.md, config.json, definitions, engine/state.db
841
+ Engine writes: control.json, artifacts, engine/state.db
844
842
  ```
package/docs/watches.md CHANGED
@@ -4,7 +4,7 @@ Persistent monitoring jobs that fire inbox notifications and follow-up actions w
4
4
 
5
5
  ## What a Watch Is
6
6
 
7
- A watch is a record persisted in `engine/state.db`; `engine/watches.json` is a passive diagnostic mirror. It binds:
7
+ A watch is a record persisted in `engine/state.db`. It binds:
8
8
 
9
9
  | Field | Purpose |
10
10
  |--------------|-------------------------------------------------------------------------|
@@ -130,7 +130,7 @@ module.exports = {
130
130
 
131
131
  Resolution is `path.join(shared.MINIONS_DIR, 'watches.d')` so it works in both dev checkouts and installed `~/.minions/` layouts. Missing folder is silent; per-file failures are isolated (load errors log a `WARN` line, don't abort boot). Tests can re-scan via the exported `_loadPluginTargetTypes()`.
132
132
 
133
- **Canonical plugin example:** [`watches.d/http.js`](../watches.d/http.js) (W-mp7i22mu00191b07) ships a generic `http` target type — point a watch at any URL or `{ url, method, headers, body, extract, expected, regex, timeoutMs }` config and the plugin exposes `status-change`, `value-change`, `value-equals`, `value-matches`, and `http-error` conditions. Auth headers support `{{env.GH_TOKEN}}`-style substitution at fetch time (secrets are never stored in `watches.json`). All conditions are change-based (`absoluteConditions: []`).
133
+ **Canonical plugin example:** [`watches.d/http.js`](../watches.d/http.js) (W-mp7i22mu00191b07) ships a generic `http` target type — point a watch at any URL or `{ url, method, headers, body, extract, expected, regex, timeoutMs }` config and the plugin exposes `status-change`, `value-change`, `value-equals`, `value-matches`, and `http-error` conditions. Auth headers support `{{env.GH_TOKEN}}`-style substitution at fetch time (secrets are never persisted in watch records). All conditions are change-based (`absoluteConditions: []`).
134
134
 
135
135
  ## Tick Integration
136
136
 
@@ -222,10 +222,10 @@ CC will discover the endpoints from `GET /api/routes` and `POST /api/watches` wi
222
222
 
223
223
  When a watch fires:
224
224
 
225
- - `engine/watches.json` entry gets `triggerCount++`, `last_triggered=<ISO>`, `_lastTriggerMessage=<message>`, and a refreshed `_lastState`.
225
+ - The SQL watch record gets `triggerCount++`, `last_triggered=<ISO>`, `_lastTriggerMessage=<message>`, and a refreshed `_lastState`.
226
226
  - An inbox file lands at `notes/inbox/<owner>/watch-<watchId>-<n>.md` (when `notify === 'inbox'`).
227
227
  - If `action` is set, a follow-up runs (work item, webhook POST, plan archive, ...) and writes `_lastActionResult` (`{ type, ok, summary, dispatchedItemId?, at }`) back onto the watch.
228
- - `engine.log.json` gets `Watch triggered: <id> — <message>` and `Watch <id> action <type>: <summary>`.
228
+ - The SQL engine log gets `Watch triggered: <id> — <message>` and `Watch <id> action <type>: <summary>`.
229
229
 
230
230
  Absolute conditions firing under `stopAfter === 0` flip `status` to `expired`; `stopAfter > 0` flips it to `expired` once `triggerCount >= stopAfter`.
231
231
 
@@ -236,14 +236,14 @@ Absolute conditions firing under `stopAfter === 0` flip `status` to `expired`; `
236
236
  | Watch never fires | Check `status === 'active'`; check `last_checked` advancing each cycle; confirm engine tick is running and `interval` isn't longer than your test window |
237
237
  | `evaluateWatch` returns `"<label> <target> not found"` | `fetchEntity` got nothing back — wrong `target` (e.g. PR display id vs canonical id), the target type isn't loaded, or the underlying file (PR cache, plan PRD) doesn't exist |
238
238
  | `"Unknown target type"` / `"Unknown condition"` | The registry doesn't recognise the value. Check `GET /api/watches/target-types` to see what's registered server-side; condition must be in that target type's `conditions[]` |
239
- | Change condition fires immediately on first tick | Won't happen — baseline `_lastState` is captured on the first check before `evaluate` runs *(source: `engine/watches.js:434`)*. If you see this, suspect manual edits to `watches.json` |
239
+ | Change condition fires immediately on first tick | Won't happen — baseline `_lastState` is captured on the first check before `evaluate` runs *(source: `engine/watches.js`)*. If you see this, inspect the persisted watch through the API |
240
240
  | Absolute watch fires forever instead of once | `stopAfter` is set to a non-zero value; only `stopAfter === 0` triggers fire-once expiration |
241
241
  | Action runs but inbox notification doesn't | `notify` field isn't `'inbox'`, or `owner` is empty. `notify` and `action` are independent — both can fire, or only one |
242
242
  | `_lastActionResult.ok === false` with `"unknown action type"` | The `action.type` isn't registered. List with `listActionTypes()` / `GET /api/watches/action-types` |
243
243
  | Webhook action returns `"only http/https allowed"` | URLs must use `http://` or `https://` schemes; other protocols are rejected by design *(source: `engine/watch-actions.js` `WEBHOOK` handler)* |
244
- | Trigger fires but follow-up `dispatch-work-item` is missing | Check the engine log for `Watch <id> action <type>: <summary>`. Common reasons: missing `title`, the project's `work-items.json` couldn't be written, or the WI landed in central `work-items.json` because no project was specified |
244
+ | Trigger fires but follow-up `dispatch-work-item` is missing | Check the engine log for `Watch <id> action <type>: <summary>`. Common reasons: missing `title`, a project-scope write failed, or the WI landed in the central scope because no project was specified |
245
245
  | Watch `_lastActionResult` shows `"timeout"` for webhook | Webhooks have a 10s safety timeout to keep the watches tick fast *(source: `engine/watch-actions.js:482-484`)* |
246
- | `checkWatches` block crashes silently | Wrapped in `safe('checkWatches', ...)` so one failure doesn't abort the tick *(source: `engine.js:7443`)*. Inspect `engine/log.json` for `Watch check error (<id>)` lines. Regression #1088: the block must use `getProjects(config)`, never the long-removed `PROJECTS` constant |
246
+ | `checkWatches` block crashes silently | Wrapped in `safe('checkWatches', ...)` so one failure doesn't abort the tick. Inspect the engine log for `Watch check error (<id>)` lines. Regression #1088: the block must use `getProjects(config)`, never the long-removed `PROJECTS` constant |
247
247
 
248
248
  ## See Also
249
249
 
@@ -84,7 +84,7 @@ const { MEMORY_SCOPES, WORKSPACE_MANIFEST_DEFAULTS,
84
84
 
85
85
  ## Audit trail
86
86
 
87
- Because rejections route through the standard `completeDispatch(... ERROR ...)` path, the existing dispatch + inbox + dashboard timeline machinery already surfaces every manifest rejection. No new state file or event topic was added. Per-agent audit of what each minion actually touched can be derived from existing `engine/dispatch.json` records filtered by `failure_class === 'workspace-manifest-repo-forbidden'`.
87
+ Because rejections route through the standard `completeDispatch(... ERROR ...)` path, the existing dispatch + inbox + dashboard timeline machinery already surfaces every manifest rejection. No new table or event topic was added. Per-agent audit can be derived from SQL dispatch records filtered by `failure_class === 'workspace-manifest-repo-forbidden'`.
88
88
 
89
89
  ## Rollout guidance
90
90
 
@@ -45,7 +45,7 @@ recycle worktree dirs across branches.
45
45
  - **Return** in `onAgentClose` BEFORE `completeDispatch`:
46
46
  `git reset --hard HEAD` → `git clean -fd` → `git fetch origin <main>`
47
47
  → `git checkout --detach origin/<main>` → mark IDLE.
48
- - **State** at `engine/worktree-pool.json`; git ops outside any lock.
48
+ - **State** in SQLite (`worktree_pool`); git ops outside the transaction.
49
49
 
50
50
  ## Ownership marker is never "dirty" (#284)
51
51
 
@@ -20,7 +20,7 @@
20
20
  * The version-gated one-shot model gives us exactly what we need: re-probe
21
21
  * once per release that bumps `ENGINE_DEFAULTS.abandonedReconciliationVersion`,
22
22
  * then never again until the next bump. First boot after this lands cleans up
23
- * the historical false-flips; subsequent boots are a single state.json read +
23
+ * the historical false-flips; subsequent boots are a single state-marker read +
24
24
  * version compare → no-op.
25
25
  *
26
26
  * ## Why a separate WI from W-mp5trwh60008386d?
@@ -37,14 +37,13 @@
37
37
  * Errors here must NOT block boot — the catch in cli.js logs and continues.
38
38
  */
39
39
 
40
- const path = require('path');
41
40
  const shared = require('./shared');
42
41
 
43
42
  const { ENGINE_DEFAULTS, log, mutateEngineState, readEngineState } = shared;
44
43
 
45
44
  /**
46
45
  * Run the version-gated startup reconciliation pass. Idempotent across reruns
47
- * at the same code version: state.json gates re-execution.
46
+ * at the same code version: persisted engine state gates re-execution.
48
47
  *
49
48
  * @param {object} config — engine config (used to enumerate projects)
50
49
  * @param {object} [options]
@@ -82,7 +81,7 @@ async function runStartupReconciliation(config, options = {}) {
82
81
  const projects = shared.getProjects(config);
83
82
  projectCount = projects.length;
84
83
  for (const p of projects) {
85
- const prs = shared.safeJsonArr(shared.projectPrPath(p));
84
+ const prs = shared.readPullRequests(p);
86
85
  preCount += prs.filter(pr =>
87
86
  pr.status === shared.PR_STATUS.ABANDONED && !pr._reconciliation404Confirmed
88
87
  ).length;
@@ -128,7 +127,7 @@ async function runStartupReconciliation(config, options = {}) {
128
127
  return state;
129
128
  });
130
129
  } catch (err) {
131
- // State.json write failure is unfortunate but non-fatal: next boot will
130
+ // Marker persistence failure is non-fatal: next boot will
132
131
  // re-run the pass, which is idempotent (already-flipped PRs aren't
133
132
  // abandoned anymore, confirmed-404s carry the marker). Log and continue.
134
133
  log('warn', `Abandoned PR reconciliation: failed to persist version marker: ${err.message}`);
@@ -14,7 +14,7 @@
14
14
  *
15
15
  * Output: JSON to stdout. Exit 0 on success, 1 on error/not-found.
16
16
  *
17
- * Cached (default): reads pull-requests.json maintained by the engine (~3 min stale).
17
+ * Cached (default): reads engine-maintained SQL state (~3 min stale).
18
18
  * Live (--live): makes fresh ADO API calls — use when engine isn't running or you
19
19
  * just pushed and need up-to-the-moment build status.
20
20
  */
@@ -23,7 +23,7 @@
23
23
 
24
24
  const path = require('path');
25
25
  const shared = require('./shared');
26
- const { safeJson, safeJsonArr, projectPrPath, getProjects, MINIONS_DIR } = shared;
26
+ const { safeJson, readPullRequests, getProjects, MINIONS_DIR } = shared;
27
27
 
28
28
  // ── Arg parsing ──────────────────────────────────────────────────────────────
29
29
 
@@ -56,7 +56,7 @@ const prNumber = parseInt(prNumberArg, 10);
56
56
 
57
57
  function findInCache(projects) {
58
58
  for (const project of projects) {
59
- const prs = safeJsonArr(projectPrPath(project));
59
+ const prs = readPullRequests(project);
60
60
  shared.normalizePrRecords(prs, project);
61
61
  const pr = prs.find(p => p.prNumber === prNumber || p.id === shared.getCanonicalPrId(project, prNumber) || shared.getPrDisplayId(p) === `PR-${prNumber}`);
62
62
  if (!pr) continue;
@@ -104,7 +104,7 @@ async function main() {
104
104
  if (!live) {
105
105
  const result = findInCache(projects);
106
106
  if (result) { console.log(JSON.stringify(result, null, 2)); return; }
107
- console.error(`PR #${prNumber} not found in cached pull-requests.json`);
107
+ console.error(`PR #${prNumber} not found in the cached PR store`);
108
108
  console.error('Tip: try --live for a fresh ADO API call, or check the PR number is correct.');
109
109
  process.exit(1);
110
110
  }
@@ -133,7 +133,7 @@ async function main() {
133
133
  return;
134
134
  }
135
135
 
136
- console.error(`PR #${prNumber} not found (tried live ADO API and cached pull-requests.json).`);
136
+ console.error(`PR #${prNumber} not found (tried live ADO API and the cached PR store).`);
137
137
  process.exit(1);
138
138
  }
139
139
 
package/engine/ado.js CHANGED
@@ -279,7 +279,7 @@ function repairAdoProjectConfig(project, purpose, prs = null) {
279
279
  const missingBefore = getMissingAdoProjectConfigFields(project);
280
280
  if (missingBefore.length === 0) return false;
281
281
 
282
- const trackedPrs = prs || shared.safeJsonArr(shared.projectPrPath(project));
282
+ const trackedPrs = prs || shared.readPullRequests(project);
283
283
  for (const candidate of collectAdoRepairCandidates(project, trackedPrs)) {
284
284
  const repairs = {};
285
285
  if (!project.adoOrg && candidate.adoOrg) repairs.adoOrg = candidate.adoOrg;
@@ -1199,7 +1199,7 @@ async function forEachActivePr(config, token, callback) {
1199
1199
  }
1200
1200
 
1201
1201
  if (projectUpdated > 0) {
1202
- mutateJsonFileLocked(shared.projectPrPath(project), (currentPrs) => {
1202
+ shared.mutatePullRequests(project, (currentPrs) => {
1203
1203
  // Merge back only fields changed by callbacks; preserve concurrent disk updates.
1204
1204
  for (const { before, after } of updatedRecords) {
1205
1205
  const updatedPrNumber = shared.getPrNumber(after);
@@ -1244,9 +1244,9 @@ async function forEachActivePr(config, token, callback) {
1244
1244
  }
1245
1245
  }
1246
1246
 
1247
- // BUG-C2 — Also poll manually-linked ADO PRs from central pull-requests.json.
1247
+ // BUG-C2 — Also poll manually linked ADO PRs from the central PR store.
1248
1248
  // Mirror of engine/github.js:540-633. Without this block, any ADO PR enrolled
1249
- // into central pull-requests.json via the dashboard attach UI or
1249
+ // into the central PR store via the dashboard attach UI or
1250
1250
  // lifecycle.enrollPrFromCanonicalId is never polled (status, build, review,
1251
1251
  // comments, abandoned-reconciliation all freeze permanently).
1252
1252
  //
@@ -1272,8 +1272,7 @@ async function forEachActivePr(config, token, callback) {
1272
1272
  // in that project's per-project file, so the project loop will really see
1273
1273
  // them. Blindly skipping all project-compatible IDs caused a permanent
1274
1274
  // orphan when the record lived in the central file (#328).
1275
- const centralPath = path.join(shared.MINIONS_DIR, 'pull-requests.json');
1276
- const centralPrs = shared.safeJsonArr(centralPath);
1275
+ const centralPrs = shared.readPullRequests('central');
1277
1276
  const configuredAdoProjects = projects.filter(p => !isGitHubProject(p) && p.adoOrg && p.adoProject);
1278
1277
 
1279
1278
  const activeCentral = centralPrs.filter(pr => {
@@ -1287,7 +1286,7 @@ async function forEachActivePr(config, token, callback) {
1287
1286
  // polled (fix for #328).
1288
1287
  const owningProject = configuredAdoProjects.find(p => shared.isPrCompatibleWithProject(p, { id: pr.id }, ''));
1289
1288
  if (owningProject) {
1290
- const projectPrs = shared.safeJsonArr(shared.projectPrPath(owningProject));
1289
+ const projectPrs = shared.readPullRequests(owningProject);
1291
1290
  if (projectPrs.some(p => p.id === pr.id)) return false; // present in per-project file → project loop handles it
1292
1291
  }
1293
1292
  return true;
@@ -1353,7 +1352,7 @@ async function forEachActivePr(config, token, callback) {
1353
1352
  }
1354
1353
 
1355
1354
  if (centralUpdated > 0) {
1356
- mutateJsonFileLocked(centralPath, (currentPrs) => {
1355
+ shared.mutatePullRequests('central', (currentPrs) => {
1357
1356
  for (const { before, after } of updatedCentralRecords) {
1358
1357
  const idx = currentPrs.findIndex(p => p.id === after.id);
1359
1358
  if (idx >= 0) {
@@ -1457,7 +1456,7 @@ async function pollPrStatus(config) {
1457
1456
  }
1458
1457
  // F6 (P-f6commentedit): drop the comment-edit dedup map on merge/abandon
1459
1458
  // — no further re-dispatch path can fire from this PR, so keeping it
1460
- // around would only bloat pull-requests.json.
1459
+ // around would only bloat the PR store.
1461
1460
  if (pr.humanFeedback && pr.humanFeedback.editsSeen) {
1462
1461
  delete pr.humanFeedback.editsSeen;
1463
1462
  }
@@ -1911,7 +1910,7 @@ async function pollPrStatus(config) {
1911
1910
  // review status flipped, etc.) is dropped via clearPrNoOpFixAttempt. The
1912
1911
  // dispatch gate already ignores these stale records (isPrNoOpFixCausePaused
1913
1912
  // re-validates the fingerprint), but without this sweep they linger in
1914
- // pull-requests.json forever — bloating the file and confusing the
1913
+ // PR store forever — bloating the registry and confusing the
1915
1914
  // dashboard `pr-paused-chip`. Runs AFTER all live-field updates so the
1916
1915
  // fingerprint comparison uses post-poll PR state.
1917
1916
  try {
@@ -2160,7 +2159,7 @@ async function pollPrHumanComments(config) {
2160
2159
 
2161
2160
  /**
2162
2161
  * Reconcile PRs: find active ADO PRs created by the minions that aren't tracked
2163
- * in pull-requests.json, and add them. Matches PRs to work items by branch name.
2162
+ * in the PR store, and add them. Matches PRs to work items by branch name.
2164
2163
  */
2165
2164
  async function reconcilePrs(config) {
2166
2165
  // #3758 — inert when no project actually uses ADO.
@@ -2207,18 +2206,15 @@ async function reconcilePrs(config) {
2207
2206
 
2208
2207
  if (adoPrs.length === 0) continue;
2209
2208
 
2210
- const prPath = shared.projectPrPath(project);
2211
- const existingPrs = shared.safeJsonArr(prPath);
2209
+ const existingPrs = shared.readPullRequests(project);
2212
2210
  shared.normalizePrRecords(existingPrs, project);
2213
2211
  const existingIds = new Set(existingPrs.map(p => p.id));
2214
2212
  let projectAdded = 0;
2215
2213
  let metadataUpdated = 0;
2216
2214
 
2217
2215
  // Load work items to match branches to work item IDs
2218
- const wiPath = shared.projectWorkItemsPath(project);
2219
- const workItems = shared.safeJsonArr(wiPath);
2220
- const centralWiPath = path.join(shared.MINIONS_DIR, 'work-items.json');
2221
- const centralItems = shared.safeJsonArr(centralWiPath);
2216
+ const workItems = shared.readWorkItems(project);
2217
+ const centralItems = shared.readWorkItems('central');
2222
2218
  const allItems = [...workItems, ...centralItems];
2223
2219
 
2224
2220
  let projectUpdated = 0;
@@ -2251,9 +2247,9 @@ async function reconcilePrs(config) {
2251
2247
  log('warn', `ADO: invalid sourceRefName "${branch.slice(0, 64)}" for PR ${prId}: ${refErr.message}`);
2252
2248
  }
2253
2249
  }
2254
- // PR already tracked — write link to pr-links.json if we can extract an ID
2250
+ // PR already tracked — write a PR link if we can extract an ID
2255
2251
  if (confirmedItemId) {
2256
- shared.upsertPullRequestRecord(prPath, existing || {
2252
+ shared.upsertPullRequestRecord(project, existing || {
2257
2253
  id: prId,
2258
2254
  prNumber: adoPr.pullRequestId,
2259
2255
  title: (adoPr.title || `PR #${adoPr.pullRequestId}`).slice(0, 120),
@@ -2312,7 +2308,7 @@ async function reconcilePrs(config) {
2312
2308
  sourcePlan: linkedItem?.sourcePlan || '',
2313
2309
  itemType: linkedItem?.itemType || '',
2314
2310
  };
2315
- const upserted = shared.upsertPullRequestRecord(prPath, entry, { project, itemId: confirmedItemId });
2311
+ const upserted = shared.upsertPullRequestRecord(project, entry, { project, itemId: confirmedItemId });
2316
2312
  existingPrs.push(upserted.record || entry);
2317
2313
  existingIds.add(prId);
2318
2314
  projectAdded++;
@@ -2331,7 +2327,7 @@ async function reconcilePrs(config) {
2331
2327
  const backfilled = shared.backfillPrPrdItems(existingPrs, shared.getPrLinks());
2332
2328
 
2333
2329
  if (projectAdded > 0 || projectUpdated > 0 || backfilled > 0 || metadataUpdated > 0) {
2334
- mutateJsonFileLocked(prPath, (currentPrs) => {
2330
+ shared.mutatePullRequests(project, (currentPrs) => {
2335
2331
  // Merge reconciled PRs into the locked copy by ID
2336
2332
  for (const pr of existingPrs) {
2337
2333
  const idx = currentPrs.findIndex(p => p.id === pr.id);
@@ -2905,7 +2901,7 @@ async function reconcileAbandonedPrs(config) {
2905
2901
  // log line is honest about what we left behind.
2906
2902
  let pending = 0;
2907
2903
  for (const project of projects) {
2908
- const prs = shared.safeJsonArr(shared.projectPrPath(project));
2904
+ const prs = shared.readPullRequests(project);
2909
2905
  pending += prs.filter(pr =>
2910
2906
  pr.status === shared.PR_STATUS.ABANDONED && !pr._reconciliation404Confirmed
2911
2907
  ).length;
@@ -2926,8 +2922,7 @@ async function reconcileAbandonedPrs(config) {
2926
2922
  continue;
2927
2923
  }
2928
2924
 
2929
- const prPath = shared.projectPrPath(project);
2930
- const prs = shared.safeJsonArr(prPath);
2925
+ const prs = shared.readPullRequests(project);
2931
2926
  const abandonedPrs = prs.filter(pr =>
2932
2927
  pr.status === shared.PR_STATUS.ABANDONED
2933
2928
  && !pr._reconciliation404Confirmed
@@ -3010,7 +3005,7 @@ async function reconcileAbandonedPrs(config) {
3010
3005
 
3011
3006
  if (updates.length > 0) {
3012
3007
  const reconciledAt = ts();
3013
- shared.mutatePullRequests(prPath, (currentPrs) => {
3008
+ shared.mutatePullRequests(project, (currentPrs) => {
3014
3009
  for (const upd of updates) {
3015
3010
  const pr = currentPrs.find(p => shared.getPrNumber(p) === upd.prNumber);
3016
3011
  if (!pr) continue;
@@ -112,6 +112,7 @@ const _leaseContexts = new Map(); // dispatchId → mutable session-only context
112
112
  // toward `_poolSize` so a burst of concurrent acquireWorker calls can't
113
113
  // overshoot the configured pool size while spawns are still in flight.
114
114
  let _reserved = 0;
115
+ let _draining = false;
115
116
 
116
117
  function _occupied() {
117
118
  return _free.length + _busy.size + _reserved;
@@ -169,6 +170,11 @@ async function _reuseWorker(worker, req) {
169
170
  await worker.newSession({ mcpServers: req.mcpServers, model: req.model, effort: req.effort, cwd: req.cwd });
170
171
  _trace(`dispatch=${req.dispatchId} reuse: worker=${worker.id} sessionId=${worker.sessionId}`);
171
172
  _reserved--;
173
+ if (_draining) {
174
+ try { worker.close(); } catch { /* already torn down */ }
175
+ req.reject(new Error('agent-worker-pool: draining'));
176
+ return;
177
+ }
172
178
  req.resolve(worker);
173
179
  } catch (err) {
174
180
  _reserved--;
@@ -185,6 +191,11 @@ async function _respawnWorker(stale, req) {
185
191
  try {
186
192
  const worker = await _bootWorker(req);
187
193
  _reserved--;
194
+ if (_draining) {
195
+ try { worker.close(); } catch { /* already torn down */ }
196
+ req.reject(new Error('agent-worker-pool: draining'));
197
+ return;
198
+ }
188
199
  req.resolve(worker);
189
200
  } catch (err) {
190
201
  _reserved--;
@@ -197,6 +208,11 @@ async function _spawnFreshWorker(req) {
197
208
  try {
198
209
  const worker = await _bootWorker(req);
199
210
  _reserved--;
211
+ if (_draining) {
212
+ try { worker.close(); } catch { /* already torn down */ }
213
+ req.reject(new Error('agent-worker-pool: draining'));
214
+ return;
215
+ }
200
216
  req.resolve(worker);
201
217
  } catch (err) {
202
218
  _reserved--;
@@ -299,6 +315,7 @@ async function acquireWorker({
299
315
  dispatchId, cwd, model, effort, mcpServers, hermeticDirs, processEnv, sessionContext,
300
316
  } = {}) {
301
317
  if (!dispatchId) throw new Error('agent-worker-pool.acquireWorker: dispatchId is required');
318
+ if (_draining) throw new Error('agent-worker-pool: draining');
302
319
  if (_busy.has(dispatchId) || _leaseContexts.has(dispatchId)) {
303
320
  throw new Error(
304
321
  `agent-worker-pool.acquireWorker: dispatchId ${dispatchId} already has an active worker ` +
@@ -329,13 +346,20 @@ async function acquireWorker({
329
346
  _pump();
330
347
  });
331
348
 
349
+ if (_draining) throw new Error('agent-worker-pool: draining');
350
+ _busy.set(dispatchId, worker);
332
351
  await _applyHermeticDirs(worker, hermeticDirs);
333
352
  if (worker.killed) throw new Error('agent-worker-pool: worker died during acquisition');
353
+ if (_draining) throw new Error('agent-worker-pool: draining');
334
354
  } catch (err) {
355
+ if (_busy.get(dispatchId) === worker) _busy.delete(dispatchId);
335
356
  _clearLeaseContext(dispatchId);
357
+ if (worker) {
358
+ try { worker.close(); } catch { /* already torn down */ }
359
+ _pump();
360
+ }
336
361
  throw err;
337
362
  }
338
- _busy.set(dispatchId, worker);
339
363
 
340
364
  return {
341
365
  dispatchId,
@@ -381,13 +405,42 @@ function release(dispatchId) {
381
405
  _pump();
382
406
  return true;
383
407
  }
408
+ if (_draining) {
409
+ try { worker.close(); } catch { /* already torn down */ }
410
+ _trace(`dispatch=${dispatchId} release: draining, worker=${worker.id} closed`);
411
+ return true;
412
+ }
384
413
  _free.push(worker);
385
414
  _trace(`dispatch=${dispatchId} release: worker=${worker.id} returned to free set`);
386
415
  _pump();
387
416
  return true;
388
417
  }
389
418
 
419
+ function beginDrain() {
420
+ if (_draining) return;
421
+ _draining = true;
422
+ for (const req of _queue.splice(0)) {
423
+ try { req.reject(new Error('agent-worker-pool: draining')); } catch { /* already settled */ }
424
+ }
425
+ for (const worker of _free.splice(0)) {
426
+ try { worker.close(); } catch { /* already torn down */ }
427
+ }
428
+ }
429
+
430
+ function isDraining() {
431
+ return _draining;
432
+ }
433
+
434
+ function isDrained() {
435
+ return _busy.size === 0 && _reserved === 0;
436
+ }
437
+
438
+ function getActiveLeaseIds() {
439
+ return [..._busy.keys()];
440
+ }
441
+
390
442
  function shutdown() {
443
+ _draining = true;
391
444
  for (const req of _queue.splice(0)) {
392
445
  _clearLeaseContext(req.dispatchId);
393
446
  try { req.reject(new Error('agent-worker-pool: shutdown')); } catch { /* swallow */ }
@@ -408,6 +461,10 @@ module.exports = {
408
461
  release,
409
462
  setPoolSize,
410
463
  getPoolSize,
464
+ beginDrain,
465
+ isDraining,
466
+ isDrained,
467
+ getActiveLeaseIds,
411
468
  shutdown,
412
469
  DEFAULT_POOL_SIZE,
413
470
  // Exposed for unit tests; engine code MUST go through the public API.