@effect-agent/platform-cloudflare 0.1.0-beta.79 → 0.1.0-beta.80

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 (33) hide show
  1. package/dist/Alarm.d.mts +17 -5
  2. package/dist/Alarm.mjs +48 -12
  3. package/dist/Alarm.mjs.map +1 -1
  4. package/dist/CloudflareBindings.d.mts +14 -7
  5. package/dist/CloudflareBindings.mjs +10 -7
  6. package/dist/CloudflareBindings.mjs.map +1 -1
  7. package/dist/CloudflareScheduling.mjs +2 -2
  8. package/dist/CloudflareSubscriptions.mjs +1 -1
  9. package/dist/CloudflareThreadClient.d.mts +41 -41
  10. package/dist/CloudflareThreadClient.mjs +3 -3
  11. package/dist/CloudflareThreadClient.mjs.map +1 -1
  12. package/dist/{ThreadObject-MuU9xAyn.d.mts → ThreadObject-3uDpKfB3.d.mts} +82 -44
  13. package/dist/{ThreadObject-KJf_5Isr.mjs → ThreadObject-DGkXv41C.mjs} +213 -68
  14. package/dist/ThreadObject-DGkXv41C.mjs.map +1 -0
  15. package/dist/ThreadObject.d.mts +2 -2
  16. package/dist/ThreadObject.mjs +2 -2
  17. package/dist/WakeScheduler.d.mts +2 -2
  18. package/dist/WakeScheduler.mjs +6 -6
  19. package/dist/WakeScheduler.mjs.map +1 -1
  20. package/dist/index.d.mts +1 -1
  21. package/dist/index.mjs +1 -1
  22. package/dist/{prepared-admission-G7N4DDqS.mjs → prepared-admission-DSi55W-7.mjs} +2 -2
  23. package/dist/{prepared-admission-G7N4DDqS.mjs.map → prepared-admission-DSi55W-7.mjs.map} +1 -1
  24. package/package.json +1 -1
  25. package/src/Alarm.ts +91 -14
  26. package/src/CloudflareBindings.ts +15 -6
  27. package/src/CloudflareThreadClient.ts +3 -3
  28. package/src/ThreadObject.ts +284 -59
  29. package/src/WakeScheduler.ts +6 -6
  30. package/src/internal/layers.ts +150 -22
  31. package/src/internal/message-delivery.ts +37 -21
  32. package/src/internal/transport.ts +2 -2
  33. package/dist/ThreadObject-KJf_5Isr.mjs.map +0 -1
package/src/Alarm.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { ThreadId } from "@effect-agent/core/Identifiers";
1
2
  import { type DurableBindingFailure } from "@effect-agent/thread/AgentRegistration";
2
3
  import {
3
4
  DurableAgentRuntime,
@@ -28,7 +29,7 @@ import {
28
29
  Stream,
29
30
  } from "effect";
30
31
 
31
- import { ThreadObjectIdentity, DurableObjectContext } from "./CloudflareBindings.ts";
32
+ import { DurableObjectContext } from "./CloudflareBindings.ts";
32
33
  import { CloudflareDurableRuntimeConfig } from "./CloudflareConfig.ts";
33
34
  import { safeCauseMessage } from "./internal/boundary.ts";
34
35
 
@@ -188,6 +189,8 @@ export type ThreadMaintenanceFailpointLocation =
188
189
  | "maintenance:ensure:after"
189
190
  | "maintenance:begin:before"
190
191
  | "maintenance:begin:after"
192
+ | "maintenance:select:before"
193
+ | "maintenance:select:after"
191
194
  | "maintenance:finish:before"
192
195
  | "maintenance:finish:after";
193
196
 
@@ -255,6 +258,26 @@ export const ThreadMessageDelivery = Context.Reference<{
255
258
  defaultValue: () => ({ drain: Effect.void, pendingDeadline: Effect.succeed(Option.none()) }),
256
259
  });
257
260
 
