import { type TreeChange, type TreeChangeBatch } from "../watcher.js"; import { readJournal } from "../journal.js"; import { type FeatureSwitch } from "../sync/feature-flags.js"; import { HeapCensus } from "./sync-runner-heap-telemetry.js"; import { FullPassCapController } from "../sync/full-pass-caps.js"; import { type RealtimeTelemetryReason } from "../sync/realtime-telemetry.js"; import { type PathWorkOutcome } from "../sync/sync-work.js"; import type { TelemetryClaims } from "../telemetry-events.js"; import type { JournalEntry } from "../types.js"; import type { CooperativePassCheckpoint, Direction, RunnerLoopDeps, RunnerPassOutcome } from "./sync-runner.js"; /** A deferred full chore must get the next admission after this long. */ export declare const CHORE_STARVATION_CAP_MS: number; /** * How long a realtime pass may sit behind an active full chore before it is * handed back as retryable. Twice the authorization timeout so a single hung * ObjectIO request (60s) cannot stretch "commit" into minutes, while a chore * still has one 15s yield window to admit the item. */ export declare const REALTIME_ADMISSION_DEADLINE_MS: number; /** * How long a chore checkpoint may spend *admitting* further realtime items * before it must resume. An execute() that already started is not aborted * when this bound elapses. Distinct from {@link CHORE_STARVATION_CAP_MS}: * that value only promotes a deferred chore to the front of the queue. */ export declare const REALTIME_DRAIN_BURST_MS: number; /** * How many realtime executes may run during one chore hand-off. * HQ_SYNC_MAX_CONCURRENCY caps simultaneous S3 object transfers, not these * workers. One long transfer keeps its worker; a realtime item that arrives * while that execute() is in flight starts on the other slot instead of * waiting for the whole batch. In-flight work is never aborted. */ export declare const REALTIME_WORKER_OCCUPANCY_CAP = 2; export declare function setRealtimeAdmissionDeadlineMsForTesting(ms: number | undefined): void; export declare function setRealtimeDrainBurstMsForTesting(ms: number | undefined): void; export declare class RealtimeAdmissionTimeoutError extends Error { readonly retryable = true; constructor(deadlineMs: number); } export declare class RealtimeDrainBurstError extends Error { readonly retryable = true; constructor(burstMs: number); } /** * Coordinator path status for one sync-pass exit. Exit 19 (vanished company) * is durable so a leftover local slug is not restored and retried forever; * transient 75 and partial 2 stay retryable. */ export declare function coordinatorPathOutcome(exitCode: number): { status: "durable" | "retryable"; reason?: string; }; export declare function realtimeTelemetryReason(paths: readonly PathWorkOutcome[]): RealtimeTelemetryReason | undefined; export type GuardedWorkClass = "realtime" | "chore"; export interface GuardedPassContext { /** * A durable boundary in a chore. While the chore awaits this promise it * owns no mutable pass state, so queued realtime work can use the existing * root lock under a single-flight cooperative hand-off. */ checkpoint: CooperativePassCheckpoint; /** Aborted when the caller cancels the slice. */ signal: AbortSignal; } /** * One durable-state owner at a time for a full chore. Realtime work runs only * while that chore is suspended at `checkpoint()`. Only coordinator-fenced * realtime executes may use up to {@link REALTIME_WORKER_OCCUPANCY_CAP} * occupants during that hand-off; legacy execution remains single-flight. * A separately tracked, explicitly opted-in watcher fast pass may use the * pre-existing scoped fast-over-slow exception, but never while a chore owns * durable state. */ export declare class CooperativePassScheduler { private readonly now; private readonly starvationCapMs; private readonly options; private readonly pending; private active; /** Realtime executes started by the current hand-off. Not the chore slot. */ private readonly realtimeOccupants; private handoffBacklog; private handoffDeferredAt; private handoffAdmitted; private occupantEpoch; private occupantWaiters; private activeFastRealtime; private stopped; private _handoffActive; constructor(now: () => number, starvationCapMs?: number, options?: { caps?: FullPassCapController; }); get handoffActive(): boolean; get hasActiveChore(): boolean; get hasActiveSlowRealtime(): boolean; /** Includes the running slow/fast lanes and hand-off occupants; it never changes their cap. */ get activeRealtimeWorkerCount(): number; pendingLaneCount(lane: "fast" | "slow"): number; submit(task: (context: GuardedPassContext) => Promise, workClass: GuardedWorkClass, lane?: "fast" | "slow", allowFastRealtimeOvertake?: boolean, signal?: AbortSignal, resourceCap?: boolean, allowConcurrentHandoff?: boolean): Promise; stop(): void; private abortedError; /** Realtime stays ahead of a queued full chore so retries are not buried. */ private enqueuePending; private clearAdmission; private dropPending; private expireAdmission; private drain; private takeNext; private nextRealtimeIndex; private nextFastRealtime; private hasStarvedChore; private canStartFastRealtime; private start; private complete; private startFastRealtime; private completeFastRealtime; private execute; private withinHandoffAdmission; private waitOccupantChange; private notifyOccupantChange; /** Run one realtime execute without aborting it and without taking the chore slot. */ private startRealtimeOccupant; /** Eligible arrivals may take a free slot before hand-off admission closes. */ private promoteOccupancyArrivals; /** * Pull at most one already-queued realtime item while the admission window * is open and a worker slot is free. Further backlog waits for that * execute() so pre-queued hand-offs stay one durable writer at a time. */ private admitHandoffBacklog; private drainRealtime; private checkpoint; } /** * Area storage is AUTO by default and only applies after durable cutover. * Pass the env switch (`auto`/`on`/`off`) or a boolean force-on/force-off. * `HQ_SYNC_AREA_STORAGE=off` is the kill switch. */ export declare function shouldUseAreaStorage(areaStorage: boolean | FeatureSwitch, stateDir: string, journalSlug: string): boolean; /** * Targeted-pass batch limit: a fully-enumerated event batch larger than this * is drained in bounded chunks across successive ticks instead of one giant * scoped pass. Size is not a full-reconcile signal — unknown scope is * (`overflowed` with no route hints, warm-up catch-up failure, first tick, * scheduled interval, bare wake, vanished HQ-root coverage). The crossover for a *chunk* is the number * of `--scope-path` arguments a targeted pass can carry without becoming a * de-facto whole-tree walk. `HQ_SYNC_EVENT_BATCH_LIMIT` overrides; 0/invalid * = default. Do not raise this to "fix" a large warm-up catch-up: that only * moves the cliff. */ export declare const DEFAULT_EVENT_BATCH_LIMIT = 10000; export declare const EVENT_BATCH_LIMIT_ENV = "HQ_SYNC_EVENT_BATCH_LIMIT"; /** * Fast-lane admission is deliberately conservative: a normal editor save is * a handful of files and well below 4 MiB, whereas session-log catch-up is * neither. Operators may tune both limits without changing scheduling * semantics. Invalid values retain the safe defaults. */ export declare const FAST_LANE_MAX_FILES_ENV = "HQ_SYNC_FAST_LANE_MAX_FILES"; export declare const FAST_LANE_MAX_BYTES_ENV = "HQ_SYNC_FAST_LANE_MAX_BYTES"; export declare const FAST_LANE_MAX_FILE_BYTES_ENV = "HQ_SYNC_FAST_LANE_MAX_FILE_BYTES"; export declare const DEFAULT_FAST_LANE_MAX_FILES = 32; export declare const DEFAULT_FAST_LANE_MAX_BYTES: number; /** * A per-file ceiling keeps a single medium-sized transfer from consuming the * realtime lane even when the batch aggregate is otherwise below its cap. * 256 KiB comfortably covers ordinary notes and control-plane artifacts while * leaving session logs and other transfer-heavy files in the slow lane. */ export declare const DEFAULT_FAST_LANE_MAX_FILE_BYTES: number; /** * A coordinator submission must not contain both a directory and one of its * descendants: they are one mutation scope, and the lease manager correctly * rejects internally-conflicting submissions. Prefer the widest scope so an * unlinkDir following child unlink events remains admissible. */ export declare function normalizeCoordinatorPaths(paths: readonly string[]): string[]; /** * Exit code the watch loop returns when the heap governor recycles the runner. * Deliberately 1 (no signal): hq-desktop-app's `is_benign_watcher_exit` treats * `Some(1 | 2)` with no signal as benign and its `watcher_exit_capture_policy` * maps it to LocalLogOnly, so the desktop respawns the child WITHOUT opening a * Sentry capture. Exit 0 is the desktop's `Capture` policy (it would trade this * heap_oom report for a new recurring one); the runner's AUTH_REQUIRED_PASS_EXIT * is 18, so code 1 collides with neither. Defined here (not imported from * sync-runner) because that value import would close a module cycle — the * runner already imports this loop. */ export declare const RUNNER_HEAP_RECYCLE_EXIT = 1; /** The long-lived structures a watch session's heap census can attribute to. */ export interface WatchSessionCensusSources { /** Watch-loop-owned maps/sets, keyed by fixed token → live entry count. */ ownMaps: Record number>; /** Current emitter sizes (undefined until an emitter is live). */ eventSyncSizes: () => Record | undefined; /** Current watcher sizes (undefined until a watcher is attached). */ watcherSizes: () => Record | undefined; } /** * Assemble the heap census from every long-lived watch-session structure. Each * site registers under a FIXED token; the emitter/watcher sites read the CURRENT * handle each sample, so a null or replaced handle simply reports 0 rather than * throwing. Pure and side-effect free so it can be unit-tested directly. */ export declare function buildHeapCensus(sources: WatchSessionCensusSources): HeapCensus; export declare function resolveFastLaneLimits(): { maxFiles: number; maxBytes: number; maxFileBytes: number; }; /** One latest-per-path work item selected into the realtime or bulk lane. */ export interface LanePartition { fast: T[]; slow: T[]; } /** * Partition one selected watcher batch. The returned batches retain the same * route/filter metadata, but no latest path can appear in both lane maps. */ export declare function partitionLocalBatch(batch: TreeChangeBatch): { fast: TreeChangeBatch | null; slow: TreeChangeBatch | null; }; /** A missing/stat-failed path is never allowed to bypass the slow lane. */ export declare function isFastLocalBatch(paths: Iterable, changes?: ReadonlyMap>): boolean; /** A receiver event needs a known small upsert size before it can go realtime. */ export declare function isFastReceiverBatch(eventCount: number): boolean; export declare function resolveEventBatchLimit(env?: Record): number; /** * Take the next bounded slice of a fully-enumerated watcher batch and leave * the rest for a later tick. Insertion order is preserved so a killed or * stalled runner that already drained earlier chunks does not restart the * whole batch: remaining paths stay queued. Overflow metadata is not copied * onto either side — callers must not use this helper for unknown-scope * overflow (those batches still full-reconcile). */ export declare function takeEnumeratedBatchChunk(batch: TreeChangeBatch, limit: number): { chunk: TreeChangeBatch; remainder: TreeChangeBatch | null; }; /** * Elapsed-time cadence for scheduled full reconciles: one hour by default, * measured from the last full pass (its dispatch or its completion, whichever * is later). `HQ_SYNC_FULL_RECONCILE_MS` overrides it (for example, * 10_800_000 for three hours); an unset or invalid value falls back to the * hour. * * The previous default was "every `FULL_RECONCILE_EVERY_TICKS` ticks" with no * elapsed-time floor. Ticks fire on every watcher batch, not only on the * remote poll, so on an active vault a new full pass started 4-8 minutes — * once observed 21 seconds — after the previous one finished, and each pass * on a real install takes ~10 minutes of listing and HEAD traffic. The runner * was effectively reconciling continuously. Full reconcile is the correctness * backstop behind event-push (a missed watcher event, a new directory the * native watch has not adopted yet); the surfaces-inactive, overflow, warm-up * and bare-wake triggers still force it immediately when live events cannot * be trusted, so an hour is the cadence of the backstop, not of realtime. */ export declare const FULL_RECONCILE_MS_ENV = "HQ_SYNC_FULL_RECONCILE_MS"; export declare const DEFAULT_FULL_RECONCILE_MS: number | undefined; export declare function resolveFullReconcileMs(env?: Record): number | undefined; /** Initial poll-only recovery delay after a watcher conclusively stands down. */ export declare const WATCHER_REDISCOVERY_RETRY_MS: number; /** Ceiling for exponential retry delays after consecutive watcher stand-downs. */ export declare const WATCHER_REDISCOVERY_RETRY_MAX_MS: number; /** * Map whole-box load to a poll interval in the [floor, ceil] range. * * `load1` is the 1-minute load average ({@link os.loadavg}[0]); dividing by the * CPU count yields per-core saturation. ratio 0 (idle) → floor; ratio ≥ 1 * (fully saturated) → ceil; linear in between. Pure + injectable so the loop * can be unit-tested without a real host or a 10-minute wait. */ export declare function adaptivePollMs(load1: number, cpuCount: number, floorMs?: number, ceilMs?: number): number; /** * A receiver that has observed messages owns a bounded drain, even if its * slow lane is currently waiting for the operation lock. Do not stretch the * cadence from that observed work to the CPU-derived backoff ceiling. */ export declare function resolveWatchPollMs(explicitPollMs: number | undefined, receiverHasQueuedWork: boolean, loadAvg: number, cpuCount: number): number; /** * Advance the cadence from its previous monotonic deadline while the runner is * on time. When a pass overruns its slot, give the host one complete interval * to recover before the next poll. Periodic polling is a correctness backstop, * so catching up a missed slot is less important than avoiding continuous * CPU and disk use on large vaults. */ export declare function advanceMonotonicDeadline(previousDueAt: number, now: number, intervalMs: number): { nextDueAt: number; delayMs: number; }; export interface ParsedLoopArgs { hqRoot: string; lockTimeoutSec?: number; /** Resolved parser flag; environment policy is applied by resolveSkipPersonal. */ skipPersonal?: boolean; /** * The run's resolved sync direction, straight from `parseArgs` (the single * source of truth — same default as the parser). The drain reads this to * decide whether scoped pushes and/or the pull leg run; it must NOT re-parse * `--direction` from argv, which mis-defaults an omitted flag and disagrees * with the parser on duplicate flags. */ direction?: Direction; } export interface WatchLoopRuntime { runPassWithOperationLockAlreadyHeld: (passArgv: string[], checkpoint?: CooperativePassCheckpoint, onJournalsReady?: () => void, onAppliedUpserts?: (upserts: ReadonlyArray<{ relativePath: string; contentHash: string; }>) => void) => Promise; authRequiredPassExit: number; defaultGetIdTokenClaims: () => ({ email?: string; } & TelemetryClaims) | null; defaultGetAccessToken: () => Promise; apiUrl: string; region: string; } type JournalFiles = ReturnType["files"]; /** * Select journal entries covered by one watcher delete. Exact file unlinks * deliberately avoid enumeration; only directory unlinks expand descendants. */ export declare function journalEntriesForWatcherDelete(files: JournalFiles, key: string, kind: "unlink" | "unlinkDir"): Array<[string, JournalFiles[string]]>; /** * Return a journal hash only when its row demonstrably predates this runner. * * A scoped push writes the completed upload's hash before the runner hands the * path to PushEventEmitter. The lazy tier is a startup baseline, not a live * "has this runner just pushed it?" lookup, so every row stamped at or after * this session's start is deliberately a miss. Invalid timestamps are also a * miss: publishing an extra realtime event is safer than suppressing one. */ export declare function publishedContentHashFromJournalEntryBeforeSession(entry: Readonly | undefined, sessionStartedAt: number): string | undefined; /** * Should a single `unlinkDir` expansion be quarantined instead of stamping a * version-bound delete intent on every descendant journal entry? * * One filesystem event can cover an arbitrary subtree, so the producer is * capable of minting thousands of individually-valid intents from a checkout * swap, an unmount, a partial restore or an `rm -rf` — the 2026-07-30 mass * deletion did exactly that. Quarantined entries are simply left intent-less, * which the engine already refuses (`missing-delete-intent`); nothing is * deleted locally or remotely and the next pull restores the tree. * * Pure so the threshold is unit-testable without a watcher or a journal. */ export declare function shouldQuarantineWatcherDeleteIntents(expandedEntries: number, journalEntries: number): boolean; export declare function runOneShotWithOperationLock(argv: string[], parsed: ParsedLoopArgs, deps: RunnerLoopDeps, runtime: WatchLoopRuntime): Promise; export declare function runWatchLoop(argv: string[], parsed: ParsedLoopArgs, deps: RunnerLoopDeps, runtime: WatchLoopRuntime): Promise; export {}; //# sourceMappingURL=sync-runner-watch-loop.d.ts.map