/** * Durable-claim mechanics for the workflow finalizer drive (issue #446 Phase 2). * This module owns the `teardownOwed` marker's lifecycle — the stale-claim reclaim * horizon, the retry backoff schedule, the dead-letter record shape, and every fenced * write that mutates the marker (claim CAS, settle CAS, re-arm, clear, dead-letter). The * orchestration that decides WHEN to call these lives in `./finalizer.ts`; keeping the * byte-level claim transitions here keeps each module under the size budget and isolates * the part where CAS correctness matters most. * * Concurrency contract (see `./finalizer.ts` for the full model): a holder fenced-CAS's * `owed → running` (stamping `claimedAt`) before running the finalizer, then settle-CAS's * the EXACT `running` bytes it wrote when clearing, rescheduling, or dead-lettering — so a * concurrent reclaimer can never clobber a fresher claim. Liveness is decided purely by * the clock via {@link teardownStaleThresholdMs}. * * @module core/engine/termination/finalizer-claim */ import type { BatchOperation } from '../../../storage/interface.ts'; import type { EngineInternals } from '../internals.ts'; import { type TeardownClaim } from '../state-utilities.ts'; import type { RunnableFinalizer } from './finalizer-activity.ts'; /** Maximum finalizer attempts before the teardown is dead-lettered (the leak horizon). */ export declare const MAX_TEARDOWN_ATTEMPTS = 8; /** Short re-arm delay for the self-heal timer written on a non-settling drive exit. */ export declare const TEARDOWN_SELF_HEAL_DELAY_MS = 30000; /** Backoff for the timer rescheduled after a failed finalizer attempt (1-based attempt). */ export declare function teardownBackoffMs(failedAttempt: number): number; /** * The reclaim horizon for a `running` claim: a holder that has not settled within this * window of `claimedAt` is presumed crashed/deposed and its claim is reclaimable. Equal * to the finalizer's per-attempt budget (its declared `timeout`, or * {@link DEFAULT_FINALIZER_STALE_BUDGET_MS}) plus {@link TEARDOWN_STALE_MARGIN_MS}. */ export declare function teardownStaleThresholdMs(finalizer: RunnableFinalizer): number; /** Whether a `running` claim is reclaimable: its holder has not settled within the stale window. */ export declare function runningClaimIsStale(internals: EngineInternals, claim: TeardownClaim, finalizer: RunnableFinalizer): boolean; /** * Durable audit record written to {@link KEYS.teardownDeadLetter} when a workflow's * finalizer permanently fails — the retry horizon was reached, or the recorded resource * state vanished so the finalizer can never run. It is the supported durable operator * record for a leaked external resource; {@link Engine.getFinalizerStatus} and the matching * transport operation expose it without relying on best-effort teardown events. The record * is excluded from the workflow purge delete-set so it survives after the workflow record * is gone. */ export interface TeardownDeadLetterRecord { /** The workflow type whose finalizer leaked. */ type: string; /** The last finalizer error message (or the reason teardown was abandoned). */ lastError: string; /** The attempt count reached before dead-lettering. */ attempts: number; /** Engine clock when the record was written. */ deadLetteredAt: number; /** Concrete run identity, present on records written from current workflow state. */ workflowExecutionToken?: string; /** The decoded `ctx.setFinalizerState` payload, when it was still recoverable. */ finalizerInput?: unknown; /** * The dead-lettering run's own pinned {@link import('../../types/state.ts').WorkflowState.revision} * (WFT-21) — additive, optional field: absent on a record written before * this field existed, or for a legacy run with no persisted `revision`, * in which case it never counts against any specific revision in * {@link import('../retained-recovery-record-count.ts').countTeardownDeadLettersForRevision}'s * scan, mirroring `WorkflowState.revision`'s own legacy-record precedent. * No persisted-data schema-version bump — decode already tolerates its * absence (`finalizer-status.ts`'s field-presence checks, not an * exhaustive-key check). */ revision?: string; } /** * Fixed second key segment `deadLetterTeardown()` uses for * {@link KEYS.teardownDeadLetterHistory} when the dead-lettering run has no * `workflowExecutionToken` (a legacy, pre-token run) — WFT-21, Codex review * round 3, P2. A second legacy run reusing the same workflow id and ALSO * dead-lettering would collide on this same sentinel segment, silently * losing the earlier legacy record's reference — a bounded edge case * affecting only runs that predate `WorkflowState.workflowExecutionToken`, * mirroring this file's own `revision === undefined` legacy fallback. * * Exported (not module-private) so * {@link import('../retained-recovery-record-count.ts').countTeardownDeadLettersForRevision} * can compute the exact history key a token-less single-slot * `KEYS.teardownDeadLetter` record would have produced, to detect whether a * legacy single-slot record already has a history sibling (WFT-21, Codex * review, item 7). */ export declare const LEGACY_DEAD_LETTER_HISTORY_TOKEN = "legacy"; /** Build the operations that arm a fresh `wf-teardown:` timer at `fireAt` (same token). */ export declare function teardownTimerOperations(token: string, workflowId: string, fireAt: number): BatchOperation[]; /** Encode an `owed` claim (attempts as given, no `claimedAt` while owed). */ export declare function encodeOwedClaim(attempts: number, token: string): Uint8Array; /** Encode the `running` claim a holder writes to atomically claim the marker. */ export declare function encodeRunningClaim(attempts: number, token: string, claimedAt: number): Uint8Array; /** * Re-arm a future `wf-teardown:` timer for a non-settling drive exit (lost CAS, a * presumed-live `running` claim, a shutdown abort, or a leave-the-marker case), so the * claim is not stranded after the scheduler deletes the fired timer. The marker bytes * are left untouched — only the timer is (re)written. Fenced, best-effort: a deposed * engine that loses the fence simply yields to the new owner, whose own timer drives it. */ export declare function rearmTeardownTimer(internals: EngineInternals, workflowId: string, token: string, delayMs: number): Promise; /** * Clear the teardown marker for a workflow that turned out not to owe a finalizer run * after all — a stale timer or a vanished/ineligible workflow. Conditioned on the marker * still being byte-for-byte `expectedBytes` (the bytes this drive read), so a concurrent * drive that already re-claimed or re-armed the marker — e.g. a same-id rerun whose fresh * cancellation wrote a NEW claim — is never clobbered. A lost CAS (someone changed it * first) is a benign no-op. Swallows a lost-fence error: a deposed engine simply leaves * the marker for the new owner. No timer is re-armed: clearing the marker IS the settle. */ export declare function clearTeardownMarker(internals: EngineInternals, workflowId: string, expectedBytes: Uint8Array): Promise; /** * Atomically claim the marker: CAS `owed → running` only if it is byte-for-byte the * `expectedBytes` we read (so concurrent reclaimers can't both win), fenced on the lease * epoch. Returns the `running` bytes we wrote on success (needed as the settle CAS * precondition), or `null` on a lost CAS. */ export declare function claimTeardownMarker(internals: EngineInternals, workflowId: string, expectedBytes: Uint8Array, attempts: number, token: string): Promise; /** * Commit a settle batch conditioned on the `teardownOwed` marker still equalling the * exact `running` bytes this drive wrote (Codex MF2). If a reclaimer overwrote the * marker first, the CAS fails and this returns `false` WITHOUT committing — the caller * must then skip dispatching any teardown event. A deposition still hard-halts (the * fenced helper throws), which the drive's outer try/catch routes to a cleanup error. */ export declare function settleOnRunningClaim(internals: EngineInternals, workflowId: string, runningBytes: Uint8Array, operations: BatchOperation[]): Promise; /** * Write the durable dead-letter record and clear the teardown + finalizer-state keys, * conditioned on the marker still being byte-for-byte `expectedBytes`. Used both at the * retry horizon (`expectedBytes` = the `running` bytes this drive wrote) and when the * recorded resource state vanished before any claim (`expectedBytes` = the `owed` bytes * this drive read). Conditioning on `expectedBytes` in BOTH cases prevents a stale drive * from dead-lettering after a concurrent drive already settled the marker — which would * falsely report a leak after a successful teardown. Returns whether the durable write * committed; the caller dispatches the dead-lettered event only when it did. */ export declare function deadLetterTeardown(internals: EngineInternals, workflowId: string, workflowType: string, attempts: number, expectedBytes: Uint8Array, details: { lastError: string; finalizerInput: unknown; }, workflowExecutionToken?: string, revision?: string): Promise;