@effect-agent/platform-cloudflare 0.1.0-beta.51 → 0.1.0-beta.53

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/src/Alarm.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  } from "@effect-agent/thread/DurableAgentRuntime";
7
7
  import { SubmissionLedger, type SubmissionSnapshot } from "@effect-agent/thread/SubmissionLedger";
8
8
  import {
9
+ Cause,
9
10
  Clock,
10
11
  Context,
11
12
  DateTime,
@@ -157,7 +158,7 @@ export class DurableAlarmService extends Context.Service<
157
158
  export class MaintenancePassReport extends Schema.Class<MaintenancePassReport>(
158
159
  "@effect-agent/platform-cloudflare/MaintenancePassReport",
159
160
  )({
160
- /** `caught-up` is generation-only; `actionable` ran recovery and at most one head Attempt. */
161
+ /** `caught-up` ran no runtime work (publication may be pending); `actionable` ran recovery. */
161
162
  phase: Schema.Literals(["caught-up", "actionable"]),
162
163
  /** Recovery decisions executed (or deferred) BEFORE any new claim in this pass. */
163
164
  recovered: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
@@ -196,6 +197,53 @@ export class ThreadMaintenanceFailpoint extends Context.Service<
196
197
  static readonly layer = Layer.succeed(this)({ hit: () => Effect.void });
197
198
  }
198
199
 
200
+ /**
201
+ * Durable host publication of canonical records and ledger approval/abort/resolution intents.
202
+ * The host owns schema-versioned cursors, destination idempotency and acknowledgement. Delivery
203
+ * is at least once. Hooks must not write the alarm slot or mutate the supplied raw source ports.
204
+ *
205
+ * `invalidate`, `prepareGeneration` and `pendingDeadline` must be bounded local operations.
206
+ * `prepareGeneration` durably invalidates a scan only when its generation changes; repeated
207
+ * calls must preserve partial scan progress. It runs with no source mutation in flight.
208
+ * `drain` performs bounded delivery and persists retries before returning. A pending deadline
209
+ * defers runtime recovery/Attempts, allowing committed host publications to drain first.
210
+ * Unexpected hook failures leave the prearmed generation for retry. Hooks acquire per-call
211
+ * resources with Effect.scoped; Layer construction owns incarnation resources (eviction need
212
+ * not run finalizers). Do not hold a local hook behind network I/O or call back into producers.
213
+ */
214
+ export interface ThreadPublicationService {
215
+ readonly invalidate: Effect.Effect<void, DurableAlarmError>;
216
+ readonly prepareGeneration: (generation: bigint) => Effect.Effect<void, DurableAlarmError>;
217
+ readonly drain: Effect.Effect<void, DurableAlarmError>;
218
+ readonly pendingDeadline: Effect.Effect<Option.Option<number>, DurableAlarmError>;
219
+ }
220
+
221
+ /** Opt in with `ThreadObject.layer(registrations, { publication: Layer.effect(ThreadPublication)(...) })`. */
222
+ export class ThreadPublication extends Context.Service<
223
+ ThreadPublication,
224
+ ThreadPublicationService
225
+ >()("@effect-agent/platform-cloudflare/ThreadPublication") {
226
+ static readonly layer = Layer.succeed(this)({
227
+ invalidate: Effect.void,
228
+ prepareGeneration: () => Effect.void,
229
+ drain: Effect.void,
230
+ pendingDeadline: Effect.succeed(Option.none()),
231
+ });
232
+ }
233
+
234
+ /** @internal A committed source operation must not become a failed operation because delivery failed. */
235
+ export const publishCommitted = Effect.gen(function* () {
236
+ const publication = yield* ThreadPublication;
237
+
238
+ yield* publication.invalidate.pipe(Effect.andThen(publication.drain));
239
+ }).pipe(
240
+ Effect.catchCause((cause) =>
241
+ Cause.hasInterrupts(cause)
242
+ ? Effect.interrupt
243
+ : Effect.logError("Thread publication deferred after source commit", cause),
244
+ ),
245
+ );
246
+
199
247
  const MaintenanceGeneration = Schema.BigIntFromString.check(
200
248
  Schema.isGreaterThanOrEqualToBigInt(0n),
201
249
  );
@@ -271,6 +319,84 @@ const stableExternalWait = (
271
319
  }
272
320
  };
273
321
 
322
+ /**
323
+ * Shared prearm/acknowledgement boundary for ingress and runtime-owned producers.
324
+ * `ThreadObject.layer` provides this same instance in its Services. Rebuilt runtime/maintenance
325
+ * Layers must reuse that instance; a second gate cannot observe the native producers' activity.
326
+ */
327
+ export class ThreadMutationGate extends Context.Service<
328
+ ThreadMutationGate,
329
+ {
330
+ readonly withMutation: <A, E, R>(
331
+ body: Effect.Effect<A, E, R>,
332
+ ) => Effect.Effect<A, E | DurableAlarmError, R>;
333
+ readonly withSnapshot: <A, E, R>(
334
+ body: (active: number) => Effect.Effect<A, E, R>,
335
+ ) => Effect.Effect<A, E, R>;
336
+ }
337
+ >()("@effect-agent/platform-cloudflare/internal/ThreadMutationGate") {
338
+ static readonly layer = Layer.effect(this)(
339
+ Effect.gen(function* () {
340
+ const { ctx } = yield* DurableObjectContext;
341
+ const config = yield* CloudflareDurableRuntimeConfig;
342
+ const failpoint = yield* ThreadMaintenanceFailpoint;
343
+ // A fresh incarnation has no live mutations; durable generations survive eviction.
344
+ const activeMutations = yield* Ref.make(0);
345
+ const generationGate = yield* Semaphore.make(1);
346
+ const minimumAlarmDelay = Math.max(1, Math.ceil(config.alarmBackoffBase / 2));
347
+
348
+ const runTransaction = <A>(operation: string, transaction: () => Promise<A>) =>
349
+ Effect.tryPromise({ try: transaction, catch: alarmFailure(operation) });
350
+
351
+ const beginMutation = Effect.fn("ThreadMaintenance.beginMutation")(function* () {
352
+ yield* failpoint.hit("maintenance:dirty:before");
353
+ const now = yield* Clock.currentTimeMillis;
354
+
355
+ yield* runTransaction("advance maintenance generation", () =>
356
+ ctx.storage.transaction(async (transaction) => {
357
+ const { state } = await readMaintenanceState(transaction);
358
+
359
+ const next = ThreadMaintenanceState.make({
360
+ ...state,
361
+ dirty: state.dirty + 1n,
362
+ });
363
+
364
+ await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(next));
365
+ // The earliest configured retry bounds a newly actionable mutation without relying
366
+ // on its best-effort immediate wake hint.
367
+ await ensureTransactionAlarmBy(transaction, now + minimumAlarmDelay);
368
+ }),
369
+ );
370
+ yield* failpoint.hit("maintenance:dirty:after");
371
+ yield* Ref.update(activeMutations, (active) => active + 1);
372
+ });
373
+
374
+ const endMutation = generationGate.withPermit(
375
+ Ref.update(activeMutations, (active) => Math.max(0, active - 1)),
376
+ );
377
+
378
+ const withMutation = <A, E, R>(
379
+ body: Effect.Effect<A, E, R>,
380
+ ): Effect.Effect<A, E | DurableAlarmError, R> =>
381
+ Effect.acquireUseRelease(
382
+ generationGate.withPermit(beginMutation()),
383
+ () =>
384
+ failpoint.hit("maintenance:mutation:armed").pipe(
385
+ Effect.andThen(body),
386
+ Effect.tap(() => failpoint.hit("maintenance:mutation:finished")),
387
+ ),
388
+ () => endMutation,
389
+ );
390
+
391
+ return ThreadMutationGate.of({
392
+ withMutation,
393
+ withSnapshot: (body) =>
394
+ generationGate.withPermit(Effect.flatMap(Ref.get(activeMutations), body)),
395
+ });
396
+ }),
397
+ );
398
+ }
399
+
274
400
  export type MaintenancePassFailure =
