@effect-agent/platform-node 0.1.0-beta.9 → 0.1.0-beta.91

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/NodeDurableAgentRuntime-Cs_E9W_9.d.mts +175 -0
  2. package/dist/NodeDurableAgentRuntime.d.mts +2 -0
  3. package/dist/NodeDurableAgentRuntime.mjs +221 -0
  4. package/dist/NodeDurableAgentRuntime.mjs.map +1 -0
  5. package/dist/NodeDurableHost-CG2xAhfb.mjs +194 -0
  6. package/dist/NodeDurableHost-CG2xAhfb.mjs.map +1 -0
  7. package/dist/NodeDurableHost.d.mts +160 -0
  8. package/dist/NodeDurableHost.mjs +2 -0
  9. package/dist/NodeScheduling.d.mts +22 -0
  10. package/dist/NodeScheduling.mjs +59 -0
  11. package/dist/NodeScheduling.mjs.map +1 -0
  12. package/dist/NodeSubscriptions.d.mts +27 -0
  13. package/dist/NodeSubscriptions.mjs +74 -0
  14. package/dist/NodeSubscriptions.mjs.map +1 -0
  15. package/dist/NodeWakeScheduler-CCgm1t-G.d.mts +27 -0
  16. package/dist/NodeWakeScheduler.d.mts +2 -0
  17. package/dist/NodeWakeScheduler.mjs +65 -0
  18. package/dist/NodeWakeScheduler.mjs.map +1 -0
  19. package/dist/NodeWorkflow.d.mts +27 -0
  20. package/dist/NodeWorkflow.mjs +159 -0
  21. package/dist/NodeWorkflow.mjs.map +1 -0
  22. package/dist/index.d.mts +6 -214
  23. package/dist/index.mjs +6 -286
  24. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  25. package/package.json +1 -45
  26. package/src/NodeDurableAgentRuntime.ts +590 -0
  27. package/src/NodeDurableHost.ts +358 -0
  28. package/src/NodeScheduling.ts +117 -0
  29. package/src/NodeSubscriptions.ts +158 -0
  30. package/src/{wake-scheduler.ts → NodeWakeScheduler.ts} +32 -17
  31. package/src/NodeWorkflow.ts +258 -0
  32. package/src/index.ts +5 -3
  33. package/src/internal/message-delivery.ts +53 -0
  34. package/src/internal/prepared-admission.ts +81 -0
  35. package/dist/index.mjs.map +0 -1
  36. package/src/host.ts +0 -215
  37. package/src/layers.ts +0 -381
