@yemi33/minions 0.1.2448 → 0.1.2449

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 (315) hide show
  1. package/bin/cli-api-client.js +1 -1
  2. package/bin/install-internal-minions.js +1382 -44
  3. package/bin/install-layout.js +150 -0
  4. package/bin/minions.js +460 -167
  5. package/dashboard/docs/typography.md +65 -12
  6. package/dashboard/js/command-center.js +58 -4
  7. package/dashboard/js/detail-panel.js +36 -0
  8. package/dashboard/js/memory-panel.js +59 -12
  9. package/dashboard/js/qa.js +179 -20
  10. package/dashboard/js/refresh.js +148 -12
  11. package/dashboard/js/render-dispatch.js +3 -4
  12. package/dashboard/js/render-inbox.js +2 -2
  13. package/dashboard/js/render-other.js +3 -3
  14. package/dashboard/js/render-pipelines.js +14 -0
  15. package/dashboard/js/render-plans.js +57 -9
  16. package/dashboard/js/render-prd.js +132 -23
  17. package/dashboard/js/render-prs.js +195 -166
  18. package/dashboard/js/render-schedules.js +63 -3
  19. package/dashboard/js/render-utils.js +3 -3
  20. package/dashboard/js/render-watches.js +19 -3
  21. package/dashboard/js/render-work-items.js +238 -30
  22. package/dashboard/js/settings.js +205 -54
  23. package/dashboard/js/utils.js +51 -1
  24. package/dashboard/pages/home.html +1 -1
  25. package/dashboard/pages/qa.html +1 -16
  26. package/dashboard/pages/work.html +40 -0
  27. package/dashboard/shared/cc-limits.js +79 -0
  28. package/dashboard/shared/pr-filters.js +21 -38
  29. package/dashboard/shared/project-git-summary.js +1 -1
  30. package/dashboard/shared/record-filters.js +169 -0
  31. package/dashboard/shared/watches-source.js +1 -1
  32. package/dashboard/shared/welcome-popup.js +1 -1
  33. package/dashboard/shared/wi-filters.js +302 -0
  34. package/dashboard/slim/body.html +1 -0
  35. package/dashboard/slim/js/command-send.js +26 -0
  36. package/dashboard/slim/js/modals-tiles.js +380 -39
  37. package/dashboard/slim/js/status.js +13 -21
  38. package/dashboard/slim/layout.html +1 -0
  39. package/dashboard/slim/panel-bootstrap.js +6 -2
  40. package/dashboard/slim/styles.css +38 -0
  41. package/dashboard/styles.css +159 -55
  42. package/dashboard-build.js +13 -2
  43. package/dashboard.js +956 -423
  44. package/docs/README.md +11 -6
  45. package/docs/api-errors.md +2 -2
  46. package/docs/architecture-review-2026-07-09.md +1 -1
  47. package/docs/architecture.excalidraw +2 -2
  48. package/docs/auto-discovery.md +18 -9
  49. package/docs/branch-derivation.md +4 -4
  50. package/docs/capture-demos.js +39 -2
  51. package/docs/ci-runner-canary.md +123 -0
  52. package/docs/claude-md-propagation.md +2 -2
  53. package/docs/cloud-agent-dispatch.md +204 -0
  54. package/docs/command-center.md +7 -7
  55. package/docs/completion-reports.md +43 -20
  56. package/docs/constants.md +10 -3
  57. package/docs/constellation-bridge.md +134 -6
  58. package/docs/constellation-style-telemetry.md +4 -4
  59. package/docs/contracts/capability-protocol.v1.json +165 -0
  60. package/docs/cooldown-merge-semantics.md +12 -12
  61. package/docs/copilot-cli-schema.md +7 -7
  62. package/docs/cross-repo-plans.md +10 -10
  63. package/docs/dead-code-audit-retractions.md +5 -5
  64. package/docs/default-branch-ci.md +173 -0
  65. package/docs/deprecated.json +31 -31
  66. package/docs/design-inbox-entries-schema.md +3 -3
  67. package/docs/design-language.md +1051 -0
  68. package/docs/design-state-storage.md +11 -11
  69. package/docs/diagnostics-crash-reports.md +9 -9
  70. package/docs/diagnostics-memory.md +5 -5
  71. package/docs/documentation-audit-2026-07-09.md +7 -7
  72. package/docs/engine-restart.md +90 -5
  73. package/docs/harness-mode.md +1 -1
  74. package/docs/internal-install.md +338 -39
  75. package/docs/kb-dedup-duplicate-pair-investigation.md +5 -5
  76. package/docs/kb-pr3223-cascade-archiving.md +1 -1
  77. package/docs/kb-pr696-merge-conflict-docs.md +6 -6
  78. package/docs/kb-sweep.md +35 -35
  79. package/docs/keep-processes.md +1 -1
  80. package/docs/live-checkout-mode.md +30 -30
  81. package/docs/managed-spawn.md +18 -14
  82. package/docs/named-agents.md +7 -7
  83. package/docs/plan-lifecycle.md +69 -2
  84. package/docs/pr-author-identity.md +114 -0
  85. package/docs/pr-auto-fix-dispatch.md +19 -4
  86. package/docs/pr-comment-followup.md +6 -6
  87. package/docs/pr-review-fix-loop.md +59 -10
  88. package/docs/process-termination.md +40 -0
  89. package/docs/proposals/repo-pool-for-live-checkout.md +13 -13
  90. package/docs/qa-runbook-lifecycle.md +367 -17
  91. package/docs/qa-runbooks.md +3 -3
  92. package/docs/rfc-completion-json.md +18 -18
  93. package/docs/runtime-adapters.md +26 -21
  94. package/docs/security.md +6 -6
  95. package/docs/self-improvement.md +4 -4
  96. package/docs/shared-lifecycle-module-map.md +473 -472
  97. package/docs/skills.md +52 -3
  98. package/docs/slim-ux/concepts.md +121 -116
  99. package/docs/specs/agent-configurability.md +18 -18
  100. package/docs/specs/agent-rename.md +18 -18
  101. package/docs/team-memory.md +38 -21
  102. package/docs/timeouts-and-liveness.md +118 -10
  103. package/docs/tutorials/01-install-and-connect.md +1 -1
  104. package/docs/watches.md +40 -39
  105. package/docs/workspace-manifests.md +4 -4
  106. package/docs/worktree-lifecycle.md +293 -14
  107. package/engine/README.md +46 -0
  108. package/engine/{ado-comment.js → ado/comment.js} +8 -8
  109. package/engine/{ado-git-auth.js → ado/git-auth.js} +4 -4
  110. package/engine/{ado.js → ado/index.js} +417 -63
  111. package/engine/{ado-status.js → ado/status.js} +6 -8
  112. package/engine/{ado-token.js → ado/token.js} +1 -1
  113. package/engine/{acp-transport.js → agents/acp-transport.js} +62 -22
  114. package/engine/{agent-worker-pool.js → agents/agent-worker-pool.js} +17 -8
  115. package/engine/{cc-worker-pool.js → agents/cc-worker-pool.js} +16 -6
  116. package/engine/{claude-md-context.js → agents/claude-md-context.js} +5 -5
  117. package/engine/{harness-context.js → agents/harness-context.js} +5 -5
  118. package/engine/{harness.js → agents/harness.js} +3 -3
  119. package/engine/{llm.js → agents/llm.js} +18 -14
  120. package/engine/{model-discovery.js → agents/model-discovery.js} +2 -2
  121. package/engine/{playbook.js → agents/playbook.js} +155 -22
  122. package/engine/{pooled-agent-process.js → agents/pooled-agent-process.js} +14 -12
  123. package/engine/{preflight.js → agents/preflight.js} +29 -10
  124. package/engine/{spawn-agent.js → agents/spawn-agent.js} +25 -14
  125. package/engine/{spawn-phase-watchdog.js → agents/spawn-phase-watchdog.js} +16 -7
  126. package/engine/{steering.js → agents/steering.js} +5 -5
  127. package/engine/{tools-inventory.js → agents/tools-inventory.js} +2 -2
  128. package/engine/{agent-api-validation.js → api/agent-api-validation.js} +2 -2
  129. package/engine/{api-validation.js → api/api-validation.js} +1 -1
  130. package/engine/api/bridge.js +787 -0
  131. package/engine/{cc-api-validation.js → api/cc-api-validation.js} +1 -1
  132. package/engine/api/companion.js +560 -0
  133. package/engine/{content-api-validation.js → api/content-api-validation.js} +2 -2
  134. package/engine/{pr-issue-validation.js → api/pr-issue-validation.js} +33 -6
  135. package/engine/{settings-validation.js → api/settings-validation.js} +32 -4
  136. package/engine/api-contracts/agent-content.js +4 -4
  137. package/engine/api-contracts/capability-manifest.js +236 -0
  138. package/engine/api-contracts/capability-protocol.js +333 -0
  139. package/engine/api-contracts/cc-ops.js +1 -1
  140. package/engine/api-contracts/config-runtime.js +5 -0
  141. package/engine/api-contracts/core.js +28 -1
  142. package/engine/api-contracts/index.js +100 -0
  143. package/engine/api-contracts/orchestration.js +18 -5
  144. package/engine/api-contracts/pull-requests.js +37 -6
  145. package/engine/api-contracts/qa-process.js +29 -6
  146. package/engine/api-contracts/work-plan-prd.js +21 -1
  147. package/engine/cloud/contract.js +212 -0
  148. package/engine/cloud/index.js +159 -0
  149. package/engine/{execution-model.js → core/execution-model.js} +1 -1
  150. package/engine/{features.js → core/features.js} +4 -4
  151. package/engine/{operator-identity.js → core/operator-identity.js} +1 -1
  152. package/engine/{queries.js → core/queries.js} +201 -36
  153. package/engine/{safe-expr.js → core/safe-expr.js} +1 -1
  154. package/engine/{shared.js → core/shared.js} +1637 -175
  155. package/engine/{stdio-timestamps.js → core/stdio-timestamps.js} +1 -1
  156. package/engine/{untrusted-fence.js → core/untrusted-fence.js} +3 -3
  157. package/engine/db/index.js +11 -2
  158. package/engine/db/migrations/002-dispatches.js +3 -3
  159. package/engine/db/migrations/003-work-items.js +1 -1
  160. package/engine/db/migrations/004-pull-requests.js +1 -1
  161. package/engine/db/migrations/006-metrics.js +1 -1
  162. package/engine/db/migrations/007-watches.js +2 -2
  163. package/engine/db/migrations/008-small-state.js +1 -1
  164. package/engine/db/migrations/009-qa.js +1 -1
  165. package/engine/db/migrations/010-pr-links.js +1 -1
  166. package/engine/db/migrations/011-remaining-state.js +1 -1
  167. package/engine/db/migrations/012-steering-deliveries.js +2 -2
  168. package/engine/db/migrations/013-backfill-broken-note-links.js +1 -1
  169. package/engine/db/migrations/014-pr-fix-target-prefs.js +2 -2
  170. package/engine/db/migrations/015-plans-prds.js +0 -0
  171. package/engine/db/migrations/018-sql-only-cutover.js +2 -2
  172. package/engine/db/migrations/021-archived-work-items.js +1 -1
  173. package/engine/db/migrations/022-global-cc-session.js +1 -1
  174. package/engine/db/migrations/023-engine-state.js +1 -1
  175. package/engine/db/migrations/025-malformed-work-item-phantoms.js +1 -1
  176. package/engine/db/migrations/027-review-learning-lifecycle.js +1 -1
  177. package/engine/db/migrations/029-repair-reused-versions.js +20 -0
  178. package/engine/db/migrations/031-pr-author-identity.js +137 -0
  179. package/engine/{consolidation.js → memory/consolidation.js} +6 -6
  180. package/engine/{kb-sweep-runner.js → memory/kb-sweep-runner.js} +2 -2
  181. package/engine/{kb-sweep.js → memory/kb-sweep.js} +9 -7
  182. package/engine/{memory-retrieval.js → memory/memory-retrieval.js} +46 -4
  183. package/engine/{memory-store.js → memory/memory-store.js} +3 -3
  184. package/engine/{promotion.js → memory/promotion.js} +3 -3
  185. package/engine/{review-learning-backfill.js → memory/review-learning-backfill.js} +6 -6
  186. package/engine/{review-learning.js → memory/review-learning.js} +10 -5
  187. package/engine/{diagnostics-memory.js → observability/diagnostics-memory.js} +1 -1
  188. package/engine/{logs-store.js → observability/logs-store.js} +5 -5
  189. package/engine/{metrics-store.js → observability/metrics-store.js} +4 -4
  190. package/engine/{check-status.js → operations/check-status.js} +3 -3
  191. package/engine/{cli.js → operations/cli.js} +271 -113
  192. package/engine/{distribution.js → operations/distribution.js} +5 -6
  193. package/engine/{cleanup.js → orchestration/cleanup.js} +72 -45
  194. package/engine/{cooldown.js → orchestration/cooldown.js} +5 -5
  195. package/engine/{dispatch-events.js → orchestration/dispatch-events.js} +2 -2
  196. package/engine/{dispatch.js → orchestration/dispatch.js} +129 -36
  197. package/engine/orchestration/failed-scheduled-cleanup.js +274 -0
  198. package/engine/{lifecycle.js → orchestration/lifecycle.js} +198 -90
  199. package/engine/{meeting.js → orchestration/meeting.js} +6 -16
  200. package/engine/{pipeline.js → orchestration/pipeline.js} +12 -12
  201. package/engine/{pre-dispatch-eval.js → orchestration/pre-dispatch-eval.js} +10 -9
  202. package/engine/{routing.js → orchestration/routing.js} +3 -3
  203. package/engine/{schedule-bootstrap.js → orchestration/schedule-bootstrap.js} +4 -4
  204. package/engine/{scheduler.js → orchestration/scheduler.js} +38 -8
  205. package/engine/{timeout.js → orchestration/timeout.js} +158 -109
  206. package/engine/{db-events.js → persistence/db-events.js} +2 -2
  207. package/engine/{dispatch-store.js → persistence/dispatch-store.js} +7 -7
  208. package/engine/{inbox-store.js → persistence/inbox-store.js} +2 -2
  209. package/engine/{note-link-backfill.js → persistence/note-link-backfill.js} +4 -4
  210. package/engine/{pr-fix-target-store.js → persistence/pr-fix-target-store.js} +8 -8
  211. package/engine/{pull-requests-store.js → persistence/pull-requests-store.js} +21 -7
  212. package/engine/{small-state-store.js → persistence/small-state-store.js} +31 -31
  213. package/engine/persistence/state-operations.js +350 -0
  214. package/engine/{steering-store.js → persistence/steering-store.js} +6 -6
  215. package/engine/{issues.js → planning/issues.js} +2 -2
  216. package/engine/{plan-prd-validation.js → planning/plan-prd-validation.js} +8 -2
  217. package/engine/planning/prd-result-sidecar.js +190 -0
  218. package/engine/{prd-store.js → planning/prd-store.js} +17 -17
  219. package/engine/{project-discovery.js → planning/project-discovery.js} +5 -5
  220. package/engine/{projects.js → planning/projects.js} +10 -10
  221. package/engine/{resolve-area.js → planning/resolve-area.js} +1 -1
  222. package/engine/{work-item-validation.js → planning/work-item-validation.js} +39 -3
  223. package/engine/{work-items-store.js → planning/work-items-store.js} +29 -21
  224. package/engine/{keep-process-sweep.js → processes/keep-process-sweep.js} +57 -17
  225. package/engine/{managed-spawn-launcher.js → processes/managed-spawn-launcher.js} +3 -3
  226. package/engine/{managed-spawn.js → processes/managed-spawn.js} +97 -46
  227. package/engine/{process-utils.js → processes/process-utils.js} +599 -55
  228. package/engine/{abandoned-pr-reconciliation.js → providers/abandoned-pr-reconciliation.js} +17 -7
  229. package/engine/{comment-classifier.js → providers/comment-classifier.js} +85 -17
  230. package/engine/{comment-format.js → providers/comment-format.js} +5 -5
  231. package/engine/{gh-comment.js → providers/gh-comment.js} +15 -15
  232. package/engine/{gh-token.js → providers/gh-token.js} +4 -4
  233. package/engine/{github.js → providers/github.js} +131 -54
  234. package/engine/{pr-action.js → providers/pr-action.js} +13 -12
  235. package/engine/{pr-clone-keep.js → providers/pr-clone-keep.js} +7 -7
  236. package/engine/{pr-devbox.js → providers/pr-devbox.js} +6 -6
  237. package/engine/{pr-fix-target.js → providers/pr-fix-target.js} +13 -13
  238. package/engine/{pr-remote-patch.js → providers/pr-remote-patch.js} +4 -4
  239. package/engine/{pr-resolve.js → providers/pr-resolve.js} +7 -7
  240. package/engine/{pr-temp-clone.js → providers/pr-temp-clone.js} +5 -5
  241. package/engine/{pr-track.js → providers/pr-track.js} +11 -13
  242. package/engine/{shared-branch-pr-reconcile.js → providers/shared-branch-pr-reconcile.js} +4 -4
  243. package/engine/qa/auto-prd-qa.js +313 -0
  244. package/engine/{qa-from-prd.js → qa/from-prd.js} +42 -12
  245. package/engine/qa/prd-session.js +240 -0
  246. package/engine/{qa-process-validation.js → qa/process-validation.js} +14 -9
  247. package/engine/{qa-runbooks.js → qa/runbooks.js} +1 -1
  248. package/engine/{qa-runs.js → qa/runs.js} +286 -15
  249. package/engine/{qa-sessions.js → qa/sessions.js} +595 -49
  250. package/engine/qa/visual-journey.js +654 -0
  251. package/engine/{qa-runners.js → qa-runners/index.js} +7 -7
  252. package/engine/qa-runners/maestro.js +3 -3
  253. package/engine/qa-runners/playwright.js +2 -2
  254. package/engine/{restart-health.js → recovery/restart-health.js} +48 -4
  255. package/engine/recovery/stop-stack.js +607 -0
  256. package/engine/{supervisor.js → recovery/supervisor.js} +105 -175
  257. package/engine/{watchdog.js → recovery/watchdog.js} +136 -13
  258. package/engine/runtimes/claude.js +14 -12
  259. package/engine/runtimes/codex.js +8 -6
  260. package/engine/runtimes/copilot.js +17 -16
  261. package/engine/{watch-actions.js → watches/actions.js} +13 -13
  262. package/engine/{watches.js → watches/index.js} +43 -32
  263. package/engine/{watches-store.js → watches/store.js} +4 -4
  264. package/engine/{create-pr-worktree.js → worktrees/create-pr.js} +1 -1
  265. package/engine/{worktree-gc.js → worktrees/gc.js} +70 -22
  266. package/engine/worktrees/inventory.js +671 -0
  267. package/engine/{live-checkout.js → worktrees/live-checkout.js} +4 -4
  268. package/engine/{worktree-pool.js → worktrees/pool.js} +2 -2
  269. package/engine/{worktree-preflight.js → worktrees/preflight.js} +1 -0
  270. package/engine/worktrees/quarantine-refs.js +173 -0
  271. package/engine.js +1137 -208
  272. package/minions.js +147 -77
  273. package/package.json +10 -6
  274. package/playbooks/_pr-description-audit.md +110 -78
  275. package/playbooks/build-fix-complex.md +2 -0
  276. package/playbooks/fix.md +16 -12
  277. package/playbooks/implement-shared.md +2 -0
  278. package/playbooks/implement.md +19 -20
  279. package/playbooks/plan-to-prd.md +18 -3
  280. package/playbooks/qa-session-draft.md +136 -1
  281. package/playbooks/qa-session-execute.md +80 -2
  282. package/playbooks/qa-session-setup.md +17 -1
  283. package/playbooks/qa-validate.md +1 -1
  284. package/playbooks/setup.md +2 -0
  285. package/playbooks/shared-rules.md +25 -32
  286. package/playbooks/templates/followup-dispatch.md +4 -3
  287. package/playbooks/verify.md +1 -1
  288. package/prompts/cc-system.md +19 -27
  289. package/watch-plugins/README.md +92 -0
  290. package/watch-plugins/ado-author-prs.js +336 -0
  291. package/watch-plugins/gh-author-prs.js +375 -0
  292. package/watch-plugins/http.js +474 -0
  293. package/watch-plugins/teams-channel.js +869 -0
  294. package/docs/dev-composite-workflow.md +0 -101
  295. package/docs/pr-screenshots/pr-886/after-single-header.png +0 -0
  296. package/docs/pr-screenshots/pr-886/before-duplicate-header.png +0 -0
  297. package/docs/pr-screenshots/pr-895/01-cancellation-reason-detail.png +0 -0
  298. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
  299. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
  300. package/docs/pr-screenshots/pr-901/projects-tab-default.png +0 -0
  301. package/docs/pr-screenshots/pr-901/projects-tab-fmf-selected.png +0 -0
  302. package/docs/pr-screenshots/pr-916/model-picker-AFTER-crop.png +0 -0
  303. package/docs/pr-screenshots/pr-916/model-picker-AFTER.png +0 -0
  304. package/docs/pr-screenshots/pr-916/model-picker-BEFORE-crop.png +0 -0
  305. package/docs/pr-screenshots/pr-916/model-picker-BEFORE.png +0 -0
  306. package/docs/pr-screenshots/pr-916/model-picker-dropdown-AFTER.png +0 -0
  307. package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
  308. package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
  309. package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
  310. package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
  311. package/docs/visual-evidence-ci.md +0 -103
  312. package/engine/bridge.js +0 -379
  313. package/engine/quarantine-refs.js +0 -103
  314. package/engine/state-operations.js +0 -178
  315. /package/engine/{steering-constraints.js → agents/steering-constraints.js} +0 -0
