@effect-agent/platform-cloudflare 0.1.0-beta.94 → 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.
package/dist/Alarm.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { DurableObjectContext } from "./CloudflareBindings.mjs";
2
- import { o as CloudflareDurableRuntimeConfig } from "./CloudflareConfig-f3CqTel1.mjs";
3
- import { Cause, Context, Deferred, Effect, Layer, Option, Schema } from "effect";
2
+ import { s as CloudflareDurableRuntimeConfig } from "./CloudflareConfig-DKXGo8L3.mjs";
3
+ import { Cause, Context, DateTime, Effect, Layer, Option, Schema, Scope } from "effect";
4
4
  import { DurableBindingFailure } from "effect-agent/agent-registration";
5
5
  import { DurableAgentRuntime, DurableWorkerFailure } from "effect-agent/durable-agent-runtime";
6
6
  import { SubmissionLedger } from "effect-agent/submission-ledger";
@@ -75,7 +75,7 @@ declare const MaintenancePassReport_base: Schema.Class<MaintenancePassReport, Sc
75
75
  /** What one maintenance pass did — auditable evidence mirroring `NodeDurableHost`'s report. */
76
76
  declare class MaintenancePassReport extends MaintenancePassReport_base {}
77
77
  /** Fault boundaries around every maintenance-owned durable mutation. */
78
- type ThreadMaintenanceFailpointLocation = "maintenance:dirty:before" | "maintenance:dirty:after" | "maintenance:mutation:armed" | "maintenance:mutation:finished" | "maintenance:ensure:before" | "maintenance:ensure:after" | "maintenance:begin:before" | "maintenance:begin:after" | "maintenance:select:before" | "maintenance:select:after" | "maintenance:finish:before" | "maintenance:finish:after";
78
+ type ThreadMaintenanceFailpointLocation = "maintenance:dirty:before" | "maintenance:dirty:after" | "maintenance:mutation:armed" | "maintenance:mutation:finished" | "maintenance:ensure:before" | "maintenance:ensure:after" | "maintenance:begin:before" | "maintenance:begin:after" | "maintenance:select:before" | "maintenance:select:after" | "maintenance:binding-retry:before" | "maintenance:binding-retry:after" | "maintenance:retry:before" | "maintenance:retry:after" | "maintenance:finish:before" | "maintenance:finish:after";
79
79
  type ThreadMaintenanceFailpointHandler = (location: ThreadMaintenanceFailpointLocation) => Effect.Effect<void>;
