@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
@@ -0,0 +1,607 @@
1
+ // engine/recovery/stop-stack.js
2
+ //
3
+ // P-a7f3c91d — the SINGLE owner of ordered whole-stack runtime teardown.
4
+ //
5
+ // Why this module exists
6
+ // ----------------------
7
+ // There was no whole-stack stop. `minions stop` is delegated straight to
8
+ // `engine.js stop`, and engine/operations/cli.js#stop() only (a) writes
9
+ // stop-intent and (b) stamps `_shutdownRequestedAt` into control.json before
10
+ // returning. It never touches the dashboard or the supervisor. Because the
11
+ // dashboard independently holds engine/state.db open, `engine/state.db-shm`
12
+ // stays present and non-empty after a "successful" stop and every quiescence
13
+ // probe correctly stays red — which is exactly what blocked the internal
14
+ // installer's pre-migration gate.
15
+ //
16
+ // The ordered sequence below is NOT new machinery. It already existed inline
17
+ // twice in the CLI (the `restart` path, and abbreviated in `uninstall`); this
18
+ // module extracts that proven order into one importable, injectable owner:
19
+ //
20
+ // 1. write stop-intent FIRST — so the supervisor stands down before
21
+ // anything dies and cannot race-respawn what we are about to stop
22
+ // 2. stop the supervisor — before the daemons, so its guard tick can't
23
+ // fire against a half-dead stack
24
+ // 3. stop the dashboard — the second holder of engine/state.db
25
+ // 4. request the engine's graceful drain (`node engine.js stop`)
26
+ // 5. wait for the engine PID to die inside the drain budget
27
+ // 6. identity-verified reap of whatever survived
28
+ // 7. wait for engine/state.db-shm to be released
29
+ // 8. ONE late-respawn sweep to close the supervisor/watchdog race window,
30
+ // then the final -shm verification
31
+ //
32
+ // Deliberate constraints
33
+ // ----------------------
34
+ // * bin/minions.js has NO `module.exports` — it is a CLI entry script, not an
35
+ // importable module. Nothing here may import from it. This module owns the
36
+ // sequence and builds on engine/core/shared.js (which re-exports the
37
+ // engine/processes/process-utils.js termination gateway); the CLI becomes a
38
+ // consumer, not a dependency.
39
+ // * ALL termination routes through the registered `shared.terminateProcess`
40
+ // site `bin.daemon-stop-process` — mode `process-only`, ownership
41
+ // `exact-scripts`. Process-only is load-bearing on BOTH platforms: detached
42
+ // cold agents are children of the engine and MUST survive so the replacement
43
+ // engine can reattach them. That is why the CLI restart path uses
44
+ // killPidOnly rather than a graceful tree kill, and it is preserved here.
45
+ // * Root scoping: every candidate PID is proven against daemon script paths
46
+ // rooted under the SUPPLIED `minionsHome`. A second install, a `--dev`
47
+ // checkout, or an unrelated node process is never signalled. A live PID we
48
+ // cannot prove owns this root yields a REFUSAL verdict, not a kill.
49
+ // * Never throws for an expected outcome (still alive, unverified holder,
50
+ // budget expired) — callers get a structured verdict.
51
+ // * Every process seam and the clock are injectable through `deps`, so the
52
+ // whole sequence is asserted without timing sleeps.
53
+ //
54
+ // Synchronous by design: both consumers (the CLI teardown paths and the
55
+ // spawnSync-only internal installer) run inside straight-line shutdown code.
56
+
57
+ const fs = require('fs');
58
+ const path = require('path');
59
+ const { execFileSync } = require('child_process');
60
+ const shared = require('../core/shared');
61
+
62
+ /** The ordered steps recorded on every verdict. Exported so consumers and
63
+ * tests assert ordering against names rather than array positions. */
64
+ const STOP_STACK_STEPS = Object.freeze({
65
+ STOP_INTENT: 'stop-intent',
66
+ SUPERVISOR: 'supervisor',
67
+ DASHBOARD: 'dashboard',
68
+ ENGINE_DRAIN: 'engine-drain',
69
+ ENGINE_WAIT: 'engine-wait',
70
+ REAP: 'reap',
71
+ SHM_WAIT: 'shm-wait',
72
+ LATE_SWEEP: 'late-sweep',
73
+ VERIFY: 'verify',
74
+ });
75
+
76
+ /**
77
+ * Why a PID ended up in `holders[]`. The distinction is load-bearing:
78
+ * `UNVERIFIED_IDENTITY` means the process table WAS readable and proved the PID
79
+ * is not one of our daemons — a stale PID file, not a running stack. The other
80
+ * two mean something of ours is (or may still be) alive.
81
+ */
82
+ const STOP_STACK_HOLDER_REASONS = Object.freeze({
83
+ UNVERIFIED_IDENTITY: 'unverified-identity',
84
+ IDENTITY_PROBE_FAILED: 'identity-probe-failed',
85
+ STILL_ALIVE: 'still-alive',
86
+ });
87
+
88
+ /**
89
+ * Machine-readable exit codes for callers that shell out to a whole-stack stop
90
+ * (`minions stop --all`, and the internal installer's pre-migration gate).
91
+ *
92
+ * They exist so a caller can BRANCH without scraping stdout, and they are
93
+ * deliberately not 1 — the CLI already uses 1 for generic failures, so a
94
+ * distinct code is the only way to tell "the runtime is still busy, retry or
95
+ * widen the budget" apart from "Minions refused to signal a process it could
96
+ * not prove is yours; a human must look".
97
+ */
98
+ const STOP_STACK_EXIT = Object.freeze({
99
+ OK: 0,
100
+ /** The ordered teardown ran, but the runtime was not quiesced when the
101
+ * budget expired (a daemon still alive, or the WAL index still held). */
102
+ NOT_QUIESCED: 3,
103
+ /** Nothing was force-killed because identity could not be proven. */
104
+ REFUSED_UNVERIFIED: 4,
105
+ });
106
+
107
+ // Matches the internal installer's quiescence window so a stop driven by the
108
+ // migration gate cannot be shorter than the wait that follows it.
109
+ const DEFAULT_STOP_TIMEOUT_MS = 60000;
110
+ const PID_POLL_MS = 100;
111
+ const SHM_POLL_MS = 500;
112
+ /**
113
+ * Budget-INDEPENDENT floor for every wait that follows a kill we already issued.
114
+ *
115
+ * The forced platform kill is asynchronous on both Windows and POSIX: the call
116
+ * returns once the signal is ACCEPTED, and the process leaves the table some
117
+ * milliseconds later. The forced reap exists precisely for a hung engine — and
118
+ * in that case the graceful drain (step 5) has already spent the entire caller
119
+ * budget, so a post-kill wait derived from the remaining budget is exactly 0. It
120
+ * would return instantly, the final verification would see a process that is
121
+ * already on its way out, and the verdict would report it as a surviving
122
+ * holder: `minions restart` aborts with the stack DOWN, and `stop --all --wait`
123
+ * reports NOT_QUIESCED moments after a successful reap.
124
+ *
125
+ * Once the signal is out, abandoning it mid-flight buys nothing — the process
126
+ * is dying either way, and the only thing a zero wait changes is the honesty of
127
+ * the verdict. This mirrors the budget-independent 5000ms grace the inline CLI
128
+ * code used before this module extracted it (bin/minions.js: the post-kill
129
+ * `waitForPidDeath(oldEnginePid, 5000)` and the late-respawn sweep's
130
+ * `waitForPidDeath(pid, 5000)`), and it is a FLOOR, never a cap: a caller with
131
+ * budget left still gets its full remaining budget.
132
+ */
133
+ const POST_KILL_GRACE_MS = 5000;
134
+ // Budget for the `engine.js stop` CALL itself. The call only stamps
135
+ // control.json and returns; the drain is awaited separately by ENGINE_WAIT.
136
+ const ENGINE_STOP_CALL_TIMEOUT_MS = 10000;
137
+
138
+ const STATE_SHM_SEGMENTS = ['engine', 'state.db-shm'];
139
+
140
+ /**
141
+ * The long-lived daemons this module owns, in stop order, with the PID file
142
+ * each one writes and the script that proves its identity.
143
+ *
144
+ * Agent processes are deliberately absent: they are detached, they must
145
+ * survive for restart reattachment, and any database handle they hold is still
146
+ * caught by the -shm evidence below.
147
+ */
148
+ const SERVICE_DEFS = Object.freeze([
149
+ { name: 'engine', pidFile: ['engine', 'control.json'], shape: 'json', script: ['engine.js'] },
150
+ { name: 'dashboard', pidFile: ['engine', 'dashboard-port.json'], shape: 'json', script: ['dashboard.js'] },
151
+ { name: 'supervisor', pidFile: ['engine', 'supervisor.pid'], shape: 'text', script: ['engine', 'recovery', 'supervisor.js'] },
152
+ ]);
153
+
154
+ /** Daemon script paths rooted under `minionsHome` — the identity allowlist. */
155
+ function daemonScriptPathsForRoot(minionsHome) {
156
+ const root = path.resolve(String(minionsHome || ''));
157
+ return SERVICE_DEFS.map(def => path.join(root, ...def.script));
158
+ }
159
+
160
+ /**
161
+ * Recorded service PIDs for `minionsHome`, each carrying the script path that
162
+ * must prove its identity before it may be signalled.
163
+ *
164
+ * Read from the files the daemons themselves write, so this works with the
165
+ * dashboard already down — which is exactly when a stop is most likely to be
166
+ * attempted. (bin/install-internal-minions.js#readServicePids reads the same
167
+ * three files for its read-only quiescence probe; this variant additionally
168
+ * resolves the owning script, which is what makes termination safe.)
169
+ */
170
+ function readServiceRecords(minionsHome, deps = {}) {
171
+ const root = path.resolve(String(minionsHome || ''));
172
+ const readFileSync = typeof deps.readFileSync === 'function'
173
+ ? deps.readFileSync
174
+ : (p => fs.readFileSync(p, 'utf8'));
175
+ return SERVICE_DEFS.map((def) => {
176
+ const file = path.join(root, ...def.pidFile);
177
+ let pid = null;
178
+ try {
179
+ const raw = String(readFileSync(file));
180
+ pid = def.shape === 'json' ? Number(JSON.parse(raw).pid) : Number(raw.trim());
181
+ } catch { pid = null; }
182
+ return {
183
+ name: def.name,
184
+ pid: Number.isInteger(pid) && pid > 0 ? pid : null,
185
+ file,
186
+ script: path.join(root, ...def.script),
187
+ };
188
+ });
189
+ }
190
+
191
+ function defaultWriteStopIntent(minionsHome, source) {
192
+ const target = path.join(path.resolve(String(minionsHome || '')), 'engine', 'stop-intent.json');
193
+ // Prefer the shared writer when it targets the same root, so the flag stays
194
+ // byte-identical to every other stop path. A pinned foreign root (the
195
+ // installer's case) falls through to a root-scoped write.
196
+ try {
197
+ if (path.resolve(shared.STOP_INTENT_PATH) === path.resolve(target)) {
198
+ return shared.writeStopIntent(source) !== false;
199
+ }
200
+ } catch { /* fall through to the explicit write */ }
201
+ try {
202
+ fs.mkdirSync(path.dirname(target), { recursive: true });
203
+ fs.writeFileSync(target, JSON.stringify({
204
+ stoppedAt: new Date().toISOString(),
205
+ source: source || 'stopRuntimeStack',
206
+ }, null, 2));
207
+ return true;
208
+ } catch { return false; }
209
+ }
210
+
211
+ /**
212
+ * Force-stop one PID without walking its descendants, and only after the
213
+ * gateway has proven the PID runs one of `scriptPaths`. `execFileSync` is not
214
+ * used here — the registered site owns the platform specifics.
215
+ */
216
+ function defaultKillPidOnly(pid, scriptPaths) {
217
+ const n = Number(pid);
218
+ if (!Number.isInteger(n) || n <= 0 || n === process.pid) return false;
219
+ return shared.terminateProcess('bin.daemon-stop-process', { pid: n }, { scriptPaths });
220
+ }
221
+
222
+ /** Ask the engine to drain gracefully. Mirrors the CLI restart path, but via
223
+ * execFileSync so a runtime root containing spaces needs no shell quoting.
224
+ * The `_execFileSync` seam exists only so the default env-scoping can be
225
+ * asserted directly without spawning a real engine. */
226
+ function defaultRequestEngineStop({ minionsHome, timeoutMs }, _execFileSync = execFileSync) {
227
+ const root = path.resolve(String(minionsHome || ''));
228
+ // Pin the child engine onto the SAME root every other seam here is scoped to.
229
+ // resolveMinionsHome() returns MINIONS_TEST_DIR, then MINIONS_HOME, BEFORE any
230
+ // root-pointer logic (engine/core/shared.js), so without this the spawned
231
+ // `engine.js stop` would resolve MINIONS_DIR from whatever ambient env it
232
+ // inherited — draining/rewriting an UNRELATED root's control.json while the
233
+ // pinned root's engine never drains and is force-reaped. MINIONS_TEST_DIR
234
+ // outranks MINIONS_HOME, so an inherited one must be dropped or it silently
235
+ // un-pins the child (mirrors bin/install-internal-minions.js:1992-1995).
236
+ const childEnv = { ...process.env, MINIONS_HOME: root };
237
+ delete childEnv.MINIONS_TEST_DIR;
238
+ try {
239
+ _execFileSync(process.execPath, [path.join(root, 'engine.js'), 'stop'], {
240
+ stdio: 'ignore',
241
+ cwd: root,
242
+ env: childEnv,
243
+ timeout: Math.max(1, Number(timeoutMs) || ENGINE_STOP_CALL_TIMEOUT_MS),
244
+ windowsHide: true,
245
+ });
246
+ return { ok: true };
247
+ } catch (err) {
248
+ return { ok: false, error: String((err && err.message) || err) };
249
+ }
250
+ }
251
+
252
+ function resolveDeps(deps = {}) {
253
+ const listPlural = typeof deps.listNodePidsMatchingScripts === 'function'
254
+ ? deps.listNodePidsMatchingScripts
255
+ : (typeof deps.listNodePidsMatchingScript === 'function'
256
+ // Accept the singular seam name too, so a consumer that already holds the
257
+ // one-script helper can inject it unchanged.
258
+ ? ((scriptPaths, opts) => [...new Set(
259
+ (scriptPaths || []).flatMap(script => deps.listNodePidsMatchingScript(script, opts) || []),
260
+ )])
261
+ : shared.listNodePidsMatchingScripts);
262
+
263
+ const resolved = {
264
+ platform: deps.platform || process.platform,
265
+ now: typeof deps.now === 'function' ? deps.now : (() => Date.now()),
266
+ sleep: typeof deps.sleep === 'function' ? deps.sleep : (ms => shared.sleepMs(ms)),
267
+ statSync: typeof deps.statSync === 'function' ? deps.statSync : (p => fs.statSync(p)),
268
+ readFileSync: deps.readFileSync,
269
+ isPidAlive: typeof deps.isPidAlive === 'function' ? deps.isPidAlive : (pid => shared.isPidAlive(pid)),
270
+ listNodePidsMatchingScripts: listPlural,
271
+ killPidOnly: typeof deps.killPidOnly === 'function' ? deps.killPidOnly : defaultKillPidOnly,
272
+ writeStopIntent: typeof deps.writeStopIntent === 'function' ? deps.writeStopIntent : defaultWriteStopIntent,
273
+ requestEngineStop: typeof deps.requestEngineStop === 'function' ? deps.requestEngineStop : defaultRequestEngineStop,
274
+ waitForPidDeath: deps.waitForPidDeath,
275
+ };
276
+ if (typeof resolved.waitForPidDeath !== 'function') {
277
+ resolved.waitForPidDeath = (pid, timeoutMs) => defaultWaitForPidDeath(pid, timeoutMs, resolved);
278
+ }
279
+ return resolved;
280
+ }
281
+
282
+ /** Poll until `pid` is gone or the budget expires. Built entirely on the
283
+ * injected liveness/clock/sleep seams so tests never sleep for real. */
284
+ function defaultWaitForPidDeath(pid, timeoutMs, d) {
285
+ const start = d.now();
286
+ const n = Number(pid);
287
+ if (!Number.isInteger(n) || n <= 0) return { ok: true, waitedMs: 0 };
288
+ const budget = Math.max(0, Number(timeoutMs) || 0);
289
+ for (;;) {
290
+ let alive;
291
+ try { alive = d.isPidAlive(n) === true; } catch { alive = false; }
292
+ if (!alive) return { ok: true, waitedMs: d.now() - start };
293
+ const elapsed = d.now() - start;
294
+ if (elapsed >= budget) return { ok: false, waitedMs: elapsed };
295
+ try { d.sleep(Math.min(PID_POLL_MS, Math.max(1, budget - elapsed))); }
296
+ catch { return { ok: false, waitedMs: d.now() - start }; }
297
+ }
298
+ }
299
+
300
+ /**
301
+ * `-shm` evidence for the pinned root.
302
+ *
303
+ * SQLite creates the WAL shared-memory index when the FIRST connection opens
304
+ * the database and removes it when the LAST one closes cleanly, so a present,
305
+ * non-empty `-shm` means a process still holds the database open even when
306
+ * every PID we know about is gone.
307
+ *
308
+ * A missing file is genuine evidence of release; ANY other stat failure is a
309
+ * failed probe and must fail CLOSED, never be read as "released".
310
+ */
311
+ function readShmEvidence(root, d) {
312
+ try {
313
+ const info = d.statSync(path.join(root, ...STATE_SHM_SEGMENTS));
314
+ return { probed: true, present: true, bytes: Number(info && info.size) || 0 };
315
+ } catch (err) {
316
+ const code = String((err && err.code) || '');
317
+ if (code === 'ENOENT' || code === 'ENOTDIR') return { probed: true, present: false, bytes: 0 };
318
+ return { probed: false, present: false, bytes: 0, error: String((err && err.message) || err) };
319
+ }
320
+ }
321
+
322
+ function shmHeld(evidence) {
323
+ return !!evidence.present && Number(evidence.bytes) > 0;
324
+ }
325
+
326
+ /**
327
+ * Does this holder mean a process of OURS is (or may still be) running?
328
+ *
329
+ * `unverified-identity` is the one reason that does NOT: the process table was
330
+ * readable and proved the recorded PID belongs to something else, i.e. a stale
331
+ * PID file. Callers that are about to spawn a REPLACEMENT stack use this to
332
+ * tell "a daemon survived, aborting is the only safe move" apart from "a PID
333
+ * file outlived its process, carry on" — the second is exactly the state
334
+ * `minions restart` exists to heal.
335
+ */
336
+ function holderIsLiveDaemon(holder) {
337
+ if (!holder) return false;
338
+ return holder.reason !== STOP_STACK_HOLDER_REASONS.UNVERIFIED_IDENTITY;
339
+ }
340
+
341
+ /**
342
+ * Map a verdict to its process exit code. A refusal outranks a budget expiry:
343
+ * when identity could not be proven, nothing was signalled at all, so reporting
344
+ * "did not quiesce in time" would send the operator to widen a budget that was
345
+ * never the problem.
346
+ */
347
+ function stopStackExitCode(verdict) {
348
+ if (verdict && verdict.ok === true) return STOP_STACK_EXIT.OK;
349
+ const holders = verdict && Array.isArray(verdict.holders) ? verdict.holders : [];
350
+ const probeFailures = verdict && Array.isArray(verdict.probeFailures) ? verdict.probeFailures : [];
351
+ const refused = holders.some(h => h && (
352
+ h.reason === STOP_STACK_HOLDER_REASONS.UNVERIFIED_IDENTITY
353
+ || h.reason === STOP_STACK_HOLDER_REASONS.IDENTITY_PROBE_FAILED));
354
+ if (refused || probeFailures.length > 0) return STOP_STACK_EXIT.REFUSED_UNVERIFIED;
355
+ return STOP_STACK_EXIT.NOT_QUIESCED;
356
+ }
357
+
358
+ /**
359
+ * @param {object} opts
360
+ * @param {string} opts.minionsHome runtime root to stop; scopes every identity proof
361
+ * @param {number} [opts.timeoutMs] total budget for the whole sequence
362
+ * @param {string} [opts.source] recorded in stop-intent.json
363
+ * @param {boolean} [opts.waitForRelease=true]
364
+ * Poll for `engine/state.db-shm` to be released, and require that
365
+ * release for `ok`. Callers that immediately REPLACE the stack
366
+ * (restart, the init upgrade) or DELETE the root (uninstall, nuke) pass
367
+ * false: they still run the identical ordered teardown and still get the
368
+ * `-shm` evidence reported, they simply do not block on it. Only a
369
+ * pre-migration gate needs the guarantee.
370
+ * @param {object} [opts.deps] injectable process/clock seams
371
+ */
372
+ function stopRuntimeStack({ minionsHome, timeoutMs, source, waitForRelease = true, deps } = {}) {
373
+ const d = resolveDeps(deps);
374
+ const steps = [];
375
+ const stopped = [];
376
+ const holders = new Map();
377
+ const probeFailures = [];
378
+ const startedAt = d.now();
379
+ const elapsed = () => Math.max(0, d.now() - startedAt);
380
+ const record = (step, detail) => { steps.push({ step, atMs: elapsed(), ...(detail || {}) }); };
381
+
382
+ if (!minionsHome || typeof minionsHome !== 'string') {
383
+ return {
384
+ ok: false,
385
+ stopped: [],
386
+ holders: [],
387
+ probeFailures: ['minionsHome must be a non-empty path string'],
388
+ shm: { probed: false, present: false, bytes: 0, released: false },
389
+ waitedMs: 0,
390
+ steps: [],
391
+ reason: 'refused: minionsHome must be a non-empty path string',
392
+ };
393
+ }
394
+
395
+ const root = path.resolve(minionsHome);
396
+ const scriptPaths = daemonScriptPathsForRoot(root);
397
+ const budget = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0
398
+ ? Number(timeoutMs)
399
+ : DEFAULT_STOP_TIMEOUT_MS;
400
+ const deadline = startedAt + budget;
401
+ const remaining = () => Math.max(0, deadline - d.now());
402
+ /** Budget for a wait that follows a kill we ALREADY issued. Floored at
403
+ * POST_KILL_GRACE_MS so a drain that consumed the whole budget cannot
404
+ * collapse it to zero and turn a dying process into a reported holder. */
405
+ const postKillWaitMs = () => Math.max(remaining(), POST_KILL_GRACE_MS);
406
+
407
+ const services = readServiceRecords(root, d);
408
+ const byName = new Map(services.map(svc => [svc.name, svc]));
409
+ const byPid = new Map(services.filter(svc => svc.pid).map(svc => [svc.pid, svc]));
410
+
411
+ const isAlive = (pid) => {
412
+ try { return d.isPidAlive(pid) === true; }
413
+ catch (err) {
414
+ probeFailures.push(`liveness probe failed for PID ${pid}: ${String((err && err.message) || err)}`);
415
+ return false;
416
+ }
417
+ };
418
+ /** Owned PIDs for `paths`, or null when the process table could not be read. */
419
+ const ownedPids = (paths) => {
420
+ try {
421
+ const list = d.listNodePidsMatchingScripts(paths, { platform: d.platform }) || [];
422
+ return list.map(Number).filter(n => Number.isInteger(n) && n > 0);
423
+ } catch (err) {
424
+ probeFailures.push(`process identity probe failed: ${String((err && err.message) || err)}`);
425
+ return null;
426
+ }
427
+ };
428
+ const refuse = (pid, name, reason) => {
429
+ if (!holders.has(pid)) holders.set(pid, { name, pid, reason });
430
+ };
431
+ const kill = (pid) => {
432
+ try { return d.killPidOnly(pid, scriptPaths) !== false; }
433
+ catch { return false; }
434
+ };
435
+
436
+ // ── 1. stop-intent FIRST, so the supervisor stands down before anything dies
437
+ let intentOk = false;
438
+ try { intentOk = d.writeStopIntent(root, source || 'stopRuntimeStack') !== false; }
439
+ catch { intentOk = false; }
440
+ record(STOP_STACK_STEPS.STOP_INTENT, { ok: intentOk });
441
+
442
+ /** Stop one recorded service, refusing any live PID we cannot prove is ours. */
443
+ const stopVerifiedService = (svc, step) => {
444
+ if (!svc || !svc.pid) { record(step, { result: 'no-pid' }); return; }
445
+ if (svc.pid === process.pid) { record(step, { result: 'self', pid: svc.pid }); return; }
446
+ if (!isAlive(svc.pid)) { record(step, { result: 'already-stopped', pid: svc.pid }); return; }
447
+ const owned = ownedPids([svc.script]);
448
+ if (owned === null) {
449
+ refuse(svc.pid, svc.name, STOP_STACK_HOLDER_REASONS.IDENTITY_PROBE_FAILED);
450
+ record(step, { result: 'probe-failed', pid: svc.pid });
451
+ return;
452
+ }
453
+ if (!owned.includes(svc.pid)) {
454
+ refuse(svc.pid, svc.name, STOP_STACK_HOLDER_REASONS.UNVERIFIED_IDENTITY);
455
+ record(step, { result: 'refused-unverified', pid: svc.pid });
456
+ return;
457
+ }
458
+ const killed = kill(svc.pid);
459
+ if (killed) stopped.push({ name: svc.name, pid: svc.pid, via: 'process-kill' });
460
+ record(step, { result: killed ? 'stopped' : 'kill-failed', pid: svc.pid });
461
+ };
462
+
463
+ // ── 2. supervisor, ── 3. dashboard (the second holder of engine/state.db)
464
+ stopVerifiedService(byName.get('supervisor'), STOP_STACK_STEPS.SUPERVISOR);
465
+ stopVerifiedService(byName.get('dashboard'), STOP_STACK_STEPS.DASHBOARD);
466
+
467
+ // ── 4. ask the engine to drain gracefully — ALWAYS before any forced reap.
468
+ const engine = byName.get('engine');
469
+ const engineWasAlive = !!(engine && engine.pid) && isAlive(engine.pid);
470
+ let drain = { ok: false, error: 'not-requested' };
471
+ if (engineWasAlive) {
472
+ try {
473
+ drain = d.requestEngineStop({
474
+ minionsHome: root,
475
+ timeoutMs: Math.max(1, Math.min(ENGINE_STOP_CALL_TIMEOUT_MS, remaining() || ENGINE_STOP_CALL_TIMEOUT_MS)),
476
+ }) || { ok: false };
477
+ } catch (err) {
478
+ drain = { ok: false, error: String((err && err.message) || err) };
479
+ }
480
+ }
481
+ record(STOP_STACK_STEPS.ENGINE_DRAIN, {
482
+ requested: engineWasAlive,
483
+ ok: drain.ok === true,
484
+ ...(drain.error ? { error: drain.error } : {}),
485
+ });
486
+
487
+ // ── 5. give the drain its budget before escalating. Detached cold agents are
488
+ // not part of that drain and stay alive for reattachment.
489
+ let drained = false;
490
+ if (engineWasAlive) {
491
+ const wait = d.waitForPidDeath(engine.pid, remaining()) || { ok: false };
492
+ drained = wait.ok === true;
493
+ if (drained) stopped.push({ name: 'engine', pid: engine.pid, via: 'graceful-drain' });
494
+ record(STOP_STACK_STEPS.ENGINE_WAIT, { pid: engine.pid, drained, waitedMs: wait.waitedMs });
495
+ } else {
496
+ record(STOP_STACK_STEPS.ENGINE_WAIT, { drained: false, result: 'not-running' });
497
+ }
498
+
499
+ // ── 6. identity-verified reap of whatever survived the drain.
500
+ const survivors = services.filter(svc => svc.pid && svc.pid !== process.pid && isAlive(svc.pid));
501
+ const reapOwned = survivors.length > 0 ? ownedPids(scriptPaths) : [];
502
+ const reaped = [];
503
+ for (const svc of survivors) {
504
+ if (reapOwned === null) { refuse(svc.pid, svc.name, STOP_STACK_HOLDER_REASONS.IDENTITY_PROBE_FAILED); continue; }
505
+ if (!reapOwned.includes(svc.pid)) { refuse(svc.pid, svc.name, STOP_STACK_HOLDER_REASONS.UNVERIFIED_IDENTITY); continue; }
506
+ if (!kill(svc.pid)) continue;
507
+ stopped.push({ name: svc.name, pid: svc.pid, via: 'process-kill' });
508
+ reaped.push(svc.pid);
509
+ }
510
+ for (const pid of reaped) d.waitForPidDeath(pid, postKillWaitMs());
511
+ record(STOP_STACK_STEPS.REAP, { candidates: survivors.map(s => s.pid), reaped });
512
+
513
+ // ── 7. wait for the WAL index to be released, unless the caller opted out
514
+ // because it is about to replace the stack or delete the root.
515
+ let shm = readShmEvidence(root, d);
516
+ let shmPolls = 1;
517
+ while (waitForRelease && shm.probed && shmHeld(shm) && remaining() > 0) {
518
+ try { d.sleep(Math.min(SHM_POLL_MS, remaining())); } catch { break; }
519
+ shm = readShmEvidence(root, d);
520
+ shmPolls++;
521
+ }
522
+ record(STOP_STACK_STEPS.SHM_WAIT, {
523
+ polls: shmPolls, present: shm.present, bytes: shm.bytes, awaited: waitForRelease,
524
+ });
525
+
526
+ // ── 8. ONE late-respawn sweep. The watchdog (or a surviving supervisor) can
527
+ // spawn a fresh engine/dashboard inside the kill window; those become
528
+ // orphans that re-create -shm right after the probe went green. Sweep
529
+ // BEFORE the final verification so the verdict cannot be stale.
530
+ // stop-intent is still set, so any respawn has already stood down.
531
+ const lateOwned = ownedPids(scriptPaths);
532
+ const swept = [];
533
+ for (const pid of (lateOwned || [])) {
534
+ if (pid === process.pid) continue;
535
+ if (!kill(pid)) continue;
536
+ const known = byPid.get(pid);
537
+ stopped.push({ name: known ? known.name : 'daemon', pid, via: 'late-sweep' });
538
+ swept.push(pid);
539
+ }
540
+ for (const pid of swept) d.waitForPidDeath(pid, postKillWaitMs());
541
+ record(STOP_STACK_STEPS.LATE_SWEEP, { swept });
542
+
543
+ // ── 9. final verification, strictly after the sweep.
544
+ for (const svc of services) {
545
+ if (!svc.pid || svc.pid === process.pid) continue;
546
+ if (isAlive(svc.pid)) {
547
+ refuse(svc.pid, svc.name,
548
+ holders.has(svc.pid) ? holders.get(svc.pid).reason : STOP_STACK_HOLDER_REASONS.STILL_ALIVE);
549
+ }
550
+ }
551
+ shm = readShmEvidence(root, d);
552
+ const heldShm = shmHeld(shm);
553
+ record(STOP_STACK_STEPS.VERIFY, {
554
+ holders: holders.size,
555
+ shmPresent: shm.present,
556
+ shmBytes: shm.bytes,
557
+ shmProbed: shm.probed,
558
+ });
559
+
560
+ const holderList = [...holders.values()];
561
+ const releaseVerified = shm.probed && !heldShm;
562
+ const shmReport = {
563
+ probed: shm.probed,
564
+ present: shm.present,
565
+ bytes: shm.bytes,
566
+ released: releaseVerified,
567
+ ...(shm.error ? { error: shm.error } : {}),
568
+ };
569
+ const ok = holderList.length === 0
570
+ && probeFailures.length === 0
571
+ && (waitForRelease ? releaseVerified : true);
572
+ let reason;
573
+ if (holderList.length > 0) {
574
+ reason = `still holding ${root}: ${holderList.map(h => `${h.name} (PID ${h.pid}) [${h.reason}]`).join(', ')}`;
575
+ } else if (probeFailures.length > 0) {
576
+ reason = `could not verify the runtime stack: ${probeFailures.join('; ')}`;
577
+ } else if (!waitForRelease && !releaseVerified) {
578
+ reason = 'runtime stack stopped; the engine/state.db-shm release was not awaited '
579
+ + `(${shm.probed ? `${shm.bytes} bytes still present` : `probe failed: ${shm.error}`})`;
580
+ } else if (!shm.probed) {
581
+ reason = `could not read engine/state.db-shm: ${shm.error}`;
582
+ } else if (heldShm) {
583
+ reason = `engine/state.db-shm is still present and non-empty (${shm.bytes} bytes), `
584
+ + 'so a process still holds the database open';
585
+ } else {
586
+ reason = 'runtime stack stopped; no process holds engine/state.db';
587
+ }
588
+
589
+ return { ok, stopped, holders: holderList, probeFailures, shm: shmReport, waitedMs: elapsed(), steps, reason };
590
+ }
591
+
592
+ module.exports = {
593
+ stopRuntimeStack,
594
+ stopStackExitCode,
595
+ holderIsLiveDaemon,
596
+ daemonScriptPathsForRoot,
597
+ readServiceRecords,
598
+ defaultRequestEngineStop,
599
+ STOP_STACK_STEPS,
600
+ STOP_STACK_HOLDER_REASONS,
601
+ STOP_STACK_EXIT,
602
+ DEFAULT_STOP_TIMEOUT_MS,
603
+ POST_KILL_GRACE_MS,
604
+ PID_POLL_MS,
605
+ SHM_POLL_MS,
606
+ ENGINE_STOP_CALL_TIMEOUT_MS,
607
+ };