275
401
  | DurableWorkerFailure
276
402
  | DurableBindingFailure
@@ -313,6 +439,8 @@ export class ThreadMaintenance extends Context.Service<
313
439
  static readonly layer: Layer.Layer<
314
440
  ThreadMaintenance,
315
441
  never,
442
+ | ThreadMutationGate
443
+ | ThreadPublication
316
444
  | DurableAgentRuntime
317
445
  | SubmissionLedger
318
446
  | DurableAlarmService
@@ -335,68 +463,13 @@ export class ThreadMaintenance extends Context.Service<
335
463
  * restarts at zero and merely re-arms sooner than a long-lived one would have.
336
464
  */
337
465
  const stalls = yield* Ref.make(0);
338
- /**
339
- * Incarnation-local mutation count guarded with the generation transactions below. It is
340
- * deliberately not durable: after eviction every begun mutation has stopped, while its
341
- * pre-armed dirty generation remains durable for recovery. The short gate never spans the
342
- * caller's mutation or cross-Object I/O.
343
- */
344
- const activeMutations = yield* Ref.make(0);
345
- const generationGate = yield* Semaphore.make(1);
346
- // At-least-once deliveries are idempotent, but overlapping pass bodies could otherwise
347
- // acknowledge state while a sibling pass is still mutating it. Port/RPC mutations do not
348
- // take this permit, so cross-Object I/O cannot deadlock the maintenance serialization.
466
+ const mutations = yield* ThreadMutationGate;
467
+ const publication = yield* ThreadPublication;
349
468
  const maintenancePassGate = yield* Semaphore.make(1);