261
+ /**
262
+ * Application obligations sharing this Object's alarm. The deadline read is local and
263
+ * read-only. Drain beside the native Attempt and always finish one initial bounded wave,
264
+ * even when `finished` was already signalled. Then stop starting new waves on that signal
265
+ * and finish the bounded current wave before returning. Native maintenance joins that work
266
+ * before acknowledging a generation. Mutations use the same ThreadMutationGate; hooks never
267
+ * write the raw alarm slot. Pending host work does not defer a ready model Attempt.
268
+ */
269
+ export const ThreadHostMaintenance = Context.Reference<{
270
+ readonly pendingDeadline: Effect.Effect<Option.Option<number>, DurableAlarmError>;
271
+ readonly drainUntil: (
272
+ finished: Deferred.Deferred<void>,
273
+ ) => Effect.Effect<void, DurableAlarmError>;
274
+ }>("@effect-agent/platform-cloudflare/ThreadHostMaintenance", {
275
+ defaultValue: () => ({
276
+ pendingDeadline: Effect.succeed(Option.none()),
277
+ drainUntil: () => Effect.void,
278
+ }),
279
+ });
280
+
258
281
  const earliestDeadline = (
259
282
  left: Option.Option<number>,
260
283
  right: Option.Option<number>,
@@ -290,6 +313,8 @@ class ThreadMaintenanceState extends Schema.Class<ThreadMaintenanceState>(
290
313
  dirty: MaintenanceGeneration,
291
314
  processed: MaintenanceGeneration,
292
315
  nonterminal: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
316
+ /** One physical-owner cursor; old single-lane records need no conversion. */
317
+ lastServedThreadId: Schema.optionalKey(ThreadId),
293
318
  }) {}
294
319
 
295
320
  const MAINTENANCE_STATE_KEY = "effect-agent:thread-maintenance:v1";
@@ -482,7 +507,6 @@ export class ThreadMaintenance extends Context.Service<
482
507
  | DurableAlarmService
483
508
  | ThreadMaintenanceFailpoint
484
509
  | CloudflareDurableRuntimeConfig
485
- | ThreadObjectIdentity
486
510
  | DurableObjectContext