package/src/host.ts DELETED
@@ -1,215 +0,0 @@
1
- import type { ConversationId, SubmissionId } from "@effect-agent/core";
2
- import {
3
- AgentBindingResolver,
4
- DurableAgentRuntime,
5
- type AbortCommand,
6
- type AbortIntent,
7
- type CanonicalRecordEnvelope,
8
- type ConversationNotMaterialized,
9
- type ConversationStoreError,
10
- type DurableAbortFailure,
11
- type DurableAwaitFailure,
12
- type DurableBindingFailure,
13
- type DurableExplainFailure,
14
- type DurableObserveOptions,
15
- type DurableObligationFailure,
16
- type DurableRetryFailure,
17
- type DurableSubmitAgent,
18
- type DurableSubmitFailure,
19
- type DurableSubmitOptions,
20
- type DurableVerifyFailure,
21
- type DurableWorkerFailure,
22
- type IntegrityReport,
23
- type ObligationReport,
24
- type ObligationThresholds,
25
- type OperationDenied,
26
- type Receipt,
27
- type RecoveryExplanation,
28
- type RecoveryReport,
29
- type RetryCommand,
30
- type Settlement,
31
- } from "@effect-agent/session";
32
- import { Context, Effect, Layer, Ref, Schema, Stream } from "effect";
33
-
34
- import {
35
- NodeDurableRuntime,
36
- NodeDurableRuntimeConfig,
37
- type NodeDurableRuntimeInitializationError,
38
- type NodeDurableRuntimeOptions,
39
- type NodeDurableRuntimeServices,
40
- } from "./layers.ts";
41
-
42
- /**
43
- * Admission is not open on this host: it is shutting down (deployment §6 step 1, DEPLOY-005).
44
- * Accepted work is unaffected — only NEW admissions are refused.
45
- */
46
- export class AdmissionClosed extends Schema.TaggedError<AdmissionClosed>()("AdmissionClosed", {
47
- message: Schema.String,
48
- }) {}
49
-
50
- const makeHost = Effect.gen(function* () {
51
- const runtime = yield* DurableAgentRuntime;
52
- const config = yield* NodeDurableRuntimeConfig;
53
- const bindingResolver = yield* AgentBindingResolver;
54
-
55
- // Startup gate (deployment §5, plan §host): configuration decoding and storage compatibility
56
- // already gated this Layer's dependencies; the last gate before admission opens is recovering
57
- // EVERY nonterminal Submission. Work needing a live Agent Binding is reported `deferred` and
58
- // stays a visible obligation for `runWorkers`; lanes durably blocked on an Unknown Outcome are
59
- // reported `unknown` and wait for the authorized `resolveUnknown` path (DUR-017) — they consume
60
- // no worker permit while the settlement obligation stays owed.
61
- const startupRecovery = yield* runtime.runRecovery;
62
-
63
- const admission = yield* Ref.make(true);
64
- // Shutdown step 1 (DEPLOY-005): admission closes before the ownership drain in the runtime
65
- // Layer below releases claims and before the stores close in reverse acquisition order.
66
- yield* Effect.addFinalizer(() => Ref.set(admission, false));
67
-
68
- const requireAdmission: Effect.Effect<void, AdmissionClosed> = Ref.get(admission).pipe(
69
- Effect.flatMap((open) =>
70
- open
71
- ? Effect.void
72
- : Effect.fail(
73
- AdmissionClosed.make({ message: "The host is shutting down; admission is closed." }),
74
- ),
75
- ),
76
- );
77
-
78
- const submit = <InputSchema extends Schema.Top>(
79
- agent: DurableSubmitAgent<InputSchema>,
80
- input: InputSchema["Type"],
81
- options: DurableSubmitOptions,
82
- ): Effect.Effect<
83
- Receipt,
84
- AdmissionClosed | DurableSubmitFailure,
85
- InputSchema["EncodingServices"]
86
- > => requireAdmission.pipe(Effect.andThen(runtime.submit(agent, input, options)));
87
-
88
- const runWorkers = <A, E, R>(worker: Effect.Effect<A, E, R>): Effect.Effect<void, E, R> =>
89
- Effect.forEach(
90
- Array.from({ length: config.workerConcurrency }, (_, index) => index),
91
- () => worker,
92
- { concurrency: "unbounded", discard: true },
93
- );
94
-
95
- // S2 multi-binding pool: every claimed head resolves its exact registered Binding through
96
- // the host's `AgentBindingResolver` (`NodeDurableRuntimeOptions.bindings`), so ONE bounded
97
- // pool serves parent and child lanes — the spec §12 smallest-pool suspension/wakeup proof
98
- // runs `workerConcurrency: 1` over exactly this loop.
99
- const runResolvedWorkers = runWorkers(
100
- runtime.runResolvedWorker.pipe(Effect.provideService(AgentBindingResolver, bindingResolver)),
101
- );
102
-
103
- return NodeDurableHost.of({
104
- startupRecovery,
105
- admissionOpen: Ref.get(admission),
106
- submit,
107
- awaitSettlement: runtime.awaitSettlement,
108
- observe: runtime.observe,
109
- abort: runtime.abort,
110
- explain: runtime.explain,
111
- explainConversation: runtime.explainConversation,
112
- verify: runtime.verify,
113
- retry: runtime.retry,
114
- wake: runtime.wake,
115
- scanObligations: runtime.scanObligations,
116
- runWorkers,
117
- runResolvedWorkers,
118
- });
119
- });
120
-
121
- /**
122
- * Operational host lifecycle for the DN runtime (deployment §2/§5/§6).
123
- *
124
- * Startup gates run during Layer construction, so the service existing implies readiness:
125
- * configuration was schema-decoded, the SQLite file passed the exact-version compatibility check,
126
- * and every nonterminal Submission went through one full recovery pass BEFORE admission opened.
127
- * `startupRecovery` is the auditable evidence of that reconciliation pass.
128
- *
129
- * Shutdown runs in reverse Layer order when the owning Scope closes: `submit` starts refusing
130
- * with `AdmissionClosed` first, then the runtime Layer's ownership drain releases every claim
131
- * still held so another host can take over the lanes immediately, then the SQLite resources
132
- * close. Forced termination at any point stays safe — the durability protocol, not graceful
133
- * shutdown, provides correctness (DEPLOY-006).
134
- */
135
- export class NodeDurableHost extends Context.Service<
136
- NodeDurableHost,
137
- {
138
- /**
139
- * The recovery decisions executed (or deferred) by this host's startup reconciliation.
140
- * Reports with the `unknown` disposition identify lanes blocked on Unknown Outcomes that
141
- * only the authorized DUR-017 resolution path can release.
142
- */
143
- readonly startupRecovery: ReadonlyArray<RecoveryReport>;
144
- /** Admission-role readiness (deployment §7): true until shutdown begins. */
145
- readonly admissionOpen: Effect.Effect<boolean>;
146
- /** `DurableAgentRuntime.submit` behind the host admission gate. */
147
- readonly submit: <InputSchema extends Schema.Top>(
148
- agent: DurableSubmitAgent<InputSchema>,
149
- input: InputSchema["Type"],
150
- options: DurableSubmitOptions,
151
- ) => Effect.Effect<
152
- Receipt,
153
- AdmissionClosed | DurableSubmitFailure,
154
- InputSchema["EncodingServices"]
155
- >;
156
- readonly awaitSettlement: (receipt: Receipt) => Effect.Effect<Settlement, DurableAwaitFailure>;
157
- readonly observe: (
158
- receipt: Receipt,
159
- options?: DurableObserveOptions,
160
- ) => Stream.Stream<
161
- CanonicalRecordEnvelope,
162
- ConversationStoreError | ConversationNotMaterialized | OperationDenied
163
- >;
164
- readonly abort: (command: AbortCommand) => Effect.Effect<AbortIntent, DurableAbortFailure>;
165
- /** `DurableAgentRuntime.explain` — read-only recovery explanation of one Submission (P7). */
166
- readonly explain: (
167
- submissionId: SubmissionId,
168
- ) => Effect.Effect<RecoveryExplanation, DurableExplainFailure>;
169
- /** `DurableAgentRuntime.explainConversation` — explain every nonterminal lane member. */
170
- readonly explainConversation: (
171
- conversationId: ConversationId,
172
- ) => Effect.Effect<ReadonlyArray<RecoveryExplanation>, DurableExplainFailure>;
173
- /** `DurableAgentRuntime.verify` — read-only integrity checks, never a repair (P7). */
174
- readonly verify: (
175
- conversationId: ConversationId,
176
- ) => Effect.Effect<IntegrityReport, DurableVerifyFailure>;
177
- /** `DurableAgentRuntime.retry` — audited single-Submission re-drive with typed refusals. */
178
- readonly retry: (command: RetryCommand) => Effect.Effect<RecoveryReport, DurableRetryFailure>;
179
- /** `DurableAgentRuntime.wake` — the documented operator liveness nudge for one lane. */
180
- readonly wake: (conversationId: ConversationId) => Effect.Effect<void, OperationDenied>;
181
- /** `DurableAgentRuntime.scanObligations` — the scan-based DUR-017/OPS-001 report. */
182
- readonly scanObligations: (
183
- thresholds: ObligationThresholds,
184
- ) => Effect.Effect<ObligationReport, DurableObligationFailure>;
185
- /**
186
- * Run `workerConcurrency` copies of the given worker effect (typically
187
- * `DurableAgentRuntime.runWorker(agent)`) until the caller's Scope interrupts them. The
188
- * bound is the validated finite configuration value; the host never forks daemon fibers.
189
- */
190
- readonly runWorkers: <A, E, R>(worker: Effect.Effect<A, E, R>) => Effect.Effect<void, E, R>;
191
- /**
192
- * Run `workerConcurrency` copies of `DurableAgentRuntime.runResolvedWorker` over the host's
193
- * registered Bindings (S2): every claimed head resolves its exact stored Binding before any
194
- * code runs (SUB-023), so one bounded pool serves parent and attached-child lanes.
195
- */
196
- readonly runResolvedWorkers: Effect.Effect<void, DurableWorkerFailure | DurableBindingFailure>;
197
- }
198
- >()("@effect-agent/platform-node/NodeDurableHost") {
199
- /** Host gates over an already-assembled `NodeDurableRuntime` stack. */
200
- static readonly layer: Layer.Layer<
201
- NodeDurableHost,
202
- DurableWorkerFailure,
203
- DurableAgentRuntime | NodeDurableRuntimeConfig | AgentBindingResolver
204
- > = Layer.effect(NodeDurableHost)(makeHost);
205
-
206
- /** The complete DN host: `NodeDurableRuntime.layer(options)` plus the host lifecycle gates. */
207
- static layerStack(
208
- options: NodeDurableRuntimeOptions,
209
- ): Layer.Layer<
210
- NodeDurableHost | NodeDurableRuntimeServices,
211
- DurableWorkerFailure | NodeDurableRuntimeInitializationError
212
- > {
213
- return NodeDurableHost.layer.pipe(Layer.provideMerge(NodeDurableRuntime.layer(options)));
214
- }
215
- }
package/src/layers.ts DELETED
@@ -1,381 +0,0 @@
1
- import type { SubmissionId } from "@effect-agent/core";
2
- import {
3
- AgentBindingResolver,
4
- ConversationStore,
5
- DEFAULT_OWNERSHIP_LEASE_DURATION,
6
- DeploymentId,
7
- DurableAgentRuntime,
8
- DurableRuntimeConfig,
9
- DurableRuntimeFailpoint,
10
- ProducerId,
11
- ReleaseOwnershipRequest,
12
- SubmissionLedger,
13
- ToolReconciler,
14
- WakeScheduler,
15
- type DurableRuntimeFailpointHandler,
16
- type OwnershipToken,
17
- type ResolvedBinding,
18
- } from "@effect-agent/session";
19
- import {
20
- conversationStoreLayer,
21
- SqliteStorageConfig,
22
- SqliteStorageConfigValue,
23
- storageFailpointLayer,
24
- submissionLedgerLayer,
25
- type SqliteStorageFailpointHandler,
26
- type SqliteStorageInitializationError,
27
- } from "@effect-agent/storage-sqlite";
28
- import { NodeCrypto } from "@effect/platform-node";
29
- import { SqliteClient } from "@effect/sql-sqlite-node";
30
- import { Context, Duration, Effect, Layer, Ref, Schema } from "effect";
31
-
32
- import { NodeWakeSchedulerConfig, nodeWakeSchedulerLayer } from "./wake-scheduler.ts";
33
-
34
- const PositiveMillis = Schema.Int.check(Schema.isGreaterThan(0));
35
- const NonNegativeMillis = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
36
- const WorkerConcurrency = Schema.Int.check(
37
- Schema.isGreaterThanOrEqualTo(1),
38
- Schema.isLessThanOrEqualTo(64),
39
- );
40
-
41
- /** The supplied Node durable runtime configuration failed schema validation (DEPLOY-003). */
42
- export class NodePlatformConfigError extends Schema.TaggedError<NodePlatformConfigError>()(
43
- "NodePlatformConfigError",
44
- {
45
- message: Schema.String,
46
- cause: Schema.optionalKey(Schema.Defect()),
47
- },
48
- ) {}
49
-
50
- /**
51
- * Validated Node durable runtime configuration (deployment §4: decoded once during Layer
52
- * construction, exposed as a typed service). Every cadence is in milliseconds and every bound is
53
- * finite; `workerConcurrency` caps how many worker loops `NodeDurableHost.runWorkers` drives.
54
- */
55
- export class NodeDurableRuntimeConfigValue extends Schema.Class<NodeDurableRuntimeConfigValue>(
56
- "@effect-agent/platform-node/NodeDurableRuntimeConfigValue",
57
- )({
58
- /** SQLite database file backing BOTH the Conversation Log and the Submission Ledger. */
59
- filename: Schema.NonEmptyString,
60
- deploymentId: DeploymentId,
61
- producerId: ProducerId,
62
- /** Submission ownership lease duration (D5); liveness hint only, epochs stay authoritative. */
63
- ownershipLeaseDuration: PositiveMillis,
64
- /** Finite bound on concurrent worker loops per host (rule 10). */
65
- workerConcurrency: WorkerConcurrency,
66
- /** Ledger-scan fallback cadence of the Node wake scheduler (deployment §3). */
67
- wakeScanInterval: PositiveMillis,
68
- /** `awaitSettlement` ledger re-check cadence when no wake arrives. */
69
- settlementPollInterval: PositiveMillis,
70
- /** Worker ownership-lease renewal cadence. */
71
- leaseRenewalInterval: PositiveMillis,
72
- /** Active-Run abort-intent poll cadence. */
73
- abortPollInterval: PositiveMillis,
74
- /** Bounded SQLITE_BUSY retry window for write-lock acquisition. */
75
- busyTimeout: NonNegativeMillis,
76
- /** Canonical observation poll cadence of the SQLite store. */
77
- observationPollInterval: NonNegativeMillis,
78
- /** Opt-in full payload/digest-chain audit while opening the store. */
79
- verifyOnOpen: Schema.Boolean,
80
- }) {}
81
-
82
- /** Explicit configuration authority for the assembled Node durable runtime. */
83
- export class NodeDurableRuntimeConfig extends Context.Service<
84
- NodeDurableRuntimeConfig,
85
- NodeDurableRuntimeConfigValue
86
- >()("@effect-agent/platform-node/NodeDurableRuntimeConfig") {}
87
-
88
- /**
89
- * Raw (unvalidated) construction options for `NodeDurableRuntime.layer`. Optional fields default
90
- * to the documented production values; everything is schema-decoded into
91
- * `NodeDurableRuntimeConfigValue` before any resource opens (deployment §5 gate 1).
92
- */
93
- export interface NodeDurableRuntimeOptions {
94
- readonly filename: string;
95
- readonly deploymentId: string;
96
- readonly producerId: string;
97
- /** Milliseconds; default `DEFAULT_OWNERSHIP_LEASE_DURATION` (30s, D5). */
98
- readonly ownershipLeaseDuration?: number | undefined;
99
- /** Default 1; bounded to 1..64. */
100
- readonly workerConcurrency?: number | undefined;
101
- /** Milliseconds; default 1000. */
102
- readonly wakeScanInterval?: number | undefined;
103
- /** Milliseconds; default 500. */
104
- readonly settlementPollInterval?: number | undefined;
105
- /** Milliseconds; default 10000. */
106
- readonly leaseRenewalInterval?: number | undefined;
107
- /** Milliseconds; default 500. */
108
- readonly abortPollInterval?: number | undefined;
109
- /** Milliseconds; default 5000. */
110
- readonly busyTimeout?: number | undefined;
111
- /** Milliseconds; default 25. */
112
- readonly observationPollInterval?: number | undefined;
113
- /** Default false. */
114
- readonly verifyOnOpen?: boolean | undefined;
115
- /** SQLite adapter fault injection (`ledger:*` / `append:*` locations); default none. */
116
- readonly storageFailpoint?: SqliteStorageFailpointHandler | undefined;
117
- /** Coordinator fault injection (`submit:*` / `terminalize:*` locations); default none. */
118
- readonly runtimeFailpoint?: DurableRuntimeFailpointHandler | undefined;
119
- /**
120
- * Reconciliation policy consulted for open ordinary Tool Calls before an Unknown Outcome is
121
- * recorded (durability §10, DUR-009). Defaults to the fail-closed `ToolReconciler.uncertain`:
122
- * with no registered policy, every open call stays Unknown and routes to the authorized
123
- * DUR-017 resolution path.
124
- */
125
- readonly toolReconciler?: Layer.Layer<ToolReconciler> | undefined;
126
- /**
127
- * Registered worker Bindings resolved at durable claim time (S2, spec/subagents.md §11):
128
- * build each with `DurableWorkerBinding.make(binding, digests)` so
129
- * `NodeDurableHost.runResolvedWorkers` / `DurableAgentRuntime.runResolvedWorker` can serve
130
- * parent and child lanes from one pool with exact-digest resolution (SUB-023). Defaults to
131
- * the empty registration: every resolved claim then fails closed (`BindingUnavailable` for a
132
- * root, the framework `ChildCompatibilityFailure` Settlement for a parent-linked child).
133
- */
134
- readonly bindings?: ReadonlyArray<ResolvedBinding> | undefined;
135
- }
136
-
137
- /** Every construction failure of the assembled Node durable runtime stack. */
138
- export type NodeDurableRuntimeInitializationError =
139
- | NodePlatformConfigError
140
- | SqliteStorageInitializationError;
141
-
142
- /** The services `NodeDurableRuntime.layer` provides. */
143
- export type NodeDurableRuntimeServices =
144
- | DurableAgentRuntime
145
- | SubmissionLedger
146
- | ConversationStore
147
- | WakeScheduler
148
- | DurableRuntimeConfig
149
- | NodeDurableRuntimeConfig
150
- | AgentBindingResolver;
151
-
152
- const decodeConfigValue = Schema.decodeUnknownEffect(NodeDurableRuntimeConfigValue);
153
-
154
- const configFromOptions = (
155
- options: NodeDurableRuntimeOptions,
156
- ): Effect.Effect<NodeDurableRuntimeConfigValue, NodePlatformConfigError> =>
157
- decodeConfigValue({
158
- filename: options.filename,
159
- deploymentId: options.deploymentId,
160
- producerId: options.producerId,
161
- ownershipLeaseDuration:
162
- options.ownershipLeaseDuration ?? Duration.toMillis(DEFAULT_OWNERSHIP_LEASE_DURATION),
163
- workerConcurrency: options.workerConcurrency ?? 1,
164
- wakeScanInterval: options.wakeScanInterval ?? 1_000,
165
- settlementPollInterval: options.settlementPollInterval ?? 500,
166
- leaseRenewalInterval: options.leaseRenewalInterval ?? 10_000,
167
- abortPollInterval: options.abortPollInterval ?? 500,
168
- busyTimeout: options.busyTimeout ?? 5_000,
169
- observationPollInterval: options.observationPollInterval ?? 25,
170
- verifyOnOpen: options.verifyOnOpen ?? false,
171
- }).pipe(
172
- Effect.mapError((error) =>
173
- NodePlatformConfigError.make({
174
- message: `Invalid Node durable runtime configuration: ${error.message}`,
175
- cause: error,
176
- }),
177
- ),
178
- );
179
-
180
- /** SQLite storage configuration derived from the single validated Node configuration. */
181
- const sqliteStorageConfigLayer: Layer.Layer<SqliteStorageConfig, never, NodeDurableRuntimeConfig> =
182
- Layer.effect(SqliteStorageConfig)(
183
- Effect.gen(function* () {
184
- const config = yield* NodeDurableRuntimeConfig;
185
- return SqliteStorageConfigValue.make({
186
- observationPollInterval: config.observationPollInterval,
187
- busyTimeout: config.busyTimeout,
188
- ownershipLeaseDuration: config.ownershipLeaseDuration,
189
- verifyOnOpen: config.verifyOnOpen,
190
- });
191
- }),
192
- );
193
-
194
- /** Session coordinator configuration derived from the single validated Node configuration. */
195
- const durableRuntimeConfigLayer: Layer.Layer<
196
- DurableRuntimeConfig,
197
- never,
198
- NodeDurableRuntimeConfig
199
- > = Layer.effect(DurableRuntimeConfig)(
200
- Effect.gen(function* () {
201
- const config = yield* NodeDurableRuntimeConfig;
202
- return DurableRuntimeConfig.make({
203
- deploymentId: config.deploymentId,
204
- producerId: config.producerId,
205
- settlementPollInterval: Duration.millis(config.settlementPollInterval),
206
- leaseRenewalInterval: Duration.millis(config.leaseRenewalInterval),
207
- abortPollInterval: Duration.millis(config.abortPollInterval),
208
- });
209
- }),
210
- );
211
-
212
- /** Wake fallback-scan cadence derived from the single validated Node configuration. */
213
- const wakeSchedulerConfigLayer: Layer.Layer<
214
- NodeWakeSchedulerConfig,
215
- never,
216
- NodeDurableRuntimeConfig
217
- > = Layer.effect(NodeWakeSchedulerConfig)(
218
- Effect.gen(function* () {
219
- const config = yield* NodeDurableRuntimeConfig;
220
- return { scanInterval: Duration.millis(config.wakeScanInterval) };
221
- }),
222
- );
223
-
224
- const releaseTrackedOwnership = (
225
- ledger: SubmissionLedger["Service"],
226
- registry: Ref.Ref<ReadonlyMap<SubmissionId, OwnershipToken>>,
227
- ): Effect.Effect<void> =>
228
- Effect.gen(function* () {
229
- const tracked = yield* Ref.getAndSet(registry, new Map<SubmissionId, OwnershipToken>());
230
- for (const [submissionId, ownershipToken] of tracked) {
231
- yield* ledger
232
- .releaseOwnership(ReleaseOwnershipRequest.make({ submissionId, ownershipToken }))
233
- .pipe(
234
- Effect.catchTags({
235
- // A newer epoch already owns (or settled) the lane: nothing left to drain.
236
- OwnershipLost: () => Effect.void,
237
- // Drain is best-effort by design: the lease still expires and the durability protocol,
238
- // not graceful shutdown, provides correctness (DEPLOY-006).
239
- LedgerError: (error) =>
240
- Effect.logWarning("Ownership drain failed; the lease will expire instead", error),
241
- }),
242
- );
243
- }
244
- });
245
-
246
- /**
247
- * Shutdown-drain decorator for a `SubmissionLedger` (deployment §6 step 6): every ownership
248
- * period granted through this Layer is tracked — claims start tracking, renewals follow token
249
- * rotation, releases and settlement finalizations stop it — and every ownership still held when
250
- * the Layer's Scope closes is released so another host can claim the lane immediately instead of
251
- * waiting for lease expiry. The drain is a liveness courtesy only; producer-epoch fencing remains
252
- * the correctness authority (DUR-006), and a forced kill simply falls back to lease expiry.
253
- */
254
- export const ownershipDrainLayer: Layer.Layer<SubmissionLedger, never, SubmissionLedger> =
255
- Layer.effect(SubmissionLedger)(
256
- Effect.gen(function* () {
257
- const ledger = yield* SubmissionLedger;
258
- const registry = yield* Ref.make<ReadonlyMap<SubmissionId, OwnershipToken>>(
259
- new Map<SubmissionId, OwnershipToken>(),
260
- );
261
-
262
- const track = (submissionId: SubmissionId, ownershipToken: OwnershipToken) =>
263
- Ref.update(registry, (tracked) => new Map(tracked).set(submissionId, ownershipToken));
264
- const untrack = (submissionId: SubmissionId) =>
265
- Ref.update(registry, (tracked) => {
266
- const next = new Map(tracked);
267
- next.delete(submissionId);
268
- return next;
269
- });
270
-
271
- yield* Effect.addFinalizer(() => releaseTrackedOwnership(ledger, registry));
272
-
273
- return SubmissionLedger.of({
274
- capabilities: ledger.capabilities,
275
- admit: ledger.admit,
276
- markReady: ledger.markReady,
277
- lookup: ledger.lookup,
278
- // The S2 subagent ops forward untouched: none of them grants an ownership period, so
279
- // the drain has nothing to track for them (`suspend` below already stops tracking the
280
- // waitingForChild ownership period the moment it ends).
281
- resolveAdmission: ledger.resolveAdmission,
282
- recordChildSettled: ledger.recordChildSettled,
283
- reserveChildBudget: ledger.reserveChildBudget,
284
- attachChildToReservation: ledger.attachChildToReservation,
285
- beginChildBudgetRelease: ledger.beginChildBudgetRelease,
286
- releaseChildBudget: ledger.releaseChildBudget,
287
- claim: (request) =>
288
- ledger
289
- .claim(request)
290
- .pipe(
291
- Effect.tap((claimed) =>
292
- claimed._tag === "Some"
293
- ? track(claimed.value.submissionId, claimed.value.ownershipToken)
294
- : Effect.void,
295
- ),
296
- ),
297
- renewOwnership: (request) =>
298
- ledger.renewOwnership(request).pipe(
299
- Effect.tap((renewal) => track(request.submissionId, renewal.ownershipToken)),
300
- Effect.tapError((error) =>
301
- error._tag === "OwnershipLost" ? untrack(request.submissionId) : Effect.void,
302
- ),
303
- ),
304
- releaseOwnership: (request) =>
305
- ledger.releaseOwnership(request).pipe(
306
- Effect.tap(() => untrack(request.submissionId)),
307
- Effect.tapError((error) =>
308
- error._tag === "OwnershipLost" ? untrack(request.submissionId) : Effect.void,
309
- ),
310
- ),
311
- markInputApplied: ledger.markInputApplied,
312
- reserveSettlement: ledger.reserveSettlement,
313
- finalizeSettlement: (request) =>
314
- ledger.finalizeSettlement(request).pipe(Effect.tap(() => untrack(request.submissionId))),
315
- requestAbort: ledger.requestAbort,
316
- claimJoining: ledger.claimJoining,
317
- markJoined: ledger.markJoined,
318
- revertJoining: ledger.revertJoining,
319
- // Suspension ends the ownership period by contract, so the drain stops tracking it.
320
- suspend: (request) =>
321
- ledger.suspend(request).pipe(Effect.tap(() => untrack(request.submissionId))),
322
- recordApprovalDecision: ledger.recordApprovalDecision,
323
- markUnknown: ledger.markUnknown,
324
- recordUnknownResolution: ledger.recordUnknownResolution,
325
- scanNonterminal: ledger.scanNonterminal,
326
- loadRecoverySnapshot: ledger.loadRecoverySnapshot,
327
- });
328
- }),
329
- );
330
-
331
- /**
332
- * The DN Layer assembly (deployment §12: a Layer-assembly library, not an app entrypoint).
333
- * `layer(options)` decodes the configuration, opens ONE SQLite database serving both the
334
- * Conversation Log and the Submission Ledger (so claims fence the same producer epochs), wires
335
- * the Node wake scheduler with its ledger-scan fallback, wraps the ledger with the shutdown
336
- * ownership drain, defaults the Tool reconciliation policy to the fail-closed
337
- * `ToolReconciler.uncertain` (override via `options.toolReconciler`), and provides a ready
338
- * `DurableAgentRuntime` on top. Storage compatibility is
339
- * verified during construction: an incompatible database file fails the Layer with
340
- * `SqliteStorageCompatibilityError` before anything is mutated (DEPLOY-008).
341
- */
342
- export class NodeDurableRuntime {
343
- /** Validated configuration Layer; fails typed when the supplied options are out of bounds. */
344
- static configLayer(
345
- options: NodeDurableRuntimeOptions,
346
- ): Layer.Layer<NodeDurableRuntimeConfig, NodePlatformConfigError> {
347
- return Layer.effect(NodeDurableRuntimeConfig)(configFromOptions(options));
348
- }
349
-
350
- /** The full DN runtime stack over one SQLite file. */
351
- static layer(
352
- options: NodeDurableRuntimeOptions,
353
- ): Layer.Layer<NodeDurableRuntimeServices, NodeDurableRuntimeInitializationError> {
354
- const infrastructure = Layer.mergeAll(
355
- sqliteStorageConfigLayer,
356
- storageFailpointLayer({ filename: options.filename, failpoint: options.storageFailpoint }),
357
- SqliteClient.layer({ filename: options.filename }),
358
- NodeCrypto.layer,
359
- );
360
- const runtimeFailpointLayer =
361
- options.runtimeFailpoint === undefined
362
- ? DurableRuntimeFailpoint.layer
363
- : Layer.succeed(DurableRuntimeFailpoint)({ hit: options.runtimeFailpoint });
364
- const reconcilerLayer = options.toolReconciler ?? ToolReconciler.uncertain;
365
- const bindingResolverLayer = AgentBindingResolver.layer(options.bindings ?? []);
366
- const ports = Layer.mergeAll(
367
- conversationStoreLayer,
368
- nodeWakeSchedulerLayer.pipe(
369
- Layer.provideMerge(ownershipDrainLayer.pipe(Layer.provide(submissionLedgerLayer))),
370
- ),
371
- );
372
- return DurableAgentRuntime.layer.pipe(
373
- Layer.provideMerge(Layer.mergeAll(ports, durableRuntimeConfigLayer, bindingResolverLayer)),
374
- Layer.provide(
375
- Layer.mergeAll(wakeSchedulerConfigLayer, runtimeFailpointLayer, reconcilerLayer),
376
- ),
377
- Layer.provideMerge(infrastructure),
378
- Layer.provideMerge(NodeDurableRuntime.configLayer(options)),
379
- );
380
- }
381
- }