@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,18 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * engine/supervisor.js — External watchdog for engine + dashboard.
3
+ * engine/recovery/supervisor.js — External watchdog for engine + dashboard.
4
4
  *
5
5
  * Spawned `detached: true` by `minions start`/`restart` after the engine and
6
6
  * dashboard are up. Polls every SUPERVISOR_INTERVAL_MS:
7
7
  * - engine PID from `engine/control.json`
8
- * - dashboard via a port-listener probe (port 7331 by default) AND, when the
9
- * port is bound, an HTTP `/api/health` probe. The HTTP layer catches the
10
- * "frozen but still listening" dashboard (wedged event loop) that the
11
- * port-listen probe alone reports as healthy — it respawns only after
12
- * DASH_HEALTH_MAX_FAILS consecutive failed probes so a dashboard merely
13
- * busy with a heavy synchronous getStatus() rebuild isn't killed. What
14
- * counts as "responsive" (and why engine 'degraded'/'stopped' does NOT mean
15
- * the dashboard is frozen) is documented at _probeDashboardHealth.
8
+ * - dashboard via both a port-listener probe (port 7331 by default) and HTTP
9
+ * `/api/health`. Either can false-negative on a loaded Windows host, so a
10
+ * responsive HTTP result overrides a missed port probe and replacement
11
+ * requires DASH_HEALTH_MAX_FAILS consecutive ticks where neither signal
12
+ * proves health. What counts as responsive is documented at
13
+ * _probeDashboardHealth.
16
14
  *
17
15
  * When either is dead AND the stop-intent flag is NOT set, respawns the dead
