@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.
- package/bin/cli-api-client.js +1 -1
- package/bin/install-internal-minions.js +1382 -44
- package/bin/install-layout.js +150 -0
- package/bin/minions.js +460 -167
- package/dashboard/docs/typography.md +65 -12
- package/dashboard/js/command-center.js +58 -4
- package/dashboard/js/detail-panel.js +36 -0
- package/dashboard/js/memory-panel.js +59 -12
- package/dashboard/js/qa.js +179 -20
- package/dashboard/js/refresh.js +148 -12
- package/dashboard/js/render-dispatch.js +3 -4
- package/dashboard/js/render-inbox.js +2 -2
- package/dashboard/js/render-other.js +3 -3
- package/dashboard/js/render-pipelines.js +14 -0
- package/dashboard/js/render-plans.js +57 -9
- package/dashboard/js/render-prd.js +132 -23
- package/dashboard/js/render-prs.js +195 -166
- package/dashboard/js/render-schedules.js +63 -3
- package/dashboard/js/render-utils.js +3 -3
- package/dashboard/js/render-watches.js +19 -3
- package/dashboard/js/render-work-items.js +238 -30
- package/dashboard/js/settings.js +205 -54
- package/dashboard/js/utils.js +51 -1
- package/dashboard/pages/home.html +1 -1
- package/dashboard/pages/qa.html +1 -16
- package/dashboard/pages/work.html +40 -0
- package/dashboard/shared/cc-limits.js +79 -0
- package/dashboard/shared/pr-filters.js +21 -38
- package/dashboard/shared/project-git-summary.js +1 -1
- package/dashboard/shared/record-filters.js +169 -0
- package/dashboard/shared/watches-source.js +1 -1
- package/dashboard/shared/welcome-popup.js +1 -1
- package/dashboard/shared/wi-filters.js +302 -0
- package/dashboard/slim/body.html +1 -0
- package/dashboard/slim/js/command-send.js +26 -0
- package/dashboard/slim/js/modals-tiles.js +380 -39
- package/dashboard/slim/js/status.js +13 -21
- package/dashboard/slim/layout.html +1 -0
- package/dashboard/slim/panel-bootstrap.js +6 -2
- package/dashboard/slim/styles.css +38 -0
- package/dashboard/styles.css +159 -55
- package/dashboard-build.js +13 -2
- package/dashboard.js +956 -423
- package/docs/README.md +11 -6
- package/docs/api-errors.md +2 -2
- package/docs/architecture-review-2026-07-09.md +1 -1
- package/docs/architecture.excalidraw +2 -2
- package/docs/auto-discovery.md +18 -9
- package/docs/branch-derivation.md +4 -4
- package/docs/capture-demos.js +39 -2
- package/docs/ci-runner-canary.md +123 -0
- package/docs/claude-md-propagation.md +2 -2
- package/docs/cloud-agent-dispatch.md +204 -0
- package/docs/command-center.md +7 -7
- package/docs/completion-reports.md +43 -20
- package/docs/constants.md +10 -3
- package/docs/constellation-bridge.md +134 -6
- package/docs/constellation-style-telemetry.md +4 -4
- package/docs/contracts/capability-protocol.v1.json +165 -0
- package/docs/cooldown-merge-semantics.md +12 -12
- package/docs/copilot-cli-schema.md +7 -7
- package/docs/cross-repo-plans.md +10 -10
- package/docs/dead-code-audit-retractions.md +5 -5
- package/docs/default-branch-ci.md +173 -0
- package/docs/deprecated.json +31 -31
- package/docs/design-inbox-entries-schema.md +3 -3
- package/docs/design-language.md +1051 -0
- package/docs/design-state-storage.md +11 -11
- package/docs/diagnostics-crash-reports.md +9 -9
- package/docs/diagnostics-memory.md +5 -5
- package/docs/documentation-audit-2026-07-09.md +7 -7
- package/docs/engine-restart.md +90 -5
- package/docs/harness-mode.md +1 -1
- package/docs/internal-install.md +338 -39
- package/docs/kb-dedup-duplicate-pair-investigation.md +5 -5
- package/docs/kb-pr3223-cascade-archiving.md +1 -1
- package/docs/kb-pr696-merge-conflict-docs.md +6 -6
- package/docs/kb-sweep.md +35 -35
- package/docs/keep-processes.md +1 -1
- package/docs/live-checkout-mode.md +30 -30
- package/docs/managed-spawn.md +18 -14
- package/docs/named-agents.md +7 -7
- package/docs/plan-lifecycle.md +69 -2
- package/docs/pr-author-identity.md +114 -0
- package/docs/pr-auto-fix-dispatch.md +19 -4
- package/docs/pr-comment-followup.md +6 -6
- package/docs/pr-review-fix-loop.md +59 -10
- package/docs/process-termination.md +40 -0
- package/docs/proposals/repo-pool-for-live-checkout.md +13 -13
- package/docs/qa-runbook-lifecycle.md +367 -17
- package/docs/qa-runbooks.md +3 -3
- package/docs/rfc-completion-json.md +18 -18
- package/docs/runtime-adapters.md +26 -21
- package/docs/security.md +6 -6
- package/docs/self-improvement.md +4 -4
- package/docs/shared-lifecycle-module-map.md +473 -472
- package/docs/skills.md +52 -3
- package/docs/slim-ux/concepts.md +121 -116
- package/docs/specs/agent-configurability.md +18 -18
- package/docs/specs/agent-rename.md +18 -18
- package/docs/team-memory.md +38 -21
- package/docs/timeouts-and-liveness.md +118 -10
- package/docs/tutorials/01-install-and-connect.md +1 -1
- package/docs/watches.md +40 -39
- package/docs/workspace-manifests.md +4 -4
- package/docs/worktree-lifecycle.md +293 -14
- package/engine/README.md +46 -0
- package/engine/{ado-comment.js → ado/comment.js} +8 -8
- package/engine/{ado-git-auth.js → ado/git-auth.js} +4 -4
- package/engine/{ado.js → ado/index.js} +417 -63
- package/engine/{ado-status.js → ado/status.js} +6 -8
- package/engine/{ado-token.js → ado/token.js} +1 -1
- package/engine/{acp-transport.js → agents/acp-transport.js} +62 -22
- package/engine/{agent-worker-pool.js → agents/agent-worker-pool.js} +17 -8
- package/engine/{cc-worker-pool.js → agents/cc-worker-pool.js} +16 -6
- package/engine/{claude-md-context.js → agents/claude-md-context.js} +5 -5
- package/engine/{harness-context.js → agents/harness-context.js} +5 -5
- package/engine/{harness.js → agents/harness.js} +3 -3
- package/engine/{llm.js → agents/llm.js} +18 -14
- package/engine/{model-discovery.js → agents/model-discovery.js} +2 -2
- package/engine/{playbook.js → agents/playbook.js} +155 -22
- package/engine/{pooled-agent-process.js → agents/pooled-agent-process.js} +14 -12
- package/engine/{preflight.js → agents/preflight.js} +29 -10
- package/engine/{spawn-agent.js → agents/spawn-agent.js} +25 -14
- package/engine/{spawn-phase-watchdog.js → agents/spawn-phase-watchdog.js} +16 -7
- package/engine/{steering.js → agents/steering.js} +5 -5
- package/engine/{tools-inventory.js → agents/tools-inventory.js} +2 -2
- package/engine/{agent-api-validation.js → api/agent-api-validation.js} +2 -2
- package/engine/{api-validation.js → api/api-validation.js} +1 -1
- package/engine/api/bridge.js +787 -0
- package/engine/{cc-api-validation.js → api/cc-api-validation.js} +1 -1
- package/engine/api/companion.js +560 -0
- package/engine/{content-api-validation.js → api/content-api-validation.js} +2 -2
- package/engine/{pr-issue-validation.js → api/pr-issue-validation.js} +33 -6
- package/engine/{settings-validation.js → api/settings-validation.js} +32 -4
- package/engine/api-contracts/agent-content.js +4 -4
- package/engine/api-contracts/capability-manifest.js +236 -0
- package/engine/api-contracts/capability-protocol.js +333 -0
- package/engine/api-contracts/cc-ops.js +1 -1
- package/engine/api-contracts/config-runtime.js +5 -0
- package/engine/api-contracts/core.js +28 -1
- package/engine/api-contracts/index.js +100 -0
- package/engine/api-contracts/orchestration.js +18 -5
- package/engine/api-contracts/pull-requests.js +37 -6
- package/engine/api-contracts/qa-process.js +29 -6
- package/engine/api-contracts/work-plan-prd.js +21 -1
- package/engine/cloud/contract.js +212 -0
- package/engine/cloud/index.js +159 -0
- package/engine/{execution-model.js → core/execution-model.js} +1 -1
- package/engine/{features.js → core/features.js} +4 -4
- package/engine/{operator-identity.js → core/operator-identity.js} +1 -1
- package/engine/{queries.js → core/queries.js} +201 -36
- package/engine/{safe-expr.js → core/safe-expr.js} +1 -1
- package/engine/{shared.js → core/shared.js} +1637 -175
- package/engine/{stdio-timestamps.js → core/stdio-timestamps.js} +1 -1
- package/engine/{untrusted-fence.js → core/untrusted-fence.js} +3 -3
- package/engine/db/index.js +11 -2
- package/engine/db/migrations/002-dispatches.js +3 -3
- package/engine/db/migrations/003-work-items.js +1 -1
- package/engine/db/migrations/004-pull-requests.js +1 -1
- package/engine/db/migrations/006-metrics.js +1 -1
- package/engine/db/migrations/007-watches.js +2 -2
- package/engine/db/migrations/008-small-state.js +1 -1
- package/engine/db/migrations/009-qa.js +1 -1
- package/engine/db/migrations/010-pr-links.js +1 -1
- package/engine/db/migrations/011-remaining-state.js +1 -1
- package/engine/db/migrations/012-steering-deliveries.js +2 -2
- package/engine/db/migrations/013-backfill-broken-note-links.js +1 -1
- package/engine/db/migrations/014-pr-fix-target-prefs.js +2 -2
- package/engine/db/migrations/015-plans-prds.js +0 -0
- package/engine/db/migrations/018-sql-only-cutover.js +2 -2
- package/engine/db/migrations/021-archived-work-items.js +1 -1
- package/engine/db/migrations/022-global-cc-session.js +1 -1
- package/engine/db/migrations/023-engine-state.js +1 -1
- package/engine/db/migrations/025-malformed-work-item-phantoms.js +1 -1
- package/engine/db/migrations/027-review-learning-lifecycle.js +1 -1
- package/engine/db/migrations/029-repair-reused-versions.js +20 -0
- package/engine/db/migrations/031-pr-author-identity.js +137 -0
- package/engine/{consolidation.js → memory/consolidation.js} +6 -6
- package/engine/{kb-sweep-runner.js → memory/kb-sweep-runner.js} +2 -2
- package/engine/{kb-sweep.js → memory/kb-sweep.js} +9 -7
- package/engine/{memory-retrieval.js → memory/memory-retrieval.js} +46 -4
- package/engine/{memory-store.js → memory/memory-store.js} +3 -3
- package/engine/{promotion.js → memory/promotion.js} +3 -3
- package/engine/{review-learning-backfill.js → memory/review-learning-backfill.js} +6 -6
- package/engine/{review-learning.js → memory/review-learning.js} +10 -5
- package/engine/{diagnostics-memory.js → observability/diagnostics-memory.js} +1 -1
- package/engine/{logs-store.js → observability/logs-store.js} +5 -5
- package/engine/{metrics-store.js → observability/metrics-store.js} +4 -4
- package/engine/{check-status.js → operations/check-status.js} +3 -3
- package/engine/{cli.js → operations/cli.js} +271 -113
- package/engine/{distribution.js → operations/distribution.js} +5 -6
- package/engine/{cleanup.js → orchestration/cleanup.js} +72 -45
- package/engine/{cooldown.js → orchestration/cooldown.js} +5 -5
- package/engine/{dispatch-events.js → orchestration/dispatch-events.js} +2 -2
- package/engine/{dispatch.js → orchestration/dispatch.js} +129 -36
- package/engine/orchestration/failed-scheduled-cleanup.js +274 -0
- package/engine/{lifecycle.js → orchestration/lifecycle.js} +198 -90
- package/engine/{meeting.js → orchestration/meeting.js} +6 -16
- package/engine/{pipeline.js → orchestration/pipeline.js} +12 -12
- package/engine/{pre-dispatch-eval.js → orchestration/pre-dispatch-eval.js} +10 -9
- package/engine/{routing.js → orchestration/routing.js} +3 -3
- package/engine/{schedule-bootstrap.js → orchestration/schedule-bootstrap.js} +4 -4
- package/engine/{scheduler.js → orchestration/scheduler.js} +38 -8
- package/engine/{timeout.js → orchestration/timeout.js} +158 -109
- package/engine/{db-events.js → persistence/db-events.js} +2 -2
- package/engine/{dispatch-store.js → persistence/dispatch-store.js} +7 -7
- package/engine/{inbox-store.js → persistence/inbox-store.js} +2 -2
- package/engine/{note-link-backfill.js → persistence/note-link-backfill.js} +4 -4
- package/engine/{pr-fix-target-store.js → persistence/pr-fix-target-store.js} +8 -8
- package/engine/{pull-requests-store.js → persistence/pull-requests-store.js} +21 -7
- package/engine/{small-state-store.js → persistence/small-state-store.js} +31 -31
- package/engine/persistence/state-operations.js +350 -0
- package/engine/{steering-store.js → persistence/steering-store.js} +6 -6
- package/engine/{issues.js → planning/issues.js} +2 -2
- package/engine/{plan-prd-validation.js → planning/plan-prd-validation.js} +8 -2
- package/engine/planning/prd-result-sidecar.js +190 -0
- package/engine/{prd-store.js → planning/prd-store.js} +17 -17
- package/engine/{project-discovery.js → planning/project-discovery.js} +5 -5
- package/engine/{projects.js → planning/projects.js} +10 -10
- package/engine/{resolve-area.js → planning/resolve-area.js} +1 -1
- package/engine/{work-item-validation.js → planning/work-item-validation.js} +39 -3
- package/engine/{work-items-store.js → planning/work-items-store.js} +29 -21
- package/engine/{keep-process-sweep.js → processes/keep-process-sweep.js} +57 -17
- package/engine/{managed-spawn-launcher.js → processes/managed-spawn-launcher.js} +3 -3
- package/engine/{managed-spawn.js → processes/managed-spawn.js} +97 -46
- package/engine/{process-utils.js → processes/process-utils.js} +599 -55
- package/engine/{abandoned-pr-reconciliation.js → providers/abandoned-pr-reconciliation.js} +17 -7
- package/engine/{comment-classifier.js → providers/comment-classifier.js} +85 -17
- package/engine/{comment-format.js → providers/comment-format.js} +5 -5
- package/engine/{gh-comment.js → providers/gh-comment.js} +15 -15
- package/engine/{gh-token.js → providers/gh-token.js} +4 -4
- package/engine/{github.js → providers/github.js} +131 -54
- package/engine/{pr-action.js → providers/pr-action.js} +13 -12
- package/engine/{pr-clone-keep.js → providers/pr-clone-keep.js} +7 -7
- package/engine/{pr-devbox.js → providers/pr-devbox.js} +6 -6
- package/engine/{pr-fix-target.js → providers/pr-fix-target.js} +13 -13
- package/engine/{pr-remote-patch.js → providers/pr-remote-patch.js} +4 -4
- package/engine/{pr-resolve.js → providers/pr-resolve.js} +7 -7
- package/engine/{pr-temp-clone.js → providers/pr-temp-clone.js} +5 -5
- package/engine/{pr-track.js → providers/pr-track.js} +11 -13
- package/engine/{shared-branch-pr-reconcile.js → providers/shared-branch-pr-reconcile.js} +4 -4
- package/engine/qa/auto-prd-qa.js +313 -0
- package/engine/{qa-from-prd.js → qa/from-prd.js} +42 -12
- package/engine/qa/prd-session.js +240 -0
- package/engine/{qa-process-validation.js → qa/process-validation.js} +14 -9
- package/engine/{qa-runbooks.js → qa/runbooks.js} +1 -1
- package/engine/{qa-runs.js → qa/runs.js} +286 -15
- package/engine/{qa-sessions.js → qa/sessions.js} +595 -49
- package/engine/qa/visual-journey.js +654 -0
- package/engine/{qa-runners.js → qa-runners/index.js} +7 -7
- package/engine/qa-runners/maestro.js +3 -3
- package/engine/qa-runners/playwright.js +2 -2
- package/engine/{restart-health.js → recovery/restart-health.js} +48 -4
- package/engine/recovery/stop-stack.js +607 -0
- package/engine/{supervisor.js → recovery/supervisor.js} +105 -175
- package/engine/{watchdog.js → recovery/watchdog.js} +136 -13
- package/engine/runtimes/claude.js +14 -12
- package/engine/runtimes/codex.js +8 -6
- package/engine/runtimes/copilot.js +17 -16
- package/engine/{watch-actions.js → watches/actions.js} +13 -13
- package/engine/{watches.js → watches/index.js} +43 -32
- package/engine/{watches-store.js → watches/store.js} +4 -4
- package/engine/{create-pr-worktree.js → worktrees/create-pr.js} +1 -1
- package/engine/{worktree-gc.js → worktrees/gc.js} +70 -22
- package/engine/worktrees/inventory.js +671 -0
- package/engine/{live-checkout.js → worktrees/live-checkout.js} +4 -4
- package/engine/{worktree-pool.js → worktrees/pool.js} +2 -2
- package/engine/{worktree-preflight.js → worktrees/preflight.js} +1 -0
- package/engine/worktrees/quarantine-refs.js +173 -0
- package/engine.js +1137 -208
- package/minions.js +147 -77
- package/package.json +10 -6
- package/playbooks/_pr-description-audit.md +110 -78
- package/playbooks/build-fix-complex.md +2 -0
- package/playbooks/fix.md +16 -12
- package/playbooks/implement-shared.md +2 -0
- package/playbooks/implement.md +19 -20
- package/playbooks/plan-to-prd.md +18 -3
- package/playbooks/qa-session-draft.md +136 -1
- package/playbooks/qa-session-execute.md +80 -2
- package/playbooks/qa-session-setup.md +17 -1
- package/playbooks/qa-validate.md +1 -1
- package/playbooks/setup.md +2 -0
- package/playbooks/shared-rules.md +25 -32
- package/playbooks/templates/followup-dispatch.md +4 -3
- package/playbooks/verify.md +1 -1
- package/prompts/cc-system.md +19 -27
- package/watch-plugins/README.md +92 -0
- package/watch-plugins/ado-author-prs.js +336 -0
- package/watch-plugins/gh-author-prs.js +375 -0
- package/watch-plugins/http.js +474 -0
- package/watch-plugins/teams-channel.js +869 -0
- package/docs/dev-composite-workflow.md +0 -101
- package/docs/pr-screenshots/pr-886/after-single-header.png +0 -0
- package/docs/pr-screenshots/pr-886/before-duplicate-header.png +0 -0
- package/docs/pr-screenshots/pr-895/01-cancellation-reason-detail.png +0 -0
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-901/projects-tab-default.png +0 -0
- package/docs/pr-screenshots/pr-901/projects-tab-fmf-selected.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-AFTER-crop.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-BEFORE-crop.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-dropdown-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
- package/docs/visual-evidence-ci.md +0 -103
- package/engine/bridge.js +0 -379
- package/engine/quarantine-refs.js +0 -103
- package/engine/state-operations.js +0 -178
- /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
|
+
};
|