@effect-agent/platform-cloudflare 0.1.0-beta.14 → 0.1.0-beta.16

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/client.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  IdempotencyKey,
19
19
  JoinedToHost,
20
20
  LedgerError,
21
+ OperationDenied,
21
22
  PersistedJson,
22
23
  Principal,
23
24
  Receipt,
@@ -29,7 +30,7 @@ import {
29
30
  type DurableSubmitAgent,
30
31
  type DurableSubmitOptions,
31
32
  } from "@effect-agent/session";
32
- import { Context, Effect, Layer, Schema } from "effect";
33
+ import { Context, Crypto, Duration, Effect, Layer, Schema } from "effect";
33
34
 
34
35
  import { DurableAlarmError } from "./alarm.ts";
35
36
  import { ConversationObjectNamespace, type ConversationObjectRpc } from "./bindings.ts";
@@ -73,6 +74,10 @@ export class ConversationClientError extends Schema.TaggedError<ConversationClie
73
74
  conversationId: Schema.String,
74
75
  message: Schema.String,
75
76
  cause: Schema.optionalKey(Schema.Defect()),
77
+ /** Cloudflare's own classification for a failure safe to retry with a fresh stub. */
78
+ retryable: Schema.optionalKey(Schema.Boolean),
79
+ /** Cloudflare overloads are surfaced immediately instead of adding retry pressure. */
80
+ overloaded: Schema.optionalKey(Schema.Boolean),
76
81
  },
77
82
  ) {}
78
83
 
@@ -104,6 +109,21 @@ export class ObservePageRequest extends Schema.Class<ObservePageRequest>(
104
109
  limit: Schema.Int.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(1_024)),
105
110
  }) {}
106
111
 
112
+ /** One event-driven wait for canonical progress strictly after this sequence. */
113
+ export class AwaitProgressRequest extends Schema.Class<AwaitProgressRequest>(
114
+ "@effect-agent/platform-cloudflare/AwaitProgressRequest",
115
+ )({
116
+ afterSequence: CanonicalSequence,
117
+ waiterId: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256)),
118
+ }) {}
119
+
120
+ /** Best-effort cancellation of one in-flight progress RPC. */
121
+ export class CancelProgressRequest extends Schema.Class<CancelProgressRequest>(
122
+ "@effect-agent/platform-cloudflare/CancelProgressRequest",
123
+ )({
124
+ waiterId: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256)),
125
+ }) {}
126
+
107
127
  // ---------------------------------------------------------------------------
108
128
  // Responses
109
129
  // ---------------------------------------------------------------------------
@@ -130,6 +150,7 @@ export const HostFailure = Schema.Union([
130
150
  DurableRuntimeFailpointError,
131
151
  AdmissionLimitExceeded,
132
152
  DurableAlarmError,
153
+ OperationDenied,
133
154
  HostProtocolError,
134
155
  ]);
135
156
  export type HostFailure = typeof HostFailure.Type;
@@ -152,6 +173,15 @@ export class ObservedPage extends Schema.TaggedClass<ObservedPage>(
152
173
  records: Schema.Array(CanonicalRecordEnvelope).check(Schema.isMaxLength(1_024)),
153
174
  }) {}
154
175
 
