@effect-agent/platform-cloudflare 0.1.0-beta.95 → 0.1.0-beta.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { DurableObjectContext, ThreadObjectIdentity, ThreadObjectNamespace, ThreadObjectPlacement } from "./CloudflareBindings.mjs";
2
- import { l as CloudflarePlatformConfigError, s as CloudflareDurableRuntimeConfig, t as AdmissionLimitExceeded } from "./CloudflareConfig-D472JOQA.mjs";
2
+ import { l as CloudflarePlatformConfigError, s as CloudflareDurableRuntimeConfig, t as AdmissionLimitExceeded } from "./CloudflareConfig-DKXGo8L3.mjs";
3
3
  import { DurableAlarmError, DurableAlarmService, ThreadMaintenance, ThreadMaintenanceFailpoint, ThreadMaintenanceFailpointHandler, ThreadMutationGate, ThreadPublication } from "./Alarm.mjs";
4
4
  import { HostProtocolError, SubmitRequest } from "./CloudflareThreadClient.mjs";
5
5
  import { Context, Crypto, Effect, Layer, Option, Schema, Scope } from "effect";
@@ -55,9 +55,9 @@ interface CloudflareDurableRuntimeOptions {
55
55
  readonly ownershipLeaseDuration?: number | undefined;
56
56
  /** Milliseconds; default 100. */
57
57
  readonly alarmBackoffBase?: number | undefined;
58
- /** Milliseconds; default 5000. */
58
+ /** Failed/no-progress exponential backoff ceiling in milliseconds; default 5000. */
59
59
  readonly alarmBackoffCap?: number | undefined;
60
- /** Milliseconds; default 1000. Bounds every alarm re-arm delay. */
60
+ /** Fallback scan cadence for newly dirty work in milliseconds; default 1000. */
61
61
  readonly wakeScanInterval?: number | undefined;
62
62
  /** Milliseconds; default 500. */
63
63
  readonly settlementPollInterval?: number | undefined;
@@ -721,7 +721,7 @@ declare const encodeAdminResponse: (input: AdminFailed | ExplainedRecovery | Obl
721
721
  readonly cause?: Schema.Json | undefined;
722
722
  } | {
723
723
  readonly _tag: "DurableRuntimeFailpointError";
724
- readonly location: "abort:after-intent" | "approval:after-request-append" | "approval:after-suspend" | "checkpoint:after-save" | "checkpoint:before-save" | "claim:after-claim" | "compaction:after-canonical-append" | "compaction:before-canonical-append" | "input:after-canonical-append" | "join:after-canonical-append" | "join:after-claim" | "policy:after-reservation-append" | "policy:before-reservation-append" | "resolve:after-intent" | "run:after-start-append" | "run:before-start-append" | "step:after-step-append" | "subagent:after-admit" | "subagent:after-child-abort-intent" | "subagent:after-child-ready" | "subagent:after-join-append" | "subagent:after-release" | "subagent:after-release-pending" | "subagent:after-request-append" | "subagent:after-reserve" | "subagent:after-sibling-settle" | "subagent:after-start-append" | "subagent:after-suspend" | "subagent:before-join-append" | "submit:after-admit" | "submit:after-materialize" | "terminalize:after-canonical-append" | "terminalize:after-reserve" | "tools:after-prepared-append" | "tools:before-prepared-append" | "turn:after-canonical-append" | "turn:after-response-append" | "turn:after-results-append" | "update:after-canonical-append" | "update:after-delivery-insert" | "update:before-canonical-append" | "update:before-delivery-insert" | "worker:after-completion-append" | "worker:after-origin-append" | "worker:after-report-append" | "worker:after-report-delivery" | "worker:after-source-append" | "worker:after-subtree-append" | "worker:before-completion-append" | "worker:before-origin-append" | "worker:before-report-append" | "worker:before-report-delivery" | "worker:before-source-append" | "worker:before-subtree-append";
724
+ readonly location: "abort:after-intent" | "approval:after-request-append" | "approval:after-suspend" | "checkpoint:after-save" | "checkpoint:before-save" | "claim:after-claim" | "compaction:after-canonical-append" | "compaction:before-canonical-append" | "input:after-canonical-append" | "join:after-canonical-append" | "join:after-claim" | "policy:after-reservation-append" | "policy:before-reservation-append" | "resolve:after-intent" | "run:after-duration-append" | "run:after-start-append" | "run:before-duration-append" | "run:before-start-append" | "step:after-step-append" | "subagent:after-admit" | "subagent:after-child-abort-intent" | "subagent:after-child-ready" | "subagent:after-join-append" | "subagent:after-release" | "subagent:after-release-pending" | "subagent:after-request-append" | "subagent:after-reserve" | "subagent:after-sibling-settle" | "subagent:after-start-append" | "subagent:after-suspend" | "subagent:before-join-append" | "submit:after-admit" | "submit:after-materialize" | "terminalize:after-canonical-append" | "terminalize:after-reserve" | "tools:after-prepared-append" | "tools:before-prepared-append" | "turn:after-canonical-append" | "turn:after-response-append" | "turn:after-results-append" | "update:after-canonical-append" | "update:after-delivery-insert" | "update:before-canonical-append" | "update:before-delivery-insert" | "worker:after-completion-append" | "worker:after-origin-append" | "worker:after-report-append" | "worker:after-report-delivery" | "worker:after-source-append" | "worker:after-subtree-append" | "worker:before-completion-append" | "worker:before-origin-append" | "worker:before-report-append" | "worker:before-report-delivery" | "worker:before-source-append" | "worker:before-subtree-append";
725
725
  } | {
726
726
  readonly _tag: "FenceRejected";
727
727
  readonly threadId: string;
@@ -811,4 +811,4 @@ interface Class<EventServices = never> {
811
811
  declare const make: <ApplicationServices, ApplicationError, EventServices = never, EventLayerError = never>(applicationLayer: Layer.Layer<CloudflareDurableRuntimeServices | ApplicationServices, ApplicationError, CloudflareBootstrapServices | DurableObjectState$1.DurableObjectState | WorkerEnvironment | DurableObjectContext | ThreadObjectNamespace>, options: Options<ApplicationServices, EventServices, EventLayerError>) => Class<ApplicationServices | EventServices>;
812
812
  //#endregion
813
813
  export { ThreadPublicationOptions as A, portCall as C, CloudflareDurableRuntimeOptions as D, CloudflareDurableRuntimeInitializationError as E, layerConfig as M, layerHostConfig as N, CloudflareDurableRuntimeServices as O, layerInHost as P, make as S, CloudflareBootstrapServices as T, decodeAdminVerifyRequest as _, AdminVerifyRequest as a, encodeAdminResponse as b, Instance as c, RetryExecuted as d, ThreadObject_d_exports as f, decodeAdminResponse as g, decodeAdminExplainRequest as h, AdminResponse as i, layer as j, ThreadObjectPorts as k, ObligationsScanned as l, VerifiedIntegrity as m, AdminFailed as n, Class as o, ThreadRpcOperation as p, AdminFailure as r, ExplainedRecovery as s, AdminExplainRequest as t, Options as u, decodeObligationThresholds as v, submit as w, handleRpc as x, decodeRetryCommand as y };
814
- //# sourceMappingURL=ThreadObject-CZxuDWOb.d.mts.map
814
+ //# sourceMappingURL=ThreadObject-DiqVDdJE.d.mts.map
@@ -1,2 +1,2 @@
1
- import { A as ThreadPublicationOptions, C as portCall, D as CloudflareDurableRuntimeOptions, E as CloudflareDurableRuntimeInitializationError, M as layerConfig, N as layerHostConfig, O as CloudflareDurableRuntimeServices, P as layerInHost, S as make, T as CloudflareBootstrapServices, _ as decodeAdminVerifyRequest, a as AdminVerifyRequest, b as encodeAdminResponse, c as Instance, d as RetryExecuted, g as decodeAdminResponse, h as decodeAdminExplainRequest, i as AdminResponse, j as layer, k as ThreadObjectPorts, l as ObligationsScanned, m as VerifiedIntegrity, n as AdminFailed, o as Class, p as ThreadRpcOperation, r as AdminFailure, s as ExplainedRecovery, t as AdminExplainRequest, u as Options, v as decodeObligationThresholds, w as submit, x as handleRpc, y as decodeRetryCommand } from "./ThreadObject-CZxuDWOb.mjs";
1
+ import { A as ThreadPublicationOptions, C as portCall, D as CloudflareDurableRuntimeOptions, E as CloudflareDurableRuntimeInitializationError, M as layerConfig, N as layerHostConfig, O as CloudflareDurableRuntimeServices, P as layerInHost, S as make, T as CloudflareBootstrapServices, _ as decodeAdminVerifyRequest, a as AdminVerifyRequest, b as encodeAdminResponse, c as Instance, d as RetryExecuted, g as decodeAdminResponse, h as decodeAdminExplainRequest, i as AdminResponse, j as layer, k as ThreadObjectPorts, l as ObligationsScanned, m as VerifiedIntegrity, n as AdminFailed, o as Class, p as ThreadRpcOperation, r as AdminFailure, s as ExplainedRecovery, t as AdminExplainRequest, u as Options, v as decodeObligationThresholds, w as submit, x as handleRpc, y as decodeRetryCommand } from "./ThreadObject-DiqVDdJE.mjs";
2
2
  export { AdminExplainRequest, AdminFailed, AdminFailure, AdminResponse, AdminVerifyRequest, type CloudflareBootstrapServices as BootstrapServices, Class, ExplainedRecovery, type CloudflareDurableRuntimeInitializationError as InitializationError, Instance, ObligationsScanned, Options, type ThreadPublicationOptions as PublicationOptions, RetryExecuted, type CloudflareDurableRuntimeOptions as RuntimeOptions, type CloudflareDurableRuntimeServices as Services, ThreadObjectPorts, ThreadRpcOperation, VerifiedIntegrity, decodeAdminExplainRequest, decodeAdminResponse, decodeAdminVerifyRequest, decodeObligationThresholds, decodeRetryCommand, encodeAdminResponse, handleRpc, layer, layerConfig, layerHostConfig, layerInHost, make, portCall, submit };
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as CloudflareBindings_d_exports } from "./CloudflareBindings.mjs";
2
- import { o as CloudflareConfig_d_exports } from "./CloudflareConfig-D472JOQA.mjs";
2
+ import { o as CloudflareConfig_d_exports } from "./CloudflareConfig-DKXGo8L3.mjs";
3
3
  import { t as Alarm_d_exports } from "./Alarm.mjs";
4
4
  import { t as CloudflareBrowser_d_exports } from "./CloudflareBrowser-Doe1M7R5.mjs";
5
5
  import { t as CloudflareAiGateway_d_exports } from "./CloudflareAiGateway.mjs";
@@ -8,6 +8,6 @@ import { t as CloudflareMemory_d_exports } from "./CloudflareMemory.mjs";
8
8
  import { t as CloudflareScheduling_d_exports } from "./CloudflareScheduling.mjs";
9
9
  import { t as CloudflareSubscriptions_d_exports } from "./CloudflareSubscriptions.mjs";
10
10
  import { t as CloudflareThreadClient_d_exports } from "./CloudflareThreadClient.mjs";
11
- import { f as ThreadObject_d_exports } from "./ThreadObject-CZxuDWOb.mjs";
11
+ import { f as ThreadObject_d_exports } from "./ThreadObject-DiqVDdJE.mjs";
12
12
  import { t as WakeScheduler_d_exports } from "./WakeScheduler.mjs";
13
13
  export { Alarm_d_exports as Alarm, CloudflareAiGateway_d_exports as CloudflareAiGateway, CloudflareBindings_d_exports as CloudflareBindings, CloudflareBrowser_d_exports as CloudflareBrowser, CloudflareCodeMode_d_exports as CloudflareCodeMode, CloudflareConfig_d_exports as CloudflareConfig, CloudflareMemory_d_exports as CloudflareMemory, CloudflareScheduling_d_exports as CloudflareScheduling, CloudflareSubscriptions_d_exports as CloudflareSubscriptions, CloudflareThreadClient_d_exports as CloudflareThreadClient, ThreadObject_d_exports as ThreadObject, WakeScheduler_d_exports as WakeScheduler };
package/package.json CHANGED
@@ -1 +1 @@
1
- {"name":"@effect-agent/platform-cloudflare","version":"0.1.0-beta.95","dependencies":{"@effect-agent/storage-cloudflare":"0.1.0-beta.95","@effect/platform-browser":"4.0.0-rc.115","@effect/sql-sqlite-do":"4.0.0-rc.115","effect-agent":"0.1.0-beta.95"},"devDependencies":{"@cloudflare/puppeteer":"1.1.0","@cloudflare/vitest-pool-workers":"0.21.3","@cloudflare/workers-types":"5.20260825.1","@effect-agent/testing":"0.1.0-beta.95","@effect/platform-node":"4.0.0-rc.115","@effect/sql-d1":"4.0.0-rc.115","@effect/vitest":"4.0.0-rc.115","effect":"4.0.0-rc.115","effect-cf":"0.43.0","esbuild":"0.28.1","miniflare":"5.20260811.1-alpha","typescript":"7.0.2","vite-plus":"0.3.0","vitest":"4.1.11"},"peerDependencies":{"@cloudflare/puppeteer":"^1.1.0","effect":"^4.0.0-rc.115","effect-cf":"^0.43.0"},"exports":{".":{"types":"./dist/index.d.mts","default":"./dist/index.mjs"},"./alarm":{"types":"./dist/Alarm.d.mts","default":"./dist/Alarm.mjs"},"./browser-rest-capture":{"types":"./dist/BrowserRestCapture.d.mts","default":"./dist/BrowserRestCapture.mjs"},"./browser-rest-crawl":{"types":"./dist/BrowserRestCrawl.d.mts","default":"./dist/BrowserRestCrawl.mjs"},"./cloudflare-bindings":{"types":"./dist/CloudflareBindings.d.mts","default":"./dist/CloudflareBindings.mjs"},"./cloudflare-browser":{"types":"./dist/CloudflareBrowser.d.mts","default":"./dist/CloudflareBrowser.mjs"},"./cloudflare-code-mode":{"types":"./dist/CloudflareCodeMode.d.mts","default":"./dist/CloudflareCodeMode.mjs"},"./cloudflare-config":{"types":"./dist/CloudflareConfig.d.mts","default":"./dist/CloudflareConfig.mjs"},"./cloudflare-memory":{"types":"./dist/CloudflareMemory.d.mts","default":"./dist/CloudflareMemory.mjs"},"./cloudflare-scheduling":{"types":"./dist/CloudflareScheduling.d.mts","default":"./dist/CloudflareScheduling.mjs"},"./cloudflare-subscriptions":{"types":"./dist/CloudflareSubscriptions.d.mts","default":"./dist/CloudflareSubscriptions.mjs"},"./cloudflare-thread-client":{"types":"./dist/CloudflareThreadClient.d.mts","default":"./dist/CloudflareThreadClient.mjs"},"./interactive-browser":{"types":"./dist/InteractiveBrowser.d.mts","default":"./dist/InteractiveBrowser.mjs"},"./protected-browser":{"types":"./dist/ProtectedBrowser.d.mts","default":"./dist/ProtectedBrowser.mjs"},"./thread-object":{"types":"./dist/ThreadObject.d.mts","default":"./dist/ThreadObject.mjs"},"./wake-scheduler":{"types":"./dist/WakeScheduler.d.mts","default":"./dist/WakeScheduler.mjs"},"./cloudflare-ai-gateway":{"types":"./dist/CloudflareAiGateway.d.mts","default":"./dist/CloudflareAiGateway.mjs"}},"description":"Cloudflare Layer assembly for Effect Agent: Durable Objects and Browser Run adapters.","license":"MIT","repository":{"type":"git","url":"git+https://github.com/danieljvdm/effect-agent.git","directory":"packages/platform-cloudflare"},"files":["dist","src"],"type":"module","sideEffects":[],"publishConfig":{"access":"public"},"scripts":{"build":"vp pack","check":"tsc --noEmit -p tsconfig.json"},"peerDependenciesMeta":{"@cloudflare/puppeteer":{"optional":true}}}
1
+ {"name":"@effect-agent/platform-cloudflare","version":"0.1.0-beta.96","dependencies":{"@effect-agent/storage-cloudflare":"0.1.0-beta.96","@effect/platform-browser":"4.0.0-rc.115","@effect/sql-sqlite-do":"4.0.0-rc.115","effect-agent":"0.1.0-beta.96"},"devDependencies":{"@cloudflare/puppeteer":"1.1.0","@cloudflare/vitest-pool-workers":"0.21.3","@cloudflare/workers-types":"5.20260825.1","@effect-agent/testing":"0.1.0-beta.96","@effect/platform-node":"4.0.0-rc.115","@effect/sql-d1":"4.0.0-rc.115","@effect/vitest":"4.0.0-rc.115","effect":"4.0.0-rc.115","effect-cf":"0.44.1","esbuild":"0.28.1","miniflare":"5.20260811.1-alpha","typescript":"7.0.2","vite-plus":"0.3.0","vitest":"4.1.11"},"peerDependencies":{"@cloudflare/puppeteer":"^1.1.0","effect":"^4.0.0-rc.115","effect-cf":"^0.44.1"},"exports":{".":{"types":"./dist/index.d.mts","default":"./dist/index.mjs"},"./alarm":{"types":"./dist/Alarm.d.mts","default":"./dist/Alarm.mjs"},"./browser-rest-capture":{"types":"./dist/BrowserRestCapture.d.mts","default":"./dist/BrowserRestCapture.mjs"},"./browser-rest-crawl":{"types":"./dist/BrowserRestCrawl.d.mts","default":"./dist/BrowserRestCrawl.mjs"},"./cloudflare-bindings":{"types":"./dist/CloudflareBindings.d.mts","default":"./dist/CloudflareBindings.mjs"},"./cloudflare-browser":{"types":"./dist/CloudflareBrowser.d.mts","default":"./dist/CloudflareBrowser.mjs"},"./cloudflare-code-mode":{"types":"./dist/CloudflareCodeMode.d.mts","default":"./dist/CloudflareCodeMode.mjs"},"./cloudflare-config":{"types":"./dist/CloudflareConfig.d.mts","default":"./dist/CloudflareConfig.mjs"},"./cloudflare-memory":{"types":"./dist/CloudflareMemory.d.mts","default":"./dist/CloudflareMemory.mjs"},"./cloudflare-scheduling":{"types":"./dist/CloudflareScheduling.d.mts","default":"./dist/CloudflareScheduling.mjs"},"./cloudflare-subscriptions":{"types":"./dist/CloudflareSubscriptions.d.mts","default":"./dist/CloudflareSubscriptions.mjs"},"./cloudflare-thread-client":{"types":"./dist/CloudflareThreadClient.d.mts","default":"./dist/CloudflareThreadClient.mjs"},"./interactive-browser":{"types":"./dist/InteractiveBrowser.d.mts","default":"./dist/InteractiveBrowser.mjs"},"./protected-browser":{"types":"./dist/ProtectedBrowser.d.mts","default":"./dist/ProtectedBrowser.mjs"},"./thread-object":{"types":"./dist/ThreadObject.d.mts","default":"./dist/ThreadObject.mjs"},"./wake-scheduler":{"types":"./dist/WakeScheduler.d.mts","default":"./dist/WakeScheduler.mjs"},"./cloudflare-ai-gateway":{"types":"./dist/CloudflareAiGateway.d.mts","default":"./dist/CloudflareAiGateway.mjs"}},"description":"Cloudflare Layer assembly for Effect Agent: Durable Objects and Browser Run adapters.","license":"MIT","repository":{"type":"git","url":"git+https://github.com/danieljvdm/effect-agent.git","directory":"packages/platform-cloudflare"},"files":["dist","src"],"type":"module","sideEffects":[],"publishConfig":{"access":"public"},"scripts":{"build":"vp pack","check":"tsc --noEmit -p tsconfig.json"},"peerDependenciesMeta":{"@cloudflare/puppeteer":{"optional":true}}}
package/src/Alarm.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  Scope,
16
16
  Semaphore,
17
17
  Stream,
18
+ Struct,
18
19
  } from "effect";
19
20
  import { type DurableBindingFailure } from "effect-agent/agent-registration";
20
21
  import {
@@ -22,7 +23,7 @@ import {
22
23
  type DurableWorkerFailure,
23
24
  type RecoveryReport,
24
25
  } from "effect-agent/durable-agent-runtime";
25
- import { ThreadId } from "effect-agent/identifiers";
26
+ import { ThreadId, SubmissionId } from "effect-agent/identifiers";
26
27
  import { SubmissionLedger, type SubmissionSnapshot } from "effect-agent/submission-ledger";
27
28
  import {
28
29
  ThreadProjectionMaintenance,
@@ -207,6 +208,10 @@ export type ThreadMaintenanceFailpointLocation =
207
208
  | "maintenance:begin:after"
208
209
  | "maintenance:select:before"
209
210
  | "maintenance:select:after"
211
+ | "maintenance:binding-retry:before"
212
+ | "maintenance:binding-retry:after"
213
+ | "maintenance:retry:before"
214
+ | "maintenance:retry:after"
210
215
  | "maintenance:finish:before"
211
216
  | "maintenance:finish:after";
212
217
 
@@ -338,6 +343,26 @@ const MaintenanceGeneration = Schema.BigIntFromString.check(
338
343
  Schema.isGreaterThanOrEqualToBigInt(0n),
339
344
  );
340
345
 
346
+ class BindingRetry extends Schema.Class<BindingRetry>("BindingRetry")({
347
+ threadId: ThreadId,
348
+ submissionId: SubmissionId,
349
+ attempts: Schema.Natural,
350
+ notBefore: Schema.Finite,
351
+ reportedAt: Schema.Finite,
352
+ }) {}
353
+
354
+ interface MaintenanceObservation {
355
+ generation?: bigint;
356
+ nativeOnly: boolean;
357
+ }
358
+
359
+ class MaintenanceRetry extends Schema.Class<MaintenanceRetry>("MaintenanceRetry")({
360
+ generation: MaintenanceGeneration,
361
+ notBefore: Schema.Finite,
362
+ nativeOnly: Schema.Boolean,
363
+ stalls: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(30)),
364
+ }) {}
365
+
341
366
  /** Versioned, platform-private maintenance state stored through Durable Object KV. */
342
367
  class ThreadMaintenanceState extends Schema.Class<ThreadMaintenanceState>(
343
368
  "@effect-agent/platform-cloudflare/ThreadMaintenanceState",
@@ -348,6 +373,9 @@ class ThreadMaintenanceState extends Schema.Class<ThreadMaintenanceState>(
348
373
  nonterminal: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
349
374
  /** One physical-owner cursor; old single-lane records need no conversion. */
350
375
  lastServedThreadId: Schema.optionalKey(ThreadId),
376
+ bindingRetries: Schema.optionalKey(Schema.Array(BindingRetry)),
377
+ /** Absent on older records. A newer mutation makes this retry obsolete. */
378
+ retry: Schema.optionalKey(MaintenanceRetry),
351
379
  }) {}
352
380
 
353
381
  const MAINTENANCE_STATE_KEY = "effect-agent:thread-maintenance:v1";
@@ -525,7 +553,7 @@ export type MaintenancePassFailure =
525
553
  export class ThreadMaintenance extends Context.Service<
526
554
  ThreadMaintenance,
527
555
  {
528
- /** One idempotent maintenance pass; failures propagate so workerd retries the alarm. */
556
+ /** One idempotent pass; failures propagate after durably scheduling bounded recovery. */
529
557
  readonly pass: Effect.Effect<MaintenancePassReport, MaintenancePassFailure>;
530
558
  /**
531
559
  * Constructor gate: initialize/inspect only the O(1) maintenance record and ensure a dirty
@@ -564,11 +592,6 @@ export class ThreadMaintenance extends Context.Service<
564
592
  const { ctx } = yield* DurableObjectContext;
565
593
  const failpoint = yield* ThreadMaintenanceFailpoint;
566
594
 
567
- /**
568
- * Consecutive no-progress passes — an in-memory CACHE, not state: a fresh incarnation
569
- * restarts at zero and merely re-arms sooner than a long-lived one would have.
570
- */
571
- const stalls = yield* Ref.make(0);
572
595
  const mutations = yield* ThreadMutationGate;
573
596
  const publication = yield* ThreadPublication;
574
597
  const projection = yield* ThreadProjectionMaintenance;
@@ -602,7 +625,7 @@ export class ThreadMaintenance extends Context.Service<
602
625
  yield* failpoint.hit("maintenance:ensure:before");
603
626
  const now = yield* Clock.currentTimeMillis;
604
627
 
605
- yield* runTransaction("ensure maintenance alarm", () =>
628
+ const retry = yield* runTransaction("ensure maintenance alarm", () =>
606
629
  ctx.storage.transaction(async (transaction) => {
607
630
  const { state, initialized } = await readMaintenanceState(transaction);
608
631
 
@@ -610,10 +633,18 @@ export class ThreadMaintenance extends Context.Service<
610
633
  await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(state));
611
634
  }
612
635
  if (state.dirty > state.processed) {
613
- await ensureTransactionAlarmBy(transaction, now + config.wakeScanInterval);
636
+ await ensureTransactionAlarmBy(
637
+ transaction,
638
+ state.retry?.generation === state.dirty
639
+ ? Math.max(now + minimumAlarmDelay, state.retry.notBefore)
640
+ : now + config.wakeScanInterval,
641
+ );
614
642
  }
643
+
644
+ return state.retry?.generation === state.dirty ? state.retry : undefined;
615
645
  }),
616
646
  );
647
+
617
648
  const deadline = yield* pendingDeadline;
618
649
 
619
650
  if (Option.isSome(deadline)) {
@@ -621,7 +652,12 @@ export class ThreadMaintenance extends Context.Service<
621
652
  ctx.storage.transaction((transaction) =>
622
653
  ensureTransactionAlarmBy(
623
654
  transaction,
624
- Math.max(now + minimumAlarmDelay, deadline.value),
655
+ Math.max(
656
+ now + minimumAlarmDelay,
657
+ deadline.value <= now && retry !== undefined && !retry.nativeOnly
658
+ ? Math.max(deadline.value, retry.notBefore)
659
+ : deadline.value,
660
+ ),
625
661
  ),
626
662
  ),
627
663
  );
@@ -629,7 +665,9 @@ export class ThreadMaintenance extends Context.Service<
629
665
  yield* failpoint.hit("maintenance:ensure:after");
630
666
  });