487
511
  > = Layer.effect(ThreadMaintenance)(
488
512
  Effect.gen(function* () {
@@ -490,7 +514,6 @@ export class ThreadMaintenance extends Context.Service<
490
514
  const ledger = yield* SubmissionLedger;
491
515
  const alarm = yield* DurableAlarmService;
492
516
  const config = yield* CloudflareDurableRuntimeConfig;
493
- const identity = yield* ThreadObjectIdentity;
494
517
  const { ctx } = yield* DurableObjectContext;
495
518
  const failpoint = yield* ThreadMaintenanceFailpoint;
496
519
 
@@ -503,6 +526,7 @@ export class ThreadMaintenance extends Context.Service<
503
526
  const publication = yield* ThreadPublication;
504
527
  const projection = yield* ThreadProjectionMaintenance;
505
528
  const messages = yield* ThreadMessageDelivery;
529
+ const host = yield* ThreadHostMaintenance;
506
530
 
507
531
  // A broken disposable index still needs a retry alarm and must not prevent startup.
508
532
  const projectionDeadline = projection.pendingDeadline.pipe(
@@ -518,7 +542,7 @@ export class ThreadMaintenance extends Context.Service<
518
542
  const pendingDeadline = Effect.gen(function* () {
519
543
  return earliestDeadline(
520
544
  earliestDeadline(yield* publication.pendingDeadline, yield* messages.pendingDeadline),
521
- yield* projectionDeadline,
545
+ earliestDeadline(yield* projectionDeadline, yield* host.pendingDeadline),
522
546
  );
523
547
  });
524
548
 
@@ -643,8 +667,13 @@ export class ThreadMaintenance extends Context.Service<
643
667
  messages.drainUntil?.(deliveryFinished) ?? messages.drain,
644
668
  );
645
669
 
670
+ const hostWork = yield* Effect.forkChild(host.drainUntil(deliveryFinished));
671
+
646
672
  const finishDelivery = Deferred.succeed(deliveryFinished, undefined).pipe(
647
- Effect.andThen(Fiber.join(delivery)),
673
+ Effect.andThen(Fiber.awaitAll([delivery, hostWork])),
674
+ Effect.flatMap((outcomes) =>
675
+ Effect.forEach(outcomes, (outcome) => outcome, { discard: true }),
676
+ ),
648
677
  );
649
678
 
650
679
  // Capture derived-index failures until canonical work has had its turn. Interruption
@@ -717,24 +746,72 @@ export class ThreadMaintenance extends Context.Service<
717
746
  }
718
747
  // Step 2 — reconciliation strictly precedes new work in this pass (exit gate).
719
748
  const recovered: ReadonlyArray<RecoveryReport> = yield* runtime.runRecovery;
720
- // One head Attempt per event. The runtime yields after a committed turn when the
721
- // soft deadline is reached; queued followers belong to a subsequent alarm.
722
- const settlement = yield* runtime.processThreadHead(identity.threadId, { yieldAfter });
749
+ const reports = new Map(recovered.map((report) => [report.submissionId, report]));
750
+ const current = yield* Stream.runCollect(ledger.scanNonterminal);
751
+ const heads = new Map<ThreadId, SubmissionSnapshot>();
752
+
753
+ for (const row of current) {
754
+ if (!heads.has(row.threadId)) heads.set(row.threadId, row);
755
+ }
756
+
757
+ const eligible = [...heads.values()]
758
+ .filter((head) => !stableExternalWait(head, reports))
759
+ .map((head) => head.threadId)
760
+ .sort();
761
+
762
+ let selected = eligible[0];
763
+
764
+ if (heads.size > 1 && selected !== undefined) {
765
+ yield* failpoint.hit("maintenance:select:before");
766
+ selected = yield* runTransaction("select maintenance lane", () =>
767
+ ctx.storage.transaction(async (transaction) => {
768
+ const { state } = await readMaintenanceState(transaction);
769
+
770
+ const next =
771
+ eligible.find(
772
+ (threadId) =>
773
+ state.lastServedThreadId === undefined || threadId > state.lastServedThreadId,
774
+ ) ?? eligible[0];
775
+
776
+ if (next !== undefined) {
777
+ // Persist before the Attempt so an eviction or repeated yield cannot
778
+ // monopolize the first lane. The generation and prearmed alarm survive.
779
+ await transaction.put(
780
+ MAINTENANCE_STATE_KEY,
781
+ encodeMaintenanceState(
782
+ ThreadMaintenanceState.make({ ...state, lastServedThreadId: next }),
783
+ ),
784
+ );
785
+ }
786
+
787
+ return next;
788
+ }),
789
+ );
790
+ yield* failpoint.hit("maintenance:select:after");
791
+ }
792
+
793
+ // One FIFO head per event, across all local lanes. The runtime keeps its normal
794
+ // bounded Attempt and recovery contracts; followers belong to another alarm.
795
+ const settlement =
796
+ selected === undefined
797
+ ? Option.none()
798
+ : yield* runtime.processThreadHead(selected, { yieldAfter });
723
799
 
724
800
  yield* finishDelivery;
725
801
  if (Exit.isFailure(projected)) return yield* Effect.failCause(projected.cause);
726
802
  // Observe residual state before acknowledging this exact pass-start generation.
727
803
  const remaining = yield* Stream.runCollect(ledger.scanNonterminal);
728
- const reports = new Map(recovered.map((report) => [report.submissionId, report]));
729
- const head = remaining[0];
730
- const headWaiting = head !== undefined && stableExternalWait(head, reports);
804
+ const waitingHeads = new Map<ThreadId, boolean>();
805
+
806
+ const autonomous = remaining.some((snapshot) => {
807
+ const headWaiting = waitingHeads.get(snapshot.threadId);
731
808
 
732
- const autonomous = remaining.some((snapshot, index) => {
809
+ if (headWaiting === undefined)
810
+ waitingHeads.set(snapshot.threadId, stableExternalWait(snapshot, reports));
733
811
  // FIFO followers cannot execute through a stable external wait. Only plain queued
734
812
  // input is dormant here; admission repairs and accepted aborts still need a pass.
735
813
  if (
736
- index > 0 &&
737
- headWaiting &&
814
+ headWaiting === true &&
738
815
  snapshot.state === "ready" &&
739
816
  reports.get(snapshot.submissionId)?.decision._tag === "ApplyInput"
740
817
  )
@@ -52,15 +52,18 @@ export interface ThreadObjectRpc extends Rpc.DurableObjectBranded {
52
52
  wake(): Promise<void>;
53
53
  }
54
54
 
55
+ /** A logical Thread endpoint may be bound to an application Object without forging its brand. */
56
+ export type ThreadObjectClient = Omit<ThreadObjectRpc, keyof Rpc.DurableObjectBranded>;
57
+
55
58
  /**
56
- * The `DurableObjectNamespace` binding that addresses Thread Objects. The Object
57
- * identity rule is `namespace.idFromName(threadId)` (plan §1.2): Thread IDs are
58
- * globally unique, so the mapping is total and deterministic and no directory service exists.
59
+ * Deterministic logical Thread placement. The native adapter uses `idFromName(threadId)`;
60
+ * a shared application owner can bind that logical identity into its RPC adapter instead.
61
+ * Lookup performs no I/O and grants no authority. Every call resolves its target afresh.
59
62
  */
60
63
  export class ThreadObjectNamespace extends Context.Service<
61
64
  ThreadObjectNamespace,
62
65
  {
63
- readonly namespace: DurableObjectNamespace<ThreadObjectRpc>;
66
+ readonly get: (threadId: ThreadId) => ThreadObjectClient;
64
67
  /** Stable binding name for opted-in native RPC tracing; absent by default. */
65
68
  readonly rpcTracing?: string;
66
69
  }
@@ -70,7 +73,7 @@ export class ThreadObjectNamespace extends Context.Service<
70
73
  options: { readonly rpcTracing?: string } = {},
71
74
  ): Layer.Layer<ThreadObjectNamespace> {
72
75
  return Layer.succeed(ThreadObjectNamespace)({
73
- namespace,
76
+ get: (threadId) => namespace.get(namespace.idFromName(threadId)),
74
77
  ...(options.rpcTracing === undefined ? {} : { rpcTracing: options.rpcTracing }),
75
78
  });
76
79
  }
@@ -135,7 +138,7 @@ export const threadNamespaceLayer = (
135
138
  ): Layer.Layer<ThreadObjectNamespace, CloudflareBindingError> =>
136
139
  Layer.effect(ThreadObjectNamespace)(
137
140
  Effect.map(threadNamespaceFromEnv(env, binding), (namespace) => ({
138
- namespace,
141
+ get: (threadId) => namespace.get(namespace.idFromName(threadId)),
139
142
  ...(options.rpcTracing === true ? { rpcTracing: binding } : {}),
140
143
  })),
141
144
  );
@@ -169,3 +172,9 @@ export class ThreadObjectIdentity extends Context.Service<
169
172
  readonly producerId: ProducerId;
170
173
  }
171
174
  >()("@effect-agent/platform-cloudflare/ThreadObjectIdentity") {}
175
+
176
+ /** Logical Threads whose canonical stores and admission ledger live in this physical Object. */
177
+ export class ThreadObjectPlacement extends Context.Service<
178
+ ThreadObjectPlacement,
179
+ { readonly ownsThread: (threadId: ThreadId) => boolean }
180
+ >()("@effect-agent/platform-cloudflare/ThreadObjectPlacement") {}
@@ -445,12 +445,12 @@ export class CloudflareThreadClient extends Context.Service<
445
445
  ThreadObjectNamespace | Crypto.Crypto
446
446
  > = Layer.effect(CloudflareThreadClient)(
447
447
  Effect.gen(function* () {
448
- const { namespace, rpcTracing } = yield* ThreadObjectNamespace;
448
+ const { get, rpcTracing } = yield* ThreadObjectNamespace;
449
449
  const crypto = yield* Crypto.Crypto;
450
450
 
451
451
  const call = Effect.fn(
452
452
  function* (
453
- threadId: string,
453
+ threadId: ThreadId,
454
454
  operation: keyof typeof hostRpcMethods,
455
455
  encoded: unknown,
456
456
  ): Effect.fn.Return<HostResponse, ThreadClientError | HostProtocolError> {
@@ -460,7 +460,7 @@ export class CloudflareThreadClient extends Context.Service<
460
460
 
461
461
  const raw = yield* Effect.tryPromise({
462
462
  try: () => {
463
- const stub = namespace.get(namespace.idFromName(threadId));
463
+ const stub = get(threadId);
464
464
 
465
465
  return stub[hostRpcMethods[operation]](encoded, ...traceArgs);
466
466
  },