176
+ /** A record was already committed or an incarnation-local hint says the caller should re-read. */
177
+ export class ProgressObserved extends Schema.TaggedClass<ProgressObserved>(
178
+ "@effect-agent/platform-cloudflare/ProgressObserved",
179
+ )("ProgressObserved", {}) {}
180
+
181
+ export class ProgressCancelled extends Schema.TaggedClass<ProgressCancelled>(
182
+ "@effect-agent/platform-cloudflare/ProgressCancelled",
183
+ )("ProgressCancelled", {}) {}
184
+
155
185
  export class AbortRecorded extends Schema.TaggedClass<AbortRecorded>(
156
186
  "@effect-agent/platform-cloudflare/AbortRecorded",
157
187
  )("AbortRecorded", {
@@ -182,6 +212,8 @@ export const HostResponse = Schema.Union([
182
212
  SubmitSucceeded,
183
213
  SettlementReached,
184
214
  ObservedPage,
215
+ ProgressObserved,
216
+ ProgressCancelled,
185
217
  AbortRecorded,
186
218
  ApprovalRecorded,
187
219
  UnknownResolutionRecorded,
@@ -199,6 +231,10 @@ export const decodeReceipt = Schema.decodeUnknownEffect(Receipt);
199
231
  export const encodeReceipt = Schema.encodeEffect(Receipt);
200
232
  export const decodeObservePageRequest = Schema.decodeUnknownEffect(ObservePageRequest);
201
233
  export const encodeObservePageRequest = Schema.encodeEffect(ObservePageRequest);
234
+ export const decodeAwaitProgressRequest = Schema.decodeUnknownEffect(AwaitProgressRequest);
235
+ export const encodeAwaitProgressRequest = Schema.encodeEffect(AwaitProgressRequest);
236
+ export const decodeCancelProgressRequest = Schema.decodeUnknownEffect(CancelProgressRequest);
237
+ export const encodeCancelProgressRequest = Schema.encodeEffect(CancelProgressRequest);
202
238
  export const decodeAbortCommand = Schema.decodeUnknownEffect(AbortCommand);
203
239
  export const encodeAbortCommand = Schema.encodeEffect(AbortCommand);
204
240
  export const decodeApprovalDecisionCommand = Schema.decodeUnknownEffect(ApprovalDecisionCommand);
@@ -237,6 +273,14 @@ export type ClientAwaitFailure =
237
273
  export type ClientObserveFailure =
238
274
  | ConversationStoreError
239
275
  | ConversationNotMaterialized
276
+ | OperationDenied
277
+ | HostProtocolError
278
+ | ConversationClientError;
279
+
280
+ export type ClientProgressFailure =
281
+ | ConversationStoreError
282
+ | ConversationNotMaterialized
283
+ | OperationDenied
240
284
  | HostProtocolError
241
285
  | ConversationClientError;
242
286
 
@@ -253,6 +297,7 @@ export type ClientApprovalFailure =
253
297
  | LedgerError
254
298
  | SettlementConflict
255
299
  | ApprovalConflict
300
+ | OperationDenied
256
301
  | DurableAlarmError
257
302
  | HostProtocolError
258
303
  | ConversationClientError;
@@ -263,6 +308,7 @@ export type ClientUnknownFailure =
263
308
  | UnknownResolutionConflict
264
309
  | JoinedToHost
265
310
  | DurableRuntimeFailpointError
311
+ | OperationDenied
266
312
  | DurableAlarmError
267
313
  | HostProtocolError
268
314
  | ConversationClientError;
@@ -289,8 +335,10 @@ const AWAIT_FAILURE_TAGS: ReadonlySet<string> = new Set([
289
335
  const OBSERVE_FAILURE_TAGS: ReadonlySet<string> = new Set([
290
336
  "ConversationStoreError",
291
337
  "ConversationNotMaterialized",
338
+ "OperationDenied",
292
339
  "HostProtocolError",
293
340
  ]);
341
+ const PROGRESS_FAILURE_TAGS = OBSERVE_FAILURE_TAGS;
294
342
  const ABORT_FAILURE_TAGS: ReadonlySet<string> = new Set([
295
343
  "LedgerError",
296
344
  "SettlementConflict",
@@ -303,6 +351,7 @@ const APPROVAL_FAILURE_TAGS: ReadonlySet<string> = new Set([
303
351
  "LedgerError",
304
352
  "SettlementConflict",
305
353
  "ApprovalConflict",
354
+ "OperationDenied",
306
355
  "DurableAlarmError",
307
356
  "HostProtocolError",
308
357
  ]);
@@ -312,6 +361,7 @@ const UNKNOWN_FAILURE_TAGS: ReadonlySet<string> = new Set([
312
361
  "UnknownResolutionConflict",
313
362
  "JoinedToHost",
314
363
  "DurableRuntimeFailpointError",
364
+ "OperationDenied",
315
365
  "DurableAlarmError",
316
366
  "HostProtocolError",
317
367
  ]);
@@ -351,6 +401,14 @@ export class CloudflareConversationClient extends Context.Service<
351
401
  ) => Effect.Effect<Receipt, ClientSubmitFailure, InputSchema["EncodingServices"]>;
352
402
  /** Wake-hinted, poll-guaranteed settlement wait executed inside the owning Object. */
353
403
  readonly awaitSettlement: (receipt: Receipt) => Effect.Effect<Settlement, ClientAwaitFailure>;
404
+ /**
405
+ * Wait without polling until progress after `afterSequence` is already durable or hinted.
406
+ * The result is deliberately void: canonical records remain authoritative and must be read.
407
+ */
408
+ readonly awaitProgress: (
409
+ conversationId: ConversationId,
410
+ afterSequence: CanonicalSequence,
411
+ ) => Effect.Effect<void, ClientProgressFailure>;
354
412
  /** One bounded page of canonical records. */
355
413
  readonly readPage: (
356
414
  conversationId: ConversationId,
@@ -389,10 +447,37 @@ export class CloudflareConversationClient extends Context.Service<
389
447
  static readonly layer: Layer.Layer<
390
448
  CloudflareConversationClient,
391
449
  never,
392
- ConversationObjectNamespace
450
+ ConversationObjectNamespace | Crypto.Crypto
393
451
  > = Layer.effect(CloudflareConversationClient)(
394
452
  Effect.gen(function* () {
395
453
  const { namespace } = yield* ConversationObjectNamespace;
454
+ const crypto = yield* Crypto.Crypto;
455
+
456
+ const platformSignals = (cause: unknown) => {
457
+ let retryable: boolean | undefined;
458
+ let overloaded: boolean | undefined;
459
+ if (typeof cause === "object" && cause !== null) {
460
+ if ("retryable" in cause && typeof cause.retryable === "boolean") {
461
+ retryable = cause.retryable;
462
+ }
463
+ if ("overloaded" in cause && typeof cause.overloaded === "boolean") {
464
+ overloaded = cause.overloaded;
465
+ }
466
+ // Miniflare's faithful `ctx.abort()` signal predates the public `retryable` field.
467
+ // Treat only its explicit reset marker as the same idempotent-retry classification.
468
+ if (
469
+ retryable === undefined &&
470
+ "durableObjectReset" in cause &&
471
+ cause.durableObjectReset === true
472
+ ) {
473
+ retryable = true;
474
+ }
475
+ }
476
+ return {
477
+ ...(retryable === undefined ? {} : { retryable }),
478
+ ...(overloaded === undefined ? {} : { overloaded }),
479
+ };
480
+ };
396
481
 
397
482
  const call = (
398
483
  conversationId: string,
@@ -410,6 +495,7 @@ export class CloudflareConversationClient extends Context.Service<
410
495
  }`,
411
496
  ),
412
497
  cause,
498
+ ...platformSignals(cause),
413
499
  }),
414
500
  }).pipe(
415
501
  Effect.flatMap((raw) =>
@@ -488,6 +574,19 @@ export class CloudflareConversationClient extends Context.Service<
488
574
  return page.records;
489
575
  });
490
576
 
577
+ const cancelProgress = (
578
+ conversationId: ConversationId,
579
+ waiterId: string,
580
+ ): Effect.Effect<void> =>
581
+ encodeCancelProgressRequest(CancelProgressRequest.make({ waiterId })).pipe(
582
+ Effect.mapError(() => undefined),
583
+ Effect.flatMap((encoded) =>
584
+ call(conversationId, "cancelProgress", (stub) => stub.cancelProgressEncoded(encoded)),
585
+ ),
586
+ Effect.asVoid,
587
+ Effect.ignore,
588
+ );
589
+
491
590
  return CloudflareConversationClient.of({
492
591
  submit: <InputSchema extends Schema.Top>(
493
592
  agent: DurableSubmitAgent<InputSchema>,
@@ -558,6 +657,55 @@ export class CloudflareConversationClient extends Context.Service<
558
657
  return settled.settlement;
559
658
  }),
560
659
 
660
+ awaitProgress: (conversationId, afterSequence) =>
661
+ Effect.gen(function* () {
662
+ const waiterId = yield* crypto.randomUUIDv4.pipe(
663
+ Effect.mapError((error) =>
664
+ HostProtocolError.make({
665
+ message: boundHostDiagnostic(
666
+ `awaitProgress cancellation identity generation failed: ${error.message}`,
667
+ ),
668
+ }),
669
+ ),
670
+ );
671
+ const request = AwaitProgressRequest.make({ afterSequence, waiterId });
672
+ const encoded = yield* encodeAwaitProgressRequest(request).pipe(
673
+ Effect.mapError((error) =>
674
+ HostProtocolError.make({
675
+ message: boundHostDiagnostic(
676
+ `awaitProgress request encode failed: ${error.message}`,
677
+ ),
678
+ }),
679
+ ),
680
+ );
681
+
682
+ const attempt = (retry: number): Effect.Effect<void, ClientProgressFailure> =>
683
+ call(conversationId, "awaitProgress", (stub) =>
684
+ stub.awaitProgressEncoded(encoded),
685
+ ).pipe(
686
+ Effect.flatMap(
687
+ expect<ProgressObserved, ClientProgressFailure & HostFailure>(
688
+ conversationId,
689
+ "awaitProgress",
690
+ "ProgressObserved",
691
+ PROGRESS_FAILURE_TAGS,
692
+ ),
693
+ ),
694
+ Effect.asVoid,
695
+ Effect.catchTag("ConversationClientError", (error) =>
696
+ error.retryable === true && error.overloaded !== true && retry < 5
697
+ ? Effect.sleep(Duration.millis(10 * 2 ** retry)).pipe(
698
+ Effect.andThen(attempt(retry + 1)),
699
+ )
700
+ : Effect.fail(error),
701
+ ),
702
+ );
703
+
704
+ yield* attempt(0).pipe(
705
+ Effect.onInterrupt(() => cancelProgress(conversationId, waiterId)),
706
+ );
707
+ }),
708
+
561
709
  readPage,
562
710
 
563
711
  readAll: (conversationId) =>
@@ -27,6 +27,7 @@ import {
27
27
  SettlementConflict,
28
28
  SubmissionLedger,
29
29
  SubmissionLookupByKey,
30
+ WakeScheduler,
30
31
  type DurableSubmitAgent,
31
32
  } from "@effect-agent/session";
32
33
  import type { DurableObject as CloudflareDurableObject } from "cloudflare:workers";
@@ -56,11 +57,15 @@ import {
56
57
  HostFailed,
57
58
  HostProtocolError,
58
59
  ObservedPage,
60
+ ProgressObserved,
61
+ ProgressCancelled,
59
62
  SettlementReached,
60
63
  SubmitSucceeded,
61
64
  UnknownResolutionRecorded,
62
65
  boundHostDiagnostic,
63
66
  decodeAbortCommand,
67
+ decodeAwaitProgressRequest,
68
+ decodeCancelProgressRequest,
64
69
  decodeApprovalDecisionCommand,
65
70
  decodeObservePageRequest,
66
71
  decodeReceipt,
@@ -78,6 +83,7 @@ import {
78
83
  type CloudflareDurableRuntimeOptions,
79
84
  type CloudflareDurableRuntimeServices,
80
85
  } from "./layers.ts";
86
+ import { ProgressWaitRegistry } from "./progress-wait.ts";
81
87
 
82
88
  /**
83
89
  * `makeConversationObjectClass(options, observability?)` — the Conversation Durable Object
@@ -246,17 +252,15 @@ const submitEndpoint = (encoded: unknown): Effect.Effect<unknown, never, Endpoin
246
252
  const maintenance = yield* ConversationMaintenance;
247
253
  const runtime = yield* DurableAgentRuntime;
248
254
  yield* gateAdmissionLimits(request);
249
- // Alarm invariant: the alarm commits BEFORE the admission it will finish (D-P6-2).
250
- yield* maintenance.preArm;
251
- const receipt = yield* runtime.submit(
252
- passthroughSubmitAgent(request.agentId),
253
- request.inputPayload,
254
- {
255
+ // Alarm invariant: the generation + alarm commit BEFORE the admission, and maintenance
256
+ // cannot acknowledge that generation until this mutation leaves its public RPC seam.
257
+ const receipt = yield* maintenance.withMutation(
258
+ runtime.submit(passthroughSubmitAgent(request.agentId), request.inputPayload, {
255
259
  conversationId: identity.conversationId,
256
260
  principal: request.principal,
257
261
  idempotencyKey: request.idempotencyKey,
258
262
  definitions: request.definitions,
259
- },
263
+ }),
260
264
  );
261
265
  return SubmitSucceeded.make({ receipt });
262
266
  }),
@@ -281,6 +285,46 @@ const awaitSettlementEndpoint = (
281
285
  Effect.flatMap(encodeResponse),
282
286
  );
283
287
 
288
+ const awaitProgressEndpoint = (encoded: unknown): Effect.Effect<unknown, never, EndpointServices> =>
289
+ decodeAwaitProgressRequest(encoded).pipe(
290
+ Effect.mapError(protocolFailure("The progress request could not be decoded")),
291
+ Effect.flatMap((request) =>
292
+ Effect.gen(function* () {
293
+ const identity = yield* ConversationObjectIdentity;
294
+ const runtime = yield* DurableAgentRuntime;
295
+ const registry = yield* ProgressWaitRegistry;
296
+ yield* Effect.scoped(
297
+ Effect.gen(function* () {
298
+ const cancelled = yield* registry.subscribe(request.waiterId);
299
+ yield* Effect.raceFirst(
300
+ runtime.awaitProgress(identity.conversationId, request.afterSequence),
301
+ cancelled,
302
+ );
303
+ }),
304
+ );
305
+ return ProgressObserved.make();
306
+ }),
307
+ ),
308
+ respond,
309
+ Effect.flatMap(encodeResponse),
310
+ );
311
+
312
+ const cancelProgressEndpoint = (
313
+ encoded: unknown,
314
+ ): Effect.Effect<unknown, never, EndpointServices> =>
315
+ decodeCancelProgressRequest(encoded).pipe(
316
+ Effect.mapError(protocolFailure("The progress cancellation could not be decoded")),
317
+ Effect.flatMap((request) =>
318
+ Effect.gen(function* () {
319
+ const registry = yield* ProgressWaitRegistry;
320
+ yield* registry.cancel(request.waiterId);
321
+ return ProgressCancelled.make();
322
+ }),
323
+ ),
324
+ respond,
325
+ Effect.flatMap(encodeResponse),
326
+ );
327
+
284
328
  const observePageEndpoint = (encoded: unknown): Effect.Effect<unknown, never, EndpointServices> =>
285
329
  decodeObservePageRequest(encoded).pipe(
286
330
  Effect.mapError(protocolFailure("The observe request could not be decoded")),
@@ -291,14 +335,12 @@ const observePageEndpoint = (encoded: unknown): Effect.Effect<unknown, never, En
291
335
  // The same fail-closed authorization seam the runtime's `observe` consults (P7 WP1);
292
336
  // the default reference preserves the possession behavior.
293
337
  const authorizer = yield* OperationAuthorizer;
294
- yield* authorizer
295
- .authorize(
296
- OperationAuthorizationRequest.make({
297
- operation: "observe",
298
- conversationId: identity.conversationId,
299
- }),
300
- )
301
- .pipe(Effect.catchTag("OperationDenied", deniedToProtocolFailure));
338
+ yield* authorizer.authorize(
339
+ OperationAuthorizationRequest.make({
340
+ operation: "observe",
341
+ conversationId: identity.conversationId,
342
+ }),
343
+ );
302
344
  const records = yield* Stream.runCollect(
303
345
  store.read(
304
346
  ConversationRead.make({
@@ -324,8 +366,7 @@ const abortEndpoint = (encoded: unknown): Effect.Effect<unknown, never, Endpoint
324
366
  Effect.gen(function* () {
325
367
  const maintenance = yield* ConversationMaintenance;
326
368
  const runtime = yield* DurableAgentRuntime;
327
- yield* maintenance.preArm;
328
- const intent = yield* runtime.abort(command);
369
+ const intent = yield* maintenance.withMutation(runtime.abort(command));
329
370
  return AbortRecorded.make({ intent });
330
371
  }),
331
372
  ),
@@ -333,24 +374,6 @@ const abortEndpoint = (encoded: unknown): Effect.Effect<unknown, never, Endpoint
333
374
  Effect.flatMap(encodeResponse),
334
375
  );
335
376
 
336
- /**
337
- * The pre-P7 host protocol's failure union does not carry `OperationDenied` (the Worker client
338
- * predates the authorizer). This assembly always runs the default possession authorizer — no
339
- * `CloudflareDurableRuntimeOptions` authorizer lever exists yet — so a denial here is
340
- * unreachable today; if one ever surfaces it degrades to the protocol failure instead of an
341
- * out-of-contract throw. The four P7 admin entry points below carry `OperationDenied` typed.
342
- */
343
- const deniedToProtocolFailure = (
344
- denied: OperationDenied,
345
- ): Effect.Effect<never, HostProtocolError> =>
346
- Effect.fail(
347
- HostProtocolError.make({
348
- message: boundHostDiagnostic(
349
- `The ${denied.operation} operation was denied: ${denied.reason}`,
350
- ),
351
- }),
352
- );
353
-
354
377
  const resolveApprovalEndpoint = (
355
378
  encoded: unknown,
356
379
  ): Effect.Effect<unknown, never, EndpointServices> =>
@@ -360,10 +383,7 @@ const resolveApprovalEndpoint = (
360
383
  Effect.gen(function* () {
361
384
  const maintenance = yield* ConversationMaintenance;
362
385
  const runtime = yield* DurableAgentRuntime;
363
- yield* maintenance.preArm;
364
- const intent = yield* runtime
365
- .resolveApproval(command)
366
- .pipe(Effect.catchTag("OperationDenied", deniedToProtocolFailure));
386
+ const intent = yield* maintenance.withMutation(runtime.resolveApproval(command));
367
387
  return ApprovalRecorded.make({ intent });
368
388
  }),
369
389
  ),
@@ -380,10 +400,7 @@ const resolveUnknownEndpoint = (
380
400
  Effect.gen(function* () {
381
401
  const maintenance = yield* ConversationMaintenance;
382
402
  const runtime = yield* DurableAgentRuntime;
383
- yield* maintenance.preArm;
384
- const intent = yield* runtime
385
- .resolveUnknown(command)
386
- .pipe(Effect.catchTag("OperationDenied", deniedToProtocolFailure));
403
+ const intent = yield* maintenance.withMutation(runtime.resolveUnknown(command));
387
404
  return UnknownResolutionRecorded.make({ intent });
388
405
  }),
389
406
  ),
@@ -541,10 +558,8 @@ const retryEndpoint = (encoded: unknown): Effect.Effect<unknown, never, Endpoint
541
558
  Effect.gen(function* () {
542
559
  const maintenance = yield* ConversationMaintenance;
543
560
  const runtime = yield* DurableAgentRuntime;
544
- // Alarm invariant: retry may repair durable state, so the alarm that will finish the
545
- // lane commits BEFORE the mutation (D-P6-2), exactly like abort.
546
- yield* maintenance.preArm;
547
- const report = yield* runtime.retry(command);
561
+ // Retry may repair durable state, so its generation + alarm commit before the mutation.
562
+ const report = yield* maintenance.withMutation(runtime.retry(command));
548
563
  return RetryExecuted.make({ report });
549
564
  }),
550
565
  ),
@@ -567,10 +582,11 @@ const obligationsEndpoint = (encoded: unknown): Effect.Effect<unknown, never, En
567
582
  );
568
583
 
569
584
  /**
570
- * Owner-side `portCall`: pre-arm before a mutating envelope (a routed admission committed by
571
- * THIS Object must already carry the alarm that will finish it), execute on the LOCAL facets
572
- * (never the routed decorators), then arm an immediate alarm so the mutated lane is
573
- * processed promptly. Protocol anomalies answer `PortFailed(PortProtocolError)`.
585
+ * Owner-side `portCall`: wrap a mutating envelope in the same pre-armed generation protocol as
586
+ * public RPC (a routed mutation committed by THIS Object must already carry the alarm that will
587
+ * finish it), execute on the LOCAL facets (never the routed decorators), then arm an immediate
588
+ * alarm so the mutated lane is processed promptly. Protocol anomalies answer
589
+ * `PortFailed(PortProtocolError)`.
574
590
  */
575
591
  const portCallEndpoint = (encoded: unknown): Effect.Effect<unknown, never, EndpointServices> =>
576
592
  Effect.gen(function* () {
@@ -578,16 +594,17 @@ const portCallEndpoint = (encoded: unknown): Effect.Effect<unknown, never, Endpo
578
594
  const maintenance = yield* ConversationMaintenance;
579
595
  const alarm = yield* DurableAlarmService;
580
596
  const mutating = isMutatingPortRequest(encoded);
581
- if (mutating) {
582
- const preArmed = yield* maintenance.preArm.pipe(Effect.exit);
583
- if (preArmed._tag === "Failure") {
584
- // Without the committed alarm the invariant cannot be promised; refuse the mutation.
585
- return encodedPortProtocolFailure(
586
- "The owner Object could not arm its maintenance alarm before the mutation.",
587
- );
588
- }
597
+ const handled = yield* (
598
+ mutating ? maintenance.withMutation(ports.handle(encoded)) : ports.handle(encoded)
599
+ ).pipe(Effect.exit);
600
+ if (handled._tag === "Failure") {
601
+ // Without the committed generation/alarm the invariant cannot be promised; refuse before
602
+ // the port mutation runs. `ports.handle` itself is total, so this is the maintenance error.
603
+ return encodedPortProtocolFailure(
604
+ "The owner Object could not arm its maintenance alarm before the mutation.",
605
+ );
589
606
  }
590
- const response = yield* ports.handle(encoded);
607
+ const response = handled.value;
591
608
  if (mutating) {
592
609
  // Prompt processing hint; the pre-armed alarm already guarantees convergence.
593
610
  yield* alarm.scheduleNow.pipe(
@@ -600,19 +617,18 @@ const portCallEndpoint = (encoded: unknown): Effect.Effect<unknown, never, Endpo
600
617
  });
601
618
 
602
619
  const wakeEndpoint: Effect.Effect<void, never, EndpointServices> = Effect.gen(function* () {
603
- const alarm = yield* DurableAlarmService;
604
- // Wake hints are droppable by contract: a failed alarm write is logged and swallowed; the
605
- // sender's own alarm/scan pairing (or this Object's next entry point) restores liveness.
606
- yield* alarm.scheduleNow.pipe(
607
- Effect.catch((error) => Effect.logWarning("ConversationObject.wake dropped", error)),
608
- );
620
+ const identity = yield* ConversationObjectIdentity;
621
+ const wake = yield* WakeScheduler;
622
+ // Route the remote hint through this incarnation's scheduler so scoped progress waiters and
623
+ // the alarm receive the same hint. Delivery remains droppable; canonical storage is authority.
624
+ yield* wake.notify(identity.conversationId);
609
625
  });
610
626
 
611
627
  const alarmEndpoint: Effect.Effect<void, MaintenancePassFailure, EndpointServices> = Effect.gen(
612
628
  function* () {
613
629
  const maintenance = yield* ConversationMaintenance;
614
630
  // Typed pass failures propagate: the rejected promise makes workerd retry the alarm
615
- // (at-least-once delivery), and the pass's own pre-arm keeps the slot committed meanwhile.
631
+ // (at-least-once delivery), and the dirty generation retains a committed slot meanwhile.
616
632
  yield* maintenance.pass;
617
633
  },
618
634
  );
@@ -660,6 +676,8 @@ const effectCfPlatformLayer = (
660
676
  export interface ConversationObjectInstance extends CloudflareDurableObject {
661
677
  submitEncoded(encoded: unknown): Promise<unknown>;
662
678
  awaitSettlementEncoded(encoded: unknown): Promise<unknown>;
679
+ awaitProgressEncoded(encoded: unknown): Promise<unknown>;
680
+ cancelProgressEncoded(encoded: unknown): Promise<unknown>;
663
681
  observePage(encoded: unknown): Promise<unknown>;
664
682
  abortEncoded(encoded: unknown): Promise<unknown>;
665
683
  resolveApprovalEncoded(encoded: unknown): Promise<unknown>;
@@ -728,6 +746,8 @@ export const makeConversationObjectClass = <EventLayerError = never, EventServic
728
746
  const rpc = {
729
747
  submitEncoded: (encoded: unknown) => submitEndpoint(encoded),
730
748
  awaitSettlementEncoded: (encoded: unknown) => awaitSettlementEndpoint(encoded),
749
+ awaitProgressEncoded: (encoded: unknown) => awaitProgressEndpoint(encoded),
750
+ cancelProgressEncoded: (encoded: unknown) => cancelProgressEndpoint(encoded),
731
751
  observePage: (encoded: unknown) => observePageEndpoint(encoded),
732
752
  abortEncoded: (encoded: unknown) => abortEndpoint(encoded),
733
753
  resolveApprovalEncoded: (encoded: unknown) => resolveApprovalEndpoint(encoded),
package/src/index.ts CHANGED
@@ -6,8 +6,9 @@
6
6
  * `makeConversationObjectClass` builds the class applications export from their Worker,
7
7
  * `CloudflareDurableRuntime.layer` assembles the coordinator over the storage-cloudflare
8
8
  * adapters and the WP2 cross-Object routing, `DurableAlarmService`/`ConversationMaintenance`
9
- * multiplex every cadence into the Object's single alarm slot (nonterminal work implies a
10
- * committed alarm, so eviction recovers without an incoming request), and
9
+ * multiplex every cadence into the Object's single alarm slot (dirty or autonomously
10
+ * actionable work retains a committed alarm; stable external waits quiesce until their next
11
+ * durably pre-armed mutation), and
11
12
  * `CloudflareConversationClient` is the Worker-side ingress. Platform bindings enter ONLY
12
13
  * through the `bindings.ts` Layers (DEPLOY-010). This is the only workspace package allowed
13
14
  * to import the `cloudflare:workers` runtime module.
@@ -16,6 +17,7 @@ export * from "./bindings.ts";
16
17
  export * from "./config.ts";
17
18
  export * from "./alarm.ts";
18
19
  export * from "./wake-scheduler.ts";
20
+ export * from "./progress-wait.ts";
19
21
  export * from "./transport.ts";
20
22
  export * from "./layers.ts";
21
23
  export * from "./conversation-object.ts";