350
469
  const minimumAlarmDelay = Math.max(1, Math.ceil(config.alarmBackoffBase / 2));
351
470
 
352
- const runTransaction = <A>(
353
- operation: string,
354
- transaction: () => Promise<A>,
355
- ): Effect.Effect<A, DurableAlarmError> =>
356
- Effect.tryPromise({
357
- try: transaction,
358
- catch: alarmFailure(operation),
359
- });
360
-
361
- const beginMutation = Effect.fn("ThreadMaintenance.beginMutation")(function* () {
362
- yield* failpoint.hit("maintenance:dirty:before");
363
- const now = yield* Clock.currentTimeMillis;
364
-
365
- yield* runTransaction("advance maintenance generation", () =>
366
- ctx.storage.transaction(async (transaction) => {
367
- const { state } = await readMaintenanceState(transaction);
368
-
369
- const next = ThreadMaintenanceState.make({
370
- ...state,
371
- dirty: state.dirty + 1n,
372
- });
373
-
374
- await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(next));
375
- // The earliest configured retry bounds a newly actionable mutation without relying
376
- // on its best-effort immediate wake hint.
377
- await ensureTransactionAlarmBy(transaction, now + minimumAlarmDelay);
378
- }),
379
- );
380
- yield* failpoint.hit("maintenance:dirty:after");
381
- yield* Ref.update(activeMutations, (active) => active + 1);
382
- });
383
-
384
- const endMutation = generationGate.withPermit(
385
- Ref.update(activeMutations, (active) => Math.max(0, active - 1)),
386
- );
387
-
388
- const withMutation = <A, E, R>(
389
- body: Effect.Effect<A, E, R>,
390
- ): Effect.Effect<A, E | DurableAlarmError, R> =>
391
- Effect.acquireUseRelease(
392
- generationGate.withPermit(beginMutation()),
393
- () =>
394
- failpoint.hit("maintenance:mutation:armed").pipe(
395
- Effect.andThen(body),
396
- Effect.tap(() => failpoint.hit("maintenance:mutation:finished")),
397
- ),
398
- () => endMutation,
399
- );
471
+ const runTransaction = <A>(operation: string, transaction: () => Promise<A>) =>
472
+ Effect.tryPromise({ try: transaction, catch: alarmFailure(operation) });
400
473
 
401
474
  const ensureAlarm = Effect.fn("ThreadMaintenance.ensureAlarm")(function* () {
402
475
  yield* failpoint.hit("maintenance:ensure:before");
@@ -414,6 +487,18 @@ export class ThreadMaintenance extends Context.Service<
414
487
  }
415
488
  }),
416
489
  );
