@oisincoveney/pipeline 4.0.4 → 4.1.1

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.
@@ -1,20 +1,25 @@
1
+ import { loadMokaDbUrl } from "../moka-global-config.js";
1
2
  import { reconcileAllRuns } from "../remote/jobs/reconcile.js";
2
3
  import { createRunSpecRegistry, startRunSpecServer } from "../remote/jobs/run-spec-server.js";
3
4
  import { runJobFleetWatchLoop } from "../remote/jobs/watch.js";
4
5
  import { submitParsedMoka } from "../remote/submit/service.js";
5
6
  import { withRunControlStore } from "../run-control/command-context.js";
7
+ import { resolveExecutionStore } from "../run-control/postgres/postgres-execution-store.js";
6
8
  import { kubernetesJobFleetReadApi } from "../runtime/services/kubernetes-jobs-read-service.js";
7
9
  import { kubernetesJobFleetWatchApi } from "../runtime/services/kubernetes-jobs-watch-service.js";
8
10
  import { loadCoordinatorJobsConfig } from "./config.js";
9
11
  import { createRunScopedOwnerReference } from "./run-owner.js";
10
- import { submitWaveJobs } from "./wave-advance.js";
12
+ import { makeSubmitWaveJobs } from "./wave-advance.js";
13
+ import { advanceReconciledWaves, makeWaveTickRegistry } from "./wave-tick-registry.js";
11
14
  import * as DateTime from "effect/DateTime";
12
15
  import * as Effect from "effect/Effect";
13
16
  import * as ManagedRuntime from "effect/ManagedRuntime";
14
17
  import * as Layer from "effect/Layer";
18
+ import * as Exit from "effect/Exit";
15
19
  import * as Cause from "effect/Cause";
16
20
  import { randomBytes } from "node:crypto";
17
21
  import * as Fiber from "effect/Fiber";
22
+ import * as Scope from "effect/Scope";
18
23
  //#region src/coordinator/composition.ts
19
24
  /**
20
25
  * ENG-57.1/57.2/57.3: the coordinator's composition root (doc-1 decision 3,
@@ -80,27 +85,58 @@ const defaultOnWatchLoopFailure = (error) => {
80
85
  * drives the startup pass, every event-triggered pass, and every
81
86
  * post-reconnect pass, so dynamic and static Jobs runs finalize through one
82
87
  * run-id-keyed path (AC#4). */
83
- const buildWatchLoopEffect = (dependencies, config, store) => runJobFleetWatchLoop({
84
- ...dependencies.watchLoop,
85
- namespace: config.namespace,
86
- reconcile: reconcileAllRuns({
87
- fleet: dependencies.jobFleetReadApi ?? kubernetesJobFleetReadApi({}),
88
+ const buildWatchLoopEffect = (dependencies, config, store, executionStore, registry) => {
89
+ const fleet = dependencies.jobFleetReadApi ?? kubernetesJobFleetReadApi({});
90
+ const now = () => DateTime.formatIso(DateTime.nowUnsafe());
91
+ return runJobFleetWatchLoop({
92
+ ...dependencies.watchLoop,
88
93
  namespace: config.namespace,
89
- now: () => DateTime.formatIso(DateTime.nowUnsafe()),
90
- store
91
- }).pipe(Effect.tap((reports) => Effect.sync(() => dependencies.onReconcile?.(reports))), Effect.asVoid),
92
- watch: dependencies.jobFleetWatchApi ?? kubernetesJobFleetWatchApi({})
93
- });
94
+ reconcile: reconcileAllRuns({
95
+ fleet,
96
+ namespace: config.namespace,
97
+ now,
98
+ store,
99
+ submitted: (runId) => executionStore.listSubmitted({ runId })
100
+ }).pipe(Effect.tap((reports) => Effect.sync(() => dependencies.onReconcile?.(reports))), Effect.flatMap((reports) => advanceReconciledWaves({
101
+ executionStore,
102
+ fleet,
103
+ now,
104
+ registry
105
+ }, reports)), Effect.asVoid),
106
+ watch: dependencies.jobFleetWatchApi ?? kubernetesJobFleetWatchApi({})
107
+ });
108
+ };
94
109
  /** ENG-57.3: starts the watch loop as its own fiber over the coordinator's
95
110
  * lifetime. Resolves the store via the injected test override, or the
96
111
  * production `withRunControlStore` seam (Postgres-backed, scoped so
97
112
  * interrupting the returned fiber also releases the store's connection). A
98
113
  * permanent failure is reported via `onWatchLoopFailure`, never left silent. */
