/** * **DbWriterLease** — single in-process writer-lease arbitration over a persisted * SQLite row (T11627 ST-2 · local-mode engine). * * Heals the T5158 multi-writer corruption (`E_NOT_INITIALIZED` / `E_INTERNAL` on * the consolidated `cleo.db`) **while the supervisor daemon stays DISABLED in * production** by serializing writers through a `BEGIN IMMEDIATE` claim transaction * against a persisted `_writer_leases` row, fenced by an epoch-CAS. The supervisor * (ST-5, daemon-on) and Node (this module, daemon-off) share the SAME arbitration * primitive and row format — IPC is a coordination optimization over the persisted * source-of-truth, never a second source. * * ## Modes ({@link resolveLeaseMode}) * * `CLEO_WRITER_LEASE_MODE` ∈ `{ supervisor | local | off | require }`, default * `local`: * * - `local` — Node arbitrates the row directly. No IPC. **Heals T5158 with the * daemon off** (the shipping config). DEFAULT. * - `supervisor` — would prefer IPC to the supervisor; in ST-2 (no IPC client * wired) it transparently behaves as `local`, logging the demotion once. * - `off` — pure pass-through: {@link withWriterLease} just runs `fn` under the * existing `busy_timeout=30000`. Byte-identical to pre-lease behaviour. Rollback * escape hatch. * - `require` — strict: an acquire that cannot take the row throws * `E_LEASE_UNAVAILABLE`. No fallback. * * `busy_timeout=30000` (SSoT in `specs/sqlite-pragmas.json`) backstops every * `BEGIN IMMEDIATE`, so a contended claim degrades to today's bounded wait rather * than a hang in any mode. * * ## Re-entrancy * * A process-local grant memo (`Map<\`${scope}::${lane}\`, { handle, refcount }>`) * memoizes the active grant so a nested same-lane write in the SAME process shares * one lease (`refcount++` + the row's `reentrancy_depth++`) instead of re-running * the claim txn — no second `BEGIN IMMEDIATE`, no IPC. `release()` decrements; the * row is freed (`active = 0`) at depth 0. * * ## DB Open Guard (Gate 3) * * This module lives under `packages/core/src/store/**`, which is inside the Gate-3 * raw-open allowlist (the DB chokepoint). It obtains the native handle via * {@link openDualScopeDb} (no new raw `new DatabaseSync(`) and runs raw * `BEGIN IMMEDIATE` claim/reclaim/release transactions on it. * * ## Cold-open seam (T11627 ST-3 · Seam 0 — the T5158 heal) * * {@link withColdOpenLease} leases the `dual-scope-db.ts` cold-open critical * section (`reconcileJournal` + `migrateWithRetry` + the exodus-on-open hook) * against the SAME native handle the open just created — it does NOT route through * the default resolver (which would re-enter `openDualScopeDb` and recurse). It * idempotently bootstraps the lease tables on that handle FIRST (the full * migration that creates them runs INSIDE the leased section), so exactly one * process per scope runs the migrate/reconcile write-txn while peers * `BEGIN IMMEDIATE`-queue and then observe a ready DB. This is the heal that ships * with the supervisor daemon disabled. * * @module * @task T11627 * @epic T11625 * @see ./writer-lease-schema.ts — the drizzle table decls + bootstrap index assertion * @see ./dual-scope-db.ts — the open chokepoint this engine routes through (Seams 0 & 1) */ import type { DatabaseSync } from 'node:sqlite'; import type { LeaseLane, LeaseScope } from '@cleocode/contracts'; export type { LeaseLane, LeaseScope } from '@cleocode/contracts'; /** * The writer-lease arbitration mode. Resolved once at process start from * `CLEO_WRITER_LEASE_MODE`; default `'local'`. */ export type LeaseMode = 'supervisor' | 'local' | 'off' | 'require'; /** * A held writer-lease grant. Returned by {@link acquireWriterLease}; passed to the * callback of {@link withWriterLease}. */ export interface LeaseHandle { /** The cleo.db scope this lease arbitrates within. */ readonly scope: LeaseScope; /** The write lane this lease arbitrates within the scope. */ readonly lane: LeaseLane; /** * The epoch fence assigned to this grant. A write whose epoch no longer matches * the row (the lease was reclaimed) must fail rather than corrupt * (`E_WRITER_LEASE_STALE`). */ readonly epoch: number; /** * Release the lease. Decrements the process-local refcount and the row's * `reentrancy_depth`; frees the row (`active = 0`) and stops the heartbeat at * depth 0. Idempotent — a second call is a no-op. */ release(): Promise; /** * Advance the lease heartbeat (`heartbeat_at = now`) under the epoch guard. A * reclaimed holder's heartbeat no-ops. Called automatically by the internal * timer; exposed for callers that want to assert liveness mid-batch. */ heartbeat(): void; } /** Options accepted by {@link withWriterLease} / {@link acquireWriterLease}. */ export interface LeaseAcquireOptions { /** Advisory priority — lower acquires sooner. `0` = highest. Default `100`. */ priority?: number; /** Lease time-to-live in milliseconds. Default {@link DEFAULT_TTL_MS}. */ ttlMs?: number; /** * When true (default), a nested same-(scope,dbPath,lane) acquire in this process * re-enters the existing grant (refcount++) instead of contending. When false, * a nested acquire is forced to take a fresh row (used only by tests exercising * cross-holder contention within one process). */ reentrant?: boolean; /** * Pin the lease to a SPECIFIC cleo.db file (its resolved on-disk path) instead * of the cwd-default canonical path the scope→path resolver returns. The * chokepoint write primitives pass the dbPath recorded at cold-open * ({@link activeScopeDbPath}) so the lease row lands in the SAME file the write * targets — correct when more than one project's cleo.db is open in one process * (Finding 1). When omitted, the canonical scope→path resolution is used. */ dbPath?: string; } /** Default lease TTL — aligned to `busy_timeout=30000` (specs/sqlite-pragmas.json). */ export declare const DEFAULT_TTL_MS = 30000; /** The cold-open schema-bootstrap priority (highest). */ export declare const SCHEMA_BOOTSTRAP_PRIORITY = 0; /** * Resolve the writer-lease mode from `CLEO_WRITER_LEASE_MODE`, once per process. * * Unknown / unset values resolve to `'local'` — the production-safe default while * the supervisor daemon is disabled. * * @returns The resolved {@link LeaseMode}. * * @task T11627 */ export declare function resolveLeaseMode(): LeaseMode; /** * Reset cached process-global state (mode + demotion flag + grant memo). Tests * only — production resolves these once and never resets. * * @internal */ export declare function _resetWriterLeaseStateForTest(): void; /** * A resolved lease target: the native `cleo.db` handle for a scope PLUS the * absolute on-disk path that physically distinguishes it. * * The lease scope key is the EXISTING `cacheKey(scope, dbPath)` composite * (`${scope}::${dbPath}`), so two DIFFERENT project files opened in one process * are DISTINCT lease scopes (spec §6 Seam 0). Keying the grant memo + the * resolver on the abstract scope LABEL alone would mis-share an in-process grant * across two projects and route the second project's lease writes to the * cwd-default file. `dbPath` closes that latent cross-project gap. */ export interface LeaseTarget { /** The native `DatabaseSync` handle for the scope's `cleo.db`. */ readonly native: DatabaseSync; /** The absolute on-disk path of that `cleo.db` (the lease-scope discriminator). */ readonly dbPath: string; } /** * Resolver that yields the native handle + path for a scope's `cleo.db`. When * `dbPath` is supplied the resolver MUST open THAT file (an explicit-path lease, * e.g. a second project's cleo.db); otherwise it resolves the cwd-default * canonical path for the scope. */ export type NativeDbResolver = (scope: LeaseScope, dbPath?: string) => Promise; /** * Override how the engine obtains a scope's native `cleo.db` handle + path. Tests * inject a temp-dir handle here so arbitration runs against an isolated fixture * with no supervisor and no canonical-path side effects. * * For backward-compatibility a resolver that returns a bare `DatabaseSync` is * accepted and adapted to a {@link LeaseTarget} whose `dbPath` is a stable * synthetic per-scope token (`test://`) — sufficient for single-file test * fixtures, which key one file per scope. * * @param resolver - The resolver to use, or `undefined` to restore the default. * @internal */ export declare function _setNativeDbResolverForTest(resolver: ((scope: LeaseScope, dbPath?: string) => Promise) | undefined): void; /** * Record the scope (and optionally the resolved dbPath) of the cold-open * currently in progress / most recently opened. Called by `dual-scope-db.ts` at * the head of its cold-open critical section (Seam 0). Idempotent — last writer * wins; a project open after a global open makes `'project'` the active scope, * which is correct because the chokepoint write primitives only ever write the * project-tier `tasks_*` tables. * * @param scope - The scope of the in-progress cold-open. * @param dbPath - The resolved on-disk path of that scope's cleo.db. When * omitted, the active dbPath is cleared so {@link activeScopeDbPath} falls back * to the canonical scope→path resolver. * @internal * @task T11627 */ export declare function setActiveScope(scope: LeaseScope, dbPath?: string): void; /** * The scope the chokepoint write primitives should lease against. Defaults to * `'project'` (the tasks chokepoint is project-tier) when no cold-open has * recorded a scope yet — a write before any open is degenerate, but `'project'` * is the only correct lease scope for `tasks_*` mutations. * * @returns The active {@link LeaseScope}. * @internal * @task T11627 */ export declare function activeScope(): LeaseScope; /** * The dbPath the chokepoint write primitives should lease their row in. Returns * the path recorded by the most-recent cold-open, or — defensively — the * canonical scope→path resolution for {@link activeScope} when none was recorded. * * @returns The active cleo.db on-disk path. * @internal * @task T11627 */ export declare function activeScopeDbPath(): string; /** * Clear the recorded active scope + dbPath. Tests only — production records once * per cold-open and never clears (the scope of the last canonical open remains * the lease target for subsequent writes). * * @internal */ export declare function _clearActiveScopeForTest(): void; /** * Thrown when `require` mode cannot acquire the lease, or (future) when a * supervisor explicitly denies. Carries a stable `codeName` for envelope mapping. * * @public */ export declare class LeaseUnavailableError extends Error { /** Stable string error code for envelope `codeName` / log correlation. */ readonly codeName: "E_LEASE_UNAVAILABLE"; constructor(scope: LeaseScope, lane: LeaseLane, reason: string); } /** * Thrown when a write primitive opens the WRITE handle without a held lease * (T11627 ST-4 · Seam 2 · AC4). The brain worker (and any other dedicated-handle * writer outside the chokepoint) MUST hold its lane's grant before it writes — a * lease-less write is exactly the multi-writer race the lease exists to kill. * * Enforced (not merely advised): the brain writer bootstrap calls * {@link assertWriterLeaseHeld} before draining, and a lease-less drain throws * this. `off` mode (the rollback escape hatch) is exempt — there is no lease to * hold and the underlying `busy_timeout` serializes writes as before. * * @public * @task T11627 */ export declare class WriterLeaseRequiredError extends Error { /** Stable string error code for envelope `codeName` / log correlation. */ readonly codeName: "E_WRITER_LEASE_REQUIRED"; constructor(scope: LeaseScope, lane: LeaseLane); } /** * Whether THIS process currently holds an active grant for `(scope, lane)` in the * process-local grant memo. Used by dedicated-handle writers (the brain worker) to * assert AC4 without re-running the claim txn. * * @param scope - The cleo.db scope. * @param lane - The write lane. * @returns `true` iff a memoized grant with `refcount > 0` exists for the key. * * @task T11627 */ export declare function hasActiveGrant(scope: LeaseScope, lane: LeaseLane): boolean; /** * AC4 guard. Assert this process holds the `(scope, lane)` writer lease before it * opens a dedicated WRITE handle. In `off` mode the assertion is a no-op (there is * no lease; `busy_timeout` serializes). In every other mode a missing grant throws * {@link WriterLeaseRequiredError}. * * @param scope - The cleo.db scope the handle writes to. * @param lane - The write lane that must be held. * @throws {WriterLeaseRequiredError} when no grant is held and mode is not `off`. * * @task T11627 */ export declare function assertWriterLeaseHeld(scope: LeaseScope, lane: LeaseLane): void; /** * Acquire (or re-enter) the writer lease for `(scope, lane)`. Caller MUST call * `release()` on the returned handle (use {@link withWriterLease} to do so * automatically). * * - `off` mode → returns a no-op handle (pass-through; no row written). * - `local`/`supervisor` mode → arbitrates over the persisted row via * `BEGIN IMMEDIATE` (+ epoch-CAS). A nested same-(scope,lane) acquire in this * process re-enters the memoized grant (refcount++). * - `require` mode → throws {@link LeaseUnavailableError} if the row cannot be * taken within the acquire window (`min(ACQUIRE_DEADLINE_MS, ttlMs)`). * * @param scope - The cleo.db scope. * @param lane - The write lane within the scope. * @param opts - Priority / TTL / re-entrancy options. * @returns A held {@link LeaseHandle}. * * @task T11627 */ export declare function acquireWriterLease(scope: LeaseScope, lane: LeaseLane, opts?: LeaseAcquireOptions): Promise; /** * Primary surface: acquire → run `fn` → release (always, even on throw). * * Refcounted + re-entrant by `(scope, lane)`: a nested same-lane write in the same * process re-enters the memoized grant rather than re-running the claim txn. * * - `off` mode → pass-through (runs `fn` under today's busy_timeout, no row). * - `require` mode → an unacquirable lease throws {@link LeaseUnavailableError} * before `fn` runs. * * @param scope - The cleo.db scope. * @param lane - The write lane. * @param fn - The work to run while holding the lease; receives the handle. * @param opts - Priority / TTL / re-entrancy options. * @returns The resolved value of `fn`. * * @task T11627 */ export declare function withWriterLease(scope: LeaseScope, lane: LeaseLane, fn: (h: LeaseHandle) => Promise, opts?: LeaseAcquireOptions): Promise; /** * Lease the dual-scope-db COLD-OPEN critical section (Seam 0 — the T5158 heal). * * This is the high-value gate: it serializes the cold-open `reconcileJournal` + * `migrateWithRetry` write-txn — the precise span that races in T5158 — so EXACTLY * ONE process per scope runs it while peers `BEGIN IMMEDIATE`-queue and then observe * a ready DB. It heals the T5158 `E_NOT_INITIALIZED` / `E_INTERNAL` corruption * **with the supervisor daemon disabled** (`local` mode default). The caller * (`dual-scope-db.ts`) runs the exodus-on-open hook AFTER this lease releases — the * exodus engine owns its own single-flight lock + dedicated connections and * closes/re-opens handles, so it must not run under this row lease. * * Unlike {@link withWriterLease} it operates DIRECTLY on the already-opened native * handle — it never routes through the default resolver (which would re-enter * `openDualScopeDb` and recurse) and it bootstraps the lease tables first (the full * migration that creates them runs inside `fn`). It also records the scope in the * Seam-1 active-scope registry ({@link setActiveScope}) so chokepoint write * primitives lease against the right scope. * * Mode semantics mirror {@link withWriterLease}: * - `off` → pass-through: runs `fn` under today's `busy_timeout=30000`, byte- * identical to pre-lease cold-open behaviour. * - `local` / `supervisor` (demoted) → claim the row via `BEGIN IMMEDIATE`; on a * contended live holder, spin under the busy-timeout-backstopped deadline, then * degrade to running `fn` (busy_timeout still serializes the migrate write-txn). * - `require` → throw {@link LeaseUnavailableError} if the row cannot be taken. * * @param scope - The cleo.db scope being cold-opened. * @param nativeDb - The native handle the cold-open just created (pragmas applied). * @param fn - The cold-open body to run while holding the lease. * @param opts - Priority / TTL options + the resolved `dbPath` of this cold-open. * `dbPath` is recorded in the Seam-1 active-scope registry so the chokepoint * write primitives lease their row in the SAME file this open targeted (correct * when more than one project is open in one process — Finding 1). Cold-open * defaults to highest priority ({@link SCHEMA_BOOTSTRAP_PRIORITY}) and a 60s TTL * (schema bootstrap can be slow). * @returns The resolved value of `fn`. * * @task T11627 */ export declare function withColdOpenLease(scope: LeaseScope, nativeDb: DatabaseSync, fn: () => Promise, opts?: { priority?: number; ttlMs?: number; dbPath?: string; }): Promise; //# sourceMappingURL=writer-lease.d.ts.map