490
+ const deadline = yield* publication.pendingDeadline;
491
+
492
+ if (Option.isSome(deadline)) {
493
+ yield* runTransaction("ensure publication alarm", () =>
494
+ ctx.storage.transaction((transaction) =>
495
+ ensureTransactionAlarmBy(
496
+ transaction,
497
+ Math.max(now + minimumAlarmDelay, deadline.value),
498
+ ),
499
+ ),
500
+ );
501
+ }
417
502
  yield* failpoint.hit("maintenance:ensure:after");
418
503
  });
419
504
 
@@ -429,7 +514,8 @@ export class ThreadMaintenance extends Context.Service<
429
514
  await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(state));
430
515
  }
431
516
  if (state.processed >= state.dirty) {
432
- await transaction.deleteAlarm();
517
+ // Prearm even a publication-only pass before invoking any host hook.
518
+ await ensureTransactionAlarmBy(transaction, now + minimumAlarmDelay);
433
519
 
434
520
  return { _tag: "CaughtUp" as const, nonterminal: state.nonterminal };
435
521
  }
@@ -437,7 +523,11 @@ export class ThreadMaintenance extends Context.Service<
437
523
  // LATER to its bounded backoff, which does not cancel the running handler.
438
524
  await ensureTransactionAlarmBy(transaction, now + minimumAlarmDelay);
439
525
 
440
- return { _tag: "Actionable" as const, generation: state.dirty };
526
+ return {
527
+ _tag: "Actionable" as const,
528
+ generation: state.dirty,
529
+ nonterminal: state.nonterminal,
530
+ };
441
531
  }),
442
532
  );
443
533
 
@@ -474,23 +564,74 @@ export class ThreadMaintenance extends Context.Service<
474
564
  alarm: report.alarm,
475
565
  }).pipe(Effect.as(report));
476
566
 