80
80
  declare const ThreadMaintenanceFailpoint_base: Context.ServiceClass<ThreadMaintenanceFailpoint, "@effect-agent/platform-cloudflare/ThreadMaintenanceFailpoint", {
81
81
  readonly hit: ThreadMaintenanceFailpointHandler;
@@ -92,6 +92,8 @@ declare class ThreadMaintenanceFailpoint extends ThreadMaintenanceFailpoint_base
92
92
  * `invalidate`, `prepareGeneration` and `pendingDeadline` must be bounded local operations.
93
93
  * `prepareGeneration` durably invalidates a scan only when its generation changes; repeated
94
94
  * calls must preserve partial scan progress. It runs with no source mutation in flight.
95
+ * Use this gate only for publication required before dependent native execution. Independent
96
+ * UI relays and outboxes belong to ThreadHostMaintenance.
95
97
  * `drain` performs bounded delivery and persists retries before returning. A pending deadline
96
98
  * defers runtime recovery/Attempts, allowing committed host publications to drain first.
97
99
  * Unexpected hook failures leave the prearmed generation for retry. Hooks acquire per-call
@@ -110,33 +112,46 @@ declare class ThreadPublication extends ThreadPublication_base {
110
112
  static readonly layer: Layer.Layer<ThreadPublication, never, never>;
111
113
  }
112
114
  /**
113
- * Host-assembled message recovery, supplied by ThreadObject.layer even when the application
114
- * rebuilds ThreadMaintenance. These obligations outlive source Runs and never defer a ready
115
- * source Attempt while a destination is processing an accepted message.
115
+ * Host-assembled native message recovery. The driver bounds each actual Claim and persists its
116
+ * timeout/retry before this pump returns. Do not add a second timer starting at batch selection:
117
+ * local Claim setup may take time, and expiration still owes the driver's local retry commit.
116
118
  */
117
119
  declare const ThreadMessageDelivery: Context.Reference<{
118
- readonly drain: Effect.Effect<void, DurableAlarmError>;
119
- /** Drain inserts and due retries during source work, finishing the bounded wave on completion. */
120
- readonly drainUntil?: (finished: Deferred.Deferred<void>) => Effect.Effect<void, DurableAlarmError>;
120
+ readonly drainUntil: (sourceFinished: Effect.Effect<void>, dispatchUntil: DateTime.Utc) => Effect.Effect<void, DurableAlarmError, Scope.Scope>;
121
121
  readonly pendingDeadline: Effect.Effect<Option.Option<number>, DurableAlarmError>;
122
122
  }>;
123
123
  /**
124
- * Application obligations sharing this Object's alarm. The deadline read is local and
125
- * read-only. Drain beside the native Attempt and always finish one initial bounded wave,
126
- * even when `finished` was already signalled. Then stop starting new waves on that signal
127
- * and finish the bounded current wave before returning. Native maintenance joins that work
128
- * before acknowledging a generation. Mutations use the same ThreadMutationGate; hooks never
129
- * write the raw alarm slot. Pending host work does not defer a ready model Attempt.
124
+ * Application obligations sharing this Object's alarm. Admit one initial external wave even on
125
+ * a caught-up pass, then respond to wakes while native work runs. sourceFinished closes only
126
+ * admission of NEW external waves. Keep local admission/hub subscriptions in the supplied event
127
+ * Scope until maintenance tears it down; do not scope them to sourceFinished or to this Effect.
128
+ * No deadline sleeps or automatic retry loops. Return after already-admitted waves finish.
129
+ *
130
+ * Declare a finite whole-wave allowance (1..300000ms): maximum for parallel lanes, sum for
131
+ * sequential operations. Admit a wave only if its allowance fits before dispatchUntil. Later
132
+ * arrivals cannot renew the retirement window. Maintenance bounds the join after sourceFinished,
133
+ * interrupts and joins event Scope, then reads local deadlines under the mutation gate.
134
+ *
135
+ * Setup and pendingDeadline are bounded local operations. Persist claims/envelopes before
136
+ * dispatch; interrupted waits leave exact retries/receipts recoverable. Cancellation is local,
137
+ * not remote rollback. Retry accounting is unchanged. Network waits/finalizers must be
138
+ * interruptible; short local atomic commits may be uninterruptible. Hooks never write the raw
139
+ * alarm slot. Required native publication gates belong to ThreadPublication.
130
140
  */
131
141
  declare const ThreadHostMaintenance: Context.Reference<{
142
+ readonly dispatchTimeoutMillis: number;
143
+ readonly drainUntil: (sourceFinished: Effect.Effect<void>, dispatchUntil: DateTime.Utc) => Effect.Effect<void, DurableAlarmError, Scope.Scope>;
132
144
  readonly pendingDeadline: Effect.Effect<Option.Option<number>, DurableAlarmError>;
133
- readonly drainUntil: (finished: Deferred.Deferred<void>) => Effect.Effect<void, DurableAlarmError>;
134
145
  }>;
135
146
  /** @internal A committed source operation must not become a failed operation because delivery failed. */
136
147
  declare const publishCommitted: Effect.Effect<void, never, ThreadPublication>;
137
148
  declare const ThreadMutationGate_base: Context.ServiceClass<ThreadMutationGate, "@effect-agent/platform-cloudflare/internal/ThreadMutationGate", {
138
149
  readonly withMutation: <A, E, R>(body: Effect.Effect<A, E, R>,
139
- /** Indexed host work has its own durable deadline and does not invalidate ledger recovery. */
150
+ /**
151
+ * Native admission, approval, abort and unknown resolution keep the default true.
152
+ * Projection/relay/reply receipt-only bookkeeping must use false: its local durable
153
+ * pendingDeadline owns scheduling without creating native recovery debt.
154
+ */
140
155
  options?: {
141
156
  readonly invalidatesRecovery: boolean;
142
157
  }) => Effect.Effect<A, E | DurableAlarmError, R>;
@@ -152,7 +167,7 @@ declare class ThreadMutationGate extends ThreadMutationGate_base {
152
167
  }
153
168
  type MaintenancePassFailure = DurableWorkerFailure | DurableBindingFailure | DurableAlarmError | ThreadProjectionError;
154
169
  declare const ThreadMaintenance_base: Context.ServiceClass<ThreadMaintenance, "@effect-agent/platform-cloudflare/ThreadMaintenance", {
155
- /** One idempotent maintenance pass; failures propagate so workerd retries the alarm. */
170
+ /** One idempotent pass; failures propagate after durably scheduling bounded recovery. */
156
171
  readonly pass: Effect.Effect<MaintenancePassReport, MaintenancePassFailure>;
157
172
  /**
158
173
  * Constructor gate: initialize/inspect only the O(1) maintenance record and ensure a dirty
@@ -175,9 +190,13 @@ declare const ThreadMaintenance_base: Context.ServiceClass<ThreadMaintenance, "@
175
190
  * alarm takes an O(1) path without recovery, ledger scans, or canonical-history reads.
176
191
  * 2. Recovery strictly precedes a new claim. One head Attempt advances the lane and requests
177
192
  * a safe yield after ten minutes. The whole event has a fourteen-minute cooperative timeout.
178
- * 3. The final transaction acknowledges only the generation observed at pass start. A racing
193
+ * 3. Auxiliary dispatch and listeners belong to the event Scope. After native completion,
194
+ * stop new external waves, join native delivery through its driver-owned Claim deadline,
195
+ * and bound host/backfill work by explicit allowances before closing and joining Scope.
196
+ * Ordinary auxiliary setup failures are reported after the one native opportunity.
197
+ * 4. The final transaction acknowledges only the generation observed at pass start. A racing
179
198
  * mutation therefore remains `dirty > processed` and retains its atomically-established alarm.
180
- * 4. Stable external waits acknowledge and clear. Autonomous retry, indeterminate, and lease
199
+ * 5. Stable external waits acknowledge and clear. Autonomous retry, indeterminate, and lease
181
200
  * recovery states leave their generation dirty and retain bounded backoff rearming.
182
201
  */
183
202
  declare class ThreadMaintenance extends ThreadMaintenance_base {
package/dist/Alarm.mjs CHANGED
@@ -1,11 +1,11 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
2
  import { DurableObjectContext } from "./CloudflareBindings.mjs";
3
- import { CloudflareDurableRuntimeConfig } from "./CloudflareConfig.mjs";
3
+ import { AuxiliaryDispatchMillis, CloudflareDurableRuntimeConfig } from "./CloudflareConfig.mjs";
4
4
  import { r as safeCauseMessage } from "./boundary-BguazVkh.mjs";
5
- import { Cause, Clock, Context, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Random, Ref, Schema, Semaphore, Stream } from "effect";
5
+ import { Cause, Clock, Context, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Random, Ref, Schema, Scope, Semaphore, Stream, Struct } from "effect";
6
6
  import "effect-agent/agent-registration";
7
7
  import { DurableAgentRuntime } from "effect-agent/durable-agent-runtime";
8
- import { ThreadId } from "effect-agent/identifiers";
8
+ import { SubmissionId, ThreadId } from "effect-agent/identifiers";
9
9
  import { SubmissionLedger } from "effect-agent/submission-ledger";
10
10
  import { ThreadProjectionMaintenance, drainDue } from "effect-agent/thread-projection-maintenance";
11
11
  import { SqlClient } from "effect/unstable/sql/SqlClient";
@@ -109,25 +109,36 @@ var ThreadPublication = class extends Context.Service()("@effect-agent/platform-
109
109
  });
110
110
  };
111
111
  /**
112
- * Host-assembled message recovery, supplied by ThreadObject.layer even when the application
113
- * rebuilds ThreadMaintenance. These obligations outlive source Runs and never defer a ready
114
- * source Attempt while a destination is processing an accepted message.
112
+ * Host-assembled native message recovery. The driver bounds each actual Claim and persists its
113
+ * timeout/retry before this pump returns. Do not add a second timer starting at batch selection:
114
+ * local Claim setup may take time, and expiration still owes the driver's local retry commit.
115
115
  */
116
116
  const ThreadMessageDelivery = Context.Reference("@effect-agent/platform-cloudflare/ThreadMessageDelivery", { defaultValue: () => ({
117
- drain: Effect.void,
117
+ drainUntil: () => Effect.void,
118
118
  pendingDeadline: Effect.succeed(Option.none())
119
119
  }) });
120
120
  /**
121
- * Application obligations sharing this Object's alarm. The deadline read is local and
122
- * read-only. Drain beside the native Attempt and always finish one initial bounded wave,
123
- * even when `finished` was already signalled. Then stop starting new waves on that signal
124
- * and finish the bounded current wave before returning. Native maintenance joins that work
125
- * before acknowledging a generation. Mutations use the same ThreadMutationGate; hooks never
126
- * write the raw alarm slot. Pending host work does not defer a ready model Attempt.
121
+ * Application obligations sharing this Object's alarm. Admit one initial external wave even on
122
+ * a caught-up pass, then respond to wakes while native work runs. sourceFinished closes only
123
+ * admission of NEW external waves. Keep local admission/hub subscriptions in the supplied event
124
+ * Scope until maintenance tears it down; do not scope them to sourceFinished or to this Effect.
125
+ * No deadline sleeps or automatic retry loops. Return after already-admitted waves finish.
126
+ *
127
+ * Declare a finite whole-wave allowance (1..300000ms): maximum for parallel lanes, sum for
128
+ * sequential operations. Admit a wave only if its allowance fits before dispatchUntil. Later
129
+ * arrivals cannot renew the retirement window. Maintenance bounds the join after sourceFinished,
130
+ * interrupts and joins event Scope, then reads local deadlines under the mutation gate.
131
+ *
132
+ * Setup and pendingDeadline are bounded local operations. Persist claims/envelopes before
133
+ * dispatch; interrupted waits leave exact retries/receipts recoverable. Cancellation is local,
134
+ * not remote rollback. Retry accounting is unchanged. Network waits/finalizers must be
135
+ * interruptible; short local atomic commits may be uninterruptible. Hooks never write the raw
136
+ * alarm slot. Required native publication gates belong to ThreadPublication.
127
137
  */
128
138
  const ThreadHostMaintenance = Context.Reference("@effect-agent/platform-cloudflare/ThreadHostMaintenance", { defaultValue: () => ({
129
- pendingDeadline: Effect.succeed(Option.none()),
130
- drainUntil: () => Effect.void
139
+ dispatchTimeoutMillis: 1,
140
+ drainUntil: () => Effect.void,
141
+ pendingDeadline: Effect.succeed(Option.none())
131
142
  }) });
132
143
  const earliestDeadline = (left, right) => Option.isSome(left) ? Option.isSome(right) ? Option.some(Math.min(left.value, right.value)) : left : right;
133
144
  /** @internal A committed source operation must not become a failed operation because delivery failed. */
@@ -136,6 +147,19 @@ const publishCommitted = Effect.gen(function* () {
136
147
  yield* publication.invalidate.pipe(Effect.andThen(publication.drain));
137
148
  }).pipe(Effect.catchCause((cause) => Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.logError("Thread publication deferred after source commit", cause)));
138
149
  const MaintenanceGeneration = Schema.BigIntFromString.check(Schema.isGreaterThanOrEqualToBigInt(0n));
150
+ var BindingRetry = class extends Schema.Class("BindingRetry")({
151
+ threadId: ThreadId,
152
+ submissionId: SubmissionId,
153
+ attempts: Schema.Natural,
154
+ notBefore: Schema.Finite,
155
+ reportedAt: Schema.Finite
156
+ }) {};
157
+ var MaintenanceRetry = class extends Schema.Class("MaintenanceRetry")({
158
+ generation: MaintenanceGeneration,
159
+ notBefore: Schema.Finite,
160
+ nativeOnly: Schema.Boolean,
161
+ stalls: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(30))
162
+ }) {};
139
163
  /** Versioned, platform-private maintenance state stored through Durable Object KV. */
140
164
  var ThreadMaintenanceState = class extends Schema.Class("@effect-agent/platform-cloudflare/ThreadMaintenanceState")({
141
165
  schemaVersion: Schema.Literal(1),
@@ -143,7 +167,10 @@ var ThreadMaintenanceState = class extends Schema.Class("@effect-agent/platform-
143
167
  processed: MaintenanceGeneration,
144
168
  nonterminal: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
145
169
  /** One physical-owner cursor; old single-lane records need no conversion. */
146
- lastServedThreadId: Schema.optionalKey(ThreadId)
170
+ lastServedThreadId: Schema.optionalKey(ThreadId),
171
+ bindingRetries: Schema.optionalKey(Schema.Array(BindingRetry)),
172
+ /** Absent on older records. A newer mutation makes this retry obsolete. */
173
+ retry: Schema.optionalKey(MaintenanceRetry)
147
174
  }) {};
148
175
  const MAINTENANCE_STATE_KEY = "effect-agent:thread-maintenance:v1";
149
176
  const decodeMaintenanceState = Schema.decodeUnknownSync(ThreadMaintenanceState);
@@ -230,9 +257,13 @@ var ThreadMutationGate = class ThreadMutationGate extends Context.Service()("@ef
230
257
  * alarm takes an O(1) path without recovery, ledger scans, or canonical-history reads.
231
258
  * 2. Recovery strictly precedes a new claim. One head Attempt advances the lane and requests
232
259
  * a safe yield after ten minutes. The whole event has a fourteen-minute cooperative timeout.
233
- * 3. The final transaction acknowledges only the generation observed at pass start. A racing
260
+ * 3. Auxiliary dispatch and listeners belong to the event Scope. After native completion,
261
+ * stop new external waves, join native delivery through its driver-owned Claim deadline,
262
+ * and bound host/backfill work by explicit allowances before closing and joining Scope.
263
+ * Ordinary auxiliary setup failures are reported after the one native opportunity.
264
+ * 4. The final transaction acknowledges only the generation observed at pass start. A racing
234
265
  * mutation therefore remains `dirty > processed` and retains its atomically-established alarm.
235
- * 4. Stable external waits acknowledge and clear. Autonomous retry, indeterminate, and lease
266
+ * 5. Stable external waits acknowledge and clear. Autonomous retry, indeterminate, and lease
236
267
  * recovery states leave their generation dirty and retain bounded backoff rearming.
237
268
  */
238
269
  var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effect-agent/platform-cloudflare/ThreadMaintenance") {
@@ -243,11 +274,6 @@ var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effe
243
274
  const config = yield* CloudflareDurableRuntimeConfig;
244
275
  const { ctx } = yield* DurableObjectContext;
245
276
  const failpoint = yield* ThreadMaintenanceFailpoint;
246
- /**
247
- * Consecutive no-progress passes — an in-memory CACHE, not state: a fresh incarnation
248
- * restarts at zero and merely re-arms sooner than a long-lived one would have.
249
- */
250
- const stalls = yield* Ref.make(0);
251
277
  const mutations = yield* ThreadMutationGate;
252
278
  const publication = yield* ThreadPublication;
253
279
  const projection = yield* ThreadProjectionMaintenance;
@@ -263,48 +289,74 @@ var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effe
263
289
  const ensureAlarm = Effect.fn("ThreadMaintenance.ensureAlarm")(function* () {
264
290
  yield* failpoint.hit("maintenance:ensure:before");
265
291
  const now = yield* Clock.currentTimeMillis;
266
- yield* runTransaction("ensure maintenance alarm", () => ctx.storage.transaction(async (transaction) => {
292
+ const retry = yield* runTransaction("ensure maintenance alarm", () => ctx.storage.transaction(async (transaction) => {
267
293
  const { state, initialized } = await readMaintenanceState(transaction);
268
294
  if (!initialized) await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(state));
269
- if (state.dirty > state.processed) await ensureTransactionAlarmBy(transaction, now + config.wakeScanInterval);
295
+ if (state.dirty > state.processed) await ensureTransactionAlarmBy(transaction, state.retry?.generation === state.dirty ? Math.max(now + minimumAlarmDelay, state.retry.notBefore) : now + config.wakeScanInterval);
296
+ return state.retry?.generation === state.dirty ? state.retry : void 0;
270
297
  }));
271
298
  const deadline = yield* pendingDeadline;
272
- if (Option.isSome(deadline)) yield* runTransaction("ensure publication alarm", () => ctx.storage.transaction((transaction) => ensureTransactionAlarmBy(transaction, Math.max(now + minimumAlarmDelay, deadline.value))));
299
+ if (Option.isSome(deadline)) yield* runTransaction("ensure publication alarm", () => ctx.storage.transaction((transaction) => ensureTransactionAlarmBy(transaction, Math.max(now + minimumAlarmDelay, deadline.value <= now && retry !== void 0 && !retry.nativeOnly ? Math.max(deadline.value, retry.notBefore) : deadline.value))));
273
300
  yield* failpoint.hit("maintenance:ensure:after");
274
301
  });
275
- const beginPass = Effect.fn("ThreadMaintenance.beginPass")(function* () {
302
+ const beginPass = Effect.fn("ThreadMaintenance.beginPass")(function* (observed) {
276
303
  yield* failpoint.hit("maintenance:begin:before");
277
304
  const now = yield* Clock.currentTimeMillis;
278
305
  const result = yield* runTransaction("begin maintenance pass", () => ctx.storage.transaction(async (transaction) => {
279
306
  const { state, initialized } = await readMaintenanceState(transaction);
307
+ observed.generation = state.dirty;
280
308
  if (!initialized) await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(state));
281
- if (state.processed >= state.dirty) {
282
- await ensureTransactionAlarmBy(transaction, now + minimumAlarmDelay);
283
- return {
284
- _tag: "CaughtUp",
285
- nonterminal: state.nonterminal
286
- };
287
- }
309
+ const retryAt = state.retry?.generation === state.dirty ? state.retry.notBefore : 0;
288
310
  await ensureTransactionAlarmBy(transaction, now + minimumAlarmDelay);
311
+ if (state.processed >= state.dirty || retryAt > now) return {
312
+ _tag: "CaughtUp",
313
+ nonterminal: state.nonterminal
314
+ };
289
315
  return {
290
316
  _tag: "Actionable",
291
317
  generation: state.dirty,
292
- nonterminal: state.nonterminal
318
+ nonterminal: state.nonterminal,
319
+ stalls: state.retry?.generation === state.dirty ? state.retry.stalls : 0
293
320
  };
294
321
  }));
295
322
  yield* failpoint.hit("maintenance:begin:after");
296
323
  return result;
297
324
  });
298
- const rearmDelay = Effect.fn("ThreadMaintenance.rearmDelay")(function* (progressed) {
299
- const priorStalls = yield* Ref.getAndUpdate(stalls, (count) => progressed ? 0 : count + 1);
300
- if (progressed) return config.alarmBackoffBase;
301
- const exponent = Math.min(priorStalls, 30);
302
- const backoff = Math.min(config.alarmBackoffCap, config.alarmBackoffBase * 2 ** exponent);
303
- const jitter = yield* Random.next;
304
- const jittered = Math.ceil(backoff / 2 + backoff / 2 * jitter);
305
- return Math.min(jittered, config.wakeScanInterval);
325
+ const backoffDelay = (priorStalls, jitter) => {
326
+ const backoff = Math.min(config.alarmBackoffCap, config.alarmBackoffBase * 2 ** Math.min(priorStalls, 30));
327
+ return Math.ceil(backoff / 2 + backoff / 2 * jitter);
328
+ };
329
+ const rearmDelay = Effect.fn("ThreadMaintenance.rearmDelay")(function* (progressed, priorStalls) {
330
+ return progressed ? config.alarmBackoffBase : backoffDelay(priorStalls, yield* Random.next);
331
+ });
332
+ const rearmFailure = Effect.fn("ThreadMaintenance.rearmFailure")(function* (observed) {
333
+ const { generation, nativeOnly } = observed;
334
+ if (generation === void 0) return;
335
+ yield* failpoint.hit("maintenance:retry:before");
336
+ yield* mutations.withSnapshot((active) => Effect.gen(function* () {
337
+ const deadline = yield* pendingDeadline.pipe(Effect.catchCause(() => Effect.succeed(Option.none())));
338
+ const now = yield* Clock.currentTimeMillis;
339
+ const jitter = yield* Random.next;
340
+ yield* runTransaction("back off failed maintenance", () => ctx.storage.transaction(async (transaction) => {
341
+ const { state } = await readMaintenanceState(transaction);
342
+ const previous = state.retry?.generation === generation ? state.retry : void 0;
343
+ const retry = MaintenanceRetry.make({
344
+ generation,
345
+ notBefore: Math.max(previous?.notBefore ?? 0, now + backoffDelay(previous?.stalls ?? 0, jitter)),
346
+ nativeOnly,
347
+ stalls: Math.min(30, (previous?.stalls ?? 0) + 1)
348
+ });
349
+ await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(ThreadMaintenanceState.make({
350
+ ...state,
351
+ retry
352
+ })));
353
+ const nativeDeadline = active > 0 || state.dirty !== generation ? now + minimumAlarmDelay : retry.notBefore;
354
+ await transaction.setAlarm(Math.max(now + minimumAlarmDelay, Option.isSome(deadline) && (nativeOnly || deadline.value > now) ? Math.min(nativeDeadline, deadline.value) : nativeDeadline));
355
+ }));
356
+ }));
357
+ yield* failpoint.hit("maintenance:retry:after");
306
358
  });
307
- const pass = Effect.fn("ThreadMaintenance.pass")(function* (yieldAfter) {
359
+ const pass = Effect.fn("ThreadMaintenance.pass")(function* (yieldAfter, dispatchUntil, observed) {
308
360
  const annotate = (report) => Effect.annotateCurrentSpan({
309
361
  phase: report.phase,
310
362
  recovered: report.recovered,
@@ -313,32 +365,49 @@ var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effe
313
365
  alarm: report.alarm
314
366
  }).pipe(Effect.as(report));
315
367
  const started = yield* mutations.withSnapshot((activeAtStart) => Effect.gen(function* () {
316
- const generation = yield* beginPass();
368
+ const generation = yield* beginPass(observed);
317
369
  if (generation._tag === "Actionable" && activeAtStart === 0) yield* publication.prepareGeneration(generation.generation);
318
370
  return {
319
371
  ...generation,
320
372
  activeAtStart
321
373
  };
322
374
  }));
323
- const deliveryFinished = yield* Deferred.make();
324
- const delivery = yield* Effect.forkChild(messages.drainUntil?.(deliveryFinished) ?? messages.drain);
325
- const hostWork = yield* Effect.forkChild(host.drainUntil(deliveryFinished));
326
- const finishDelivery = Deferred.succeed(deliveryFinished, void 0).pipe(Effect.andThen(Fiber.awaitAll([delivery, hostWork])), Effect.flatMap((outcomes) => Effect.forEach(outcomes, (outcome) => outcome, { discard: true })));
327
- const projected = yield* Effect.exit(drainDue.pipe(Effect.provideService(ThreadProjectionMaintenance, projection)));
328
- if (Exit.isFailure(projected) && Cause.hasInterrupts(projected.cause)) return yield* Effect.failCause(projected.cause);
375
+ const auxiliaryScope = yield* Effect.acquireRelease(Scope.make("parallel"), (scope, exit) => Scope.close(scope, exit));
376
+ const sourceFinished = yield* Deferred.make();
377
+ const finished = Deferred.await(sourceFinished);
378
+ const deliveryFiber = yield* Effect.forkIn(Scope.provide(auxiliaryScope)(messages.drainUntil(finished, dispatchUntil)), auxiliaryScope);
379
+ const hostFiber = yield* Effect.forkIn(Scope.provide(auxiliaryScope)(Effect.gen(function* () {
380
+ yield* Schema.decodeEffect(AuxiliaryDispatchMillis)(host.dispatchTimeoutMillis).pipe(Effect.mapError((cause) => DurableAlarmError.make({
381
+ operation: "host dispatch allowance",
382
+ message: "Declare an integer whole-wave allowance between 1 and 300000 milliseconds",
383
+ cause
384
+ })));
385
+ yield* host.drainUntil(finished, dispatchUntil);
386
+ })), auxiliaryScope);
387
+ const backfill = yield* Effect.forkIn(drainDue.pipe(Effect.provideService(ThreadProjectionMaintenance, projection), Effect.timeoutOption(config.projectionDispatchTimeoutMillis)), auxiliaryScope);
388
+ const finishAuxiliary = Effect.gen(function* () {
389
+ yield* Deferred.succeed(sourceFinished, void 0);
390
+ const remaining = Math.max(1, DateTime.toEpochMillis(dispatchUntil) - (yield* Clock.currentTimeMillis));
391
+ const hostJoin = yield* Effect.forkIn(Fiber.join(hostFiber).pipe(Effect.timeoutOption(Math.min(host.dispatchTimeoutMillis, remaining)), Effect.tap((result) => Effect.annotateCurrentSpan({ "host.timedOut": Option.isNone(result) }))), auxiliaryScope);
392
+ yield* Fiber.joinAll([
393
+ deliveryFiber,
394
+ hostJoin,
395
+ backfill
396
+ ]);
397
+ yield* Scope.close(auxiliaryScope, Exit.void);
398
+ });
329
399
  const deadline = yield* publication.pendingDeadline;
330
400
  if (started._tag === "Actionable" || Option.isSome(deadline) && deadline.value <= (yield* Clock.currentTimeMillis)) yield* publication.drain;
331
401
  const pending = yield* publication.pendingDeadline;
332
402
  if (started._tag === "CaughtUp" || Option.isSome(pending)) {
333
- yield* finishDelivery;
334
- if (Exit.isFailure(projected)) return yield* Effect.failCause(projected.cause);
403
+ yield* finishAuxiliary;
335
404
  yield* failpoint.hit("maintenance:finish:before");
336
405
  const disposition = yield* mutations.withSnapshot((active) => Effect.gen(function* () {
337
406
  const latest = yield* pendingDeadline;
338
407
  const now = yield* Clock.currentTimeMillis;
339
408
  return yield* runTransaction("finish publication pass", () => ctx.storage.transaction(async (transaction) => {
340
409
  const { state } = await readMaintenanceState(transaction);
341
- const nativeDeadline = active > 0 || state.dirty > state.processed ? now + config.wakeScanInterval : Infinity;
410
+ const nativeDeadline = active > 0 || state.dirty > state.processed ? state.retry?.generation === state.dirty && active === 0 ? Math.max(now + minimumAlarmDelay, state.retry.notBefore) : now + config.wakeScanInterval : Infinity;
342
411
  const next = Option.isSome(latest) ? Math.min(nativeDeadline, latest.value) : nativeDeadline;
343
412
  if (Number.isFinite(next)) {
344
413
  await transaction.setAlarm(Math.max(now + minimumAlarmDelay, next));
@@ -357,29 +426,70 @@ var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effe
357
426
  alarm: disposition
358
427
  }));
359
428
  }
429
+ observed.nativeOnly = true;
360
430
  const recovered = yield* runtime.runRecovery;
361
431
  const reports = new Map(recovered.map((report) => [report.submissionId, report]));
362
432
  const current = yield* Stream.runCollect(ledger.scanNonterminal);
363
433
  const heads = /* @__PURE__ */ new Map();
364
434
  for (const row of current) if (!heads.has(row.threadId)) heads.set(row.threadId, row);
365
435
  const eligible = [...heads.values()].filter((head) => !stableExternalWait(head, reports)).map((head) => head.threadId).sort();
366
- let selected = eligible[0];
367
- if (heads.size > 1 && selected !== void 0) {
368
- yield* failpoint.hit("maintenance:select:before");
369
- selected = yield* runTransaction("select maintenance lane", () => ctx.storage.transaction(async (transaction) => {
370
- const { state } = await readMaintenanceState(transaction);
371
- const next = eligible.find((threadId) => state.lastServedThreadId === void 0 || threadId > state.lastServedThreadId) ?? eligible[0];
372
- if (next !== void 0) await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(ThreadMaintenanceState.make({
373
- ...state,
374
- lastServedThreadId: next
375
- })));
376
- return next;
377
- }));
378
- yield* failpoint.hit("maintenance:select:after");
436
+ const selectionTime = yield* Clock.currentTimeMillis;
437
+ yield* failpoint.hit("maintenance:select:before");
438
+ const selection = yield* runTransaction("select maintenance lane", () => ctx.storage.transaction(async (transaction) => {
439
+ const { state } = await readMaintenanceState(transaction);
440
+ const retries = (state.bindingRetries ?? []).filter((retry) => heads.get(retry.threadId)?.submissionId === retry.submissionId);
441
+ const runnable = eligible.filter((threadId) => !retries.some((retry) => retry.threadId === threadId && retry.notBefore > selectionTime));
442
+ const next = runnable.find((threadId) => state.lastServedThreadId === void 0 || threadId > state.lastServedThreadId) ?? runnable[0];
443
+ if (next !== void 0) await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(ThreadMaintenanceState.make({
444
+ ...state,
445
+ lastServedThreadId: next
446
+ })));
447
+ return {
448
+ selected: next,
449
+ retries
450
+ };
451
+ }));
452
+ yield* failpoint.hit("maintenance:select:after");
453
+ const selected = selection.selected === void 0 ? void 0 : heads.get(selection.selected);
454
+ let retries = selection.retries;
455
+ let bindingFailure;
456
+ const settlement = selected === void 0 ? Option.none() : yield* runtime.processThreadHead(selected.threadId, { yieldAfter }).pipe(Effect.catchTag(["BindingUnavailable", "BindingDigestMismatch"], (failure) => {
457
+ bindingFailure = failure;
458
+ return Effect.succeed(Option.none());
459
+ }));
460
+ if (selected !== void 0) {
461
+ const previous = retries.find((retry) => retry.submissionId === selected.submissionId);
462
+ let retry;
463
+ let reportBindingFailure = false;
464
+ if (bindingFailure !== void 0) {
465
+ const now = yield* Clock.currentTimeMillis;
466
+ const attempts = Math.min(30, (previous?.attempts ?? 0) + 1);
467
+ reportBindingFailure = previous === void 0 || now - previous.reportedAt >= 9e5;
468
+ retry = BindingRetry.make({
469
+ threadId: selected.threadId,
470
+ submissionId: selected.submissionId,
471
+ attempts,
472
+ notBefore: now + Math.min(6e4, 5e3 * 2 ** (attempts - 1)),
473
+ reportedAt: reportBindingFailure ? now : previous?.reportedAt ?? now
474
+ });
475
+ }
476
+ if (retry !== void 0 || previous !== void 0) {
477
+ yield* failpoint.hit("maintenance:binding-retry:before");
478
+ retries = yield* runTransaction("record submission binding retry", () => ctx.storage.transaction(async (transaction) => {
479
+ const { state } = await readMaintenanceState(transaction);
480
+ const bindingRetries = [...(state.bindingRetries ?? []).filter((entry) => entry.submissionId !== selected.submissionId), ...retry === void 0 ? [] : [retry]];
481
+ await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(ThreadMaintenanceState.make({
482
+ ...state,
483
+ bindingRetries
484
+ })));
485
+ return bindingRetries;
486
+ }));
487
+ yield* failpoint.hit("maintenance:binding-retry:after");
488
+ }
489
+ if (bindingFailure !== void 0) yield* reportBindingFailure ? Effect.logError("Thread awaits a compatible binding; original work remains pending", Cause.fail(bindingFailure)) : Effect.logDebug("Thread binding retry remains pending", Cause.fail(bindingFailure));
379
490
  }
380
- const settlement = selected === void 0 ? Option.none() : yield* runtime.processThreadHead(selected, { yieldAfter });
381
- yield* finishDelivery;
382
- if (Exit.isFailure(projected)) return yield* Effect.failCause(projected.cause);
491
+ observed.nativeOnly = false;
492
+ yield* finishAuxiliary;
383
493
  const remaining = yield* Stream.runCollect(ledger.scanNonterminal);
384
494
  const waitingHeads = /* @__PURE__ */ new Map();
385
495
  const autonomous = remaining.some((snapshot) => {
@@ -389,8 +499,11 @@ var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effe
389
499
  return !stableExternalWait(snapshot, reports);
390
500
  });
391
501
  const progressed = Option.isSome(settlement) || recovered.some((report) => report.disposition === "repaired");
392
- const delay = autonomous ? yield* rearmDelay(progressed) : 0;
393
502
  const now = yield* Clock.currentTimeMillis;
503
+ const ordinaryDelay = autonomous ? yield* rearmDelay(progressed, started.stalls) : 0;
504
+ const nextEligible = eligible.map((threadId) => retries.find((retry) => retry.submissionId === heads.get(threadId)?.submissionId)?.notBefore ?? now);
505
+ const bindingDelay = nextEligible.length === 0 ? 0 : Math.max(0, Math.min(...nextEligible) - now);
506
+ const delay = Math.max(ordinaryDelay, bindingDelay);
394
507
  yield* failpoint.hit("maintenance:finish:before");
395
508
  const alarmDisposition = yield* mutations.withSnapshot((active) => Effect.gen(function* () {
396
509
  const publicationDeadline = yield* pendingDeadline;
@@ -398,13 +511,21 @@ var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effe
398
511
  const { state } = await readMaintenanceState(transaction);
399
512
  const processed = autonomous || started.activeAtStart > 0 || active > 0 ? state.processed : state.processed > started.generation ? state.processed : started.generation;
400
513
  const next = ThreadMaintenanceState.make({
401
- ...state,
514
+ ...Struct.omit(state, ["retry"]),
402
515
  processed,
403
- nonterminal: remaining.length
516
+ nonterminal: remaining.length,
517
+ bindingRetries: (state.bindingRetries ?? []).filter((retry) => remaining.some((row) => row.submissionId === retry.submissionId)),
518
+ ...autonomous && !progressed ? { retry: MaintenanceRetry.make({
519
+ generation: started.generation,
520
+ notBefore: now + delay,
521
+ nativeOnly: true,
522
+ stalls: Math.min(30, started.stalls + 1)
523
+ }) } : {}
404
524
  });
405
525
  await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(next));
406
526
  if (autonomous) {
407
- await transaction.setAlarm(Option.isSome(publicationDeadline) ? Math.max(now + minimumAlarmDelay, Math.min(now + delay, publicationDeadline.value)) : now + delay);
527
+ const nativeDeadline = started.activeAtStart > 0 || active > 0 || state.dirty !== started.generation ? now + minimumAlarmDelay : now + delay;
528
+ await transaction.setAlarm(Option.isSome(publicationDeadline) ? Math.max(now + minimumAlarmDelay, Math.min(nativeDeadline, publicationDeadline.value)) : nativeDeadline);
408
529
  return "rearmed";
409
530
  }
410
531
  if (started.activeAtStart > 0 || active > 0 || next.dirty > next.processed) {
@@ -420,7 +541,6 @@ var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effe
420
541
  }));
421
542
  }));
422
543
  yield* failpoint.hit("maintenance:finish:after");
423
- if (alarmDisposition === "cleared") yield* Ref.set(stalls, 0);
424
544
  return yield* annotate(MaintenancePassReport.make({
425
545
  phase: "actionable",
426
546
  recovered: recovered.length,
@@ -431,8 +551,11 @@ var ThreadMaintenance = class ThreadMaintenance extends Context.Service()("@effe
431
551
  });
432
552
  return ThreadMaintenance.of({
433
553
  pass: Effect.gen(function* () {
434
- const yieldAfter = DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + 6e5);
435
- return yield* alarm.withWakesDeferred(maintenancePassGate.withPermit(pass(yieldAfter)));
554
+ const now = yield* Clock.currentTimeMillis;
555
+ const yieldAfter = DateTime.makeUnsafe(now + 6e5);
556
+ const dispatchUntil = DateTime.makeUnsafe(now + 84e4);
557
+ const observed = { nativeOnly: false };
558
+ return yield* alarm.withWakesDeferred(maintenancePassGate.withPermit(Effect.scoped(pass(yieldAfter, dispatchUntil, observed)).pipe(Effect.onErrorIf(() => true, () => rearmFailure(observed)))));
436
559
  }).pipe(Effect.timeoutOrElse({
437
560
  duration: "14 minutes",
438
561
  orElse: () => DurableAlarmError.make({