@@ -1,5 +1,5 @@
1
1
  /**
2
- * engine/dispatch.js — Dispatch queue management: add, complete, mutate, alerts.
2
+ * engine/orchestration/dispatch.js — Dispatch queue management: add, complete, mutate, alerts.
3
3
  * Extracted from engine.js for modularity. No logic changes.
4
4
  *
5
5
  * ## Dispatch meta schema — optional fields (M006)
@@ -13,14 +13,14 @@
13
13
  * completing agent concluded before handing off.
14
14
  *
15
15
  * These fields are optional and additive. Dispatches without them are unaffected.
16
- * When present, engine/playbook.js injects a "Handoff Context" section into the
16
+ * When present, engine/agents/playbook.js injects a "Handoff Context" section into the
17
17
  * rendered prompt so the receiving agent has explicit orientation.
18
18
  */
19
19
 
20
20
  const fs = require('fs');
21
21
  const path = require('path');
22
- const shared = require('./shared');
23
- const queries = require('./queries');
22
+ const shared = require('../core/shared');
23
+ const queries = require('../core/queries');
24
24
  const { setCooldown, setCooldownFailure } = require('./cooldown');
25
25
  const dispatchEvents = require('./dispatch-events');
26
26
 
@@ -32,6 +32,11 @@ const { getConfig, INBOX_DIR } = queries;
32
32
 