631
667
 
632
- const beginPass = Effect.fn("ThreadMaintenance.beginPass")(function* () {
668
+ const beginPass = Effect.fn("ThreadMaintenance.beginPass")(function* (observed: {
669
+ generation?: bigint;
670
+ }) {
633
671
  yield* failpoint.hit("maintenance:begin:before");
634
672
  const now = yield* Clock.currentTimeMillis;
635
673
 
@@ -637,26 +675,27 @@ export class ThreadMaintenance extends Context.Service<
637
675
  ctx.storage.transaction(async (transaction) => {
638
676
  const { state, initialized } = await readMaintenanceState(transaction);
639
677
 
678
+ observed.generation = state.dirty;
640
679
  if (!initialized) {
641
680
  await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(state));
642
681
  }
643
- if (state.processed >= state.dirty) {
644
- // Prearm even a publication-only pass before invoking any host hook.
645
- await ensureTransactionAlarmBy(transaction, now + minimumAlarmDelay);
682
+ const retryAt = state.retry?.generation === state.dirty ? state.retry.notBefore : 0;
646
683
 
684
+ // Prearm before recovery or any host hook, including publication-only passes.
685
+ // A completed pass may move this crash fallback later to its bounded retry.
686
+ await ensureTransactionAlarmBy(transaction, now + minimumAlarmDelay);
687
+ if (state.processed >= state.dirty || retryAt > now) {
647
688
  return {
648
689
  _tag: "CaughtUp" as const,
649
690
  nonterminal: state.nonterminal,
650
691
  };
651
692
  }
652
- // Pre-arm the earliest retry before recovery. A successful finish may move this slot
653
- // LATER to its bounded backoff, which does not cancel the running handler.
654
- await ensureTransactionAlarmBy(transaction, now + minimumAlarmDelay);
655
693
 
656
694
  return {
657
695
  _tag: "Actionable" as const,
658
696
  generation: state.dirty,
659
697
  nonterminal: state.nonterminal,
698
+ stalls: state.retry?.generation === state.dirty ? state.retry.stalls : 0,
660
699
  };
661
700
  }),
662
701
  );
@@ -666,25 +705,88 @@ export class ThreadMaintenance extends Context.Service<
666
705
  return result;
667
706
  });
668
707
 
669
- const rearmDelay = Effect.fn("ThreadMaintenance.rearmDelay")(function* (progressed: boolean) {
670
- const priorStalls = yield* Ref.getAndUpdate(stalls, (count) =>
671
- progressed ? 0 : count + 1,
708
+ const backoffDelay = (priorStalls: number, jitter: number) => {
709
+ const backoff = Math.min(
710
+ config.alarmBackoffCap,
711
+ config.alarmBackoffBase * 2 ** Math.min(priorStalls, 30),
672
712
  );
673
713
 
674
- if (progressed) return config.alarmBackoffBase;
675
- const exponent = Math.min(priorStalls, 30);
676
- const backoff = Math.min(config.alarmBackoffCap, config.alarmBackoffBase * 2 ** exponent);
677
- const jitter = yield* Random.next;
678
- // Full jitter over [backoff/2, backoff]: desynchronizes retry storms without ever
679
- // waiting longer than the deterministic bound.
680
- const jittered = Math.ceil(backoff / 2 + (backoff / 2) * jitter);
714
+ // Jitter over [backoff/2, backoff] spreads retries without exceeding the cap.
715
+ return Math.ceil(backoff / 2 + (backoff / 2) * jitter);
716
+ };
681
717
 
682
- return Math.min(jittered, config.wakeScanInterval);
718
+ const rearmDelay = Effect.fn("ThreadMaintenance.rearmDelay")(function* (
719
+ progressed: boolean,
720
+ priorStalls: number,
721
+ ) {
722
+ return progressed ? config.alarmBackoffBase : backoffDelay(priorStalls, yield* Random.next);
723
+ });
724
+
725
+ const rearmFailure = Effect.fn("ThreadMaintenance.rearmFailure")(function* (
726
+ observed: MaintenanceObservation,
727
+ ) {
728
+ const { generation, nativeOnly } = observed;
729
+
730
+ if (generation === undefined) return;
731
+ yield* failpoint.hit("maintenance:retry:before");
732
+ yield* mutations.withSnapshot((active) =>
733
+ Effect.gen(function* () {
734
+ // A failed deadline read must not prevent committing the native retry.
735
+ const deadline = yield* pendingDeadline.pipe(
736
+ Effect.catchCause(() => Effect.succeed(Option.none<number>())),
737
+ );
738
+
739
+ const now = yield* Clock.currentTimeMillis;
740
+
741
+ const jitter = yield* Random.next;
742
+
743
+ yield* runTransaction("back off failed maintenance", () =>
744
+ ctx.storage.transaction(async (transaction) => {
745
+ const { state } = await readMaintenanceState(transaction);
746
+
747
+ const previous = state.retry?.generation === generation ? state.retry : undefined;
748
+
749
+ const retry = MaintenanceRetry.make({
750
+ generation,
751
+ notBefore: Math.max(
752
+ previous?.notBefore ?? 0,
753
+ now + backoffDelay(previous?.stalls ?? 0, jitter),
754
+ ),
755
+ nativeOnly,
756
+ stalls: Math.min(30, (previous?.stalls ?? 0) + 1),
757
+ });
758
+
759
+ await transaction.put(
760
+ MAINTENANCE_STATE_KEY,
761
+ encodeMaintenanceState(ThreadMaintenanceState.make({ ...state, retry })),
762
+ );
763
+
764
+ // Never postpone a producer that raced the failed observation. Host work
765
+ // retains its own deadline; an early delivery skips native recovery below.
766
+ const nativeDeadline =
767
+ active > 0 || state.dirty !== generation
768
+ ? now + minimumAlarmDelay
769
+ : retry.notBefore;
770
+
771
+ await transaction.setAlarm(
772
+ Math.max(
773
+ now + minimumAlarmDelay,
774
+ Option.isSome(deadline) && (nativeOnly || deadline.value > now)
775
+ ? Math.min(nativeDeadline, deadline.value)
776
+ : nativeDeadline,
777
+ ),
778
+ );
779
+ }),
780
+ );
781
+ }),
782
+ );
783
+ yield* failpoint.hit("maintenance:retry:after");
683
784
  });
684
785
 
685
786
  const pass = Effect.fn("ThreadMaintenance.pass")(function* (
686
787
  yieldAfter: DateTime.Utc,
687
788
  dispatchUntil: DateTime.Utc,
789
+ observed: MaintenanceObservation,
688
790
  ): Effect.fn.Return<MaintenancePassReport, MaintenancePassFailure, Scope.Scope> {
689
791
  const annotate = (report: MaintenancePassReport) =>
690
792
  Effect.annotateCurrentSpan({
@@ -697,7 +799,7 @@ export class ThreadMaintenance extends Context.Service<
697
799
 
698
800
  const started = yield* mutations.withSnapshot((activeAtStart) =>
699
801
  Effect.gen(function* () {
700
- const generation = yield* beginPass();
802
+ const generation = yield* beginPass(observed);
701
803
 
702
804
  if (generation._tag === "Actionable" && activeAtStart === 0) {
703
805
  // The gate excludes a producer starting between the snapshot and certification.
@@ -804,7 +906,9 @@ export class ThreadMaintenance extends Context.Service<
804
906
 
805
907
  const nativeDeadline =
806
908
  active > 0 || state.dirty > state.processed
807
- ? now + config.wakeScanInterval
909
+ ? state.retry?.generation === state.dirty && active === 0
910
+ ? Math.max(now + minimumAlarmDelay, state.retry.notBefore)
911
+ : now + config.wakeScanInterval
808
912
  : Infinity;
809
913
 
810
914
  const next = Option.isSome(latest)
@@ -837,6 +941,7 @@ export class ThreadMaintenance extends Context.Service<
837
941
  );
838
942
  }
839
943
  // Step 2 — reconciliation strictly precedes new work in this pass (exit gate).
944
+ observed.nativeOnly = true;
840
945
  const recovered: ReadonlyArray<RecoveryReport> = yield* runtime.runRecovery;
841
946
  const reports = new Map(recovered.map((report) => [report.submissionId, report]));
842
947
  const current = yield* Stream.runCollect(ledger.scanNonterminal);
@@ -851,44 +956,121 @@ export class ThreadMaintenance extends Context.Service<
851
956
  .map((head) => head.threadId)
852
957
  .sort();
853
958
 
854
- let selected = eligible[0];
959
+ const selectionTime = yield* Clock.currentTimeMillis;
855
960
 
856
- if (heads.size > 1 && selected !== undefined) {
857
- yield* failpoint.hit("maintenance:select:before");
858
- selected = yield* runTransaction("select maintenance lane", () =>
859
- ctx.storage.transaction(async (transaction) => {
860
- const { state } = await readMaintenanceState(transaction);
961
+ yield* failpoint.hit("maintenance:select:before");
861
962
 
862
- const next =
863
- eligible.find(
864
- (threadId) =>
865
- state.lastServedThreadId === undefined || threadId > state.lastServedThreadId,
866
- ) ?? eligible[0];
963
+ const selection = yield* runTransaction("select maintenance lane", () =>
964
+ ctx.storage.transaction(async (transaction) => {
965
+ const { state } = await readMaintenanceState(transaction);
867
966
 
868
- if (next !== undefined) {
869
- // Persist before the Attempt so an eviction or repeated yield cannot
870
- // monopolize the first lane. The generation and prearmed alarm survive.
871
- await transaction.put(
872
- MAINTENANCE_STATE_KEY,
873
- encodeMaintenanceState(
874
- ThreadMaintenanceState.make({ ...state, lastServedThreadId: next }),
875
- ),
876
- );
877
- }
967
+ const retries = (state.bindingRetries ?? []).filter(
968
+ (retry) => heads.get(retry.threadId)?.submissionId === retry.submissionId,
969
+ );
878
970
 
879
- return next;
880
- }),
881
- );
882
- yield* failpoint.hit("maintenance:select:after");
883
- }
971
+ const runnable = eligible.filter(
972
+ (threadId) =>
973
+ !retries.some(
974
+ (retry) => retry.threadId === threadId && retry.notBefore > selectionTime,
975
+ ),
976
+ );
977
+
978
+ const next =
979
+ runnable.find(
980
+ (threadId) =>
981
+ state.lastServedThreadId === undefined || threadId > state.lastServedThreadId,
982
+ ) ?? runnable[0];
983
+
984
+ if (next !== undefined) {
985
+ await transaction.put(
986
+ MAINTENANCE_STATE_KEY,
987
+ encodeMaintenanceState(
988
+ ThreadMaintenanceState.make({ ...state, lastServedThreadId: next }),
989
+ ),
990
+ );
991
+ }
884
992
 
885
- // One FIFO head per event, across all local lanes. The runtime keeps its normal
886
- // bounded Attempt and recovery contracts; followers belong to another alarm.
993
+ return { selected: next, retries };
994
+ }),
995
+ );
996
+
997
+ yield* failpoint.hit("maintenance:select:after");
998
+
999
+ const selected =
1000
+ selection.selected === undefined ? undefined : heads.get(selection.selected);
1001
+
1002
+ let retries = selection.retries;
1003
+ let bindingFailure: DurableBindingFailure | undefined;
1004
+
1005
+ // One FIFO head per event. An unavailable contract is a durable wait for compatible code,
1006
+ // including for children; other local lanes and host deliveries remain independently due.
887
1007
  const settlement =
888
1008
  selected === undefined
889
1009
  ? Option.none()
890
- : yield* runtime.processThreadHead(selected, { yieldAfter });
1010
+ : yield* runtime.processThreadHead(selected.threadId, { yieldAfter }).pipe(
1011
+ Effect.catchTag(["BindingUnavailable", "BindingDigestMismatch"], (failure) => {
1012
+ bindingFailure = failure;
1013
+
1014
+ return Effect.succeed(Option.none());
1015
+ }),
1016
+ );
1017
+
1018
+ if (selected !== undefined) {
1019
+ const previous = retries.find((retry) => retry.submissionId === selected.submissionId);
1020
+ let retry: BindingRetry | undefined;
1021
+ let reportBindingFailure = false;
1022
+
1023
+ if (bindingFailure !== undefined) {
1024
+ const now = yield* Clock.currentTimeMillis;
1025
+ const attempts = Math.min(30, (previous?.attempts ?? 0) + 1);
1026
+
1027
+ reportBindingFailure =
1028
+ previous === undefined || now - previous.reportedAt >= 15 * 60_000;
1029
+ retry = BindingRetry.make({
1030
+ threadId: selected.threadId,
1031
+ submissionId: selected.submissionId,
1032
+ attempts,
1033
+ notBefore: now + Math.min(60_000, 5_000 * 2 ** (attempts - 1)),
1034
+ reportedAt: reportBindingFailure ? now : (previous?.reportedAt ?? now),
1035
+ });
1036
+ }
1037
+ if (retry !== undefined || previous !== undefined) {
1038
+ // The Attempt released its Claim. Commit its binding wait (or clear) once,
1039
+ // before joining fallible auxiliary work. This local fact neither acknowledges
1040
+ // a generation nor changes the shared alarm; those require event retirement.
1041
+ yield* failpoint.hit("maintenance:binding-retry:before");
1042
+ retries = yield* runTransaction("record submission binding retry", () =>
1043
+ ctx.storage.transaction(async (transaction) => {
1044
+ const { state } = await readMaintenanceState(transaction);
1045
+
1046
+ const bindingRetries = [
1047
+ ...(state.bindingRetries ?? []).filter(
1048
+ (entry) => entry.submissionId !== selected.submissionId,
1049
+ ),
1050
+ ...(retry === undefined ? [] : [retry]),
1051
+ ];
1052
+
1053
+ await transaction.put(
1054
+ MAINTENANCE_STATE_KEY,
1055
+ encodeMaintenanceState(ThreadMaintenanceState.make({ ...state, bindingRetries })),
1056
+ );
1057
+
1058
+ return bindingRetries;
1059
+ }),
1060
+ );
1061
+ yield* failpoint.hit("maintenance:binding-retry:after");
1062
+ }
1063
+ if (bindingFailure !== undefined) {
1064
+ yield* reportBindingFailure
1065
+ ? Effect.logError(
1066
+ "Thread awaits a compatible binding; original work remains pending",
1067
+ Cause.fail(bindingFailure),
1068
+ )
1069
+ : Effect.logDebug("Thread binding retry remains pending", Cause.fail(bindingFailure));
1070
+ }
1071
+ }
891
1072
 
1073
+ observed.nativeOnly = false;
892
1074
  yield* finishAuxiliary;
893
1075
  const remaining = yield* Stream.runCollect(ledger.scanNonterminal);
894
1076
  const waitingHeads = new Map<ThreadId, boolean>();
@@ -914,8 +1096,19 @@ export class ThreadMaintenance extends Context.Service<
914
1096
  Option.isSome(settlement) ||
915
1097
  recovered.some((report) => report.disposition === "repaired");
916
1098
 
917
- const delay = autonomous ? yield* rearmDelay(progressed) : 0;
918
1099
  const now = yield* Clock.currentTimeMillis;
1100
+ const ordinaryDelay = autonomous ? yield* rearmDelay(progressed, started.stalls) : 0;
1101
+
1102
+ const nextEligible = eligible.map(
1103
+ (threadId) =>
1104
+ retries.find((retry) => retry.submissionId === heads.get(threadId)?.submissionId)
1105
+ ?.notBefore ?? now,
1106
+ );
1107
+
1108
+ const bindingDelay =
1109
+ nextEligible.length === 0 ? 0 : Math.max(0, Math.min(...nextEligible) - now);
1110
+
1111
+ const delay = Math.max(ordinaryDelay, bindingDelay);
919
1112
 
920
1113
  yield* failpoint.hit("maintenance:finish:before");
921
1114
 
@@ -937,9 +1130,22 @@ export class ThreadMaintenance extends Context.Service<
937
1130
  : started.generation;
938
1131
 
939
1132
  const next = ThreadMaintenanceState.make({
940
- ...state,
1133
+ ...Struct.omit(state, ["retry"]),
941
1134
  processed,
942
1135
  nonterminal: remaining.length,
1136
+ bindingRetries: (state.bindingRetries ?? []).filter((retry) =>
1137
+ remaining.some((row) => row.submissionId === retry.submissionId),
1138
+ ),
1139
+ ...(autonomous && !progressed
1140
+ ? {
1141
+ retry: MaintenanceRetry.make({
1142
+ generation: started.generation,
1143
+ notBefore: now + delay,
1144
+ nativeOnly: true,
1145
+ stalls: Math.min(30, started.stalls + 1),
1146
+ }),
1147
+ }
1148
+ : {}),
943
1149
  });
944
1150
 
945
1151
  await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(next));
@@ -947,13 +1153,18 @@ export class ThreadMaintenance extends Context.Service<
947
1153
  // Replace the crash-fallback slot with this pass's bounded backoff. The target
948
1154
  // is never earlier than the begin-pass fallback, so workerd does not cancel
949
1155
  // this running alarm handler before its report/span can complete.
1156
+ const nativeDeadline =
1157
+ started.activeAtStart > 0 || active > 0 || state.dirty !== started.generation
1158
+ ? now + minimumAlarmDelay
1159
+ : now + delay;
1160
+
950
1161
  await transaction.setAlarm(
951
1162
  Option.isSome(publicationDeadline)
952
1163
  ? Math.max(
953
1164
  now + minimumAlarmDelay,
954
- Math.min(now + delay, publicationDeadline.value),
1165
+ Math.min(nativeDeadline, publicationDeadline.value),
955
1166
  )
956
- : now + delay,
1167
+ : nativeDeadline,
957
1168
  );
958
1169
 
959
1170
  return "rearmed" as const;
@@ -992,9 +1203,6 @@ export class ThreadMaintenance extends Context.Service<
992
1203
  );
993
1204
 
994
1205
  yield* failpoint.hit("maintenance:finish:after");
995
- if (alarmDisposition === "cleared") {
996
- yield* Ref.set(stalls, 0);
997
- }
998
1206
 
999
1207
  return yield* annotate(
1000
1208
  MaintenancePassReport.make({
@@ -1013,9 +1221,19 @@ export class ThreadMaintenance extends Context.Service<
1013
1221
  const now = yield* Clock.currentTimeMillis;
1014
1222
  const yieldAfter = DateTime.makeUnsafe(now + 10 * 60_000);
1015
1223
  const dispatchUntil = DateTime.makeUnsafe(now + 14 * 60_000);
1224
+ const observed: MaintenanceObservation = { nativeOnly: false };
1016
1225
 
1017
1226
  return yield* alarm.withWakesDeferred(
1018
- maintenancePassGate.withPermit(Effect.scoped(pass(yieldAfter, dispatchUntil))),
1227
+ maintenancePassGate.withPermit(
1228
+ Effect.scoped(pass(yieldAfter, dispatchUntil, observed)).pipe(
1229
+ // Close event-owned auxiliary work and release Attempt ownership before
1230
+ // failure rearming, while still holding the pass permit.
1231
+ Effect.onErrorIf(
1232
+ () => true,
1233
+ () => rearmFailure(observed),
1234
+ ),
1235
+ ),
1236
+ ),
1019
1237
  );
1020
1238
  }).pipe(
1021
1239
  // Include permit waiting, recovery and acknowledgement in the event deadline.
@@ -90,13 +90,13 @@ export class CloudflareDurableRuntimeConfigValue extends Schema.Class<Cloudflare
90
90
  producerPrefix: Schema.NonEmptyString.check(Schema.isMaxLength(256)),
91
91
  /** Submission ownership lease duration (D5); fences work across Object incarnations. */
92
92
  ownershipLeaseDuration: PositiveMillis,
93
- /** Base delay of the alarm re-arm backoff when a pass makes no progress. */
93
+ /** Base delay of exponential backoff when a pass fails or makes no progress. */
94
94
  alarmBackoffBase: PositiveMillis,
95
- /** Ceiling of the alarm re-arm backoff. */
95
+ /** Ceiling of exponential backoff after failed or no-progress maintenance passes. */
96
96
  alarmBackoffCap: PositiveMillis,
97
97
  /**
98
- * The maintenance-pass scan cadence and the ceiling of every re-arm delay: nonterminal
99
- * work is revisited at least this often (wake/scan pairing, persistence §14).
98
+ * Fallback scan cadence for newly dirty work. Failed/no-progress passes use
99
+ * alarmBackoffCap independently; constructor repair never shortens their retry deadline.
100
100
  */
101
101
  wakeScanInterval: PositiveMillis,
102
102
  /** `awaitSettlement` ledger re-check cadence when no wake arrives. */