477
- const started = yield* generationGate.withPermit(
567
+ const started = yield* mutations.withSnapshot((activeAtStart) =>
478
568
  Effect.gen(function* () {
479
- const activeAtStart = yield* Ref.get(activeMutations);
480
569
  const generation = yield* beginPass();
481
570
 
571
+ if (generation._tag === "Actionable" && activeAtStart === 0) {
572
+ // The gate excludes a producer starting between the snapshot and certification.
573
+ yield* publication.prepareGeneration(generation.generation);
574
+ }
575
+
482
576
  return { ...generation, activeAtStart };
483
577
  }),
484
578
  );
485
579
 
486
- if (started._tag === "CaughtUp") {
580
+ const deadline = yield* publication.pendingDeadline;
581
+
582
+ if (
583
+ started._tag === "Actionable" ||
584
+ (Option.isSome(deadline) && deadline.value <= (yield* Clock.currentTimeMillis))
585
+ ) {
586
+ yield* publication.drain;
587
+ }
588
+ const pending = yield* publication.pendingDeadline;
589
+
590
+ if (started._tag === "CaughtUp" || Option.isSome(pending)) {
591
+ yield* failpoint.hit("maintenance:finish:before");
592
+
593
+ const disposition = yield* mutations.withSnapshot((active) =>
594
+ Effect.gen(function* () {
595
+ // Re-read under the producer gate: a concurrent append/host mutation cannot be
596
+ // cleared using a stale empty deadline. Dirty generations bound all producer races.
597
+ const latest = yield* publication.pendingDeadline;
598
+ const now = yield* Clock.currentTimeMillis;
599
+
600
+ return yield* runTransaction("finish publication pass", () =>
601
+ ctx.storage.transaction(async (transaction) => {
602
+ const { state } = await readMaintenanceState(transaction);
603
+
604
+ const nativeDeadline =
605
+ active > 0 || state.dirty > state.processed
606
+ ? now + config.wakeScanInterval
607
+ : Infinity;
608
+
609
+ const next = Option.isSome(latest)
610
+ ? Math.min(nativeDeadline, latest.value)
611
+ : nativeDeadline;
612
+
613
+ if (Number.isFinite(next)) {
614
+ await transaction.setAlarm(Math.max(now + minimumAlarmDelay, next));
615
+
616
+ return "rearmed" as const;
617
+ }
618
+ await transaction.deleteAlarm();
619
+
620
+ return "cleared" as const;
621
+ }),
622
+ );
623
+ }),
624
+ );
625
+
626
+ yield* failpoint.hit("maintenance:finish:after");
627
+
487
628
  return yield* annotate(
488
629
  MaintenancePassReport.make({
489
630
  phase: "caught-up",
490
631
  recovered: 0,
491
632
  settled: 0,
492
633
  nonterminal: started.nonterminal,
493
- alarm: "cleared",
634
+ alarm: disposition,
494
635
  }),
495
636
  );
496
637
  }
@@ -528,9 +669,9 @@ export class ThreadMaintenance extends Context.Service<
528
669
 
529
670
  yield* failpoint.hit("maintenance:finish:before");
530
671
 
531
- const alarmDisposition = yield* generationGate.withPermit(
672
+ const alarmDisposition = yield* mutations.withSnapshot((active) =>
532
673
  Effect.gen(function* () {
533
- const active = yield* Ref.get(activeMutations);
674
+ const publicationDeadline = yield* publication.pendingDeadline;
534
675
 
535
676
  return yield* runTransaction("finish maintenance pass", () =>
536
677
  ctx.storage.transaction(async (transaction) => {
@@ -556,7 +697,14 @@ export class ThreadMaintenance extends Context.Service<
556
697
  // Replace the crash-fallback slot with this pass's bounded backoff. The target
557
698
  // is never earlier than the begin-pass fallback, so workerd does not cancel
558
699
  // this running alarm handler before its report/span can complete.
559
- await transaction.setAlarm(now + delay);
700
+ await transaction.setAlarm(
701
+ Option.isSome(publicationDeadline)
702
+ ? Math.max(
703
+ now + minimumAlarmDelay,
704
+ Math.min(now + delay, publicationDeadline.value),
705
+ )
706
+ : now + delay,
707
+ );
560
708
 
561
709
  return "rearmed" as const;
562
710
  }
@@ -566,7 +714,22 @@ export class ThreadMaintenance extends Context.Service<
566
714
  // unseen effects are never acknowledged. Do not accelerate that future alarm
567
715
  // from inside the current handler: workerd cancels a running handler when it
568
716
  // writes an earlier slot.
569
- await ensureTransactionAlarmBy(transaction, now + config.wakeScanInterval);
717
+ await ensureTransactionAlarmBy(
718
+ transaction,
719
+ Option.isSome(publicationDeadline)
720
+ ? Math.max(
721
+ now + minimumAlarmDelay,
722
+ Math.min(now + config.wakeScanInterval, publicationDeadline.value),
723
+ )
724
+ : now + config.wakeScanInterval,
725
+ );
726
+
727
+ return "rearmed" as const;
728
+ }
729
+ if (Option.isSome(publicationDeadline)) {
730
+ await transaction.setAlarm(
731
+ Math.max(now + minimumAlarmDelay, publicationDeadline.value),
732
+ );
570
733
 
571
734
  return "rearmed" as const;
572
735
  }
@@ -615,8 +778,15 @@ export class ThreadMaintenance extends Context.Service<
615
778
  }),
616
779
  }),
617
780
  ),
618
- ensureAlarm: ensureAlarm(),
619
- withMutation,
781
+ ensureAlarm: mutations.withSnapshot(() => ensureAlarm()),
782
+ withMutation: (body) =>
783
+ mutations.withMutation(
784
+ body.pipe(
785
+ Effect.tap(() =>
786
+ publishCommitted.pipe(Effect.provideService(ThreadPublication, publication)),
787
+ ),
788
+ ),
789
+ ),
620
790
  });
621
791
  }),
622
792
  );
@@ -65,11 +65,17 @@ export class SubscriptionAlarmExtensionError extends Schema.TaggedError<Subscrip
65
65
  { code: Schema.NonEmptyString.check(Schema.isMaxLength(128)) },
66
66
  ) {}
67
67
 