18
16
  * one in the same way the CLI does (detached, stdio routed to the engine-
@@ -29,25 +27,30 @@
29
27
  * Windows Service via nssm — see docs/supervisor.md.
30
28
  */
31
29
 
32
- require('./stdio-timestamps').installIfNotInstalled();
30
+ require('../core/stdio-timestamps').installIfNotInstalled();
33
31
 
34
32
  const fs = require('fs');
35
33
  const path = require('path');
36
34
  const os = require('os');
37
35
  const { spawn, spawnSync, execSync } = require('child_process');
36
+ const {
37
+ terminateProcess,
38
+ commandLineRunsScript,
39
+ listNodePidsMatchingScript,
40
+ } = require('../processes/process-utils');
38
41
 
39
- // Lazy path getters route through engine/shared.js when available so the
42
+ // Lazy path getters route through engine/core/shared.js when available so the
40
43
  // supervisor's notion of "the engine dir" honors MINIONS_TEST_DIR (used by
41
44
  // the unit tests). Fall back to __dirname-based resolution when shared.js
42
45
  // isn't loadable (e.g. early load / bad upgrade) so the supervisor still
43
46
  // boots in production.
44
- const MINIONS_DIR = path.resolve(__dirname, '..');
47
+ const MINIONS_DIR = path.resolve(__dirname, '..', '..');
45
48
  function _sharedOrNull() {
46
- try { return require('./shared'); } catch { return null; }
49
+ try { return require('../core/shared'); } catch { return null; }
47
50
  }
48
51
  // Crash-loop counter (W-mr2c46590003e3ee) — best-effort. shared.js owns the
49
- // counter/threshold/inbox-alert logic (engine/shared.js#recordEngineRespawn)
50
- // so supervisor.js, engine/watchdog.js, and dashboard.js's in-process
52
+ // counter/threshold/inbox-alert logic (engine/core/shared.js#recordEngineRespawn)
53
+ // so supervisor.js, engine/recovery/watchdog.js, and dashboard.js's in-process
51
54
  // watchdog all feed the same rolling window in control.json instead of each
52
55
  // keeping (and silently ignoring) their own. Falls through to a no-op when
53
56
  // shared.js can't load, matching this file's existing fail-open posture.
@@ -56,8 +59,8 @@ function _recordCrashLoopRespawn(source) {
56
59
  if (!shared || typeof shared.recordEngineRespawn !== 'function') return;
57
60
  try { shared.recordEngineRespawn(source); } catch { /* best-effort */ }
58
61
  }
59
- function _engineDir() { return _sharedOrNull()?.ENGINE_DIR || __dirname; }
60
- const STATIC_ENGINE_DIR = __dirname; // for require-time paths that can't be lazy
62
+ function _engineDir() { return _sharedOrNull()?.ENGINE_DIR || path.resolve(__dirname, '..'); }
63
+ const STATIC_ENGINE_DIR = path.resolve(__dirname, '..'); // for require-time paths that can't be lazy
61
64
  function CONTROL_PATH_FN() { return path.join(_engineDir(), 'control.json'); }
62
65
  function STOP_INTENT_PATH_FN() { return _sharedOrNull()?.STOP_INTENT_PATH || path.join(_engineDir(), 'stop-intent.json'); }
63
66
  function SUPERVISOR_PID_PATH_FN() { return path.join(_engineDir(), 'supervisor.pid'); }
@@ -94,14 +97,15 @@ function _resolveDashPort() {
94
97
  const POST_SPAWN_GRACE_MS = Number(process.env.MINIONS_SUPERVISOR_GRACE_MS) || 15000;
95
98
  // #421 — heartbeat age threshold before supervisor considers the engine event loop frozen.
96
99
  const SUPERVISOR_STALE_ENGINE_HEARTBEAT_MS = Number(process.env.MINIONS_SUPERVISOR_STALE_HEARTBEAT_MS) || 180000; // 3 min
97
- // Dashboard HTTP liveness (frozen-but-listening detection). A dashboard whose
98
- // event loop is wedged keeps the socket LISTENING, so the port-listen probe
99
- // alone reports it healthy while the in-browser SPA shows the "unreachable"
100
- // banner forever. We layer an HTTP /api/health probe on top: only respawn after
101
- // DASH_HEALTH_MAX_FAILS *consecutive* failed probes so a dashboard that is
102
- // merely busy with a heavy synchronous getStatus() rebuild (documented to block
103
- // the loop 15-25s) isn't mistaken for a hang. With the default 30s interval,
104
- // 3 strikes ≈ 90s of continuous unresponsiveness before we act.
100
+ // A recent shell-free heartbeat vetoes a failed PID shell-out. This is shorter
101
+ // than the stale-heartbeat watchdog but long enough to absorb several missed
102
+ // 15s heartbeat writes under host load.
103
+ const SUPERVISOR_FRESH_HEARTBEAT_MS = Number(process.env.MINIONS_SUPERVISOR_FRESH_HEARTBEAT_MS) || 60000;
104
+ // Dashboard liveness needs two independent signals. A frozen event loop can
105
+ // retain its listening socket, while netstat can time out or return no rows on
106
+ // a loaded Windows host even though HTTP still works. Only respawn after both
107
+ // fail for DASH_HEALTH_MAX_FAILS consecutive ticks. With the default 30s
108
+ // interval, 3 strikes is about 90s of continuous unresponsiveness.
105
109
  const DASH_HEALTH_TIMEOUT_MS = Number(process.env.MINIONS_SUPERVISOR_DASH_HEALTH_TIMEOUT_MS) || 5000;
106
110
  const DASH_HEALTH_MAX_FAILS = Number(process.env.MINIONS_SUPERVISOR_DASH_HEALTH_FAILS) || 3;
107
111
  const REAP_WAIT_TIMEOUT_MS = Number(process.env.MINIONS_SUPERVISOR_REAP_TIMEOUT_MS) || 10000;
@@ -112,15 +116,15 @@ function safeReadJson(p) {
112
116
  try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
113
117
  }
114
118
 
115
- // Single source of truth lives in engine/shared.js (writeStopIntent /
119
+ // Single source of truth lives in engine/core/shared.js (writeStopIntent /
116
120
  // clearStopIntent / isStopIntentSet). The supervisor used to re-implement
117
121
  // this locally; centralizing it ensures dashboard.js, bin/minions.js,
118
- // engine/cli.js, and the supervisor all read/write through one contract.
122
+ // engine/operations/cli.js, and the supervisor all read/write through one contract.
119
123
  // Fall back to a local file-presence check if shared.js fails to load (e.g.
120
124
  // during a bad upgrade) — we'd rather fail-closed than crash the supervisor.
121
125
  function isStopIntentSet() {
122
126
  try {
123
- const shared = require('./shared');
127
+ const shared = require('../core/shared');
124
128
  if (shared && typeof shared.isStopIntentSet === 'function') {
125
129
  return shared.isStopIntentSet();
126
130
  }
@@ -151,7 +155,7 @@ function isPidAlive(pid, {
151
155
  const out = execSync(`tasklist /FI "PID eq ${n}" /NH`, {
152
156
  encoding: 'utf8', timeout: 3000, windowsHide: true,
153
157
  });
154
- // Word-boundary + image-name match (mirrors engine/restart-health.js) so a
158
+ // Word-boundary + image-name match (mirrors engine/recovery/restart-health.js) so a
155
159
  // digit-substring collision in another column can't read a dead pid as
156
160
  // alive. Fall back to an inline regex if shared.js isn't loadable.
157
161
  const shared = _sharedOrNull();
@@ -206,125 +210,12 @@ function listeningPidsForPort(port) {
206
210
  // checkout on the same machine is never touched. Opt out via
207
211
  // MINIONS_SUPERVISOR_REAP=0.
208
212
  const REAP_ENABLED = process.env.MINIONS_SUPERVISOR_REAP !== '0';
209
-
210
- function _normPath(p) { return String(p).replace(/\\/g, '/').toLowerCase(); }
211
-
212
- // Split a command line into argv tokens, honoring double-quote grouping
213
- // (Windows CommandLine quotes any path containing spaces, e.g.
214
- // `"C:\Program Files\nodejs\node.exe"`). We only need this accurate enough to
215
- // tell which token is the *script* being executed.
216
- function _tokenizeCmdline(cmdline) {
217
- const toks = [];
218
- let cur = '';
219
- let inQuote = false;
220
- for (const ch of String(cmdline)) {
221
- if (ch === '"') { inQuote = !inQuote; continue; }
222
- if (!inQuote && (ch === ' ' || ch === '\t')) { if (cur) { toks.push(cur); cur = ''; } continue; }
223
- cur += ch;
224
- }
225
- if (cur) toks.push(cur);
226
- return toks;
227
- }
228
-
229
- // True iff `cmdline` RUNS `target` as its script — not merely mentions it as an
230
- // argument. The script is the first `.js` token after the node executable, so
231
- // `node .../engine.js start` matches but `node eslint.js .../engine.js` (where
232
- // engine.js is an argument to a different script) does NOT. Exact, normalized
233
- // equality also rejects longer-name false positives (`.../engine.js.bak`,
234
- // `.../engine.json`) that a plain substring `includes` would catch. `target` is
235
- // already normalized by the caller.
236
- //
237
- // This is intentionally stricter than substring matching: every orphan the
238
- // supervisor needs to reap was spawned by spawnEngine()/spawnDashboard() with a
239
- // byte-identical `path.join(MINIONS_DIR, '<script>.js')`, so exact script-token
240
- // match catches all of them while a foreign process that only *names* the path
241
- // is left alone (fail-safe: never kill on a loose match).
242
- //
243
- // ENGINE subcommand gate: for an `engine.js` target we additionally require the
244
- // token AFTER the script to be `start`, so a short-lived `node engine.js stop`
245
- // one-shot (spawned by `minions restart`/`down`/`update` and the `stop` verb
246
- // during the restart window) is NEVER reaped. The long-lived daemon is ALWAYS
247
- // spawned with `'start'` (supervisor/dashboard spawnEngine + bin/minions.js), so
248
- // the requirement loses zero real orphans. The `dashboard.js` match stays BARE
249
- // (no subcommand) — only the engine candidate gets the `start` requirement.
250
- function _cmdRunsScript(cmdline, target) {
251
- const toks = _tokenizeCmdline(cmdline);
252
- const isEngineTarget = target === 'engine.js' || target.endsWith('/engine.js');
253
- // toks[0] is the node executable; the script is the first later .js token.
254
- for (let i = 1; i < toks.length; i++) {
255
- const norm = _normPath(toks[i]);
256
- if (norm.endsWith('.js')) {
257
- if (norm !== target) return false;
258
- if (isEngineTarget) {
259
- // Only the daemon (`engine.js start`) is reapable; one-shots like
260
- // `engine.js stop` are left alone.
261
- const next = toks[i + 1] ? _normPath(toks[i + 1]) : '';
262
- return next === 'start';
263
- }
264
- return true;
265
- }
266
- }
267
- return false;
268
- }
269
-
270
- // PIDs of `node` processes that RUN `needle` (a script path) as their script.
271
- // Cross-platform: PowerShell CIM on Windows, `ps` elsewhere. Best effort —
272
- // returns [] on any enumeration failure (fail-open: leak rather than risk
273
- // killing the wrong process).
274
- //
275
- // The OS does the coarse filtering (WQL `CommandLine LIKE` / a `grep` on the
276
- // script basename) so only candidate rows — not the machine's entire node/
277
- // process table — cross into the JS heap; `_cmdRunsScript` then applies the
278
- // exact script-position match. On a busy host (CI runners, many node procs)
279
- // this keeps the buffered stdout to a handful of rows instead of megabytes.
280
- // `maxBuffer` caps the read defensively in case the coarse filter still matches
281
- // a lot. The basename is an internal constant (`engine.js`/`dashboard.js`), so
282
- // it carries no shell/WQL-injection risk.
283
- const _ENUM_MAX_BUFFER = 8 * 1024 * 1024;
284
- function listNodePidsMatching(needle) {
285
- const target = _normPath(needle);
286
- const base = path.basename(needle); // coarse prefilter, e.g. 'engine.js'
287
- const pids = [];
288
- try {
289
- let out = '';
290
- if (isWin) {
291
- // spawnSync (no shell) + -EncodedCommand sidesteps cmd.exe quoting — an
292
- // inline `powershell -Command "... $(...) | ..."` via execSync gets its
293
- // `$(...)`/`|` mangled by cmd.exe before PowerShell ever sees them.
294
- const ps = `Get-CimInstance Win32_Process -Filter "name='node.exe' AND CommandLine LIKE '%${base}%'" | `
295
- + 'ForEach-Object { "$($_.ProcessId)|$($_.CommandLine)" }';
296
- const res = spawnSync('powershell',
297
- ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(ps, 'utf16le').toString('base64')],
298
- { encoding: 'utf8', timeout: 8000, windowsHide: true, maxBuffer: _ENUM_MAX_BUFFER });
299
- out = (res && res.stdout) || '';
300
- } else {
301
- // grep prefilters to candidate rows; no match exits 1 -> execSync throws
302
- // -> caught below -> [] (correct: nothing to reap).
303
- out = execSync(`ps -eo pid=,args= | grep -iF -- '${base}'`,
304
- { encoding: 'utf8', timeout: 8000, maxBuffer: _ENUM_MAX_BUFFER });
305
- }
306
- for (const line of out.split('\n')) {
307
- const trimmed = line.trim();
308
- if (!trimmed) continue;
309
- // Windows rows are "PID|<full command line>"; ps rows are "PID <args>".
310
- const m = isWin ? trimmed.match(/^(\d+)\|(.*)$/) : trimmed.match(/^(\d+)\s+(.*)$/);
311
- if (!m) continue;
312
- const pid = Number(m[1]);
313
- if (pid && _cmdRunsScript(m[2], target)) pids.push(pid);
314
- }
315
- } catch { /* enumeration failed — fail-open with whatever we collected */ }
316
- return pids;
317
- }
318
-
319
- function _killHard(pid) {
320
- const shared = _sharedOrNull();
321
- try {
322
- if (shared && typeof shared.killImmediate === 'function') { shared.killImmediate({ pid }); return; }
323
- } catch { /* fall through to platform kill */ }
324
- try {
325
- if (isWin) execSync(`taskkill /F /T /PID ${pid}`, { timeout: 5000, windowsHide: true, stdio: 'ignore' });
326
- else process.kill(pid, 'SIGKILL');
327
- } catch { /* already gone */ }
213
+ const _cmdRunsScript = commandLineRunsScript;
214
+ const listNodePidsMatching = listNodePidsMatchingScript;
215
+ function _killHard(pid, scriptPath) {
216
+ terminateProcess('engine.supervisor.daemon-replace', { pid }, {
217
+ scriptPath,
218
+ });
328
219
  }
329
220
 
330
221
  function _sleepSync(ms) {
@@ -357,9 +248,17 @@ function reapStrayProcesses(scriptPath, label, options = {}) {
357
248
  const listPids = options.listPids || listNodePidsMatching;
358
249
  const kill = options.kill || _killHard;
359
250
  const pids = listPids(scriptPath).filter(p => p !== process.pid);
251
+ const expectedPids = (options.expectedPids || [])
252
+ .map(Number)
253
+ .filter(pid => Number.isInteger(pid) && pid > 0 && pid !== process.pid);
254
+ const expectedIsAlive = options.isAlive || isPidAlive;
255
+ const unproven = expectedPids.filter(pid => expectedIsAlive(pid) && !pids.includes(pid));
256
+ if (unproven.length > 0) {
257
+ throw new Error(`Refusing to respawn ${label}: live PID ownership was not proven for ${unproven.join(', ')}`);
258
+ }
360
259
  if (!pids.length) return 0;
361
260
  console.log(`[supervisor] Reaping ${pids.length} stray ${label} process(es) before respawn: ${pids.join(', ')}`);
362
- for (const pid of pids) kill(pid);
261
+ for (const pid of pids) kill(pid, scriptPath);
363
262
  const waited = waitForPidsToExit(pids, options);
364
263
  if (!waited.ok) {
365
264
  throw new Error(`Refusing to respawn ${label}: stale PID(s) still alive after ${options.timeoutMs || REAP_WAIT_TIMEOUT_MS}ms: ${waited.remaining.join(', ')}`);
@@ -381,7 +280,7 @@ function openAppendFd(name) {
381
280
  // still serves HTTP 200, but you can't see anything it logs).
382
281
  const dir = _engineDir();
383
282
  try {
384
- const shared = require('./shared');
283
+ const shared = require('../core/shared');
385
284
  if (shared && typeof shared.openAppendLogFd === 'function') {
386
285
  return shared.openAppendLogFd(name, dir, { fallback: 'ignore' }).fd;
387
286
  }
@@ -507,6 +406,12 @@ function _isRespawnEligibleEngineState(state) {
507
406
  return state === 'running' || state === 'degraded';
508
407
  }
509
408
 
409
+ function _engineHeartbeatAgeMs(control, now) {
410
+ const heartbeat = Number(control?.heartbeat);
411
+ if (!heartbeat || !Number.isFinite(heartbeat)) return null;
412
+ return now - heartbeat;
413
+ }
414
+
510
415
  const ENGINE_WATCHDOG_OPERATION_NAMES = [
511
416
  'isPidAlive',
512
417
  'reapStrayProcesses',
@@ -564,6 +469,15 @@ function checkEngine(now, operations) {
564
469
  const ops = _resolveEngineWatchdogOperations(operations);
565
470
  if (control.pid && ops.isPidAlive(control.pid)) return;
566
471
 
472
+ const heartbeatAgeMs = _engineHeartbeatAgeMs(control, now);
473
+ if (control.pid && heartbeatAgeMs !== null && heartbeatAgeMs < SUPERVISOR_FRESH_HEARTBEAT_MS) {
474
+ console.log(
475
+ `[supervisor] Engine PID ${control.pid} reported dead by the liveness probe but its heartbeat is ` +
476
+ `${Math.round(heartbeatAgeMs / 1000)}s old — treating as up (false-negative suppressed)`,
477
+ );
478
+ return;
479
+ }
480
+
567
481
  console.log(`[supervisor] Engine PID ${control.pid || '(none)'} is dead — respawning...`);
568
482
  // Reap any orphan engines first so a stale control.json PID can't snowball
569
483
  // into an unbounded pile of contending engine processes.
@@ -578,7 +492,7 @@ function checkEngine(now, operations) {
578
492
  // event loop has frozen (sync busy-loop, blocked system call). Unlike
579
493
  // checkEngine (which only fires when the PID is dead), this fires when the PID
580
494
  // is alive but control.heartbeat has not advanced for SUPERVISOR_STALE_ENGINE_HEARTBEAT_MS.
581
- // The heartbeat is written every ~15s by a dedicated timer in engine/cli.js; if
495
+ // The heartbeat is written every ~15s by a dedicated timer in engine/operations/cli.js; if
582
496
  // it goes stale while the PID persists, the event loop is stuck.
583
497
  function checkEngineHung(now, operations) {
584
498
  if (now - _lastEngineRespawnAt < POST_SPAWN_GRACE_MS) return;
@@ -605,17 +519,26 @@ function checkEngineHung(now, operations) {
605
519
  `(${Math.round(heartbeatAge / 1000)}s old, threshold ${SUPERVISOR_STALE_ENGINE_HEARTBEAT_MS / 1000}s) — ` +
606
520
  `event loop likely frozen; restarting engine`,
607
521
  );
608
- ops.reapStrayProcesses(path.join(MINIONS_DIR, 'engine.js'), 'engine');
522
+ ops.reapStrayProcesses(path.join(MINIONS_DIR, 'engine.js'), 'engine', {
523
+ expectedPids: [control.pid],
524
+ isAlive: ops.isPidAlive,
525
+ });
609
526
  const newPid = ops.spawnEngine();
610
527
  _lastEngineRespawnAt = now;
611
528
  ops.recordCrashLoopRespawn('supervisor:stale-heartbeat');
612
529
  console.log(`[supervisor] Engine restarted due to stale heartbeat (new PID: ${newPid})`);
613
530
  }
614
531
 
615
- async function checkDashboard(now) {
532
+ async function checkDashboard(now, operations = {}) {
616
533
  if (now - _lastDashboardRespawnAt < POST_SPAWN_GRACE_MS) { _dashHealthFailStreak = 0; return; }
617
- const dashPort = _resolveDashPort();
618
- const pids = listeningPidsForPort(dashPort);
534
+ const resolveDashPort = operations.resolveDashPort || _resolveDashPort;
535
+ const findListeningPids = operations.listeningPidsForPort || listeningPidsForPort;
536
+ const probeDashboardHealth = operations.probeDashboardHealth || _probeDashboardHealth;
537
+ const reap = operations.reapStrayProcesses || reapStrayProcesses;
538
+ const spawnDash = operations.spawnDashboard || spawnDashboard;
539
+ const pidAlive = operations.isPidAlive || isPidAlive;
540
+ const dashPort = resolveDashPort();
541
+ const pids = findListeningPids(dashPort);
619
542
 
620
543
  const respawn = (why) => {
621
544
  console.log(`[supervisor] Dashboard ${why} on port ${dashPort} — respawning...`);
@@ -624,38 +547,43 @@ async function checkDashboard(now) {
624
547
  // the canonical port for the fresh one whose beacon _resolveDashPort() reads.
625
548
  // Critically, for the frozen-but-listening case the reap also KILLS the wedged
626
549
  // process still holding the port so spawnDashboard() can bind it.
627
- reapStrayProcesses(path.join(MINIONS_DIR, 'dashboard.js'), 'dashboard');
628
- const newPid = spawnDashboard(dashPort);
550
+ reap(path.join(MINIONS_DIR, 'dashboard.js'), 'dashboard', {
551
+ expectedPids: pids,
552
+ isAlive: pidAlive,
553
+ });
554
+ const newPid = spawnDash(dashPort);
629
555
  _lastDashboardRespawnAt = now;
630
556
  _dashHealthFailStreak = 0;
631
557
  console.log(`[supervisor] Dashboard respawned (new PID: ${newPid})`);
558
+ return { status: 'respawned', pid: newPid, port: dashPort };
632
559
  };
633
560
 
634
- // Hard-down: nothing bound to the port → respawn immediately.
635
- if (pids.length === 0) {
561
+ const healthy = await probeDashboardHealth(dashPort);
562
+ if (healthy === true) {
563
+ if (pids.length === 0) {
564
+ console.log(
565
+ `[supervisor] Dashboard port probe missed ${dashPort}, but /api/health responded — ` +
566
+ 'treating as up (false-negative suppressed)',
567
+ );
568
+ }
636
569
  _dashHealthFailStreak = 0;
637
- respawn('not listening');
638
- return;
570
+ return { status: 'healthy', port: dashPort };
639
571
  }
640
572
 
641
- // Port is bound. Verify the event loop is actually responsive — a frozen-but-
642
- // listening dashboard keeps the socket LISTENING yet serves the in-browser
643
- // "unreachable" banner. Probe /api/health; only respawn after a streak of
644
- // failures so a legitimately busy getStatus() rebuild isn't mistaken for a hang.
645
- const healthy = await _probeDashboardHealth(dashPort);
646
- if (healthy === null) {
647
- // Probe helper unavailable — fall back to port-only liveness (the pre-existing
648
- // behavior). Don't count this as a failure; we won't respawn a bound dashboard
649
- // we can't actually prove is frozen.
573
+ if (healthy === null && pids.length > 0) {
650
574
  _dashHealthFailStreak = 0;
651
- return;
575
+ return { status: 'listening', port: dashPort };
652
576
  }
653
- if (healthy) { _dashHealthFailStreak = 0; return; }
654
577
 
655
578
  _dashHealthFailStreak++;
656
- console.log(`[supervisor] Dashboard bound but unresponsive on port ${dashPort} (${_dashHealthFailStreak}/${DASH_HEALTH_MAX_FAILS} strikes)`);
657
- if (_dashHealthFailStreak < DASH_HEALTH_MAX_FAILS) return;
658
- respawn('frozen (health probe failed)');
579
+ console.log(
580
+ `[supervisor] Dashboard has no responsive health signal on port ${dashPort} ` +
581
+ `(${_dashHealthFailStreak}/${DASH_HEALTH_MAX_FAILS} strikes; listeners=${pids.length})`,
582
+ );
583
+ if (_dashHealthFailStreak < DASH_HEALTH_MAX_FAILS) {
584
+ return { status: 'unhealthy', strikes: _dashHealthFailStreak, port: dashPort };
585
+ }
586
+ return respawn(pids.length > 0 ? 'frozen (health probe failed)' : 'unreachable');
659
587
  }
660
588
 
661
589
  let _tickInFlight = false;
@@ -747,6 +675,8 @@ module.exports = {
747
675
  _isRespawnEligibleEngineState,
748
676
  checkDashboard,
749
677
  _probeDashboardHealth,
678
+ _killHard,
679
+ SUPERVISOR_FRESH_HEARTBEAT_MS,
750
680
  SUPERVISOR_STALE_ENGINE_HEARTBEAT_MS,
751
681
  DASH_HEALTH_MAX_FAILS,
752
682
  tick,