/** * Project-wide admission gate and retention policy for SQLite snapshots. * * `cleo session end` snapshots the project databases. When many agents end * their sessions together, each process used to run its own snapshot, because * the debounce lived in process memory and the session-end hook bypassed it * with `force: true`. On 2026-09-27 that produced four 1.3 GB snapshots in ten * seconds, and the newest-10 rotation then evicted the older recovery points. * * This module closes that path with three mechanisms: * * 1. **Lock** — a cross-process lock ({@link acquireLock}, proper-lockfile) * on `/.snapshot-gate`. At most one snapshot runs per project. * 2. **Admission** — gate state lives in the project `cleo.db` * (`tasks_schema_meta`, key {@link SNAPSHOT_GATE_META_KEY}) and is re-read * under the lock. It holds a monotonic snapshot GENERATION counter, * claimed under the lock when a snapshot starts, and per prefix the * generation and start time of its last satisfied snapshot. Two modes * ({@link SnapshotGateMode}) use it: * - `routine` (per-write checkpoints): debounced for * {@link SNAPSHOT_DEBOUNCE_MS}, and the lock is tried once — a caller * that finds a snapshot in flight skips. * - `required` (session end, pre-destructive checkpoints): not * debounced. The caller records the generation it saw when it made * the request, WAITS for the lock (bounded), and is satisfied only * by a snapshot of a LATER generation — one that started after the * request, so it contains every write made before it. Requests queued * behind one snapshot are all covered by it, so a burst produces one * snapshot. Works without a state store. No wall clock is involved, * so a stepped clock cannot fake coverage. * A prefix whose database does not exist in this project (e.g. no * `llmtxt.db`) is `absent`: it satisfies admission like a written * snapshot and is never reported as failed. There is no mode that * bypasses the lock. * 3. **Retention** — {@link selectSnapshotsToKeep} keeps time-spread slots * (latest, quarter-hourly, hourly, daily) instead of the newest N, so a * burst cannot evict older recovery points. The file just written is * always kept. * * @task T12508 */ import type { DatabaseSync } from 'node:sqlite'; /** Minimum interval between two `routine` snapshot starts of a prefix, per project. */ export declare const SNAPSHOT_DEBOUNCE_MS: number; /** * Age after which a held gate lock is considered abandoned. `VACUUM INTO` is * synchronous and blocks the event loop, so the lock's mtime cannot be * refreshed while it runs; this value must exceed the longest snapshot. */ export declare const SNAPSHOT_LOCK_STALE_MS: number; /** * Default lock retries for a `required` request (pre-destructive * checkpoints). proper-lockfile backs off from 100 ms to 1 s per attempt, so * 90 retries bound the wait to about 90 s. */ export declare const SNAPSHOT_LOCK_WAIT_RETRIES = 90; /** * Lock retries for the session-end snapshot: about 6.5 s (100 + 200 + 400 + * 800 ms, then 1 s each). Session end runs inside host shutdown hooks that * may be killed, so it must not wait long; the session is already persisted * when it runs. */ export declare const SESSION_END_LOCK_WAIT_RETRIES = 9; /** `tasks_schema_meta` key holding the persisted snapshot gate state. */ export declare const SNAPSHOT_GATE_META_KEY = "sqlite_snapshot_gate"; /** * How a snapshot request is admitted. * * - `routine` — debounced; skips when a snapshot is in flight; needs the state * store (without it nothing is admitted, so a broken store cannot storm). * - `required` — not debounced; waits for the lock; skipped only when a * snapshot of a later generation than the one seen at request time * satisfied the prefix. */ export type SnapshotGateMode = 'routine' | 'required'; /** * What a snapshot function did for one prefix: wrote a file, hard-linked the * file already written in this run for the same physical database, or found * no database to snapshot. */ export type SnapshotOutcome = 'written' | 'linked' | 'absent'; /** Why a gated snapshot request did not snapshot anything. */ export type SnapshotGateSkipReason = 'debounced' | 'in-flight' | 'covered' | 'state-unavailable' | 'lock-timeout'; /** Outcome of {@link runGatedSnapshot}. */ export interface SnapshotGateResult { /** Prefixes that have a snapshot file from this call (written or hard-linked). */ readonly snapshotted: readonly string[]; /** * Subset of `snapshotted` that shares its file with an earlier prefix of the * same run (same physical database — one VACUUM, hard-linked). */ readonly linked: readonly string[]; /** Prefixes whose database does not exist in this project (nothing to snapshot). */ readonly absent: readonly string[]; /** Prefixes whose snapshot was attempted and threw. */ readonly failed: readonly string[]; /** Set when no snapshot was attempted; `null` when at least one prefix was admitted. */ readonly skipped: SnapshotGateSkipReason | null; /** Human-readable cause when `skipped` is `lock-timeout`. */ readonly error?: string; } /** Options for {@link runGatedSnapshot}. */ export interface SnapshotGateOptions { /** Absolute snapshot directory; the lock lives here. Must exist. */ readonly backupDir: string; /** * Handle on the project `cleo.db` that stores the gate state, or `null` when * it cannot be opened. `routine` requests admit nothing without it; * `required` requests snapshot anyway. */ readonly stateDb: DatabaseSync | null; /** Snapshot prefixes requested by the caller, in snapshot order. */ readonly prefixes: readonly string[]; /** Admission mode. Defaults to `routine`. */ readonly mode?: SnapshotGateMode; /** * `required` mode: the gate generation observed when the request was made * (after the caller's last write). Only a later generation covers it. * Defaults to the generation read at the start of this call. */ readonly seenGeneration?: number; /** * `required` mode: never treat a prefix as covered — snapshot every * requested prefix once the lock is held. Used by the session-end worker: * requests coalesce onto it while it is queued, so no generation it could * observe proves those requests' writes are in an earlier snapshot. */ readonly alwaysSnapshot?: boolean; /** Lock retries for `required` mode. Defaults to {@link SNAPSHOT_LOCK_WAIT_RETRIES}. */ readonly lockWaitRetries?: number; /** * Called under the lock BEFORE the state is re-read and the generation is * claimed. The session-end worker uses it to drop its "worker pending" * marker: a request that saw the marker was made before this point, so the * generation claimed next covers it. Errors are ignored. */ readonly onLockAcquired?: () => void; /** Clock override for tests (debounce and timestamps only). Defaults to `Date.now`. */ readonly now?: () => number; } /** * Read the current snapshot generation — what a `required` request should * pass as `seenGeneration` when it is made. Returns `null` without a usable * state store (the request is then never considered covered). * * @param db - Handle on the project `cleo.db`, or `null`. * @returns The highest claimed generation, or `null`. * @task T12508 */ export declare function readSnapshotGeneration(db: DatabaseSync | null): number | null; /** * Run `snapshot` for every requested prefix that needs one, while holding the * project-wide snapshot lock. See the module comment for the two modes. * * `snapshot` resolves `'written'` when it wrote a snapshot file and * `'absent'` when the database does not exist; a rejection counts as a * failure. Written and absent prefixes are recorded (they satisfy admission); * a failed one is not, so a failed attempt never uses up the debounce window * or covers a later request. * * Never throws; every failure is reported in the result. * * @param opts - Backup directory, state store, prefixes, and mode. * @param snapshot - Performs one prefix's snapshot. Runs under the lock. * @returns Which prefixes were written, absent or failed, or why none ran. * @task T12508 */ export declare function runGatedSnapshot(opts: SnapshotGateOptions, snapshot: (prefix: string) => Promise): Promise; /** * Explain why a `required` snapshot request left no fresh snapshot behind, or * return `null` when it succeeded (written, covered by a later snapshot, or * every prefix absent). Used by callers that must tell the user. * * @param result - Gate outcome; `null` means nothing could be attempted. * @returns A one-line reason, or `null` on success. * @task T12508 */ export declare function describeSnapshotMiss(result: SnapshotGateResult | null): string | null; /** * Time-spread retention policy. Each slot kind keeps the NEWEST snapshot in * each of its most recent non-empty buckets; the kept set is the union. Slots * overlap (the latest snapshot is usually also the newest of its quarter hour, * hour and day), so the slotted file count is at most the sum of the counts. */ export interface SnapshotRetentionPolicy { /** Number of most recent snapshots kept unconditionally. */ readonly latest: number; /** Number of most recent distinct 15-minute buckets that keep one snapshot each. */ readonly quarterHourly: number; /** Number of most recent distinct clock hours that keep one snapshot each. */ readonly hourly: number; /** Number of most recent distinct calendar days that keep one snapshot each. */ readonly daily: number; } /** * Default policy: at most 10 slotted files per prefix, the same ceiling as the * former newest-10 rotation, spread across quarter hours, hours and days. The * quarter-hour slots keep an active session's history from collapsing into * the one hourly bucket it is in. */ export declare const DEFAULT_SNAPSHOT_RETENTION: SnapshotRetentionPolicy; /** Inputs to {@link selectSnapshotsToKeep} besides the filenames. */ export interface SnapshotRetentionContext { /** Slot counts; defaults to {@link DEFAULT_SNAPSHOT_RETENTION}. */ readonly policy?: SnapshotRetentionPolicy; /** The snapshot just written; always kept, whatever its stamp. */ readonly pinned?: string; /** * The current local time as a `YYYYMMDD-HHmmss` stamp. Files stamped later * than this are unslotted (kept, but they win no slot). Omit to skip the check. */ readonly nowStamp?: string; } /** * Choose which snapshot files survive retention. * * Buckets come from the `YYYYMMDD-HHmmss` stamp in each filename (the local * time the snapshot was taken), not from mtime, so copying or touching a file * cannot move it between buckets. Buckets are the most recent ones that HOLD * a snapshot, not the most recent hours on the clock: when no snapshots are * taken for a week, last week's history is kept rather than aged out. * * Never deleted: the pinned file (the one just written), files without a * stamp, files with an impossible stamp, and files stamped in the future. * The last two are also unslotted — a skewed or bogus stamp cannot win a slot * and push a real snapshot out. * * Local stamps repeat an hour at the DST fall-back (01:30 PDT, then 01:10 * PST). During the repeated hour the earlier PDT file carries a stamp later * than the clock, so it is unslotted and kept; the PST file just written is * pinned. Once the hour passes, both share one hourly bucket. * * @param names - Snapshot filenames for a single prefix. * @param ctx - Policy, pinned file, and current stamp. * @returns The filenames to keep. * @task T12508 */ export declare function selectSnapshotsToKeep(names: readonly string[], ctx?: SnapshotRetentionContext): Set; //# sourceMappingURL=snapshot-gate.d.ts.map