68
- export interface SubscriptionPartitionAlarmHandler {
68
+ /** Native partition services supplied at alarm invocation, after the host Layer is built. */
69
+ export type SubscriptionPartitionAlarmServices =
70
+ | Subscriptions
71
+ | SubscriptionIntake
72
+ | SubscriptionDriver;
73
+
74
+ export interface SubscriptionPartitionAlarmHandler<R = SubscriptionPartitionAlarmServices> {
69
75
  readonly tag: string;
70
76
  readonly handle: (
71
77
  event: DurableObjectAlarm.DurableObjectAlarmEvent,
72
- ) => Effect.Effect<void, SubscriptionAlarmProtocolError | SubscriptionAlarmExtensionError>;
78
+ ) => Effect.Effect<void, SubscriptionAlarmProtocolError | SubscriptionAlarmExtensionError, R>;
73
79
  }
74
80
 
75
81
  /** Host-only handlers; the framework reserves its namespace and rejects every unknown tag. */
@@ -79,7 +85,8 @@ export const SubscriptionPartitionAlarmExtension = Context.Reference<{
79
85
  defaultValue: () => ({ handlers: [] }),
80
86
  });
81
87
 
82
- /** Capture host services once; each invocation owns its codec/handler Scope and timeout.
88
+ /** Capture host services once, deferring native partition services to invocation.
89
+ * Each invocation owns its codec/handler Scope and timeout.
83
90
  * Callback failures stay typed. Defects and interruption reach the native alarm multiplexer.
84
91
  * The host owns durable idempotency, prearming and external-effect uncertainty.
85
92
  */
@@ -95,9 +102,17 @@ export const makeSubscriptionPartitionAlarmHandler = Effect.fn(
95
102
  },
96
103
  ) => Effect.Effect<void, SubscriptionAlarmExtensionError, R>;
97
104
  }): Effect.fn.Return<
98
- SubscriptionPartitionAlarmHandler,
105
+ SubscriptionPartitionAlarmHandler<
106
+ Exclude<
107
+ Exclude<R | Payload["DecodingServices"], Scope.Scope>,
108
+ Exclude<
109
+ Exclude<R | Payload["DecodingServices"], Scope.Scope | SubscriptionPartitionAlarmServices>,
110
+ SubscriptionPartitionAlarmServices
111
+ >
112
+ >
113
+ >,
99
114
  SubscriptionAlarmProtocolError,