99
- const startWatchLoop = (dependencies, config) => {
114
+ const startWatchLoop = (dependencies, config, executionStore, registry) => {
100
115
  const onWatchLoopFailure = dependencies.onWatchLoopFailure ?? defaultOnWatchLoopFailure;
101
- const watchLoopEffect = dependencies.runControlStore === void 0 ? withRunControlStore((store) => buildWatchLoopEffect(dependencies, config, store)) : buildWatchLoopEffect(dependencies, config, dependencies.runControlStore);
116
+ const watchLoopEffect = dependencies.runControlStore === void 0 ? withRunControlStore((store) => buildWatchLoopEffect(dependencies, config, store, executionStore, registry)) : buildWatchLoopEffect(dependencies, config, dependencies.runControlStore, executionStore, registry);
102
117
  return watchLoopRuntime.runFork(watchLoopEffect.pipe(Effect.tapCause((cause) => Effect.sync(() => onWatchLoopFailure(Cause.squash(cause))))));
103
118
  };
119
+ /**
120
+ * ENG-57.10: acquire the ONE durable {@link ExecutionStore} the coordinator
121
+ * shares between `submit` and the watch loop, for the coordinator's whole
122
+ * lifetime. A test override is used as-is (the test owns its release). Otherwise
123
+ * the Postgres store is bound inside a coordinator-held {@link Scope} via
124
+ * `resolveExecutionStore`'s `acquireRelease`, so `close()` releases the connection
125
+ * exactly once (`Scope.close`) — never leaked, even under watch-fiber interrupt.
126
+ */
127
+ const acquireSharedExecutionStore = async (dependencies) => {
128
+ if (dependencies.executionStore !== void 0) return {
129
+ release: async () => Promise.resolve(),
130
+ store: dependencies.executionStore
131
+ };
132
+ const scope = await watchLoopRuntime.runPromise(Scope.make());
133
+ return {
134
+ release: async () => {
135
+ await watchLoopRuntime.runPromise(Scope.close(scope, Exit.void));
136
+ },
137
+ store: await watchLoopRuntime.runPromise(Effect.provideService(resolveExecutionStore(loadMokaDbUrl()), Scope.Scope, scope))
138
+ };
139
+ };
104
140
  const resolveRunSpecBind = (dependencies, env) => {
105
141
  const host = dependencies.runSpecBind?.host ?? env[RUN_SPEC_HOST_ENV];
106
142
  const port = dependencies.runSpecBind?.port ?? parseRunSpecPortEnv(env);
@@ -132,7 +168,9 @@ const createCoordinator = async (dependencies = {}) => {
132
168
  registry
133
169
  }, resolveRunSpecBind(dependencies, env));
134
170
  const runSpecBaseUrl = dependencies.runSpecBaseUrl ?? env[RUN_SPEC_BASE_URL_ENV] ?? runSpecServer.baseUrl;
135
- const watchLoopFiber = startWatchLoop(dependencies, config);
171
+ const { release: releaseExecutionStore, store: executionStore } = await acquireSharedExecutionStore(dependencies);
172
+ const waveRegistry = watchLoopRuntime.runSync(makeWaveTickRegistry());
173
+ const watchLoopFiber = startWatchLoop(dependencies, config, executionStore, waveRegistry);
136
174
  const submit = async (options, callerDependencies = {}) => {
137
175
  const runId = options.run?.id ?? (dependencies.generateRunId ?? generateFallbackRunId)();
138
176
  const ownerReference = await createRunScopedOwnerReference({
@@ -145,7 +183,10 @@ const createCoordinator = async (dependencies = {}) => {
145
183
  ownerReference,
146
184
  registry,
147
185
  runSpecBaseUrl,
148
- submitJobs: submitWaveJobs,
186
+ submitJobs: makeSubmitWaveJobs({
187
+ executionStore,
188
+ registerWaveContext: (context) => watchLoopRuntime.runSync(waveRegistry.register(context))
189
+ }),
149
190
  ...dependencies.jobsDependencies === void 0 ? {} : { jobsDependencies: dependencies.jobsDependencies }
150
191
  };
151
192
  return await submitParsedMoka(resolveSubmitOptions(options, config), {
@@ -157,6 +198,7 @@ const createCoordinator = async (dependencies = {}) => {
157
198
  return {
158
199
  close: async () => {
159
200
  await watchLoopRuntime.runPromise(Fiber.interrupt(watchLoopFiber));
201
+ await releaseExecutionStore();
160
202
  await runSpecServer.close();
161
203
  },
162
204
  config,
@@ -1,10 +1,10 @@
1
- import "../schema-boundary.js";
1
+ import { parseStrictWithSchema } from "../schema-boundary.js";
2
2
  import { compileScheduleArtifact, parseScheduleArtifact } from "../planning/generate.js";
3
- import "../remote/jobs/job-outcome.js";
3
+ import { classifyNodeOutcome } from "../remote/jobs/job-outcome.js";
4
4
  import { submitRunnerNodeJobs } from "../remote/jobs/submit.js";
5
5
  import { parseJobsSubmitResult } from "../remote/submit/jobs-submit-result.js";
6
6
  import { defaultBuildNodeRunSpec, requireCompiledSchedule, requireSubmitNamespace, sharedJobOptions } from "../remote/submit/jobs-submission.js";
7
- import { inMemoryExecutionStore } from "../run-control/execution-store.js";
7
+ import { executionTerminalStatusSchema } from "../run-control/execution-store.js";
8
8
  import { planNextWave } from "./wave-scheduler.js";
9
9
  import * as DateTime from "effect/DateTime";
10
10
  import * as Effect from "effect/Effect";
@@ -15,6 +15,7 @@ import * as Schema from "effect/Schema";
15
15
  import * as Arr from "effect/Array";
16
16
  import * as HashMap from "effect/HashMap";
17
17
  import * as Layer from "effect/Layer";
18
+ import * as HashSet from "effect/HashSet";
18
19
  import * as Str from "effect/String";
19
20
  //#region src/coordinator/wave-advance.ts
20
21
  /**
@@ -79,6 +80,53 @@ const advanceWave = (deps) => Effect.gen(function* body() {
79
80
  })
80
81
  };
81
82
  });
83
+ /**
84
+ * The cluster-truth → ExecutionStore bridge. Observes the run's Jobs/pods once
85
+ * and, for every node NOT already terminal in the store, folds a terminal
86
+ * cluster outcome (via the ENG-19 {@link classifyNodeOutcome} classifier, reused
87
+ * not reimplemented) into the ExecutionStore under CAS. A node whose Job is still
88
+ * pending is left untouched. This is what makes a later {@link advanceWave} tick
89
+ * see a predecessor's terminal success and release the next wave.
90
+ */
91
+ const recordClusterTerminals = (deps) => Effect.gen(function* body() {
92
+ const observation = yield* deps.fleet.observeRun({
93
+ namespace: deps.namespace,
94
+ runId: deps.runId
95
+ });
96
+ const submitted = yield* deps.executionStore.listSubmitted({ runId: deps.runId });
97
+ yield* Effect.forEach(deps.nodes, (node) => deps.executionStore.getExecution({
98
+ nodeId: node.id,
99
+ runId: deps.runId
100
+ }).pipe(Effect.flatMap((existing) => {
101
+ if (Option$1.isSome(existing)) return Effect.void;
102
+ const outcome = classifyNodeOutcome(observation.nodes[node.id] ?? { pods: [] }, { submitted: HashSet.has(submitted, node.id) });
103
+ if (outcome.kind === "pending") return Effect.void;
104
+ const status = parseStrictWithSchema(executionTerminalStatusSchema, outcome.status);
105
+ return deps.executionStore.recordTerminal({
106
+ at: deps.now(),
107
+ attempt: 1,
108
+ identity: {
109
+ generation: FIRST_EXECUTION_GENERATION,
110
+ nodeId: node.id,
111
+ runId: deps.runId
112
+ },
113
+ status
114
+ }).pipe(Effect.asVoid);
115
+ })), { concurrency: 1 });
116
+ });
117
+ /**
118
+ * A full reconcile-driven tick: fold cluster truth into the ExecutionStore, then
119
+ * advance the wave. This is the unit the coordinator's reconcile loop calls per
120
+ * cycle to release later waves as predecessors finish.
121
+ */
122
+ const runWaveTick = (deps) => recordClusterTerminals({
123
+ executionStore: deps.executionStore,
124
+ fleet: deps.fleet,
125
+ namespace: deps.namespace,
126
+ nodes: deps.nodes,
127
+ now: deps.now,
128
+ runId: deps.runId
129
+ }).pipe(Effect.flatMap(() => advanceWave(deps)));
82
130
  /** A wave-submit requested a node id absent from the compiled schedule — a
83
131
  * scheduler invariant violation, never expected in normal operation. */
84
132
  var WaveSubmitMissingNodeError = class extends Schema.TaggedErrorClass()("WaveSubmitMissingNodeError", { message: Schema.String }) {
@@ -87,15 +135,13 @@ var WaveSubmitMissingNodeError = class extends Schema.TaggedErrorClass()("WaveSu
87
135
  }
88
136
  };
89
137
  /**
90
- * The `SubmitMokaJobsTransport.submitJobs` seam (ENG-57.1/57.2): submit a run's
91
- * FIRST wave as per-node Jobs. Compiles the schedule, then runs a single
92
- * {@link advanceWave} tick over a fresh (empty) ExecutionStore which resolves
93
- * to the root wave (nodes with no predecessors), fanning parallel roots out and
94
- * NEVER eagerly submitting a downstream node. Later waves are the reconcile
95
- * driver's concern ({@link runWaveTick}). Replaces ENG-57.1's single-ready-node
96
- * primitive so static and dynamic (post-schedule) runs share this one scheduler.
138
+ * Compile a submission into its {@link WaveTickContext}: the node graph and the
139
+ * per-wave create-or-observe submit closure. Extracted from the wave transport so
140
+ * the coordinator can register the SAME `{ nodes, submitReadyNodes }` the first
141
+ * tick used and re-tick it from the watch loop one scheduler, one submit path,
142
+ * static and dynamic alike.
97
143
  */
98
- const submitWaveJobs = async (input) => {
144
+ const compileWaveContext = (input, executionStore) => {
99
145
  const scheduleYaml = requireCompiledSchedule(input.plan);
100
146
  const namespace = requireSubmitNamespace(input.options);
101
147
  const nodes = compileScheduleArtifact(input.plan.config, parseScheduleArtifact(scheduleYaml, "schedule.yaml"), input.worktreePath).plan.topologicalOrder;
@@ -127,17 +173,44 @@ const submitWaveJobs = async (input) => {
127
173
  nodeIds: [...nodeIds]
128
174
  }, jobsDependencies);
129
175
  }
130
- });
131
- const result = await runWaveEffect(advanceWave({
132
- executionStore: inMemoryExecutionStore(),
176
+ }).pipe(Effect.tap((submitted) => Effect.forEach(submitted, (job) => executionStore.recordSubmitted({
177
+ nodeId: job.nodeId,
178
+ runId: input.plan.runId
179
+ }), {
180
+ concurrency: 1,
181
+ discard: true
182
+ })));
183
+ return {
184
+ namespace,
133
185
  nodes,
134
- now: () => DateTime.formatIso(DateTime.nowUnsafe()),
135
186
  runId: input.plan.runId,
136
187
  submitReadyNodes
188
+ };
189
+ };
190
+ /**
191
+ * The `SubmitMokaJobsTransport.submitJobs` seam (ENG-57.1/57.2/57.10): submit a
192
+ * run's FIRST wave as per-node Jobs over the coordinator's SHARED durable
193
+ * {@link ExecutionStore} (never a fresh in-memory one), registering the run's
194
+ * {@link WaveTickContext} so the watch loop re-ticks later waves as predecessors
195
+ * terminalize. Compiles the schedule, then runs a single {@link advanceWave} tick
196
+ * — resolving to the root wave (nodes with no predecessors), fanning parallel
197
+ * roots out and NEVER eagerly submitting a downstream node. Later waves are the
198
+ * reconcile driver's concern ({@link runWaveTick}). One scheduler for static and
199
+ * dynamic (post-schedule) runs alike.
200
+ */
201
+ const makeSubmitWaveJobs = (deps) => async (input) => {
202
+ const context = compileWaveContext(input, deps.executionStore);
203
+ deps.registerWaveContext(context);
204
+ const result = await runWaveEffect(advanceWave({
205
+ executionStore: deps.executionStore,
206
+ nodes: context.nodes,
207
+ now: () => DateTime.formatIso(DateTime.nowUnsafe()),
208
+ runId: context.runId,
209
+ submitReadyNodes: context.submitReadyNodes
137
210
  }));
138
211
  return parseJobsSubmitResult({
139
212
  jobs: [...result.submitted],
140
- namespace,
213
+ namespace: context.namespace,
141
214
  runId: input.plan.runId,
142
215
  waves: [{ nodeIds: [...result.plan.ready] }],
143
216
  workflowName: input.plan.workflowId,
@@ -145,4 +218,4 @@ const submitWaveJobs = async (input) => {
145
218
  });
146
219
  };
147
220
  //#endregion
148
- export { WaveSubmitMissingNodeError, advanceWave, submitWaveJobs };
221
+ export { WaveSubmitMissingNodeError, advanceWave, compileWaveContext, makeSubmitWaveJobs, recordClusterTerminals, runWaveTick };
@@ -0,0 +1,56 @@
1
+ import { isTerminalRunStatus } from "../remote/jobs/job-outcome.js";
2
+ import { runWaveTick } from "./wave-advance.js";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Option$1 from "effect/Option";
5
+ import * as HashMap from "effect/HashMap";
6
+ import * as Ref from "effect/Ref";
7
+ //#region src/coordinator/wave-tick-registry.ts
8
+ const makeWaveTickRegistry = () => Ref.make(HashMap.empty()).pipe(Effect.map((ref) => ({
9
+ get: (runId) => Ref.get(ref).pipe(Effect.map((map) => HashMap.get(map, runId))),
10
+ register: (context) => Ref.update(ref, (map) => HashMap.set(map, context.runId, context)),
11
+ remove: (runId) => Ref.update(ref, (map) => HashMap.remove(map, runId))
12
+ })));
13
+ /** A run whose reconcile report shows a terminal run status is finished — drop
14
+ * its context so it never re-ticks; nothing left to advance. */
15
+ const isReportTerminal = (report) => Option$1.match(report.runStatus, {
16
+ onNone: () => false,
17
+ onSome: (status) => isTerminalRunStatus(status)
18
+ });
19
+ /**
20
+ * Advance one reconciled run one wave-tick. A run with a terminal status is
21
+ * dropped from the registry. A run with a registered context is folded through
22
+ * {@link runWaveTick} over the SHARED durable ExecutionStore — releasing any wave
23
+ * whose predecessors just terminalized — and dropped once the whole run is
24
+ * complete. A run with no registered context (e.g. not re-driven after a restart)
25
+ * is a no-op: the reconcile loop still finalized it against cluster truth.
26
+ */
27
+ const advanceReconciledRun = (deps, report) => {
28
+ if (isReportTerminal(report)) return deps.registry.remove(report.runId);
29
+ return deps.registry.get(report.runId).pipe(Effect.flatMap(Option$1.match({
30
+ onNone: () => Effect.void,
31
+ onSome: (context) => runWaveTick({
32
+ executionStore: deps.executionStore,
33
+ fleet: deps.fleet,
34
+ namespace: context.namespace,
35
+ nodes: context.nodes,
36
+ now: deps.now,
37
+ runId: context.runId,
38
+ submitReadyNodes: context.submitReadyNodes
39
+ }).pipe(Effect.flatMap((result) => result.plan.runComplete ? deps.registry.remove(context.runId) : Effect.void))
40
+ })));
41
+ };
42
+ /**
43
+ * ENG-57.10: the per-reconcile-cycle wave advancement the coordinator watch loop
44
+ * runs after {@link reconcileAllRuns}. For every run in the cycle's reports it
45
+ * runs one {@link runWaveTick} over the ONE shared durable ExecutionStore, so a
46
+ * later wave submits only once its predecessors terminalize — the missing driver
47
+ * that advanced a multi-wave graph past wave 1. Idempotent: re-running a tick with
48
+ * unchanged inputs re-observes existing Jobs (create-or-observe) and re-reads the
49
+ * ExecutionStore CAS, never a duplicate Job.
50
+ */
51
+ const advanceReconciledWaves = (deps, reports) => Effect.forEach(reports, (report) => advanceReconciledRun(deps, report), {
52
+ concurrency: 1,
53
+ discard: true
54
+ });
55
+ //#endregion
56
+ export { advanceReconciledWaves, makeWaveTickRegistry };
@@ -42,7 +42,7 @@ const OUTCOME_RULES = [
42
42
  status: "failed"
43
43
  },
44
44
  {
45
- matches: ({ job }) => job === void 0,
45
+ matches: ({ job }, submitted) => job === void 0 && submitted,
46
46
  reason: "job-lost",
47
47
  status: "failed"
48
48
  },
@@ -57,15 +57,24 @@ const OUTCOME_RULES = [
57
57
  * `pending` (no terminal signal yet — leave it running). Only ever consulted
58
58
  * for nodes the store still holds non-terminal, so a `finalize` result never
59
59
  * overwrites a node that already reported its own outcome.
60
+ *
61
+ * ENG-57.10: `submitted` (default `true`) scopes the dead-Job (`job-lost`)
62
+ * sweeper. Whole-run callers submit every node up front and omit it (keeping the
63
+ * always-finalize net). Incremental wave callers pass the run's durable
64
+ * submitted-set membership so a not-yet-submitted future wave stays `pending`
65
+ * instead of being terminalized failed.
60
66
  */
61
- const classifyNodeOutcome = (observation) => Option$1.match(Arr.findFirst(OUTCOME_RULES, (candidate) => candidate.matches(observation)), {
62
- onNone: () => ({ kind: "pending" }),
63
- onSome: (rule) => ({
64
- kind: "finalize",
65
- reason: rule.reason,
66
- status: rule.status
67
- })
68
- });
67
+ const classifyNodeOutcome = (observation, context = {}) => {
68
+ const submitted = context.submitted ?? true;
69
+ return Option$1.match(Arr.findFirst(OUTCOME_RULES, (candidate) => candidate.matches(observation, submitted)), {
70
+ onNone: () => ({ kind: "pending" }),
71
+ onSome: (rule) => ({
72
+ kind: "finalize",
73
+ reason: rule.reason,
74
+ status: rule.status
75
+ })
76
+ });
77
+ };
69
78
  const TERMINAL_NODE_STATUSES = [
70
79
  "passed",
71
80
  "failed",
@@ -3,6 +3,7 @@ import * as Effect from "effect/Effect";
3
3
  import * as Option$1 from "effect/Option";
4
4
  import * as R from "effect/Record";
5
5
  import * as Arr from "effect/Array";
6
+ import * as HashSet from "effect/HashSet";
6
7
  //#region src/remote/jobs/reconcile.ts
7
8
  /** A node absent from the cluster observation has no Job present — the dead-Job
8
9
  * signal the classifier finalizes to failed. */
@@ -10,12 +11,12 @@ const observationForNode = (observation, nodeId) => Option$1.getOrElse(R.get(obs
10
11
  const emit = (deps, event) => Effect.sync(() => {
11
12
  deps.onEvent?.(event);
12
13
  });
13
- const reconcileNode = (deps, runId, observation, nodeId, currentStatus) => {
14
+ const reconcileNode = (deps, runId, observation, nodeId, currentStatus, submitted) => {
14
15
  if (isTerminalNodeStatus(currentStatus)) return Effect.succeed({
15
16
  finalization: Option$1.none(),
16
17
  status: currentStatus
17
18
  });
18
- const outcome = classifyNodeOutcome(observationForNode(observation, nodeId));
19
+ const outcome = classifyNodeOutcome(observationForNode(observation, nodeId), { submitted });
19
20
  if (outcome.kind === "pending") return Effect.succeed({
20
21
  finalization: Option$1.none(),
21
22
  status: currentStatus
@@ -77,7 +78,9 @@ const reconcileRun = (deps, runId) => Effect.gen(function* body() {
77
78
  namespace: deps.namespace,
78
79
  runId
79
80
  });
80
- const results = yield* Effect.forEach(R.toEntries(manifest.nodes), ([nodeId, status]) => reconcileNode(deps, runId, observation, nodeId, status), { concurrency: 1 });
81
+ const submittedSet = deps.submitted === void 0 ? void 0 : yield* deps.submitted(runId);
82
+ const isSubmitted = (nodeId) => submittedSet === void 0 || HashSet.has(submittedSet, nodeId);
83
+ const results = yield* Effect.forEach(R.toEntries(manifest.nodes), ([nodeId, status]) => reconcileNode(deps, runId, observation, nodeId, status, isSubmitted(nodeId)), { concurrency: 1 });
81
84
  const finalizedNodes = results.flatMap((result) => Arr.fromOption(result.finalization));
82
85
  const runStatus = aggregateRunStatus(results.map((result) => result.status));
83
86
  yield* finalizeRun(deps, runId, runStatus);
Binary file
@@ -0,0 +1,43 @@
1
+ import { mokaPostgresSchema } from "../../runtime/durable-store/postgres/schema.js";
2
+ import { integer, primaryKey, text, timestamp } from "drizzle-orm/pg-core";
3
+ //#region src/run-control/postgres/execution-schema.ts
4
+ /**
5
+ * ENG-57.6: Drizzle schema backing the Postgres {@link ExecutionStore}. Exactly
6
+ * one row per `(run_id, node_id)` holds that node's AUTHORITATIVE terminal
7
+ * execution — the `generation`/`attempt` that won the compare-and-set, its
8
+ * terminal `status`, and the server wall time it terminalized.
9
+ *
10
+ * The CAS is enforced at the database layer: the write is a guarded upsert whose
11
+ * `ON CONFLICT ... DO UPDATE` only fires when the incoming `(generation, attempt)`
12
+ * is strictly newer than the stored row, so a late or replayed older execution
13
+ * can never overwrite a newer terminal state even under concurrent writers.
14
+ *
15
+ * Lives in the shared `moka` substrate schema alongside the run-control and
16
+ * durable-store tables. Deliberately carries no foreign key to a run table: an
17
+ * execution can be terminalized independently of the event-sourced run-control
18
+ * row, and the coordinator owns `(run_id, node_id)` as the sole key.
19
+ */
20
+ const execution = mokaPostgresSchema.table("moka_execution", {
21
+ attempt: integer("attempt").notNull(),
22
+ generation: integer("generation").notNull(),
23
+ nodeId: text("node_id").notNull(),
24
+ runId: text("run_id").notNull(),
25
+ status: text("status").$type().notNull(),
26
+ terminalizedAt: timestamp("terminalized_at", { withTimezone: true }).defaultNow().notNull()
27
+ }, (table) => [primaryKey({ columns: [table.runId, table.nodeId] })]);
28
+ /**
29
+ * ENG-57.10: the durable SUBMITTED-SET the dead-Job sweeper scopes to. One row per
30
+ * `(run_id, node_id)` records that the wave scheduler actually submitted that
31
+ * node's Job. First-writer-wins (`ON CONFLICT DO NOTHING`): a node is submitted
32
+ * once per run and stays submitted. The sweeper only finalizes a node absent from
33
+ * the cluster as `job-lost` when it appears here — so a not-yet-submitted future
34
+ * wave stays pending instead of being terminalized failed, while a submitted node
35
+ * whose Job later vanished still fails.
36
+ */
37
+ const executionSubmission = mokaPostgresSchema.table("moka_execution_submission", {
38
+ nodeId: text("node_id").notNull(),
39
+ runId: text("run_id").notNull(),
40
+ submittedAt: timestamp("submitted_at", { withTimezone: true }).defaultNow().notNull()
41
+ }, (table) => [primaryKey({ columns: [table.runId, table.nodeId] })]);
42
+ //#endregion
43
+ export { execution, executionSubmission };
@@ -0,0 +1,103 @@
1
+ import { requireMokaDbUrl } from "../../moka-global-config.js";
2
+ import { migratePostgresSubstrate } from "../../runtime/durable-store/postgres/migrate-substrate.js";
3
+ import { openMokaSubstrateClient } from "../../runtime/durable-store/postgres/client.js";
4
+ import { parseStoredExecution, toStoredExecution } from "../execution-store.js";
5
+ import { execution, executionSubmission } from "./execution-schema.js";
6
+ import * as Effect from "effect/Effect";
7
+ import * as Option$1 from "effect/Option";
8
+ import * as HashSet from "effect/HashSet";
9
+ import { drizzle } from "drizzle-orm/postgres-js";
10
+ import { and, eq, lt, or } from "drizzle-orm";
11
+ //#region src/run-control/postgres/postgres-execution-store.ts
12
+ /** Apply the shared Drizzle migrations (delegates to the lock-guarded substrate migrator). */
13
+ const migratePostgresExecutionStore = async (dbUrl) => {
14
+ await migratePostgresSubstrate(dbUrl);
15
+ };
16
+ const dbEffect = (run) => Effect.tryPromise({
17
+ catch: (error) => error,
18
+ try: run
19
+ });
20
+ const rowToStored = (row) => parseStoredExecution({
21
+ attempt: row.attempt,
22
+ generation: row.generation,
23
+ nodeId: row.nodeId,
24
+ runId: row.runId,
25
+ status: row.status,
26
+ terminalizedAt: row.terminalizedAt.toISOString()
27
+ });
28
+ const readExecution = (db, runId, nodeId) => Effect.gen(function* body() {
29
+ const row = (yield* dbEffect(() => db.select().from(execution).where(and(eq(execution.runId, runId), eq(execution.nodeId, nodeId))).limit(1))).at(0);
30
+ return row === void 0 ? Option$1.none() : Option$1.some(rowToStored(row));
31
+ });
32
+ const recordTerminal = (db, request) => Effect.gen(function* body() {
33
+ const incoming = toStoredExecution(request);
34
+ const values = {
35
+ attempt: incoming.attempt,
36
+ generation: incoming.generation,
37
+ nodeId: incoming.nodeId,
38
+ runId: incoming.runId,
39
+ status: incoming.status,
40
+ terminalizedAt: new Date(incoming.terminalizedAt)
41
+ };
42
+ const appliedRow = (yield* dbEffect(() => db.insert(execution).values(values).onConflictDoUpdate({
43
+ set: {
44
+ attempt: values.attempt,
45
+ generation: values.generation,
46
+ status: values.status,
47
+ terminalizedAt: values.terminalizedAt
48
+ },
49
+ setWhere: or(lt(execution.generation, values.generation), and(eq(execution.generation, values.generation), lt(execution.attempt, values.attempt))),
50
+ target: [execution.runId, execution.nodeId]
51
+ }).returning())).at(0);
52
+ if (appliedRow !== void 0) return {
53
+ decision: "applied",
54
+ record: rowToStored(appliedRow)
55
+ };
56
+ const current = yield* readExecution(db, incoming.runId, incoming.nodeId);
57
+ const surviving = Option$1.getOrElse(current, () => incoming);
58
+ return {
59
+ decision: surviving.generation === incoming.generation && surviving.attempt === incoming.attempt ? "idempotent" : "rejected-stale",
60
+ record: surviving
61
+ };
62
+ });
63
+ const recordSubmitted = (db, runId, nodeId) => dbEffect(() => db.insert(executionSubmission).values({
64
+ nodeId,
65
+ runId
66
+ }).onConflictDoNothing()).pipe(Effect.asVoid);
67
+ const listSubmitted = (db, runId) => dbEffect(() => db.select({ nodeId: executionSubmission.nodeId }).from(executionSubmission).where(eq(executionSubmission.runId, runId))).pipe(Effect.map((rows) => HashSet.fromIterable(rows.map((row) => row.nodeId))));
68
+ const postgresExecutionStore = (dbUrl) => {
69
+ const client = openMokaSubstrateClient(dbUrl);
70
+ const db = drizzle(client);
71
+ return {
72
+ close: async () => {
73
+ await client.end();
74
+ },
75
+ getExecution: (input) => readExecution(db, input.runId, input.nodeId),
76
+ listSubmitted: (input) => listSubmitted(db, input.runId),
77
+ recordSubmitted: (input) => recordSubmitted(db, input.runId, input.nodeId),
78
+ recordTerminal: (request) => recordTerminal(db, request)
79
+ };
80
+ };
81
+ /**
82
+ * ENG-57.10: the scoped durable {@link ExecutionStore} the coordinator shares
83
+ * between `submit()` and the watch loop. `db.url` presence is the durable-substrate
84
+ * switch (mirroring {@link resolveRunControlStore}): set → the Postgres store,
85
+ * migrated then bound as a scoped resource whose connection is released on scope
86
+ * exit; absent → `db.url-required` before any store is bound. Because the store's
87
+ * connection is acquired via {@link Effect.acquireRelease}, closing the coordinator
88
+ * scope releases it exactly once — no leaked connection even if the watch fiber is
89
+ * interrupted.
90
+ */
91
+ const resolveExecutionStore = (dbUrl) => requireMokaDbUrl(dbUrl).pipe(Effect.flatMap((requiredDbUrl) => Effect.tryPromise({
92
+ catch: (error) => error,
93
+ try: async () => {
94
+ await migratePostgresExecutionStore(requiredDbUrl);
95
+ }
96
+ }).pipe(Effect.flatMap(() => Effect.acquireRelease(Effect.sync(() => postgresExecutionStore(requiredDbUrl)), (store) => Effect.tryPromise({
97
+ catch: (error) => error,
98
+ try: async () => {
99
+ await store.close();
100
+ }
101
+ }).pipe(Effect.orDie))))));
102
+ //#endregion
103
+ export { migratePostgresExecutionStore, postgresExecutionStore, resolveExecutionStore };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oisincoveney/pipeline",
3
- "version": "4.0.4",
3
+ "version": "4.1.1",
4
4
  "description": "Config-driven multi-agent pipeline runner for repository work",
5
5
  "keywords": [],
6
6
  "license": "ISC",