/** * The reclaim-scan candidate-discovery and takeover-attempt machinery for * `ownership: 'workflow-lease'` ([ADR 0002](../../../documentation/contributing/architecture-decisions/0002-multiengine-per-workflow-ownership.md)). * Extracted from `ownership-bootstrap.ts` — which still composes this module's * {@link createWorkflowClaimReclaimTarget} into the renewal task's * `reclaimTarget` option — so that file stays under the repository's * implementation-file-size ceiling as this seam grows disposal-quiescence and * epoch-fenced-release handling on top of the original takeover-retry loop. * This is a real responsibility boundary, not an arbitrary split: everything * here is "how one engine discovers and attempts to reclaim a stranded * workflow's claim," while `ownership-bootstrap.ts` keeps gate execution, * registry/renewal-task construction, and the owner-side signal-poll seam. * * @module core/engine/workflow-claim-reclaim-target */ import type { Storage } from '../../storage/interface.ts'; import type { WorkflowClaimMetricsCollector } from './workflow-claim-metrics.ts'; import type { WorkflowClaimRegistry } from './workflow-claim-registry.ts'; import type { WorkflowClaimReclaimTarget } from './workflow-claim-renewal-subpasses.ts'; /** Bound on retrying a lost-race `takeover` CAS for one reclaim candidate within one pass — ADR 0002's `takeover` row. */ export declare const WORKFLOW_CLAIM_TAKEOVER_MAX_ATTEMPTS = 5; /** * A {@link WorkflowClaimReclaimTarget} with one additional, non-interface * method: {@link markDisposing}. Structurally still a valid * `WorkflowClaimReclaimTarget` (every caller that only knows that narrower * type — e.g. `createWorkflowClaimRenewalTask`'s `reclaimTarget` option — * keeps working unchanged), so existing tests that only exercise * `listReclaimCandidateWorkflowIds`/`attemptWorkflowClaimTakeover` are * unaffected by this addition. */ export type WorkflowClaimReclaimTargetHandle = WorkflowClaimReclaimTarget & { /** * Synchronously flip this target into "disposing" mode: every future * `attemptWorkflowClaimTakeover` call becomes an immediate `'not-eligible'` * no-op (no CAS attempted, no `onReclaimed` drive invoked), and * `listReclaimCandidateWorkflowIds` returns `[]`. A reclaim attempt already * past this checkpoint when disposal begins keeps running to its next * checkpoint — every checkpoint after an `await` re-checks the flag — and, * if it lands a takeover/acquire CAS after disposal was signaled, releases * that claim immediately instead of driving it or leaving it held. See this * module's doc and `ownership-bootstrap.ts`'s `bootstrapWorkflowLeaseOwnership` * for how this is wired to `WorkflowClaimRenewalTask.stop()`. * * Idempotent. Calling this before any pass has started simply prevents one * from ever discovering or attempting a candidate. */ markDisposing(): void; }; /** * Adapt a {@link WorkflowClaimRegistry} plus `storage` to the renewal task's * {@link WorkflowClaimReclaimTarget} contract. Candidate discovery excludes * this engine's own currently-held ids (`registry.listHeldWorkflowIds()`) — * see `workflow-claim-reclaim-scan.ts`'s doc for why — then adds back any * workflow this engine holds but whose `onReclaimed` drive previously * failed (see `driveReclaimedWorkflow` below). `attemptWorkflowClaimTakeover` * retries a `'lost-race'` CAS, bounded at {@link WORKFLOW_CLAIM_TAKEOVER_MAX_ATTEMPTS} * per the ADR, and records `weft_workflow_claim_attempts_total{outcome="backoff_skipped"}` * the moment the registry's own anti-thrash cooldown suppresses an attempt — * the one `WorkflowClaimAttemptOutcome` this stage wires; the other four * remain unrecorded by design (see the ADR's Observability section for the * full set — that wiring is a later stage's work). * * **A failed `onReclaimed` drive is retried in place, never released.** * Releasing on failure was considered and rejected: `onReclaimed` (bound to * `resumeWorkflowFromStorage` in production) can throw AFTER * `relaunchInlineWorkflowAfterResume` has already adopted the generator — * `InlineExecutionStrategy#continueWorkflow` fires the drive and returns * without awaiting it, so a caught error here does not prove no local user * code started. Releasing the claim in that state would let another engine * `acquire` it while this engine may still be mid-turn — the exact * duplicate-execution hazard ADR 0002 exists to close, just re-opened via * the failure path instead of the happy path. Retrying in place keeps the * claim (and its write fence) intact and simply asks `onReclaimed` again on * a later pass, via `pendingRedriveWorkflowIds` below. * * **Discovered and pending-redrive candidates are merged through a `Set` * (WFT-79 Finding 4).** After a failed redrive loses this engine's local * claim (a renewal loss between the failed drive and the next pass), the * same workflow id can surface BOTH through `listWorkflowClaimReclaimCandidates` * (as a foreign holder, since `registry.listHeldWorkflowIds()` no longer * excludes it) AND through `pendingRedriveWorkflowIds`. Without deduping, * one renewal pass would call `attemptWorkflowClaimTakeover` for that id * twice, and each call independently retries up to * {@link WORKFLOW_CLAIM_TAKEOVER_MAX_ATTEMPTS} — doubling the advertised * per-pass bound to 10 attempts for that workflow, exactly when contention is * already highest (deposition churn). * * **Disposal quiescence (WFT-79 Finding 2).** {@link WorkflowClaimReclaimTargetHandle.markDisposing} * closes two related hazards when disposal overlaps an in-flight * interval-driven pass: a late-landing takeover/acquire CAS stranding a claim * this engine will never renew again, and a late `onReclaimed` drive running * against a torn-down host. Every checkpoint that follows an `await` inside * this target re-checks the flag; a CAS that lands after disposal was * signaled is released immediately rather than driven, so the claim never * outlives this engine's own best-effort `WorkflowClaimRegistry.releaseAll()` * call regardless of the ordering race between them. */ export declare function createWorkflowClaimReclaimTarget(registry: WorkflowClaimRegistry, storage: Storage, metrics: WorkflowClaimMetricsCollector, onReclaimed?: (workflowId: string) => Promise, /** * Optional workflow-type eligibility check, consulted before this engine * ever attempts a fresh takeover/acquire CAS for a candidate (never for a * `redriveAlreadyHeldClaim` retry, since that claim already passed this * check when it was first taken). Omitted (the default) skips the check — * every existing caller/test that does not care about mixed workflow-type * fleets keeps working unchanged. */ isTypeRegistered?: (workflowType: string, revision: string | undefined) => boolean): WorkflowClaimReclaimTargetHandle;