33
33
  const MINIONS_DIR = shared.MINIONS_DIR;
34
34
 
35
+ // W-ms9tcs3o01y03383 — closed vocabulary for the `completionPath` observability
36
+ // field written by completeDispatch. Sourced from the shared constant so the
37
+ // engine, timeout watchdog, and any future finalizer cannot invent new values.
38
+ const COMPLETION_PATH_VALUES = new Set(Object.values(shared.DISPATCH_COMPLETION_PATH || {}));
39
+
35
40
  // Dispatch types that do not push code to a PR branch. These remain dispatchable
36
41
  // against context-only PRs because casting a review vote, posting a
37
42
  // comment, asking a question, or running a read-only explore never mutates the
@@ -110,7 +115,7 @@ function _sidecarChangedPrompts(dispatch, prevSnap) {
110
115
  // they round-trip automatically. SQLite failures propagate; the CLI
111
116
  // shim in bin/minions.js guarantees node:sqlite is loadable.
112
117
  function mutateDispatch(mutator) {
113
- const store = require('./dispatch-store');
118
+ const store = require('../persistence/dispatch-store');
114
119
  const { wrote, result } = store.applyDispatchMutation((dispatch) => {
115
120
  dispatch.pending = Array.isArray(dispatch.pending) ? dispatch.pending : [];
116
121
  dispatch.active = Array.isArray(dispatch.active) ? dispatch.active : [];
@@ -124,8 +129,9 @@ function mutateDispatch(mutator) {
124
129
  return next;
125
130
  });
126
131
  if (wrote) {
127
- try { require('./queries').invalidateDispatchCache(); } catch {}
128
- try { require('./db-events').emitStateEvent('dispatch'); } catch { /* optional */ }
132
+ try { require('../core/queries').invalidateDispatchCache(); } catch {}
133
+ try { require('../core/queries').invalidateWorkItemsCache(); } catch {}
134
+ try { require('../persistence/db-events').emitStateEvent('dispatch'); } catch { /* optional */ }
129
135
  }
130
136
  return result;
131
137
  }
@@ -445,7 +451,7 @@ function _isPrdSourcedAndVetted(wi) {
445
451
  const sourcePlan = wi && wi.sourcePlan;
446
452
  if (!sourcePlan || typeof sourcePlan !== 'string') return false;
447
453
  try {
448
- const plan = require('./prd-store').readPrd(sourcePlan);
454
+ const plan = require('../planning/prd-store').readPrd(sourcePlan);
449
455
  if (!plan || !plan.missing_features) return false;
450
456
  const status = plan.status || shared.PLAN_STATUS.ACTIVE;
451
457
  return status === shared.PLAN_STATUS.APPROVED || status === shared.PLAN_STATUS.ACTIVE;
@@ -697,13 +703,13 @@ async function _fetchChangedFilesForPrunedEntry(entry, project) {
697
703
  const host = String(project.repoHost || '').toLowerCase();
698
704
  try {
699
705
  if (host === 'github') {
700
- const github = require('./github');
706
+ const github = require('../providers/github');
701
707
  const slug = github.getRepoSlug(project);
702
708
  if (!slug) return null;
703
709
  return await github.getPrChangedFiles(slug, prNumber);
704
710
  }
705
711
  if (host === 'ado' || !host) {
706
- const ado = require('./ado');
712
+ const ado = require('../ado');
707
713
  return await ado.getPrChangedFiles(project, prNumber);
708
714
  }
709
715
  } catch (e) {
@@ -847,7 +853,9 @@ function isRetryableFailureReason(reason = '', failureClass = '') {
847
853
  FAILURE_CLASS.LIVE_CHECKOUT_STALE_BASE, // W-mr98op8w000ma4ad — live-checkout STALE-BASE GUARD refused to fork a new branch off a local mainRef that has diverged from origin/<mainRef> with unpushed commits; contaminated local base does not change on retry (operator must reconcile the local base branch — the engine never resets it — then re-dispatch)
848
854
  FAILURE_CLASS.LIVE_CHECKOUT_WRONG_BASE, // W-mr3lunnq000o9f41 — live-checkout refused to fork a new branch because the operator HEAD is on an unrelated branch (not the project base) and no local base ref was usable; forking off it would bake that branch's commits into the new PR (scope contamination). Deterministic — mechanical retry reproduces it (operator must `git checkout <mainRef>`, then re-dispatch)
849
855
  FAILURE_CLASS.OUTPUT_TRUNCATED, // P-8e4c2a17 — agent stdout exceeded the hard capture cap before the terminal result event; mechanical retry just reproduces the overflow (agent must reduce output volume or the task must be split)
850
- FAILURE_CLASS.PRE_DISPATCH_EVAL_STUCK, // W-mrcrh5hj0017d22f — pre-dispatch-eval rejected the SAME unchanged description ENGINE_DEFAULTS.preDispatchEvalMaxRejections times in a row; non-retryable until the description changes (engine/shared.js:4986)
856
+ FAILURE_CLASS.PRE_DISPATCH_EVAL_STUCK, // W-mrcrh5hj0017d22f — pre-dispatch-eval rejected the SAME unchanged description ENGINE_DEFAULTS.preDispatchEvalMaxRejections times in a row; non-retryable until the description changes (engine/core/shared.js:4986)
857
+ FAILURE_CLASS.INVALID_EXECUTION_SURFACE, // W-ms5tb6ha016fda10 — the WI declared a meta.executionSurface that failed fail-closed validation (unknown value, `control-plane` on a repo-reading/mutating type, or `cloud` on a type whose deliverable is not a PR); re-running with the same declaration reproduces it (operator must fix or remove the field)
858
+ FAILURE_CLASS.CLOUD_PROVIDER_UNAVAILABLE, // Cloud dispatch surface — the WI validly declared `cloud` but no registered+configured provider resolved (or provider resolution threw). Structural: the same dispatch reproduces it identically until an operator configures a provider or drops the surface declaration. Deliberately NOT retryable so a misconfiguration cannot silently burn the item's retry budget — and note the engine never downgrades the refusal to a local dispatch.
851
859
  ]);
852
860
  if (neverRetry.has(failureClass)) return false;
853
861
  }
@@ -979,6 +987,7 @@ function markCompletedSourceWorkItem(item) {
979
987
  delete wi.failReason;
980
988
  delete wi.failedAt;
981
989
  delete wi._retryCount;
990
+ delete wi._infrastructureRetryCount;
982
991
  if (!wi.completedAt) wi.completedAt = ts();
983
992
  if (item.agent && !wi.dispatched_to) wi.dispatched_to = item.agent;
984
993
  terminal = true;
@@ -1021,6 +1030,47 @@ function deferDispatchForWorktreeProtection(id, protection = {}, terminal = {})
1021
1030
  return deferred;
1022
1031
  }
1023
1032
 
1033
+ // W-mrzqfozm00035365 — Return an active (or pending) dispatch to a RECOVERABLE
1034
+ // pending gate without recording a failure or consuming a retry. Used when a
1035
+ // dependency-branch merge conflict is detected in engine.js#spawnAgent BEFORE
1036
+ // the agent starts: the downstream work item is not at fault, so it must not
1037
+ // burn its retry budget or go terminal-`failed` while its deterministic
1038
+ // conflict-repair WI is worked. This ONLY tears down the dispatch record
1039
+ // cleanly (drop prompt sidecar, clear the completed dedupe key so the item can
1040
+ // re-dispatch once the gate clears); the caller stamps the work item's
1041
+ // status/_pendingReason/_dependencyConflict marker.
1042
+ function deferDispatchToPending(id, reason = '') {
1043
+ let item = null;
1044
+ mutateDispatch((dispatch) => {
1045
+ let idx = (dispatch.active || []).findIndex(d => d.id === id);
1046
+ if (idx >= 0) {
1047
+ item = dispatch.active.splice(idx, 1)[0];
1048
+ } else {
1049
+ idx = (dispatch.pending || []).findIndex(d => d.id === id);
1050
+ if (idx >= 0) item = dispatch.pending.splice(idx, 1)[0];
1051
+ }
1052
+ if (!item) return dispatch;
1053
+ try { deleteDispatchPromptSidecar(item); } catch { /* best-effort */ }
1054
+ delete item.prompt;
1055
+ delete item._promptFile;
1056
+ delete item._promptBytes;
1057
+ delete item._worktreeProtectionDeferred;
1058
+ // Clear the dedupe key so the item is free to re-dispatch on a later tick
1059
+ // once the conflict-repair WI clears the gate. No `completed` record is
1060
+ // pushed — this is not a completion, it is a deferral.
1061
+ if (item.meta?.dispatchKey) {
1062
+ dispatch.completed = Array.isArray(dispatch.completed)
1063
+ ? dispatch.completed.filter(d => d.meta?.dispatchKey !== item.meta.dispatchKey)
1064
+ : [];
1065
+ }
1066
+ return dispatch;
1067
+ });
1068
+ if (item) {
1069
+ log('info', `Deferred dispatch ${id} back to pending (recoverable gate)${reason ? ': ' + reason : ''}`);
1070
+ }
1071
+ return !!item;
1072
+ }
1073
+
1024
1074
  function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', resultSummary = '', opts = {}) {
1025
1075
  const {
1026
1076
  processWorkItemFailure = true,
@@ -1028,6 +1078,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
1028
1078
  countAgentRetryFailure = true,
1029
1079
  failureClass,
1030
1080
  } = opts;
1081
+ const infrastructureRetry = opts.retryBudget === 'infrastructure';
1031
1082
  const agentRetryable = normalizeRetryableDecision(opts.agentRetryable ?? opts.retryable);
1032
1083
  let item = null;
1033
1084
  let completedSourceWorkItem = false;
@@ -1057,6 +1108,14 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
1057
1108
  if (reason) item.reason = reason;
1058
1109
  if (resultSummary) item.resultSummary = resultSummary;
1059
1110
  if (failureClass && result === DISPATCH_RESULT.ERROR) item.failureClass = failureClass;
1111
+ // W-ms9tcs3o01y03383 — record HOW the dispatch reached its terminal state so
1112
+ // a normal close, an immediate report recovery, and a genuinely stalled live
1113
+ // resume stay distinguishable on the completed record. Values come from
1114
+ // shared.DISPATCH_COMPLETION_PATH; unknown strings are ignored rather than
1115
+ // persisted so the field stays a closed vocabulary.
1116
+ if (opts.completionPath && COMPLETION_PATH_VALUES.has(opts.completionPath)) {
1117
+ item.completionPath = opts.completionPath;
1118
+ }
1060
1119
  item.meta = item.meta && typeof item.meta === 'object' ? item.meta : {};
1061
1120
  if (opts.completionReportPath && !item.meta.completionReportPath) {
1062
1121
  item.meta.completionReportPath = opts.completionReportPath;
@@ -1195,14 +1254,17 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
1195
1254
  }
1196
1255
  } else {
1197
1256
  let retries = (item.meta.item._retryCount || 0);
1257
+ if (infrastructureRetry) retries = item.meta.item._infrastructureRetryCount || 0;
1198
1258
  try {
1199
1259
  const wi = liveWi || readLiveWorkItem(item.meta);
1200
1260
  if (wi) retries = wi._retryCount || 0;
1261
+ if (wi && infrastructureRetry) retries = wi._infrastructureRetryCount || 0;
1201
1262
  } catch (e) { log('warn', 'read retry count: ' + e.message); }
1202
1263
  const maxRetries = ENGINE_DEFAULTS.maxRetries;
1203
1264
  const withinSafetyCap = retries < maxRetries;
1204
1265
  if (retryableFailure && withinSafetyCap) {
1205
- log('info', `Dispatch error for ${item.meta.item.id} — auto-retry ${retries + 1}/${maxRetries}${failureClass ? ' [' + failureClass + ']' : ''}`);
1266
+ const retryLabel = infrastructureRetry ? 'infrastructure recovery' : 'auto-retry';
1267
+ log('info', `Dispatch error for ${item.meta.item.id} — ${retryLabel} ${retries + 1}/${maxRetries}${failureClass ? ' [' + failureClass + ']' : ''}`);
1206
1268
  lifecycle().updateWorkItemStatus(item.meta, WI_STATUS.PENDING, '');
1207
1269
  // Remove this dispatch key from completed so dedupe doesn't block immediate redispatch.
1208
1270
  if (item.meta?.dispatchKey) {
@@ -1220,7 +1282,8 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
1220
1282
  mutateWorkItems(wiScope, items => {
1221
1283
  const wi = items.find(i => i.id === item.meta.item.id);
1222
1284
  if (wi && wi.status !== WI_STATUS.PAUSED && wi.status !== WI_STATUS.DONE && !wi.completedAt) {
1223
- wi._retryCount = retries + 1;
1285
+ if (infrastructureRetry) wi._infrastructureRetryCount = retries + 1;
1286
+ else wi._retryCount = retries + 1;
1224
1287
  wi.status = WI_STATUS.PENDING;
1225
1288
  wi._lastRetryReason = reason || '';
1226
1289
  wi._lastRetryAt = ts();
@@ -1229,13 +1292,19 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
1229
1292
  // when the previous attempt was bounced for MODEL_UNAVAILABLE
1230
1293
  // (overloaded_error / 503). Empty string clears any stale
1231
1294
  // value from an earlier failure cycle.
1232
- wi._lastFailureClass = failureClass || '';
1295
+ if (infrastructureRetry) {
1296
+ wi._lastInfrastructureFailureClass = failureClass || '';
1297
+ } else {
1298
+ wi._lastFailureClass = failureClass || '';
1299
+ }
1233
1300
  // W-mpmwxn1j — Bump per-agent retry count so the next dispatch
1234
1301
  // can reassign to a different eligible agent once the same
1235
1302
  // agent hits the threshold. Skip when no agent is resolvable
1236
1303
  // (anonymous failures shouldn't corrupt the map shape).
1237
1304
  const failedAgent = item.agent || wi.dispatched_to;
1238
- if (failedAgent && countAgentRetryFailure) shared.bumpAgentRetryCount(wi, failedAgent);
1305
+ if (!infrastructureRetry && failedAgent && countAgentRetryFailure) {
1306
+ shared.bumpAgentRetryCount(wi, failedAgent);
1307
+ }
1239
1308
  delete wi.failReason;
1240
1309
  delete wi.failedAt;
1241
1310
  delete wi.dispatched_at;
@@ -1260,6 +1329,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
1260
1329
  [FAILURE_CLASS.DEPENDENCY_MERGE_SETUP]: 'dependency merge setup failed',
1261
1330
  [FAILURE_CLASS.MAX_TURNS]: 'reached max turn limit',
1262
1331
  [FAILURE_CLASS.TIMEOUT]: 'timed out waiting for agent',
1332
+ [FAILURE_CLASS.ENGINE_RESTART]: 'engine restart lost dispatch process ownership',
1263
1333
  [FAILURE_CLASS.SPAWN_ERROR]: 'agent process failed to start',
1264
1334
  [FAILURE_CLASS.NETWORK_ERROR]: 'network or API error',
1265
1335
  [FAILURE_CLASS.OUT_OF_CONTEXT]: 'context window exhausted',
@@ -1283,7 +1353,9 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
1283
1353
  const classSuffix = failureClass ? ` [${failureClass.toUpperCase().replace(/-/g, '_')}]` : '';
1284
1354
  const finalReason = !retryableFailure
1285
1355
  ? `Non-retryable failure: ${effectiveReason}${classSuffix}`
1286
- : (reason || `Failed after ${maxRetries} retries${classSuffix}`);
1356
+ : (infrastructureRetry
1357
+ ? `Failed after ${maxRetries} infrastructure recovery attempts: ${effectiveReason}${classSuffix}`
1358
+ : (reason || `Failed after ${maxRetries} retries${classSuffix}`));
1287
1359
  lifecycle().updateWorkItemStatus(item.meta, WI_STATUS.FAILED, finalReason);
1288
1360
  // Surface blocked dependents in logs without creating failure inbox noise.
1289
1361
  try {
@@ -1471,6 +1543,42 @@ function cancelPendingDispatchesForPr(prId) {
1471
1543
  return cancelled;
1472
1544
  }
1473
1545
 
1546
+ /**
1547
+ * Stop runtime processes owned by active dispatch entries. Pooled entries are
1548
+ * cancelled by the engine's tracked-process reconciliation on the next tick;
1549
+ * their PID belongs to a shared worker and must never be killed here.
1550
+ */
1551
+ function stopDispatchProcesses(entries) {
1552
+ const processesToKill = [];
1553
+ for (const entry of entries || []) {
1554
+ if (!entry || entry.pooled) continue;
1555
+ const pidPath = shared.findDispatchPidFile(entry);
1556
+ if (!pidPath) continue;
1557
+ try {
1558
+ processesToKill.push({
1559
+ pid: shared.validatePid(fs.readFileSync(pidPath, 'utf8').trim()),
1560
+ observedAt: fs.statSync(pidPath).mtimeMs,
1561
+ });
1562
+ } catch { /* PID file may not exist */ }
1563
+ }
1564
+
1565
+ const processSnapshot = processesToKill.length > 0 ? shared.listAllProcesses() : [];
1566
+ let stopped = 0;
1567
+ for (const processToKill of processesToKill) {
1568
+ try {
1569
+ const safePid = shared.validatePid(processToKill.pid);
1570
+ if (!shared.terminateProcess('engine.dispatch.cancel', { pid: safePid }, {
1571
+ graceMs: 5000,
1572
+ recordedAtMs: processToKill.observedAt,
1573
+ processes: processSnapshot,
1574
+ skewMs: 2000,
1575
+ })) continue;
1576
+ stopped++;
1577
+ } catch { /* may already be dead */ }
1578
+ }
1579
+ return stopped;
1580
+ }
1581
+
1474
1582
  /**
1475
1583
  * Remove dispatch entries matching a predicate from pending/active/completed.
1476
1584
  * For matched active entries, kills the agent process and deletes its
@@ -1483,7 +1591,7 @@ function cancelPendingDispatchesForPr(prId) {
1483
1591
  function cleanDispatchEntries(matchFn) {
1484
1592
  const tmpDir = path.join(MINIONS_DIR, 'engine', 'tmp');
1485
1593
  let removed = 0;
1486
- const pidsToKill = [];
1594
+ const activeEntriesToStop = [];
1487
1595
  const filesToDelete = [];
1488
1596
  const dispatchDirsToRemove = [];
1489
1597
  try {
@@ -1499,13 +1607,7 @@ function cleanDispatchEntries(matchFn) {
1499
1607
  // back to legacy flat layout for active dispatches that pre-date
1500
1608
  // the per-dispatch-dir layout. shared.findDispatchPidFile honors
1501
1609
  // the same resolution order so we agree on which PID to kill.
1502
- const pidPath = shared.findDispatchPidFile(d);
1503
- if (pidPath) {
1504
- try {
1505
- const pid = parseInt(fs.readFileSync(pidPath, 'utf8').trim());
1506
- if (pid) pidsToKill.push(pid);
1507
- } catch { /* PID file may not exist */ }
1508
- }
1610
+ activeEntriesToStop.push(d);
1509
1611
  if (d.tmpDir && shared.validateDispatchTmpDir(d.tmpDir)) {
1510
1612
  dispatchDirsToRemove.push(d.tmpDir);
1511
1613
  } else {
@@ -1523,18 +1625,7 @@ function cleanDispatchEntries(matchFn) {
1523
1625
  return dispatch;
1524
1626
  });
1525
1627
  } catch { return 0; }
1526
- // Kill processes outside the lock — taskkill on Windows can take hundreds of ms
1527
- for (const pid of pidsToKill) {
1528
- try {
1529
- const safePid = shared.validatePid(pid);
1530
- if (process.platform === 'win32') {
1531
- const { execFileSync } = require('child_process');
1532
- execFileSync('taskkill', ['/PID', String(safePid), '/F', '/T'], { stdio: 'pipe', timeout: 5000, windowsHide: true });
1533
- } else {
1534
- shared.killGracefully({ pid: safePid }, 5000);
1535
- }
1536
- } catch { /* may already be dead */ }
1537
- }
1628
+ stopDispatchProcesses(activeEntriesToStop);
1538
1629
  for (const fp of filesToDelete) {
1539
1630
  try { fs.unlinkSync(fp); } catch { /* may not exist */ }
1540
1631
  }
@@ -1573,6 +1664,7 @@ module.exports = {
1573
1664
  getPrDispatchDedupeKey,
1574
1665
  isRetryableFailureReason,
1575
1666
  deferDispatchForWorktreeProtection,
1667
+ deferDispatchToPending,
1576
1668
  completeDispatch,
1577
1669
  writeInboxAlert,
1578
1670
  updateAgentStatus,
@@ -1580,6 +1672,7 @@ module.exports = {
1580
1672
  pruneStalePrDispatches,
1581
1673
  pruneStalePrDispatchesAsync, // W-mr9jo2n2000667ad — scope-overlap-verified variant; engine.js tick should await this
1582
1674
  cancelPendingDispatchesForPr,
1675
+ stopDispatchProcesses,
1583
1676
  cleanDispatchEntries,
1584
1677
  cancelPendingWorkItems,
1585
1678
  // exported for testing
@@ -0,0 +1,274 @@
1
+ // engine/orchestration/failed-scheduled-cleanup.js — W-msckn1lp002xdf6b
2
+ //
3
+ // Conservative, low-frequency (daily) retention for FAILED scheduled work
4
+ // items so machine-generated scheduled-run failures (hourly review/fetch
5
+ // sweeps, etc.) do not accumulate indefinitely in the work-item store.
6
+ //
7
+ // Contract (see docs/auto-discovery.md → "Scheduled-failure retention"):
8
+ // - Targets ONLY provably machine-generated scheduled runs, identified by
9
+ // structured provenance (`createdBy === 'scheduler'` + `_scheduleId`) or the
10
+ // canonical legacy `sched-<scheduleId>-<ts>` id prefix. Never by title/prose.
11
+ // - Groups failures by their originating schedule. The NEWEST failed
12
+ // occurrence per schedule is retained long enough to diagnose the current
13
+ // failure; older SUPERSEDED failures are pruned once they age past the
14
+ // diagnostic window (24h). A later successful run lets the newest failure be
15
+ // pruned too, once that window has elapsed. An absolute threshold prunes
16
+ // stale failures regardless.
17
+ // - Non-scheduled / human-authored failed items are NEVER touched.
18
+ // - Fails open: ambiguous provenance, lineage links, or an active dispatch
19
+ // reference cause the item to be skipped, not deleted.
20
+ //
21
+ // The policy is intentionally a documented shared default rather than an
22
+ // `engine.*` knob — there is nothing an operator needs to tune, and adding a
23
+ // config flag would require Settings UI + CLAUDE.md parity for no benefit.
24
+
25
+ const shared = require('../core/shared');
26
+ const { WI_STATUS, DONE_STATUSES, log } = shared;
27
+
28
+ // Retain the newest failure and any failure younger than this so the current
29
+ // failure stays diagnosable; superseded failures older than this are pruned.
30
+ const DIAGNOSTIC_RETENTION_MS = 24 * 60 * 60 * 1000; // 24h
31
+ // Absolute stale threshold — even the newest failure is pruned past this so a
32
+ // long-abandoned schedule does not pin a failure forever.
33
+ const ABSOLUTE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; // 7d
34
+ // Low-frequency cadence: the sweep itself runs at most once per this window,
35
+ // even though runCleanup fires every ~10 minutes.
36
+ const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000; // daily
37
+ // Persisted last-run marker key in the engine small-state store.
38
+ const LAST_RUN_KEY = 'lastFailedScheduledPruneAt';
39
+
40
+ // ─── Provenance ─────────────────────────────────────────────────────────────
41
+
42
+ // True iff the item is a provably machine-generated scheduled run. Structured
43
+ // provenance wins; the `sched-` id prefix is the documented legacy fallback for
44
+ // items created before `createdBy`/`_scheduleId` were consistently stamped.
45
+ function isScheduledProvenance(item) {
46
+ if (!item || typeof item !== 'object') return false;
47
+ if (item.createdBy === 'scheduler' && item._scheduleId) return true;
48
+ if (typeof item.id === 'string' && /^sched-.+-\d+$/.test(item.id)) return true;
49
+ return false;
50
+ }
51
+
52
+ // The originating schedule id for grouping. Prefers structured `_scheduleId`;
53
+ // falls back to parsing the canonical `sched-<scheduleId>-<timestamp>` id.
54
+ function scheduleKey(item) {
55
+ if (item && item._scheduleId) return String(item._scheduleId);
56
+ const m = /^sched-(.+)-\d+$/.exec(String(item && item.id || ''));
57
+ return m ? m[1] : null;
58
+ }
59
+
60
+ // An item wired into a plan/PRD/decomposition/followup lineage must not be
61
+ // pruned here — its lifecycle is owned elsewhere. Scheduled sweep items carry
62
+ // none of these, so a scheduled-provenance item that DOES is treated as
63
+ // ambiguous and skipped (fail-safe).
64
+ function hasLineage(item) {
65
+ return !!(item && (item.parent_id || item.parentId || item._prdItemId || item.prdItemId));
66
+ }
67
+
68
+ function _parseMs(v) {
69
+ if (v == null) return NaN;
70
+ if (typeof v === 'number') return Number.isFinite(v) ? v : NaN;
71
+ const ms = Date.parse(v);
72
+ return Number.isFinite(ms) ? ms : NaN;
73
+ }
74
+
75
+ // Best-effort failure timestamp — `failedAt` is stamped when a WI fails.
76
+ function failureTimestampMs(item) {
77
+ for (const cand of [item.failedAt, item.updatedAt, item.completedAt, item.dispatched_at, item.created]) {
78
+ const ms = _parseMs(cand);
79
+ if (Number.isFinite(ms)) return ms;
80
+ }
81
+ return 0;
82
+ }
83
+
84
+ // Best-effort success timestamp — `completedAt` is stamped when a WI completes.
85
+ function successTimestampMs(item) {
86
+ for (const cand of [item.completedAt, item.completed_at, item.updatedAt, item.dispatched_at, item.created]) {
87
+ const ms = _parseMs(cand);
88
+ if (Number.isFinite(ms)) return ms;
89
+ }
90
+ return 0;
91
+ }
92
+
93
+ // ─── Pure retention planner (unit-tested directly) ──────────────────────────
94
+
95
+ /**
96
+ * Decide which failed scheduled work items to prune.
97
+ *
98
+ * @param {Array<object>} items Flat list of work items across all scopes. Each
99
+ * item may carry a `_scope` hint (defaults to 'central').
100
+ * @param {number} now Epoch ms "now".
101
+ * @param {object} [opts]
102
+ * @param {Set<string>} [opts.protectedIds] WI ids referenced by a live
103
+ * dispatch — never pruned.
104
+ * @param {number} [opts.diagnosticRetentionMs]
105
+ * @param {number} [opts.absoluteRetentionMs]
106
+ * @returns {{ toPrune: Array<{id,scope,scheduleKey}>, groupCount: number }}
107
+ */
108
+ function planFailedScheduledPrune(items, now = Date.now(), opts = {}) {
109
+ const diagnosticMs = Number.isFinite(opts.diagnosticRetentionMs) ? opts.diagnosticRetentionMs : DIAGNOSTIC_RETENTION_MS;
110
+ const absoluteMs = Number.isFinite(opts.absoluteRetentionMs) ? opts.absoluteRetentionMs : ABSOLUTE_RETENTION_MS;
111
+ const protectedIds = opts.protectedIds instanceof Set ? opts.protectedIds : new Set();
112
+
113
+ const groups = new Map(); // scheduleKey -> { failures: [], latestSuccessMs }
114
+ for (const item of Array.isArray(items) ? items : []) {
115
+ if (!isScheduledProvenance(item)) continue;
116
+ const key = scheduleKey(item);
117
+ if (!key) continue;
118
+ let g = groups.get(key);
119
+ if (!g) { g = { failures: [], latestSuccessMs: 0 }; groups.set(key, g); }
120
+ if (item.status === WI_STATUS.FAILED) {
121
+ g.failures.push(item);
122
+ } else if (DONE_STATUSES.has(item.status)) {
123
+ const ms = successTimestampMs(item);
124
+ if (ms > g.latestSuccessMs) g.latestSuccessMs = ms;
125
+ }
126
+ }
127
+
128
+ const toPrune = [];
129
+ for (const [key, g] of groups) {
130
+ if (g.failures.length === 0) continue;
131
+ // Newest failure first so index 0 is the diagnostic keeper.
132
+ const failures = g.failures.slice().sort((a, b) => failureTimestampMs(b) - failureTimestampMs(a));
133
+ for (let i = 0; i < failures.length; i++) {
134
+ const f = failures[i];
135
+ if (protectedIds.has(String(f.id))) continue; // live dispatch reference
136
+ if (hasLineage(f)) continue; // plan/PRD/followup lineage
137
+ const age = now - failureTimestampMs(f);
138
+ const isNewest = i === 0;
139
+ let prune = false;
140
+ if (isNewest) {
141
+ // Keep the current failure to diagnose it — unless a later success has
142
+ // superseded it (and the diagnostic window elapsed), or it is stale
143
+ // past the absolute threshold.
144
+ const supersededBySuccess = g.latestSuccessMs > failureTimestampMs(f);
145
+ if (age > absoluteMs) prune = true;
146
+ else if (supersededBySuccess && age > diagnosticMs) prune = true;
147
+ } else {
148
+ // Superseded by a newer failure — prune once past the diagnostic window.
149
+ if (age > diagnosticMs) prune = true;
150
+ }
151
+ if (prune) toPrune.push({ id: String(f.id), scope: f._scope || 'central', scheduleKey: key });
152
+ }
153
+ }
154
+ return { toPrune, groupCount: groups.size };
155
+ }
156
+
157
+ // ─── Orchestration entrypoint (wired into runCleanup) ───────────────────────
158
+
159
+ // Collect WI ids referenced by a live (pending/active/review) dispatch so we
160
+ // never delete a work item something is actively working. Fails open.
161
+ function _collectProtectedWiIds() {
162
+ const ids = new Set();
163
+ try {
164
+ const dispatch = require('../core/queries').getDispatch();
165
+ for (const seg of [dispatch.pending, dispatch.active, dispatch.review]) {
166
+ for (const e of Array.isArray(seg) ? seg : []) {
167
+ const wi = e && e.meta && e.meta.item && e.meta.item.id;
168
+ if (wi) ids.add(String(wi));
169
+ }
170
+ }
171
+ } catch { /* fail open — no protections known */ }
172
+ return ids;
173
+ }
174
+
175
+ /**
176
+ * Daily sweep that prunes superseded failed scheduled work items. Safe to call
177
+ * every cleanup tick — the cadence gate (persisted last-run marker) skips work
178
+ * until PRUNE_INTERVAL_MS has elapsed. Concurrency-safe (the gate is claimed
179
+ * inside a single small-state transaction) and idempotent (the deletion mutator
180
+ * re-validates provenance/status against fresh SQL rows).
181
+ *
182
+ * @returns {{ skipped:boolean, reason?:string, pruned:number, bySchedule?:object }}
183
+ */
184
+ function pruneSupersededFailedScheduledItems(config, opts = {}) {
185
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
186
+ const force = opts.force === true;
187
+ const intervalMs = Number.isFinite(opts.intervalMs) ? opts.intervalMs : PRUNE_INTERVAL_MS;
188
+
189
+ // Cadence gate — claim the run atomically so overlapping ticks / a restart
190
+ // race cannot double-run the sweep in the same day.
191
+ if (!force) {
192
+ let claimed = false;
193
+ try {
194
+ shared.mutateEngineState((state) => {
195
+ const last = Number(state[LAST_RUN_KEY]) || 0;
196
+ if (last && (now - last) < intervalMs) return state; // not due yet
197
+ state[LAST_RUN_KEY] = now;
198
+ claimed = true;
199
+ return state;
200
+ });
201
+ } catch (e) {
202
+ log('warn', `failed-scheduled prune: cadence claim failed — ${e.message}`);
203
+ return { skipped: true, reason: 'cadence-claim-error', pruned: 0 };
204
+ }
205
+ if (!claimed) return { skipped: true, reason: 'cadence', pruned: 0 };
206
+ }
207
+
208
+ // Gather scheduled items across every scope (central + each project).
209
+ const scopes = ['central', ...shared.getProjects(config || {})];
210
+ const allItems = [];
211
+ for (const scope of scopes) {
212
+ let items;
213
+ try { items = shared.readWorkItems(scope); } catch { continue; }
214
+ for (const it of Array.isArray(items) ? items : []) {
215
+ if (it && typeof it === 'object') { it._scope = scope; allItems.push(it); }
216
+ }
217
+ }
218
+
219
+ const protectedIds = _collectProtectedWiIds();
220
+ const { toPrune } = planFailedScheduledPrune(allItems, now, { protectedIds });
221
+ if (toPrune.length === 0) return { skipped: false, pruned: 0, bySchedule: {} };
222
+
223
+ const idsByScope = new Map();
224
+ for (const p of toPrune) {
225
+ if (!idsByScope.has(p.scope)) idsByScope.set(p.scope, new Set());
226
+ idsByScope.get(p.scope).add(p.id);
227
+ }
228
+
229
+ let pruned = 0;
230
+ const bySchedule = {};
231
+ const idToSchedule = new Map(toPrune.map(p => [p.id, p.scheduleKey]));
232
+ for (const [scope, ids] of idsByScope) {
233
+ try {
234
+ shared.mutateWorkItems(scope, (items) => {
235
+ if (!Array.isArray(items)) return items;
236
+ return items.filter((w) => {
237
+ if (!w || !ids.has(String(w.id))) return true;
238
+ // Re-validate against the fresh row — status/provenance may have
239
+ // changed since planning (idempotency + race safety).
240
+ if (w.status !== WI_STATUS.FAILED) return true;
241
+ if (!isScheduledProvenance(w)) return true;
242
+ if (hasLineage(w)) return true;
243
+ const key = idToSchedule.get(String(w.id)) || scheduleKey(w) || 'unknown';
244
+ bySchedule[key] = (bySchedule[key] || 0) + 1;
245
+ pruned++;
246
+ return false; // delete
247
+ });
248
+ });
249
+ } catch (e) {
250
+ log('warn', `failed-scheduled prune (scope=${scope}): ${e.message}`);
251
+ }
252
+ }
253
+
254
+ if (pruned > 0) {
255
+ const summary = Object.entries(bySchedule).map(([k, n]) => `${k}:${n}`).join(', ');
256
+ log('info', `cleanup: pruned ${pruned} superseded failed scheduled work item(s) across ${Object.keys(bySchedule).length} schedule(s) — ${summary}`);
257
+ }
258
+ return { skipped: false, pruned, bySchedule };
259
+ }
260
+
261
+ module.exports = {
262
+ pruneSupersededFailedScheduledItems,
263
+ planFailedScheduledPrune,
264
+ // Exported for unit tests / reuse:
265
+ isScheduledProvenance,
266
+ scheduleKey,
267
+ hasLineage,
268
+ failureTimestampMs,
269
+ successTimestampMs,
270
+ DIAGNOSTIC_RETENTION_MS,
271
+ ABSOLUTE_RETENTION_MS,
272
+ PRUNE_INTERVAL_MS,
273
+ LAST_RUN_KEY,
274
+ };