adispatchd 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +195 -2
- package/dist/api/errors.d.ts +27 -0
- package/dist/api/errors.js +80 -0
- package/dist/api/errors.js.map +1 -0
- package/dist/api/index.d.ts +10 -0
- package/dist/api/index.js +11 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api/logs.d.ts +30 -0
- package/dist/api/logs.js +88 -0
- package/dist/api/logs.js.map +1 -0
- package/dist/api/serialize.d.ts +65 -0
- package/dist/api/serialize.js +135 -0
- package/dist/api/serialize.js.map +1 -0
- package/dist/api/server.d.ts +89 -0
- package/dist/api/server.js +751 -0
- package/dist/api/server.js.map +1 -0
- package/dist/api/sse.d.ts +90 -0
- package/dist/api/sse.js +225 -0
- package/dist/api/sse.js.map +1 -0
- package/dist/app.d.ts +173 -0
- package/dist/app.js +488 -0
- package/dist/app.js.map +1 -0
- package/dist/cli/config-validate.d.ts +6 -0
- package/dist/cli/config-validate.js +39 -0
- package/dist/cli/config-validate.js.map +1 -0
- package/dist/cli/control.d.ts +31 -0
- package/dist/cli/control.js +186 -0
- package/dist/cli/control.js.map +1 -0
- package/dist/cli/http-client.d.ts +57 -0
- package/dist/cli/http-client.js +174 -0
- package/dist/cli/http-client.js.map +1 -0
- package/dist/cli/index.d.ts +14 -0
- package/dist/cli/index.js +14 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/init-templates.d.ts +20 -0
- package/dist/cli/init-templates.js +275 -0
- package/dist/cli/init-templates.js.map +1 -0
- package/dist/cli/init.d.ts +16 -0
- package/dist/cli/init.js +107 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +25 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/cli/program.d.ts +102 -0
- package/dist/cli/program.js +133 -0
- package/dist/cli/program.js.map +1 -0
- package/dist/cli/service.d.ts +27 -0
- package/dist/cli/service.js +252 -0
- package/dist/cli/service.js.map +1 -0
- package/dist/cli/start.d.ts +22 -0
- package/dist/cli/start.js +133 -0
- package/dist/cli/start.js.map +1 -0
- package/dist/cli/workflow-dry-run.d.ts +13 -0
- package/dist/cli/workflow-dry-run.js +144 -0
- package/dist/cli/workflow-dry-run.js.map +1 -0
- package/dist/config/field-catalog.d.ts +33 -0
- package/dist/config/field-catalog.js +172 -0
- package/dist/config/field-catalog.js.map +1 -0
- package/dist/config/filter-schema.d.ts +15 -0
- package/dist/config/filter-schema.js +110 -0
- package/dist/config/filter-schema.js.map +1 -0
- package/dist/config/index.d.ts +15 -0
- package/dist/config/index.js +16 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/issues.d.ts +27 -0
- package/dist/config/issues.js +37 -0
- package/dist/config/issues.js.map +1 -0
- package/dist/config/loader.d.ts +32 -0
- package/dist/config/loader.js +250 -0
- package/dist/config/loader.js.map +1 -0
- package/dist/config/manager.d.ts +153 -0
- package/dist/config/manager.js +276 -0
- package/dist/config/manager.js.map +1 -0
- package/dist/config/model.d.ts +334 -0
- package/dist/config/model.js +2 -0
- package/dist/config/model.js.map +1 -0
- package/dist/config/normalize.d.ts +20 -0
- package/dist/config/normalize.js +200 -0
- package/dist/config/normalize.js.map +1 -0
- package/dist/config/parameters.d.ts +37 -0
- package/dist/config/parameters.js +71 -0
- package/dist/config/parameters.js.map +1 -0
- package/dist/config/public.d.ts +6 -0
- package/dist/config/public.js +18 -0
- package/dist/config/public.js.map +1 -0
- package/dist/config/schemas.d.ts +538 -0
- package/dist/config/schemas.js +321 -0
- package/dist/config/schemas.js.map +1 -0
- package/dist/config/snapshot.d.ts +38 -0
- package/dist/config/snapshot.js +57 -0
- package/dist/config/snapshot.js.map +1 -0
- package/dist/config/validate.d.ts +33 -0
- package/dist/config/validate.js +346 -0
- package/dist/config/validate.js.map +1 -0
- package/dist/core/clock.d.ts +59 -0
- package/dist/core/clock.js +133 -0
- package/dist/core/clock.js.map +1 -0
- package/dist/core/duration.d.ts +19 -0
- package/dist/core/duration.js +93 -0
- package/dist/core/duration.js.map +1 -0
- package/dist/core/errors.d.ts +62 -0
- package/dist/core/errors.js +72 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/ids.d.ts +57 -0
- package/dist/core/ids.js +88 -0
- package/dist/core/ids.js.map +1 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.js +14 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/logger.d.ts +100 -0
- package/dist/core/logger.js +207 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/paths.d.ts +84 -0
- package/dist/core/paths.js +138 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/template.d.ts +136 -0
- package/dist/core/template.js +261 -0
- package/dist/core/template.js.map +1 -0
- package/dist/core/types.d.ts +544 -0
- package/dist/core/types.js +172 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/worker-protocol.d.ts +102 -0
- package/dist/core/worker-protocol.js +75 -0
- package/dist/core/worker-protocol.js.map +1 -0
- package/dist/executors/classify.d.ts +25 -0
- package/dist/executors/classify.js +54 -0
- package/dist/executors/classify.js.map +1 -0
- package/dist/executors/dispatcher.d.ts +35 -0
- package/dist/executors/dispatcher.js +68 -0
- package/dist/executors/dispatcher.js.map +1 -0
- package/dist/executors/env.d.ts +43 -0
- package/dist/executors/env.js +66 -0
- package/dist/executors/env.js.map +1 -0
- package/dist/executors/event-parser.d.ts +41 -0
- package/dist/executors/event-parser.js +106 -0
- package/dist/executors/event-parser.js.map +1 -0
- package/dist/executors/index.d.ts +12 -0
- package/dist/executors/index.js +13 -0
- package/dist/executors/index.js.map +1 -0
- package/dist/executors/log-capture.d.ts +57 -0
- package/dist/executors/log-capture.js +155 -0
- package/dist/executors/log-capture.js.map +1 -0
- package/dist/executors/process-identity.d.ts +124 -0
- package/dist/executors/process-identity.js +283 -0
- package/dist/executors/process-identity.js.map +1 -0
- package/dist/executors/shell-executor.d.ts +35 -0
- package/dist/executors/shell-executor.js +478 -0
- package/dist/executors/shell-executor.js.map +1 -0
- package/dist/executors/stream-redactor.d.ts +40 -0
- package/dist/executors/stream-redactor.js +89 -0
- package/dist/executors/stream-redactor.js.map +1 -0
- package/dist/executors/workspace.d.ts +10 -0
- package/dist/executors/workspace.js +47 -0
- package/dist/executors/workspace.js.map +1 -0
- package/dist/filters/evaluate.d.ts +77 -0
- package/dist/filters/evaluate.js +187 -0
- package/dist/filters/evaluate.js.map +1 -0
- package/dist/filters/fields.d.ts +35 -0
- package/dist/filters/fields.js +75 -0
- package/dist/filters/fields.js.map +1 -0
- package/dist/filters/index.d.ts +11 -0
- package/dist/filters/index.js +12 -0
- package/dist/filters/index.js.map +1 -0
- package/dist/filters/iso-date.d.ts +10 -0
- package/dist/filters/iso-date.js +42 -0
- package/dist/filters/iso-date.js.map +1 -0
- package/dist/filters/predicate.d.ts +24 -0
- package/dist/filters/predicate.js +178 -0
- package/dist/filters/predicate.js.map +1 -0
- package/dist/filters/priority.d.ts +51 -0
- package/dist/filters/priority.js +91 -0
- package/dist/filters/priority.js.map +1 -0
- package/dist/filters/pushdown.d.ts +12 -0
- package/dist/filters/pushdown.js +52 -0
- package/dist/filters/pushdown.js.map +1 -0
- package/dist/filters/shell-check.d.ts +110 -0
- package/dist/filters/shell-check.js +304 -0
- package/dist/filters/shell-check.js.map +1 -0
- package/dist/identities/github-app.d.ts +55 -0
- package/dist/identities/github-app.js +160 -0
- package/dist/identities/github-app.js.map +1 -0
- package/dist/identities/index.d.ts +6 -0
- package/dist/identities/index.js +7 -0
- package/dist/identities/index.js.map +1 -0
- package/dist/identities/jwt.d.ts +24 -0
- package/dist/identities/jwt.js +56 -0
- package/dist/identities/jwt.js.map +1 -0
- package/dist/lifecycle/context.d.ts +106 -0
- package/dist/lifecycle/context.js +170 -0
- package/dist/lifecycle/context.js.map +1 -0
- package/dist/lifecycle/engine.d.ts +185 -0
- package/dist/lifecycle/engine.js +551 -0
- package/dist/lifecycle/engine.js.map +1 -0
- package/dist/lifecycle/errors.d.ts +48 -0
- package/dist/lifecycle/errors.js +71 -0
- package/dist/lifecycle/errors.js.map +1 -0
- package/dist/lifecycle/index.d.ts +9 -0
- package/dist/lifecycle/index.js +9 -0
- package/dist/lifecycle/index.js.map +1 -0
- package/dist/lifecycle/performers.d.ts +66 -0
- package/dist/lifecycle/performers.js +179 -0
- package/dist/lifecycle/performers.js.map +1 -0
- package/dist/notifications/index.d.ts +6 -0
- package/dist/notifications/index.js +7 -0
- package/dist/notifications/index.js.map +1 -0
- package/dist/notifications/service.d.ts +115 -0
- package/dist/notifications/service.js +364 -0
- package/dist/notifications/service.js.map +1 -0
- package/dist/persistence/actions.d.ts +101 -0
- package/dist/persistence/actions.js +196 -0
- package/dist/persistence/actions.js.map +1 -0
- package/dist/persistence/blocks.d.ts +85 -0
- package/dist/persistence/blocks.js +140 -0
- package/dist/persistence/blocks.js.map +1 -0
- package/dist/persistence/cron-firings.d.ts +55 -0
- package/dist/persistence/cron-firings.js +70 -0
- package/dist/persistence/cron-firings.js.map +1 -0
- package/dist/persistence/database.d.ts +60 -0
- package/dist/persistence/database.js +124 -0
- package/dist/persistence/database.js.map +1 -0
- package/dist/persistence/errors.d.ts +39 -0
- package/dist/persistence/errors.js +67 -0
- package/dist/persistence/errors.js.map +1 -0
- package/dist/persistence/events.d.ts +72 -0
- package/dist/persistence/events.js +83 -0
- package/dist/persistence/events.js.map +1 -0
- package/dist/persistence/index.d.ts +18 -0
- package/dist/persistence/index.js +19 -0
- package/dist/persistence/index.js.map +1 -0
- package/dist/persistence/instance-lock.d.ts +26 -0
- package/dist/persistence/instance-lock.js +117 -0
- package/dist/persistence/instance-lock.js.map +1 -0
- package/dist/persistence/lock-keys.d.ts +16 -0
- package/dist/persistence/lock-keys.js +41 -0
- package/dist/persistence/lock-keys.js.map +1 -0
- package/dist/persistence/notifications.d.ts +113 -0
- package/dist/persistence/notifications.js +213 -0
- package/dist/persistence/notifications.js.map +1 -0
- package/dist/persistence/persistence.d.ts +46 -0
- package/dist/persistence/persistence.js +42 -0
- package/dist/persistence/persistence.js.map +1 -0
- package/dist/persistence/retention.d.ts +46 -0
- package/dist/persistence/retention.js +68 -0
- package/dist/persistence/retention.js.map +1 -0
- package/dist/persistence/runs.d.ts +215 -0
- package/dist/persistence/runs.js +417 -0
- package/dist/persistence/runs.js.map +1 -0
- package/dist/persistence/schema.d.ts +16 -0
- package/dist/persistence/schema.js +183 -0
- package/dist/persistence/schema.js.map +1 -0
- package/dist/persistence/sqlite.d.ts +49 -0
- package/dist/persistence/sqlite.js +131 -0
- package/dist/persistence/sqlite.js.map +1 -0
- package/dist/runs/controller.d.ts +307 -0
- package/dist/runs/controller.js +1078 -0
- package/dist/runs/controller.js.map +1 -0
- package/dist/runs/errors.d.ts +20 -0
- package/dist/runs/errors.js +26 -0
- package/dist/runs/errors.js.map +1 -0
- package/dist/runs/event-bus.d.ts +106 -0
- package/dist/runs/event-bus.js +105 -0
- package/dist/runs/event-bus.js.map +1 -0
- package/dist/runs/execution.d.ts +39 -0
- package/dist/runs/execution.js +146 -0
- package/dist/runs/execution.js.map +1 -0
- package/dist/runs/index.d.ts +12 -0
- package/dist/runs/index.js +13 -0
- package/dist/runs/index.js.map +1 -0
- package/dist/runs/retention.d.ts +64 -0
- package/dist/runs/retention.js +232 -0
- package/dist/runs/retention.js.map +1 -0
- package/dist/runs/workspace.d.ts +20 -0
- package/dist/runs/workspace.js +51 -0
- package/dist/runs/workspace.js.map +1 -0
- package/dist/scheduler/checks.d.ts +41 -0
- package/dist/scheduler/checks.js +111 -0
- package/dist/scheduler/checks.js.map +1 -0
- package/dist/scheduler/cron.d.ts +32 -0
- package/dist/scheduler/cron.js +83 -0
- package/dist/scheduler/cron.js.map +1 -0
- package/dist/scheduler/dry-run.d.ts +42 -0
- package/dist/scheduler/dry-run.js +167 -0
- package/dist/scheduler/dry-run.js.map +1 -0
- package/dist/scheduler/errors.d.ts +22 -0
- package/dist/scheduler/errors.js +22 -0
- package/dist/scheduler/errors.js.map +1 -0
- package/dist/scheduler/format.d.ts +3 -0
- package/dist/scheduler/format.js +73 -0
- package/dist/scheduler/format.js.map +1 -0
- package/dist/scheduler/index.d.ts +13 -0
- package/dist/scheduler/index.js +14 -0
- package/dist/scheduler/index.js.map +1 -0
- package/dist/scheduler/planner.d.ts +88 -0
- package/dist/scheduler/planner.js +255 -0
- package/dist/scheduler/planner.js.map +1 -0
- package/dist/scheduler/scheduler.d.ts +160 -0
- package/dist/scheduler/scheduler.js +680 -0
- package/dist/scheduler/scheduler.js.map +1 -0
- package/dist/scheduler/state.d.ts +26 -0
- package/dist/scheduler/state.js +25 -0
- package/dist/scheduler/state.js.map +1 -0
- package/dist/scheduler/types.d.ts +197 -0
- package/dist/scheduler/types.js +3 -0
- package/dist/scheduler/types.js.map +1 -0
- package/dist/service/index.d.ts +6 -0
- package/dist/service/index.js +7 -0
- package/dist/service/index.js.map +1 -0
- package/dist/service/install.d.ts +93 -0
- package/dist/service/install.js +250 -0
- package/dist/service/install.js.map +1 -0
- package/dist/service/unit.d.ts +50 -0
- package/dist/service/unit.js +171 -0
- package/dist/service/unit.js.map +1 -0
- package/dist/sources/github/adapter.d.ts +99 -0
- package/dist/sources/github/adapter.js +336 -0
- package/dist/sources/github/adapter.js.map +1 -0
- package/dist/sources/github/fields.d.ts +8 -0
- package/dist/sources/github/fields.js +72 -0
- package/dist/sources/github/fields.js.map +1 -0
- package/dist/sources/github/http.d.ts +100 -0
- package/dist/sources/github/http.js +288 -0
- package/dist/sources/github/http.js.map +1 -0
- package/dist/sources/github/index.d.ts +6 -0
- package/dist/sources/github/index.js +7 -0
- package/dist/sources/github/index.js.map +1 -0
- package/dist/sources/github/pushdown.d.ts +14 -0
- package/dist/sources/github/pushdown.js +47 -0
- package/dist/sources/github/pushdown.js.map +1 -0
- package/dist/sources/github/refs.d.ts +17 -0
- package/dist/sources/github/refs.js +61 -0
- package/dist/sources/github/refs.js.map +1 -0
- package/dist/sources/index.d.ts +6 -0
- package/dist/sources/index.js +7 -0
- package/dist/sources/index.js.map +1 -0
- package/dist/ui/index.d.ts +12 -0
- package/dist/ui/index.js +28 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/public/app.css +239 -0
- package/dist/ui/public/favicon.svg +1 -0
- package/dist/ui/public/index.html +27 -0
- package/dist/ui/public/js/client/api.js +68 -0
- package/dist/ui/public/js/client/app.js +146 -0
- package/dist/ui/public/js/client/dom.js +34 -0
- package/dist/ui/public/js/client/page.js +9 -0
- package/dist/ui/public/js/client/pages/actions.js +35 -0
- package/dist/ui/public/js/client/pages/blocked.js +58 -0
- package/dist/ui/public/js/client/pages/config.js +126 -0
- package/dist/ui/public/js/client/pages/dashboard.js +175 -0
- package/dist/ui/public/js/client/pages/run.js +367 -0
- package/dist/ui/public/js/client/pages/runs.js +72 -0
- package/dist/ui/public/js/client/pages/views.js +80 -0
- package/dist/ui/public/js/client/pages/workflows.js +291 -0
- package/dist/ui/public/js/client/types.js +1 -0
- package/dist/ui/public/js/client/ui.js +152 -0
- package/dist/ui/public/js/shared/dom.js +77 -0
- package/dist/ui/public/js/shared/events.js +88 -0
- package/dist/ui/public/js/shared/format.js +213 -0
- package/dist/ui/public/js/shared/logs.js +136 -0
- package/dist/ui/public/js/shared/params.js +90 -0
- package/dist/ui/public/js/shared/route.js +36 -0
- package/dist/ui/public/js/shared/sort.js +70 -0
- package/dist/ui/public/js/shared/sse.js +193 -0
- package/dist/ui/public/js/shared/throttle.js +65 -0
- package/dist/views/index.d.ts +6 -0
- package/dist/views/index.js +7 -0
- package/dist/views/index.js.map +1 -0
- package/dist/views/service.d.ts +131 -0
- package/dist/views/service.js +264 -0
- package/dist/views/service.js.map +1 -0
- package/docs/api.md +239 -0
- package/docs/configuration-reference.md +1785 -0
- package/docs/cron-semantics.md +96 -0
- package/docs/manual-tests/github-fixture/config.yaml +30 -0
- package/docs/manual-tests/github-fixture/helpers.sh +93 -0
- package/docs/manual-tests/github-fixture/identities.yaml +18 -0
- package/docs/manual-tests/github-fixture/scripts/test-worker.sh +100 -0
- package/docs/manual-tests/github-fixture/sources.yaml +13 -0
- package/docs/manual-tests/github-fixture/views/dependencies.yaml +17 -0
- package/docs/manual-tests/github-fixture/views/needs-maintainer.yaml +17 -0
- package/docs/manual-tests/github-fixture/workflows/coder-test.yaml +76 -0
- package/docs/manual-tests/github-fixture/workflows/commenter-test.yaml +63 -0
- package/docs/manual-tests/github-fixture/workflows/reviewer-test.yaml +60 -0
- package/docs/manual-tests/github.md +1308 -0
- package/docs/manual-tests/process-supervision.md +64 -0
- package/docs/manual-tests/systemd.md +68 -0
- package/docs/operations.md +271 -0
- package/docs/worker-protocol.md +145 -0
- package/examples/config/config.yaml +35 -0
- package/examples/config/identities.yaml +18 -0
- package/examples/config/sources.yaml +13 -0
- package/examples/config/views/needs-maintainer.yaml +15 -0
- package/examples/config/workflows/coder-project-a.yaml +86 -0
- package/examples/config/workflows/deploy.yaml +15 -0
- package/examples/config/workflows/nightly-maintenance.yaml +13 -0
- package/package.json +75 -4
|
@@ -0,0 +1,1078 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run controller (Spec §8, §11, §13, §14): drives a reserved run through
|
|
3
|
+
* `starting → running → finalizing → <terminal>`, classifies the outcome,
|
|
4
|
+
* applies technical-failure counting and retry blocks, runs the completion
|
|
5
|
+
* hook, cleans up the workspace, supervises cancel, recovers after a
|
|
6
|
+
* controller crash and implements pause / drain / cancel-all shutdown.
|
|
7
|
+
*
|
|
8
|
+
* Invariants:
|
|
9
|
+
* - Every status transition is persisted before work continues; terminal
|
|
10
|
+
* statuses are reached only via `finalizing` with a persisted
|
|
11
|
+
* {@link FinalizationTarget} (WI-01/WI-03).
|
|
12
|
+
* - The retry block is created in the SAME transaction as `→ finalizing`,
|
|
13
|
+
* i.e. before the completion hook runs (Spec §11 "important order").
|
|
14
|
+
* - Hooks always use the run's configuration snapshot, never the live config.
|
|
15
|
+
* - Bus publication and notifications happen only after commit.
|
|
16
|
+
* - A process is only ever signalled after verifying its start identity
|
|
17
|
+
* (recovery); on doubt the run becomes `recovery_pending` and keeps its lock.
|
|
18
|
+
*/
|
|
19
|
+
import { HOOK_NAMES, errorMessage, hookForOutcome, isOrchestratorError, isTerminalStatus, noopLogger, parseSourceItemKey, systemClock, systemTimers, } from '../core/index.js';
|
|
20
|
+
import { ShellExecutor, classifyOutcome, processGroupExists, terminateProcessGroupIfSame, } from '../executors/index.js';
|
|
21
|
+
import { parseRunSnapshot } from '../lifecycle/index.js';
|
|
22
|
+
import { RUN_STATUS_EVENT, finalizationTarget, } from '../persistence/index.js';
|
|
23
|
+
import { RunControllerError } from './errors.js';
|
|
24
|
+
import { PersistedEventPublisher, RunEventBus } from './event-bus.js';
|
|
25
|
+
import { buildExecutionSpec } from './execution.js';
|
|
26
|
+
import { removeWorkspace, shouldCleanupWorkspace } from './workspace.js';
|
|
27
|
+
/** Orchestrator-side event types persisted by the controller (besides `run.status`). */
|
|
28
|
+
export const RUN_EVENTS = {
|
|
29
|
+
/** Worker spawn is about to happen (`{command, args, workingDirectory}`); recovery uses it. */
|
|
30
|
+
spawn: 'run.spawn',
|
|
31
|
+
/** Item re-read before `on_start` (Spec §7): `{found, matches?}`. */
|
|
32
|
+
itemReread: 'run.item_reread',
|
|
33
|
+
cancelRequested: 'run.cancel_requested',
|
|
34
|
+
/** Process group could not be terminated; run stays `cancelling` (Spec §13). */
|
|
35
|
+
cancelFailed: 'run.cancel_failed',
|
|
36
|
+
/** A completion hook threw unexpectedly. */
|
|
37
|
+
hookError: 'run.hook_error',
|
|
38
|
+
/** Unexpected internal error while supervising a run (run left as is for recovery). */
|
|
39
|
+
controllerError: 'run.controller_error',
|
|
40
|
+
/** Recovery could not identify / stop the process → `recovery_pending`. */
|
|
41
|
+
recoveryFailed: 'recovery.failed',
|
|
42
|
+
/** Recovery decided the run's fate (`{action, detail}`). */
|
|
43
|
+
recovered: 'recovery.recovered',
|
|
44
|
+
/** Workspace cleanup result. */
|
|
45
|
+
workspaceCleanup: 'workspace.cleanup',
|
|
46
|
+
};
|
|
47
|
+
const CANCELLABLE_TO_CANCELLING = ['reserved', 'starting', 'running'];
|
|
48
|
+
const CAN_BECOME_RECOVERY_PENDING = ['reserved', 'starting', 'running', 'cancelling'];
|
|
49
|
+
/** See module docs. One instance per process. */
|
|
50
|
+
export class RunController {
|
|
51
|
+
options;
|
|
52
|
+
/** In-process event bus (SSE source for WI-10). */
|
|
53
|
+
bus;
|
|
54
|
+
/** Publisher of persisted events (share with the lifecycle engine). */
|
|
55
|
+
publisher;
|
|
56
|
+
repos;
|
|
57
|
+
lifecycle;
|
|
58
|
+
clock;
|
|
59
|
+
timers;
|
|
60
|
+
logger;
|
|
61
|
+
active = new Map();
|
|
62
|
+
idleWaiters = new Set();
|
|
63
|
+
/** Woken when a running shutdown is escalated (cancel mode / stricter timeouts). */
|
|
64
|
+
shutdownWakeups = new Set();
|
|
65
|
+
shutdownState;
|
|
66
|
+
/** Aborted on a forced stop: interrupts completion hooks (resumed by recovery). */
|
|
67
|
+
hookAbort = new AbortController();
|
|
68
|
+
ready = false;
|
|
69
|
+
paused = false;
|
|
70
|
+
draining = false;
|
|
71
|
+
stopping = false;
|
|
72
|
+
recovering;
|
|
73
|
+
shuttingDown;
|
|
74
|
+
constructor(options) {
|
|
75
|
+
this.options = options;
|
|
76
|
+
this.repos = options.repositories;
|
|
77
|
+
this.lifecycle = options.lifecycle;
|
|
78
|
+
this.clock = options.clock ?? systemClock;
|
|
79
|
+
this.timers = options.timers ?? systemTimers;
|
|
80
|
+
this.logger = (options.logger ?? noopLogger).child({ component: 'run-controller' });
|
|
81
|
+
this.bus = options.publisher?.bus ?? options.bus ?? new RunEventBus(this.logger);
|
|
82
|
+
this.publisher = options.publisher ?? new PersistedEventPublisher(this.repos, this.bus);
|
|
83
|
+
}
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Status / flags (WI-09 scheduler, WI-10 /ready)
|
|
86
|
+
/** Current controller status. */
|
|
87
|
+
get status() {
|
|
88
|
+
return {
|
|
89
|
+
ready: this.ready,
|
|
90
|
+
paused: this.paused,
|
|
91
|
+
draining: this.draining,
|
|
92
|
+
stopping: this.stopping,
|
|
93
|
+
activeRuns: this.active.size,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** Recovery finished (`/ready`). */
|
|
97
|
+
isReady() {
|
|
98
|
+
return this.ready;
|
|
99
|
+
}
|
|
100
|
+
/** Scheduler gate for automatic starts: ready, not paused, not draining/stopping. */
|
|
101
|
+
canStartAutomatically() {
|
|
102
|
+
return this.ready && !this.paused && !this.draining && !this.stopping;
|
|
103
|
+
}
|
|
104
|
+
/** Gate for manual starts: pause does not apply (Spec §14), drain/stop does. */
|
|
105
|
+
canStartManually() {
|
|
106
|
+
return this.ready && !this.draining && !this.stopping;
|
|
107
|
+
}
|
|
108
|
+
/** Run IDs currently supervised by this process. */
|
|
109
|
+
activeRunIds() {
|
|
110
|
+
return [...this.active.keys()];
|
|
111
|
+
}
|
|
112
|
+
/** Pause automatic starts (running runs are not touched). */
|
|
113
|
+
pause() {
|
|
114
|
+
if (this.paused)
|
|
115
|
+
return;
|
|
116
|
+
this.paused = true;
|
|
117
|
+
this.publishStatus();
|
|
118
|
+
}
|
|
119
|
+
/** Resume automatic starts. */
|
|
120
|
+
resume() {
|
|
121
|
+
if (!this.paused)
|
|
122
|
+
return;
|
|
123
|
+
this.paused = false;
|
|
124
|
+
this.publishStatus();
|
|
125
|
+
}
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Blocks (Spec §11)
|
|
128
|
+
/** True if an active retry block exists for the workflow/item (`itemKey = null` → workflow scope). */
|
|
129
|
+
isBlocked(workflowId, itemKey) {
|
|
130
|
+
return this.repos.blocks.isBlocked(workflowId, itemKey);
|
|
131
|
+
}
|
|
132
|
+
/** Remove a block: starts a new counting period; runs/history stay (idempotent). */
|
|
133
|
+
clearBlock(blockId, clearedBy = null) {
|
|
134
|
+
const before = this.repos.blocks.get(blockId);
|
|
135
|
+
const block = this.repos.blocks.clear(blockId, clearedBy);
|
|
136
|
+
if (before && before.clearedAt === null) {
|
|
137
|
+
this.repos.afterCommit(() => this.bus.publish({ type: 'block.cleared', block }));
|
|
138
|
+
}
|
|
139
|
+
return block;
|
|
140
|
+
}
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Start
|
|
143
|
+
/**
|
|
144
|
+
* Start supervising a reserved run (from the scheduler or a manual start).
|
|
145
|
+
* Returns immediately; the run proceeds asynchronously (`handle.done`).
|
|
146
|
+
*
|
|
147
|
+
* If the controller does not accept runs (not ready, draining, stopping),
|
|
148
|
+
* the reserved run is finalized as `cancelled` (never started, not counted,
|
|
149
|
+
* no hooks) so its lock is released, and a {@link RunControllerError} is thrown.
|
|
150
|
+
*/
|
|
151
|
+
startRun(run, options = {}) {
|
|
152
|
+
const runId = typeof run === 'string' ? run : run.id;
|
|
153
|
+
if (this.active.has(runId)) {
|
|
154
|
+
throw new RunControllerError('run_already_started', `Run ${runId} is already being executed`, { runId });
|
|
155
|
+
}
|
|
156
|
+
const current = this.repos.runs.require(runId);
|
|
157
|
+
if (current.status !== 'reserved') {
|
|
158
|
+
throw new RunControllerError('run_not_reserved', `Run ${runId} is '${current.status}', expected 'reserved'`, {
|
|
159
|
+
runId,
|
|
160
|
+
status: current.status,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
if (!this.ready || this.draining || this.stopping) {
|
|
164
|
+
const notReady = !this.ready;
|
|
165
|
+
const reason = notReady ? 'controller not ready (recovery running)' : 'controller is draining / shutting down';
|
|
166
|
+
this.finalizeUnstarted(runId, reason);
|
|
167
|
+
throw new RunControllerError(notReady ? 'run_controller_not_ready' : 'run_controller_not_accepting', `Run ${runId} not started: ${reason}`, { runId });
|
|
168
|
+
}
|
|
169
|
+
const entry = {
|
|
170
|
+
runId,
|
|
171
|
+
kind: 'run',
|
|
172
|
+
cancel: new AbortController(),
|
|
173
|
+
cancelRequested: false,
|
|
174
|
+
cancelReason: 'cancelled',
|
|
175
|
+
proc: undefined,
|
|
176
|
+
done: Promise.resolve(current),
|
|
177
|
+
};
|
|
178
|
+
this.active.set(runId, entry);
|
|
179
|
+
entry.done = this.track(runId, this.execute(entry, options).catch((err) => this.onUnexpected(entry, err)));
|
|
180
|
+
this.publishStatus();
|
|
181
|
+
return { runId, done: entry.done };
|
|
182
|
+
}
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Cancel (Spec §13)
|
|
185
|
+
/**
|
|
186
|
+
* Cancel a run: persist `cancelling`, stop the process group (SIGTERM →
|
|
187
|
+
* grace → SIGKILL → confirmed), run `on_cancel`, end as `cancelled`.
|
|
188
|
+
* Resolves with the final run. If the group cannot be terminated the run
|
|
189
|
+
* stays `cancelling`, the failure is persisted and `kill_failed` is thrown
|
|
190
|
+
* (calling again retries).
|
|
191
|
+
*/
|
|
192
|
+
async cancelRun(runId, options = {}) {
|
|
193
|
+
const entry = this.beginCancel(runId, options.reason ?? 'cancelled by user');
|
|
194
|
+
if (!entry)
|
|
195
|
+
return this.repos.runs.require(runId);
|
|
196
|
+
if (entry.proc)
|
|
197
|
+
await this.cancelProcess(entry);
|
|
198
|
+
return entry.done;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Synchronous variant for the API (HTTP 202/409): validates and persists the
|
|
202
|
+
* cancel request (`cancelling`) and stops the process in the background.
|
|
203
|
+
* Throws the same errors as {@link cancelRun}; a later kill failure is only
|
|
204
|
+
* visible on the run (`run.cancel_failed`, `error.code = kill_failed`).
|
|
205
|
+
*/
|
|
206
|
+
requestCancel(runId, options = {}) {
|
|
207
|
+
const entry = this.beginCancel(runId, options.reason ?? 'cancelled by user');
|
|
208
|
+
if (entry?.proc) {
|
|
209
|
+
this.cancelProcess(entry).catch(() => {
|
|
210
|
+
/* persisted as run.cancel_failed */
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return { runId, status: this.repos.runs.require(runId).status };
|
|
214
|
+
}
|
|
215
|
+
/** Validate + persist a cancel request; `null` if the run was finalized directly (never started). */
|
|
216
|
+
beginCancel(runId, reason) {
|
|
217
|
+
const run = this.repos.runs.require(runId);
|
|
218
|
+
if (isTerminalStatus(run.status)) {
|
|
219
|
+
throw new RunControllerError('run_not_active', `Run ${runId} already finished (${run.status})`, { runId, status: run.status });
|
|
220
|
+
}
|
|
221
|
+
const entry = this.active.get(runId);
|
|
222
|
+
if (run.status === 'finalizing' || run.status === 'recovery_pending' || entry?.kind === 'recovery') {
|
|
223
|
+
const what = entry?.kind === 'recovery' ? `'${run.status}' (being recovered)` : `'${run.status}'`;
|
|
224
|
+
throw new RunControllerError('run_not_cancellable', `Run ${runId} is ${what} and cannot be cancelled`, {
|
|
225
|
+
runId,
|
|
226
|
+
status: run.status,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
if (!entry) {
|
|
230
|
+
if (run.status === 'reserved') {
|
|
231
|
+
// Reserved but never handed to startRun: nothing ran, nothing to undo.
|
|
232
|
+
this.finalizeUnstarted(runId, reason);
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
throw new RunControllerError('run_not_controlled', `Run ${runId} is '${run.status}' but not supervised by this controller`, {
|
|
236
|
+
runId,
|
|
237
|
+
status: run.status,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if (!entry.cancelRequested) {
|
|
241
|
+
entry.cancelRequested = true;
|
|
242
|
+
entry.cancelReason = reason;
|
|
243
|
+
this.repos.transaction(() => {
|
|
244
|
+
const cur = this.repos.runs.require(runId);
|
|
245
|
+
if (CANCELLABLE_TO_CANCELLING.includes(cur.status))
|
|
246
|
+
this.repos.runs.transition(runId, 'cancelling');
|
|
247
|
+
this.repos.events.append(runId, RUN_EVENTS.cancelRequested, { reason });
|
|
248
|
+
});
|
|
249
|
+
this.publisher.flush();
|
|
250
|
+
entry.cancel.abort(new Error(reason));
|
|
251
|
+
}
|
|
252
|
+
return entry;
|
|
253
|
+
}
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Shutdown (Spec §14)
|
|
256
|
+
/** Drain: no new runs, wait for all active runs, then stop. */
|
|
257
|
+
drain() {
|
|
258
|
+
return this.shutdown({ mode: 'drain' });
|
|
259
|
+
}
|
|
260
|
+
/** Cancel all runs (with `on_cancel`), then stop. */
|
|
261
|
+
cancelAllAndShutdown(reason = 'cancelled by shutdown') {
|
|
262
|
+
return this.shutdown({ mode: 'cancel', reason });
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Shutdown. The first call starts it; later calls return the same promise
|
|
266
|
+
* but ESCALATE a running shutdown: `mode: 'cancel'` turns a drain into
|
|
267
|
+
* cancel-all, and stricter `drainTimeoutMs` / `cancelTimeoutMs` (counted
|
|
268
|
+
* from the later call) replace looser ones – e.g. API drain followed by
|
|
269
|
+
* SIGTERM with timeouts, or a second SIGTERM → `mode: 'cancel'`.
|
|
270
|
+
*
|
|
271
|
+
* SIGTERM recommendation (WI-10): `shutdown({mode: 'drain', drainTimeoutMs, cancelTimeoutMs})`
|
|
272
|
+
* – drain with timeout, then cancel-all, then interrupt remaining hooks.
|
|
273
|
+
* A running recovery is awaited before anything stops.
|
|
274
|
+
*/
|
|
275
|
+
shutdown(options = {}) {
|
|
276
|
+
const now = this.clock.now().getTime();
|
|
277
|
+
const st = this.shutdownState;
|
|
278
|
+
if (!st || !this.shuttingDown) {
|
|
279
|
+
const state = {
|
|
280
|
+
mode: options.mode ?? 'drain',
|
|
281
|
+
drainDeadline: options.drainTimeoutMs !== undefined ? now + options.drainTimeoutMs : undefined,
|
|
282
|
+
cancelTimeoutMs: options.cancelTimeoutMs,
|
|
283
|
+
cancelDeadline: undefined,
|
|
284
|
+
reason: options.reason,
|
|
285
|
+
};
|
|
286
|
+
this.shutdownState = state;
|
|
287
|
+
this.shuttingDown = this.doShutdown(state);
|
|
288
|
+
return this.shuttingDown;
|
|
289
|
+
}
|
|
290
|
+
if (options.mode === 'cancel' && st.mode === 'drain') {
|
|
291
|
+
st.mode = 'cancel';
|
|
292
|
+
if (options.reason !== undefined)
|
|
293
|
+
st.reason = options.reason;
|
|
294
|
+
}
|
|
295
|
+
if (options.drainTimeoutMs !== undefined)
|
|
296
|
+
st.drainDeadline = minDefined(st.drainDeadline, now + options.drainTimeoutMs);
|
|
297
|
+
if (options.cancelTimeoutMs !== undefined) {
|
|
298
|
+
st.cancelTimeoutMs = minDefined(st.cancelTimeoutMs, options.cancelTimeoutMs);
|
|
299
|
+
if (st.cancelDeadline !== undefined)
|
|
300
|
+
st.cancelDeadline = Math.min(st.cancelDeadline, now + options.cancelTimeoutMs);
|
|
301
|
+
}
|
|
302
|
+
for (const wake of [...this.shutdownWakeups])
|
|
303
|
+
wake();
|
|
304
|
+
return this.shuttingDown;
|
|
305
|
+
}
|
|
306
|
+
async doShutdown(st) {
|
|
307
|
+
this.draining = true;
|
|
308
|
+
this.publishStatus();
|
|
309
|
+
if (this.recovering) {
|
|
310
|
+
// Recovery finalizes runs and runs their hooks: never stop underneath it.
|
|
311
|
+
await this.recovering.catch(() => undefined);
|
|
312
|
+
}
|
|
313
|
+
let outcome = 'drained';
|
|
314
|
+
while (st.mode === 'drain') {
|
|
315
|
+
this.logger.info('draining: waiting for active runs', { activeRuns: this.active.size });
|
|
316
|
+
const r = await this.waitIdle(this.remaining(st.drainDeadline), true);
|
|
317
|
+
if (r === 'idle')
|
|
318
|
+
break;
|
|
319
|
+
if (r === 'timeout') {
|
|
320
|
+
this.logger.warn('drain timeout exceeded, cancelling remaining runs', { activeRuns: this.active.size });
|
|
321
|
+
st.mode = 'cancel';
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (st.mode === 'cancel') {
|
|
325
|
+
outcome = 'cancelled';
|
|
326
|
+
this.stopping = true;
|
|
327
|
+
this.publishStatus();
|
|
328
|
+
if (st.cancelTimeoutMs !== undefined)
|
|
329
|
+
st.cancelDeadline = this.clock.now().getTime() + st.cancelTimeoutMs;
|
|
330
|
+
const reason = st.reason ?? 'cancelled by shutdown';
|
|
331
|
+
for (const entry of [...this.active.values()]) {
|
|
332
|
+
if (entry.kind !== 'run')
|
|
333
|
+
continue;
|
|
334
|
+
this.cancelRun(entry.runId, { reason }).catch((err) => {
|
|
335
|
+
// finalizing runs just finish their hook; kill failures are persisted on the run.
|
|
336
|
+
if (!isOrchestratorError(err, 'run_not_cancellable')) {
|
|
337
|
+
this.logger.warn('cancel during shutdown failed', { runId: entry.runId, error: err });
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
for (;;) {
|
|
342
|
+
const r = await this.waitIdle(this.remaining(st.cancelDeadline), true);
|
|
343
|
+
if (r === 'idle')
|
|
344
|
+
break;
|
|
345
|
+
if (r === 'timeout') {
|
|
346
|
+
this.logger.warn('cancel timeout exceeded, interrupting hooks (recovery resumes them)', { activeRuns: this.active.size });
|
|
347
|
+
this.hookAbort.abort(new Error('controller stopping'));
|
|
348
|
+
await this.waitIdle(this.options.abortSettleMs ?? 10_000);
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
this.stopping = true;
|
|
354
|
+
this.publishStatus();
|
|
355
|
+
const result = { outcome, leftActive: [...this.active.keys()] };
|
|
356
|
+
try {
|
|
357
|
+
await this.options.onStop?.(result);
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
this.logger.error('stop callback failed', { error: err });
|
|
361
|
+
}
|
|
362
|
+
return result;
|
|
363
|
+
}
|
|
364
|
+
remaining(deadline) {
|
|
365
|
+
return deadline === undefined ? undefined : Math.max(0, deadline - this.clock.now().getTime());
|
|
366
|
+
}
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// Recovery (Spec §14, variant A)
|
|
369
|
+
/**
|
|
370
|
+
* Recover after a (crashed) previous controller. MUST run before the
|
|
371
|
+
* scheduler starts; sets `ready` only at the end. Idempotent.
|
|
372
|
+
*/
|
|
373
|
+
recover() {
|
|
374
|
+
this.recovering ??= this.doRecover();
|
|
375
|
+
return this.recovering;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Re-attempt the recovery of a `recovery_pending` run (e.g. after the
|
|
379
|
+
* operator stopped the process). With `assumeGone: true` the operator
|
|
380
|
+
* confirms the process is gone; nothing is signalled and the run is
|
|
381
|
+
* finalized (`crashed`, or `cancelled` if it was being cancelled).
|
|
382
|
+
* An unsupervised `finalizing` run (e.g. after an internal error) resumes
|
|
383
|
+
* its completion hook and finishes.
|
|
384
|
+
*
|
|
385
|
+
* @throws RunControllerError `run_controller_not_ready` during recovery,
|
|
386
|
+
* `run_controller_not_accepting` while draining/stopping,
|
|
387
|
+
* `run_already_started` if the run is supervised, `run_not_recovery_pending`
|
|
388
|
+
*/
|
|
389
|
+
async retryRecovery(runId, options = {}) {
|
|
390
|
+
if (!this.ready) {
|
|
391
|
+
throw new RunControllerError('run_controller_not_ready', 'Recovery is still running', { runId });
|
|
392
|
+
}
|
|
393
|
+
if (this.draining || this.stopping) {
|
|
394
|
+
throw new RunControllerError('run_controller_not_accepting', 'Controller is draining / shutting down', { runId });
|
|
395
|
+
}
|
|
396
|
+
const run = this.repos.runs.require(runId);
|
|
397
|
+
if (this.active.has(runId)) {
|
|
398
|
+
throw new RunControllerError('run_already_started', `Run ${runId} is already supervised or being recovered`, { runId });
|
|
399
|
+
}
|
|
400
|
+
if (run.status !== 'recovery_pending' && run.status !== 'finalizing') {
|
|
401
|
+
throw new RunControllerError('run_not_recovery_pending', `Run ${runId} is '${run.status}', not 'recovery_pending'`, {
|
|
402
|
+
runId,
|
|
403
|
+
status: run.status,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
return this.trackRecovery(run, async () => {
|
|
407
|
+
if (run.status === 'finalizing')
|
|
408
|
+
await this.resumeFinalizing(run);
|
|
409
|
+
else {
|
|
410
|
+
await this.recoverProcessRun(run, options.assumeGone ? { gone: true, detail: 'operator confirmed the worker is gone' } : undefined);
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
/** Register a recovery job in `active`: drain waits for it, cancel refuses it, no double processing. */
|
|
415
|
+
trackRecovery(run, job) {
|
|
416
|
+
const entry = {
|
|
417
|
+
runId: run.id,
|
|
418
|
+
kind: 'recovery',
|
|
419
|
+
cancel: new AbortController(),
|
|
420
|
+
cancelRequested: false,
|
|
421
|
+
cancelReason: '',
|
|
422
|
+
proc: undefined,
|
|
423
|
+
done: Promise.resolve(run),
|
|
424
|
+
};
|
|
425
|
+
this.active.set(run.id, entry);
|
|
426
|
+
this.publishStatus();
|
|
427
|
+
entry.done = this.track(run.id, job().then(() => this.repos.runs.require(run.id)));
|
|
428
|
+
return entry.done;
|
|
429
|
+
}
|
|
430
|
+
async doRecover() {
|
|
431
|
+
const report = { finalized: [], pending: [], resumedActions: [], errors: [] };
|
|
432
|
+
const activeRuns = this.repos.runs.listActive();
|
|
433
|
+
this.logger.info('recovery started', { activeRuns: activeRuns.length });
|
|
434
|
+
await Promise.all(activeRuns.map(async (run) => {
|
|
435
|
+
try {
|
|
436
|
+
await this.trackRecovery(run, () => this.recoverRun(run));
|
|
437
|
+
const after = this.repos.runs.require(run.id);
|
|
438
|
+
if (after.status === 'recovery_pending') {
|
|
439
|
+
report.pending.push({ runId: run.id, reason: recoveryReason(after) });
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
report.finalized.push({ runId: run.id, status: after.status, detail: after.finalReason ?? '' });
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
this.logger.error('recovery of run failed', { runId: run.id, error: err });
|
|
447
|
+
report.errors.push({ runId: run.id, message: errorMessage(err) });
|
|
448
|
+
this.appendEvent(run.id, RUN_EVENTS.controllerError, { phase: 'recovery', message: errorMessage(err) });
|
|
449
|
+
}
|
|
450
|
+
}));
|
|
451
|
+
// Finished runs with interrupted action chains (e.g. a manual retry during the crash).
|
|
452
|
+
for (const runId of this.repos.actions.runIdsWithIncompleteActions()) {
|
|
453
|
+
const run = this.repos.runs.get(runId);
|
|
454
|
+
if (!run || !isTerminalStatus(run.status))
|
|
455
|
+
continue;
|
|
456
|
+
try {
|
|
457
|
+
await this.resumeTerminalRunActions(run);
|
|
458
|
+
report.resumedActions.push(runId);
|
|
459
|
+
}
|
|
460
|
+
catch (err) {
|
|
461
|
+
this.logger.error('resuming lifecycle actions failed', { runId, error: err });
|
|
462
|
+
report.errors.push({ runId, message: errorMessage(err) });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
this.publisher.flush();
|
|
466
|
+
this.ready = true;
|
|
467
|
+
this.logger.info('recovery finished', {
|
|
468
|
+
finalized: report.finalized.length,
|
|
469
|
+
pending: report.pending.length,
|
|
470
|
+
resumedActions: report.resumedActions.length,
|
|
471
|
+
errors: report.errors.length,
|
|
472
|
+
});
|
|
473
|
+
this.publishStatus();
|
|
474
|
+
return report;
|
|
475
|
+
}
|
|
476
|
+
async recoverRun(run) {
|
|
477
|
+
switch (run.status) {
|
|
478
|
+
case 'finalizing':
|
|
479
|
+
await this.resumeFinalizing(run);
|
|
480
|
+
return;
|
|
481
|
+
case 'reserved':
|
|
482
|
+
// Never handed to a worker; nothing to undo – not a technical failure.
|
|
483
|
+
this.finalizeUnstarted(run.id, 'controller restarted before the run was started');
|
|
484
|
+
this.recordRecovered(run.id, 'finalized', 'reserved run was never started');
|
|
485
|
+
return;
|
|
486
|
+
case 'starting':
|
|
487
|
+
case 'running':
|
|
488
|
+
case 'cancelling':
|
|
489
|
+
case 'recovery_pending':
|
|
490
|
+
await this.recoverProcessRun(run);
|
|
491
|
+
return;
|
|
492
|
+
default:
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/** Runs that may have a worker process: verify/terminate it, then finalize or mark `recovery_pending`. */
|
|
497
|
+
async recoverProcessRun(run, forced) {
|
|
498
|
+
const snapshot = tryParseSnapshot(run);
|
|
499
|
+
const previous = previousStatus(run);
|
|
500
|
+
const check = forced ?? (await this.checkProcess(run, snapshot));
|
|
501
|
+
if (!check.gone) {
|
|
502
|
+
this.markRecoveryPending(run, previous, check.reason);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const started = this.reachedStarting(run.id);
|
|
506
|
+
if (snapshot)
|
|
507
|
+
await this.makeActionsSafe(run.id, { abandonOnStart: true });
|
|
508
|
+
const neverStarted = previous === 'reserved' || (!started && previous === 'cancelling');
|
|
509
|
+
const target = previous === 'cancelling' || neverStarted
|
|
510
|
+
? { status: 'cancelled', businessResult: null, reason: `cancelled (recovery: ${check.detail})` }
|
|
511
|
+
: { status: 'crashed', businessResult: null, reason: `controller restart: ${check.detail}` };
|
|
512
|
+
this.recordRecovered(run.id, target.status, check.detail);
|
|
513
|
+
await this.finalize(run.id, snapshot, target, {
|
|
514
|
+
hooks: started && snapshot !== null,
|
|
515
|
+
update: { error: { code: 'recovered', message: check.detail, previousStatus: previous } },
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
async checkProcess(run, snapshot) {
|
|
519
|
+
const identity = run.processStartIdentity;
|
|
520
|
+
if (identity) {
|
|
521
|
+
const grace = this.options.recoveryGraceMs ?? snapshot?.workflow.execute.cancelGraceMs ?? 10_000;
|
|
522
|
+
const result = await terminateProcessGroupIfSame(identity, grace, {
|
|
523
|
+
...(this.options.processInspector ? { inspector: this.options.processInspector } : {}),
|
|
524
|
+
...(this.options.kill ? { kill: this.options.kill } : {}),
|
|
525
|
+
timers: this.timers,
|
|
526
|
+
killConfirmMs: this.options.recoveryKillConfirmMs ?? 5_000,
|
|
527
|
+
});
|
|
528
|
+
switch (result) {
|
|
529
|
+
case 'terminated':
|
|
530
|
+
return { gone: true, detail: `orphaned worker (pid ${identity.pid}) terminated after verifying its start identity` };
|
|
531
|
+
case 'gone':
|
|
532
|
+
return { gone: true, detail: `worker (pid ${identity.pid}) no longer running` };
|
|
533
|
+
case 'not_same':
|
|
534
|
+
// The PID belongs to another process now. If a group with the old PGID still
|
|
535
|
+
// exists we cannot tell whose it is: never signal, never assume it is gone.
|
|
536
|
+
return this.groupExists(identity.pgid)
|
|
537
|
+
? { gone: false, reason: `pid ${identity.pid} now belongs to a different process and process group ${identity.pgid} exists; identity cannot be verified` }
|
|
538
|
+
: { gone: true, detail: `worker (pid ${identity.pid}) gone, pid reused by another process` };
|
|
539
|
+
case 'group_alive_unverifiable':
|
|
540
|
+
return { gone: false, reason: `worker leader (pid ${identity.pid}) is gone but members of process group ${identity.pgid} are alive and cannot be verified` };
|
|
541
|
+
case 'kill_failed':
|
|
542
|
+
return { gone: false, reason: `worker process group ${identity.pgid} could not be terminated` };
|
|
543
|
+
case 'unknown':
|
|
544
|
+
return { gone: false, reason: `start identity of pid ${identity.pid} could not be determined` };
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (run.pid !== null) {
|
|
548
|
+
return this.groupExists(run.pid)
|
|
549
|
+
? { gone: false, reason: `no start identity was persisted for pid ${run.pid} and its process group exists` }
|
|
550
|
+
: { gone: true, detail: `worker (pid ${run.pid}) no longer running` };
|
|
551
|
+
}
|
|
552
|
+
if (this.spawnAttempted(run.id)) {
|
|
553
|
+
return { gone: false, reason: 'the worker may have been spawned but its pid was not persisted' };
|
|
554
|
+
}
|
|
555
|
+
return { gone: true, detail: 'no worker process had been started' };
|
|
556
|
+
}
|
|
557
|
+
markRecoveryPending(run, previous, reason) {
|
|
558
|
+
const firstTime = run.status !== 'recovery_pending';
|
|
559
|
+
const error = {
|
|
560
|
+
code: 'recovery_failed',
|
|
561
|
+
message: reason,
|
|
562
|
+
previousStatus: previous,
|
|
563
|
+
pid: run.pid,
|
|
564
|
+
at: this.clock.now().toISOString(),
|
|
565
|
+
};
|
|
566
|
+
this.repos.transaction(() => {
|
|
567
|
+
if (firstTime)
|
|
568
|
+
this.repos.runs.transition(run.id, 'recovery_pending', { update: { error } });
|
|
569
|
+
else
|
|
570
|
+
this.repos.runs.update(run.id, { error });
|
|
571
|
+
this.repos.events.append(run.id, RUN_EVENTS.recoveryFailed, { reason, previousStatus: previous, pid: run.pid, repeated: !firstTime });
|
|
572
|
+
if (firstTime) {
|
|
573
|
+
const payload = {
|
|
574
|
+
runId: run.id,
|
|
575
|
+
workflowId: run.workflowId,
|
|
576
|
+
sourceItemKey: run.sourceItemKey,
|
|
577
|
+
sourceUrl: run.sourceUrl,
|
|
578
|
+
pid: run.pid,
|
|
579
|
+
previousStatus: previous,
|
|
580
|
+
reason,
|
|
581
|
+
};
|
|
582
|
+
this.repos.afterCommit(() => this.notify('recovery_failed', payload));
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
this.publisher.flush();
|
|
586
|
+
this.logger.error('recovery failed: run kept as recovery_pending (lock held)', { runId: run.id, reason });
|
|
587
|
+
}
|
|
588
|
+
async resumeFinalizing(run) {
|
|
589
|
+
const snapshot = tryParseSnapshot(run);
|
|
590
|
+
const started = this.reachedStarting(run.id);
|
|
591
|
+
if (snapshot)
|
|
592
|
+
await this.makeActionsSafe(run.id, { abandonOnStart: true });
|
|
593
|
+
this.recordRecovered(run.id, 'resumed', 'resuming finalization');
|
|
594
|
+
await this.completeFinalization(run.id, snapshot, { hooks: started && snapshot !== null });
|
|
595
|
+
}
|
|
596
|
+
async resumeTerminalRunActions(run) {
|
|
597
|
+
if (!tryParseSnapshot(run))
|
|
598
|
+
return;
|
|
599
|
+
await this.makeActionsSafe(run.id, { abandonOnStart: true });
|
|
600
|
+
await this.lifecycle.resumeIncomplete(run.id, { hooks: HOOK_NAMES.filter((h) => h !== 'on_start'), wait: true });
|
|
601
|
+
this.publisher.flush();
|
|
602
|
+
}
|
|
603
|
+
/** Actions left `running` → safe state (WI-07); optionally drop the rest of an interrupted `on_start`. */
|
|
604
|
+
async makeActionsSafe(runId, options) {
|
|
605
|
+
const journal = this.repos.actions.listForRun(runId);
|
|
606
|
+
if (journal.some((a) => a.status === 'running')) {
|
|
607
|
+
await this.lifecycle.resumeIncomplete(runId, { hooks: [], wait: true });
|
|
608
|
+
}
|
|
609
|
+
if (options.abandonOnStart && journal.some((a) => a.hook === 'on_start' && (a.status === 'pending' || a.status === 'running'))) {
|
|
610
|
+
await this.lifecycle.abandonHook(runId, 'on_start', { wait: true });
|
|
611
|
+
}
|
|
612
|
+
this.publisher.flush();
|
|
613
|
+
}
|
|
614
|
+
// ---------------------------------------------------------------------------
|
|
615
|
+
// Run execution
|
|
616
|
+
async execute(entry, options) {
|
|
617
|
+
const { runId } = entry;
|
|
618
|
+
const run = this.repos.runs.require(runId);
|
|
619
|
+
let snapshot;
|
|
620
|
+
try {
|
|
621
|
+
snapshot = parseRunSnapshot(run.configSnapshot, runId);
|
|
622
|
+
}
|
|
623
|
+
catch (err) {
|
|
624
|
+
return this.finalize(runId, null, startError('invalid configuration snapshot'), { hooks: false, update: { error: errorInfo(err) } });
|
|
625
|
+
}
|
|
626
|
+
// 1. Re-read the item before on_start (Spec §7 race).
|
|
627
|
+
let item = null;
|
|
628
|
+
let adapter;
|
|
629
|
+
if (run.sourceItemKey) {
|
|
630
|
+
const { sourceId, itemId } = parseSourceItemKey(run.sourceItemKey);
|
|
631
|
+
adapter = this.options.sources(run.sourceId ?? sourceId);
|
|
632
|
+
if (!adapter) {
|
|
633
|
+
return this.finalize(runId, snapshot, startError(`source '${run.sourceId ?? sourceId}' is not available`), { hooks: false });
|
|
634
|
+
}
|
|
635
|
+
try {
|
|
636
|
+
item = await adapter.getItem(itemId, { signal: entry.cancel.signal });
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
if (entry.cancelRequested)
|
|
640
|
+
return this.finalize(runId, snapshot, cancelled(entry.cancelReason), { hooks: false });
|
|
641
|
+
return this.finalize(runId, snapshot, startError(`item could not be re-read: ${errorMessage(err)}`), {
|
|
642
|
+
hooks: false,
|
|
643
|
+
update: { error: errorInfo(err) },
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
if (entry.cancelRequested)
|
|
647
|
+
return this.finalize(runId, snapshot, cancelled(entry.cancelReason), { hooks: false });
|
|
648
|
+
let matches = item !== null;
|
|
649
|
+
if (item && options.verifyItem) {
|
|
650
|
+
try {
|
|
651
|
+
matches = await options.verifyItem(item);
|
|
652
|
+
}
|
|
653
|
+
catch (err) {
|
|
654
|
+
this.logger.warn('item verification failed', { runId, error: err });
|
|
655
|
+
matches = false;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
this.appendEvent(runId, RUN_EVENTS.itemReread, { found: item !== null, matches });
|
|
659
|
+
if (entry.cancelRequested)
|
|
660
|
+
return this.finalize(runId, snapshot, cancelled(entry.cancelReason), { hooks: false });
|
|
661
|
+
if (!item)
|
|
662
|
+
return this.finalize(runId, snapshot, cancelled('item no longer available'), { hooks: false });
|
|
663
|
+
if (!matches)
|
|
664
|
+
return this.finalize(runId, snapshot, cancelled('item no longer matches the workflow'), { hooks: false });
|
|
665
|
+
}
|
|
666
|
+
if (entry.cancelRequested)
|
|
667
|
+
return this.finalize(runId, snapshot, cancelled(entry.cancelReason), { hooks: false });
|
|
668
|
+
// 2. starting + on_start.
|
|
669
|
+
this.repos.runs.transition(runId, 'starting', { expectedFrom: 'reserved' });
|
|
670
|
+
this.publisher.flush();
|
|
671
|
+
const startedAt = this.clock.now();
|
|
672
|
+
const ctx = { item };
|
|
673
|
+
if (hasActions(snapshot, 'on_start')) {
|
|
674
|
+
let result;
|
|
675
|
+
let hookError;
|
|
676
|
+
try {
|
|
677
|
+
result = await this.lifecycle.runHook(runId, 'on_start', snapshot, {
|
|
678
|
+
...(item ? { item } : {}),
|
|
679
|
+
signal: AbortSignal.any([entry.cancel.signal, this.hookAbort.signal]),
|
|
680
|
+
wait: true,
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
catch (err) {
|
|
684
|
+
hookError = err;
|
|
685
|
+
}
|
|
686
|
+
this.publisher.flush();
|
|
687
|
+
if (entry.cancelRequested || result?.status === 'interrupted') {
|
|
688
|
+
await this.makeActionsSafe(runId, { abandonOnStart: true });
|
|
689
|
+
return this.finalize(runId, snapshot, cancelled(entry.cancelReason), { hooks: true, ...ctx });
|
|
690
|
+
}
|
|
691
|
+
if (hookError !== undefined || result?.status === 'failed') {
|
|
692
|
+
// Decision (WI-08): a stopped on_start chain means the item was not (fully) claimed –
|
|
693
|
+
// the worker is NOT started and the run ends as `start_error` (technical, counted) with on_start_error.
|
|
694
|
+
// The remaining on_start actions must never run once the run moved on (a later manual
|
|
695
|
+
// skip of the failed action would otherwise continue the chain on a finished run).
|
|
696
|
+
await this.makeActionsSafe(runId, { abandonOnStart: true });
|
|
697
|
+
const name = result?.failedAction ? (result.failedAction.name ?? String(result.failedAction.id)) : null;
|
|
698
|
+
const reason = hookError !== undefined ? `on_start failed: ${errorMessage(hookError)}` : `on_start action '${name}' failed`;
|
|
699
|
+
return this.finalize(runId, snapshot, startError(reason), {
|
|
700
|
+
hooks: true,
|
|
701
|
+
...ctx,
|
|
702
|
+
...(hookError !== undefined ? { update: { error: errorInfo(hookError) } } : {}),
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
if (entry.cancelRequested)
|
|
707
|
+
return this.finalize(runId, snapshot, cancelled(entry.cancelReason), { hooks: true, ...ctx });
|
|
708
|
+
// 3. Workspace, context, secrets.
|
|
709
|
+
let prepared;
|
|
710
|
+
try {
|
|
711
|
+
prepared = await buildExecutionSpec({
|
|
712
|
+
run: this.repos.runs.require(runId),
|
|
713
|
+
snapshot,
|
|
714
|
+
item,
|
|
715
|
+
...(adapter?.repository ? { repository: adapter.repository } : {}),
|
|
716
|
+
dataDir: this.options.dataDir,
|
|
717
|
+
startedAt,
|
|
718
|
+
now: this.clock.now(),
|
|
719
|
+
...(this.options.credentials ? { credentials: this.options.credentials } : {}),
|
|
720
|
+
...(this.maxLogBytes() !== undefined ? { maxLogBytes: this.maxLogBytes() } : {}),
|
|
721
|
+
signal: entry.cancel.signal,
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
catch (err) {
|
|
725
|
+
if (entry.cancelRequested)
|
|
726
|
+
return this.finalize(runId, snapshot, cancelled(entry.cancelReason), { hooks: true, ...ctx });
|
|
727
|
+
return this.finalize(runId, snapshot, startError(`worker could not be prepared: ${errorMessage(err)}`), {
|
|
728
|
+
hooks: true,
|
|
729
|
+
...ctx,
|
|
730
|
+
update: { error: errorInfo(err) },
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
if (entry.cancelRequested)
|
|
734
|
+
return this.finalize(runId, snapshot, cancelled(entry.cancelReason), { hooks: true, ...ctx });
|
|
735
|
+
// 4. Spawn. Persist the intent first: recovery must know a process may exist.
|
|
736
|
+
const { spec } = prepared;
|
|
737
|
+
this.repos.transaction(() => {
|
|
738
|
+
this.repos.runs.update(runId, { workspacePath: spec.workingDirectory, command: { command: spec.command, args: spec.args } });
|
|
739
|
+
this.repos.events.append(runId, RUN_EVENTS.spawn, {
|
|
740
|
+
command: spec.command,
|
|
741
|
+
args: [...spec.args],
|
|
742
|
+
workingDirectory: spec.workingDirectory,
|
|
743
|
+
});
|
|
744
|
+
});
|
|
745
|
+
this.publisher.flush();
|
|
746
|
+
const executor = this.options.createExecutor
|
|
747
|
+
? this.options.createExecutor({ workspaceBaseDir: prepared.workspaceBaseDir })
|
|
748
|
+
: new ShellExecutor({
|
|
749
|
+
clock: this.clock,
|
|
750
|
+
logger: this.logger,
|
|
751
|
+
...(prepared.workspaceBaseDir !== undefined ? { workspaceBaseDir: prepared.workspaceBaseDir } : {}),
|
|
752
|
+
});
|
|
753
|
+
let proc;
|
|
754
|
+
try {
|
|
755
|
+
proc = await executor.start(spec);
|
|
756
|
+
}
|
|
757
|
+
catch (err) {
|
|
758
|
+
return this.finalize(runId, snapshot, startError(`worker could not be started: ${errorMessage(err)}`), {
|
|
759
|
+
hooks: true,
|
|
760
|
+
...ctx,
|
|
761
|
+
update: { error: errorInfo(err) },
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
entry.proc = proc;
|
|
765
|
+
// Subscribe before yielding to the event loop (no output is missed).
|
|
766
|
+
proc.onOutput((chunk) => this.bus.publish({ type: 'run.output', runId, stream: chunk.stream, data: chunk.data, at: chunk.at.toISOString() }));
|
|
767
|
+
proc.onEvent((event) => {
|
|
768
|
+
try {
|
|
769
|
+
this.repos.events.appendWorkerEvent(runId, event);
|
|
770
|
+
this.publisher.flush();
|
|
771
|
+
}
|
|
772
|
+
catch (err) {
|
|
773
|
+
this.logger.warn('persisting worker event failed', { runId, error: err });
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
proc.onDiagnostic((diagnostic) => {
|
|
777
|
+
try {
|
|
778
|
+
this.repos.events.appendDiagnostic(runId, diagnostic);
|
|
779
|
+
this.publisher.flush();
|
|
780
|
+
}
|
|
781
|
+
catch (err) {
|
|
782
|
+
this.logger.warn('persisting executor diagnostic failed', { runId, error: err });
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
if (proc.pid !== undefined) {
|
|
786
|
+
const update = { pid: proc.pid, processStartIdentity: proc.startIdentity ?? null };
|
|
787
|
+
const cur = this.repos.runs.require(runId);
|
|
788
|
+
if (cur.status === 'starting')
|
|
789
|
+
this.repos.runs.transition(runId, 'running', { expectedFrom: 'starting', update });
|
|
790
|
+
else
|
|
791
|
+
this.repos.runs.update(runId, { ...update, startedAt: this.clock.now() });
|
|
792
|
+
this.publisher.flush();
|
|
793
|
+
if (!proc.startIdentity)
|
|
794
|
+
this.logger.warn('worker start identity unavailable; recovery will not signal it', { runId, pid: proc.pid });
|
|
795
|
+
}
|
|
796
|
+
if (entry.cancelRequested) {
|
|
797
|
+
this.cancelProcess(entry).catch(() => {
|
|
798
|
+
/* persisted as run.cancel_failed */
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
// 5. Outcome.
|
|
802
|
+
const outcome = await proc.wait();
|
|
803
|
+
const classification = classifyOutcome(outcome);
|
|
804
|
+
const target = classification.status === 'cancelled'
|
|
805
|
+
? cancelled(entry.cancelReason)
|
|
806
|
+
: { status: classification.status, businessResult: classification.businessResult, reason: classification.reason };
|
|
807
|
+
return this.finalize(runId, snapshot, target, {
|
|
808
|
+
hooks: true,
|
|
809
|
+
...ctx,
|
|
810
|
+
update: {
|
|
811
|
+
exitCode: outcome.exitCode,
|
|
812
|
+
result: classification.result,
|
|
813
|
+
...(classification.status !== 'completed' || outcome.logs.truncated ? { error: outcomeError(outcome, classification.reason) } : {}),
|
|
814
|
+
},
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
async cancelProcess(entry) {
|
|
818
|
+
const proc = entry.proc;
|
|
819
|
+
if (!proc)
|
|
820
|
+
return;
|
|
821
|
+
try {
|
|
822
|
+
await proc.cancel();
|
|
823
|
+
}
|
|
824
|
+
catch (err) {
|
|
825
|
+
const message = errorMessage(err);
|
|
826
|
+
this.repos.transaction(() => {
|
|
827
|
+
this.repos.events.append(entry.runId, RUN_EVENTS.cancelFailed, { message });
|
|
828
|
+
this.repos.runs.update(entry.runId, { error: { code: 'kill_failed', message } });
|
|
829
|
+
});
|
|
830
|
+
this.publisher.flush();
|
|
831
|
+
this.logger.error('cancel failed: process group could not be terminated', { runId: entry.runId, error: err });
|
|
832
|
+
throw new RunControllerError('kill_failed', `Run ${entry.runId}: process group could not be terminated (${message})`, {
|
|
833
|
+
runId: entry.runId,
|
|
834
|
+
}, err);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
// ---------------------------------------------------------------------------
|
|
838
|
+
// Finalization
|
|
839
|
+
/**
|
|
840
|
+
* `→ finalizing` (target, counter and block in ONE transaction), then the
|
|
841
|
+
* completion hook, workspace cleanup and `→ <terminal>` (lock released).
|
|
842
|
+
*/
|
|
843
|
+
async finalize(runId, snapshot, target, options) {
|
|
844
|
+
this.beginFinalization(runId, snapshot, target, options.update);
|
|
845
|
+
return this.completeFinalization(runId, snapshot, options);
|
|
846
|
+
}
|
|
847
|
+
beginFinalization(runId, snapshot, target, update) {
|
|
848
|
+
this.repos.transaction(() => {
|
|
849
|
+
const res = this.repos.runs.beginFinalization(runId, target, {
|
|
850
|
+
...(update ? { update } : {}),
|
|
851
|
+
...(snapshot ? { blockPolicy: { maxTechnicalFailures: snapshot.workflow.retry.maxTechnicalFailures } } : {}),
|
|
852
|
+
});
|
|
853
|
+
const block = res.blockCreated;
|
|
854
|
+
if (block) {
|
|
855
|
+
const run = res.run;
|
|
856
|
+
const payload = {
|
|
857
|
+
blockId: block.id,
|
|
858
|
+
workflowId: block.workflowId,
|
|
859
|
+
itemKey: block.itemKey,
|
|
860
|
+
reason: block.reason,
|
|
861
|
+
failureCount: block.failureCount,
|
|
862
|
+
runId: run.id,
|
|
863
|
+
sourceUrl: run.sourceUrl,
|
|
864
|
+
affectedRunIds: this.repos.blocks.affectedRunIds(block.id),
|
|
865
|
+
};
|
|
866
|
+
this.repos.afterCommit(() => {
|
|
867
|
+
this.publisher.flush(); // the run's `→ finalizing` status event (with blockId) first
|
|
868
|
+
this.bus.publish({ type: 'block.created', block });
|
|
869
|
+
this.notify('retry_block_created', payload);
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
this.publisher.flush();
|
|
874
|
+
}
|
|
875
|
+
async completeFinalization(runId, snapshot, options) {
|
|
876
|
+
const run = this.repos.runs.require(runId);
|
|
877
|
+
const target = finalizationTarget(run);
|
|
878
|
+
if (run.status !== 'finalizing' || !target)
|
|
879
|
+
return run;
|
|
880
|
+
if (options.hooks && snapshot) {
|
|
881
|
+
const hook = hookForOutcome(target.status, target.businessResult);
|
|
882
|
+
if (hasActions(snapshot, hook)) {
|
|
883
|
+
try {
|
|
884
|
+
const result = await this.lifecycle.runHook(runId, hook, snapshot, {
|
|
885
|
+
...(options.item ? { item: options.item } : {}),
|
|
886
|
+
signal: this.hookAbort.signal,
|
|
887
|
+
wait: true,
|
|
888
|
+
});
|
|
889
|
+
this.publisher.flush();
|
|
890
|
+
// Interrupted by a forced stop: stay `finalizing`, recovery resumes the chain.
|
|
891
|
+
if (result.status === 'interrupted')
|
|
892
|
+
return this.repos.runs.require(runId);
|
|
893
|
+
}
|
|
894
|
+
catch (err) {
|
|
895
|
+
this.publisher.flush();
|
|
896
|
+
if (this.hookAbort.signal.aborted)
|
|
897
|
+
return this.repos.runs.require(runId);
|
|
898
|
+
this.logger.error('completion hook failed unexpectedly', { runId, hook, error: err });
|
|
899
|
+
this.appendEvent(runId, RUN_EVENTS.hookError, { hook, message: errorMessage(err) });
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
if (snapshot)
|
|
904
|
+
await this.cleanupWorkspace(runId, snapshot, target.status, target.businessResult);
|
|
905
|
+
const finished = this.repos.runs.finish(runId);
|
|
906
|
+
this.publisher.flush();
|
|
907
|
+
return finished;
|
|
908
|
+
}
|
|
909
|
+
/** Finalize a run that never reached `starting`: `cancelled`, not counted, no hooks (synchronous). */
|
|
910
|
+
finalizeUnstarted(runId, reason) {
|
|
911
|
+
this.repos.transaction(() => {
|
|
912
|
+
this.repos.runs.beginFinalization(runId, cancelled(reason));
|
|
913
|
+
this.repos.runs.finish(runId);
|
|
914
|
+
});
|
|
915
|
+
this.publisher.flush();
|
|
916
|
+
}
|
|
917
|
+
async cleanupWorkspace(runId, snapshot, status, result) {
|
|
918
|
+
const run = this.repos.runs.require(runId);
|
|
919
|
+
if (!run.workspacePath || !shouldCleanupWorkspace(snapshot.workspace.cleanup, status, result))
|
|
920
|
+
return;
|
|
921
|
+
const removal = await removeWorkspace(snapshot.workspace.baseDir, run.workspacePath);
|
|
922
|
+
if (!removal.removed && removal.reason !== 'missing') {
|
|
923
|
+
this.logger.warn('workspace not removed', { runId, ...removal });
|
|
924
|
+
}
|
|
925
|
+
this.appendEvent(runId, RUN_EVENTS.workspaceCleanup, { ...removal, policy: snapshot.workspace.cleanup });
|
|
926
|
+
}
|
|
927
|
+
// ---------------------------------------------------------------------------
|
|
928
|
+
// Helpers
|
|
929
|
+
track(runId, promise) {
|
|
930
|
+
return promise.finally(() => {
|
|
931
|
+
this.active.delete(runId);
|
|
932
|
+
this.publishStatus();
|
|
933
|
+
if (this.active.size === 0) {
|
|
934
|
+
for (const resolve of [...this.idleWaiters])
|
|
935
|
+
resolve();
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* `idle` once no run is supervised, `timeout` after `timeoutMs`, `woken`
|
|
941
|
+
* (only with `wakeable`) when a running shutdown was escalated.
|
|
942
|
+
*/
|
|
943
|
+
waitIdle(timeoutMs, wakeable = false) {
|
|
944
|
+
if (this.active.size === 0)
|
|
945
|
+
return Promise.resolve('idle');
|
|
946
|
+
return new Promise((resolve) => {
|
|
947
|
+
let timer;
|
|
948
|
+
const finish = (value) => {
|
|
949
|
+
this.idleWaiters.delete(onIdle);
|
|
950
|
+
this.shutdownWakeups.delete(onWake);
|
|
951
|
+
if (timer)
|
|
952
|
+
this.timers.clearTimeout(timer);
|
|
953
|
+
resolve(value);
|
|
954
|
+
};
|
|
955
|
+
const onIdle = () => finish('idle');
|
|
956
|
+
const onWake = () => finish('woken');
|
|
957
|
+
this.idleWaiters.add(onIdle);
|
|
958
|
+
if (wakeable)
|
|
959
|
+
this.shutdownWakeups.add(onWake);
|
|
960
|
+
if (timeoutMs !== undefined)
|
|
961
|
+
timer = this.timers.setTimeout(() => finish(this.active.size === 0 ? 'idle' : 'timeout'), timeoutMs);
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Unexpected internal error while supervising a run: never finalize blindly
|
|
966
|
+
* (the worker may still run). Where the state machine allows it the run
|
|
967
|
+
* becomes `recovery_pending` (lock kept, error, `recovery_failed`
|
|
968
|
+
* notification); a `finalizing` run stays as is. The entry stays supervised
|
|
969
|
+
* until a started worker process ended; then `retryRecovery` resolves the run.
|
|
970
|
+
*/
|
|
971
|
+
async onUnexpected(entry, err) {
|
|
972
|
+
const runId = entry.runId;
|
|
973
|
+
this.logger.error('unexpected error while supervising run', { runId, error: err });
|
|
974
|
+
this.appendEvent(runId, RUN_EVENTS.controllerError, { message: errorMessage(err) });
|
|
975
|
+
try {
|
|
976
|
+
const run = this.repos.runs.require(runId);
|
|
977
|
+
if (CAN_BECOME_RECOVERY_PENDING.includes(run.status)) {
|
|
978
|
+
this.markRecoveryPending(run, run.status, `internal error while supervising the run: ${errorMessage(err)}`);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
catch (inner) {
|
|
982
|
+
this.logger.error('could not mark run as recovery_pending', { runId, error: inner });
|
|
983
|
+
}
|
|
984
|
+
if (entry.proc)
|
|
985
|
+
await entry.proc.wait().catch(() => undefined);
|
|
986
|
+
return this.repos.runs.require(runId);
|
|
987
|
+
}
|
|
988
|
+
appendEvent(runId, type, payload) {
|
|
989
|
+
try {
|
|
990
|
+
this.repos.events.append(runId, type, payload);
|
|
991
|
+
this.publisher.flush();
|
|
992
|
+
}
|
|
993
|
+
catch (err) {
|
|
994
|
+
this.logger.warn('persisting run event failed', { runId, type, error: err });
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
recordRecovered(runId, action, detail) {
|
|
998
|
+
this.appendEvent(runId, RUN_EVENTS.recovered, { action, detail });
|
|
999
|
+
}
|
|
1000
|
+
reachedStarting(runId) {
|
|
1001
|
+
return this.repos.events
|
|
1002
|
+
.listForRun(runId)
|
|
1003
|
+
.some((e) => e.type === RUN_STATUS_EVENT && e.payload.to === 'starting');
|
|
1004
|
+
}
|
|
1005
|
+
spawnAttempted(runId) {
|
|
1006
|
+
return this.repos.events.listForRun(runId).some((e) => e.type === RUN_EVENTS.spawn);
|
|
1007
|
+
}
|
|
1008
|
+
groupExists(pgid) {
|
|
1009
|
+
return (this.options.groupExists ?? processGroupExists)(pgid);
|
|
1010
|
+
}
|
|
1011
|
+
maxLogBytes() {
|
|
1012
|
+
const v = this.options.maxLogBytesPerRun;
|
|
1013
|
+
return typeof v === 'function' ? v() : v;
|
|
1014
|
+
}
|
|
1015
|
+
notify(type, payload) {
|
|
1016
|
+
try {
|
|
1017
|
+
this.options.notifier?.notify(type, payload);
|
|
1018
|
+
}
|
|
1019
|
+
catch (err) {
|
|
1020
|
+
this.logger.error('notification failed', { type, error: err });
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
publishStatus() {
|
|
1024
|
+
this.bus.publish({ type: 'system.status', status: this.status });
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
// ---------------------------------------------------------------------------
|
|
1028
|
+
// Pure helpers
|
|
1029
|
+
function minDefined(a, b) {
|
|
1030
|
+
return a === undefined ? b : Math.min(a, b);
|
|
1031
|
+
}
|
|
1032
|
+
function hasActions(snapshot, hook) {
|
|
1033
|
+
return (snapshot.workflow.hooks[hook] ?? []).length > 0;
|
|
1034
|
+
}
|
|
1035
|
+
function startError(reason) {
|
|
1036
|
+
return { status: 'start_error', businessResult: null, reason };
|
|
1037
|
+
}
|
|
1038
|
+
function cancelled(reason) {
|
|
1039
|
+
return { status: 'cancelled', businessResult: null, reason };
|
|
1040
|
+
}
|
|
1041
|
+
function tryParseSnapshot(run) {
|
|
1042
|
+
try {
|
|
1043
|
+
return parseRunSnapshot(run.configSnapshot, run.id);
|
|
1044
|
+
}
|
|
1045
|
+
catch {
|
|
1046
|
+
return null;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
/** Status before `recovery_pending` (persisted in `error.previousStatus`). */
|
|
1050
|
+
function previousStatus(run) {
|
|
1051
|
+
if (run.status !== 'recovery_pending')
|
|
1052
|
+
return run.status;
|
|
1053
|
+
const prev = run.error?.previousStatus;
|
|
1054
|
+
return typeof prev === 'string' ? prev : 'running';
|
|
1055
|
+
}
|
|
1056
|
+
function recoveryReason(run) {
|
|
1057
|
+
const message = run.error?.message;
|
|
1058
|
+
return typeof message === 'string' ? message : 'recovery failed';
|
|
1059
|
+
}
|
|
1060
|
+
function errorInfo(err) {
|
|
1061
|
+
return {
|
|
1062
|
+
code: isOrchestratorError(err) ? err.code : 'error',
|
|
1063
|
+
message: errorMessage(err),
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
function outcomeError(outcome, reason) {
|
|
1067
|
+
return {
|
|
1068
|
+
code: 'worker_outcome',
|
|
1069
|
+
message: reason,
|
|
1070
|
+
exitCode: outcome.exitCode,
|
|
1071
|
+
signal: outcome.signal,
|
|
1072
|
+
timedOut: outcome.timedOut,
|
|
1073
|
+
cancelled: outcome.cancelled,
|
|
1074
|
+
...(outcome.spawnError ? { spawnError: outcome.spawnError } : {}),
|
|
1075
|
+
logsTruncated: outcome.logs.truncated,
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
//# sourceMappingURL=controller.js.map
|