100
- Exclude<R | Payload["DecodingServices"], Scope.Scope>
115
+ Exclude<R | Payload["DecodingServices"], Scope.Scope | SubscriptionPartitionAlarmServices>
101
116
  > {
102
117
  if (
103
118
  options.tag.length === 0 ||
@@ -110,7 +125,11 @@ export const makeSubscriptionPartitionAlarmHandler = Effect.fn(
110
125
  return yield* SubscriptionAlarmProtocolError.make({
111
126
  message: "Invalid ancillary alarm tag or timeout",
112
127
  });
113
- const services = yield* Effect.context<Exclude<R | Payload["DecodingServices"], Scope.Scope>>();
128
+
129
+ // Context capture includes unrequested services too; never retain a host override of native work.
130
+ const services = (yield* Effect.context<
131
+ Exclude<R | Payload["DecodingServices"], Scope.Scope | SubscriptionPartitionAlarmServices>
132
+ >()).pipe(Context.omit(Subscriptions, SubscriptionIntake, SubscriptionDriver));
114
133
 
115
134
  return {
116
135
  tag: options.tag,
@@ -103,6 +103,7 @@ import { ProgressWaitRegistry } from "./internal/progress-wait.ts";
103
103
  export {
104
104
  layer,
105
105
  layerConfig,
106
+ type ThreadPublicationOptions as PublicationOptions,
106
107
  type CloudflareDurableRuntimeOptions as RuntimeOptions,
107
108
  type CloudflareDurableRuntimeServices as Services,
108
109
  type CloudflareDurableRuntimeInitializationError as InitializationError,
@@ -47,8 +47,8 @@ import {
47
47
  type OperationAuthorizerService,
48
48
  } from "@effect-agent/thread/OperationAuthorizer";
49
49
  import { ProducerId } from "@effect-agent/thread/Records";
50
- import { type SubmissionLedger } from "@effect-agent/thread/SubmissionLedger";
51
- import { type ThreadStore } from "@effect-agent/thread/ThreadStore";
50
+ import { LedgerError, SubmissionLedger } from "@effect-agent/thread/SubmissionLedger";
51
+ import { ThreadStoreError, ThreadStore } from "@effect-agent/thread/ThreadStore";
52
52
  import { ToolReconciler } from "@effect-agent/thread/ToolReconciler";
53
53
  import { type WakeScheduler } from "@effect-agent/thread/WakeScheduler";
54
54
  import { BrowserCrypto } from "@effect/platform-browser";
@@ -58,6 +58,9 @@ import { Context, Duration, Effect, Layer, Schema } from "effect";
58
58
 
59
59
  import {
60
60
  ThreadMaintenance,
61
+ ThreadMutationGate,
62
+ ThreadPublication,
63
+ publishCommitted,
61
64
  ThreadMaintenanceFailpoint,
62
65
  DurableAlarmService,
63
66
  type ThreadMaintenanceFailpointHandler,
@@ -168,6 +171,8 @@ export type CloudflareDurableRuntimeServices =
168
171
  | WakeScheduler
169
172
  | DurableAlarmService
170
173
  | ThreadMaintenance
174
+ | ThreadMutationGate
175
+ | ThreadPublication
171
176
  | ThreadObjectPorts
172
177
  | ProgressWaitRegistry;
173
178
 
@@ -311,15 +316,33 @@ export const layerConfig = (
311
316
  }),
312
317
  );
313
318
 
319
+ export interface ThreadPublicationOptions<E = never, R = never> {
320
+ /**
321
+ * Optional host outbox consumer, built once per incarnation with RAW LOCAL ThreadStore and
322
+ * SubmissionLedger services. Yield DurableObjectContext and ThreadObjectIdentity for native
323
+ * bindings and identity. Initialization is local-only, inside the constructor gate; setup
324
+ * errors and additional requirements remain in the returned Layer. Layer.effect owns Scope.
325
+ * Canonical appends and durable approval, abort and unknown-resolution intents invalidate
326
+ * publication after commit. Custom host facts must use ThreadMaintenance.withMutation.
327
+ */
328
+ readonly publication?: Layer.Layer<ThreadPublication, E, R>;
329
+ }
330
+
314
331
  /**
315
332
  * Register typed Agents and version declarations. Hashing and dependency capture happen in
316
333
  * this Layer's Scope, after application Layers have been provided. Every Agent's instruction,
317
334
  * Tool, Schema, and model requirements remain visible until satisfied by Layer composition.
318
335
  * Use Layer.unwrap for registration values that need effectful application setup.
319
336
  */
320
- export const layer = <const Entries extends ReadonlyArray<AgentRegistration>>(
337
+ export const layer = <const Entries extends ReadonlyArray<AgentRegistration>, E = never, R = never>(
321
338
  registrations: Entries,
322
- ) => Layer.unwrap(Effect.map(compileRegistrations(registrations), layerFromBindings));
339
+ options: ThreadPublicationOptions<E, R> = {},
340
+ ) =>
341
+ Layer.unwrap(
342
+ Effect.map(compileRegistrations(registrations), (bindings) =>
343
+ layerFromBindings(bindings, options),
344
+ ),
345
+ );
323
346
 
324
347
  /**
325
348
  * Assemble the durable runtime from already-resolved Agent Bindings.
@@ -327,12 +350,16 @@ export const layer = <const Entries extends ReadonlyArray<AgentRegistration>>(
327
350
  * Supply host services through `ThreadObject.make` or `ThreadObject.layerConfig` and
328
351
  * the Durable Object context and namespace Layers when composing a custom host.
329
352
  */
330
- export const layerFromBindings = (
353
+ export const layerFromBindings = <E = never, R = never>(
331
354
  bindings: ReadonlyArray<ResolvedBinding>,
355
+ options: ThreadPublicationOptions<E, R> = {},
332
356
  ): Layer.Layer<
333
357
  CloudflareDurableRuntimeServices,
334
- DoStorageInitializationError,
335
- DurableObjectContext | ThreadObjectNamespace | CloudflareBootstrapServices
358
+ DoStorageInitializationError | E,
359
+ | DurableObjectContext
360
+ | ThreadObjectNamespace
361
+ | CloudflareBootstrapServices
362
+ | Exclude<R, ThreadStore | SubmissionLedger>
336
363
  > =>
337
364
  Layer.unwrap(
338
365
  Effect.gen(function* () {
@@ -355,10 +382,67 @@ export const layerFromBindings = (
355
382
 
356
383
  // The same local ports serve routed decorators and owner-side RPC execution.
357
384
  // The RPC executor must never receive routed ports and bounce requests between Objects.
358
- const localPorts = Layer.mergeAll(threadStoreLayer, submissionLedgerLayer).pipe(
385
+ const rawLocalPorts = Layer.mergeAll(threadStoreLayer, submissionLedgerLayer).pipe(
359
386
  Layer.provide(infrastructure),
360
387
  );
361
388
 
389
+ const publication = (options.publication ?? ThreadPublication.layer).pipe(
390
+ Layer.provide(rawLocalPorts),
391
+ );
392
+
393
+ const localPorts =
394
+ options.publication === undefined
395
+ ? rawLocalPorts
396
+ : Layer.effectContext(
397
+ Effect.gen(function* () {
398
+ const store = yield* ThreadStore;
399
+ const ledger = yield* SubmissionLedger;
400
+ const mutations = yield* ThreadMutationGate;
401
+ const publish = yield* Effect.context<ThreadPublication>();
402
+ const afterCommit = publishCommitted.pipe(Effect.provide(publish));
403
+
404
+ // Every runtime-owned producer prearms too: a crash between commit and invalidation
405
+ // leaves a NEW, uncertified generation. Source errors keep their native port types.
406
+ const observedStore = ThreadStore.of({
407
+ ...store,
408
+ append: (request) =>
409
+ mutations
410
+ .withMutation(store.append(request).pipe(Effect.tap(() => afterCommit)))
411
+ .pipe(
412
+ Effect.catchTag("DurableAlarmError", (cause) =>
413
+ ThreadStoreError.make({
414
+ operation: "prearm publication append",
415
+ message: cause.message,
416
+ cause,
417
+ }),
418
+ ),
419
+ ),
420
+ });
421
+
422
+ const observeIntent = <A, Failure>(body: Effect.Effect<A, Failure>) =>
423
+ mutations.withMutation(body.pipe(Effect.tap(() => afterCommit))).pipe(
424
+ Effect.catchTag("DurableAlarmError", (cause) =>
425
+ LedgerError.make({
426
+ operation: "prearm publication intent",
427
+ message: "The publication generation could not be armed",
428
+ cause,
429
+ }),
430
+ ),
431
+ );
432
+
433
+ return Context.make(ThreadStore, observedStore).pipe(
434
+ Context.add(SubmissionLedger, {
435
+ ...ledger,
436
+ recordApprovalDecision: (request) =>
437
+ observeIntent(ledger.recordApprovalDecision(request)),
438
+ requestAbort: (request) => observeIntent(ledger.requestAbort(request)),
439
+ recordUnknownResolution: (request) =>
440
+ observeIntent(ledger.recordUnknownResolution(request)),
441
+ }),
442
+ );
443
+ }),
444
+ ).pipe(Layer.provide(rawLocalPorts));
445
+
362
446
  const portsEndpointLayer = Layer.effect(ThreadObjectPorts)(
363
447
  Effect.gen(function* () {
364
448
  const local = yield* Effect.context<SubmissionLedger | ThreadStore>();
@@ -386,6 +470,6 @@ export const layerFromBindings = (
386
470
  runtimeStack,
387
471
  ThreadMaintenance.layer.pipe(Layer.provide(runtimeStack)),
388
472
  portsEndpointLayer,
389
- );
473
+ ).pipe(Layer.provideMerge(publication), Layer.provideMerge(ThreadMutationGate.layer));
390
474
  }),
391
475
  );
@@ -107,7 +107,8 @@ export const browserRunProtectedBindingLayer = (options: {
107
107
 
108
108
  const acquired = await puppeteer.acquire(binding, {
109
109
  recording: false,
110
- keep_alive: Math.max(10_000, policy.maxElapsedMillis),
110
+ // Provider inactivity timeout, independent of the finite total pass deadline.
111
+ keep_alive: Math.min(600_000, Math.max(10_000, policy.maxElapsedMillis)),
111
112
  });
112
113
 
113
114
  sessionId = Redacted.make(