/** * First-party heap telemetry for the auto-sync watch runner. * * The watch runner is a long-lived process whose V8 old space grows across * watch cycles until it reaches the desktop-injected `--max-old-space-size` * ceiling and V8 aborts with an out-of-memory fatal. Before this module the * runner shipped no memory telemetry at all, so nothing in the abort evidence * could name which structure held the memory. This module closes that gap: it * samples the process heap on a bounded cadence, attributes live entry counts * to a fixed set of long-lived structures (the "census"), and — before V8 * aborts — recycles the process so the desktop supervisor respawns a fresh one. * * The governor keys on the POST-GC LIVE HEAP, not the instantaneous * `used_heap_size`. Between major collections `used_heap_size` is live-set plus * not-yet-collected garbage, so it sawtooths by a large fraction of the ceiling * from minute to minute; a rule that watches it can sit at a sub-threshold * sample forever while the true live set climbs to the abort point (the * 2026-09-01 regression: a full production day produced ZERO recycle verdicts * against 1462 samples while the runner kept aborting). The monotonic quantity * is the live set that survives a major GC — exactly what V8 uses for its own * OOM verdict — so the primary signal is read on each observed MAJOR GC. A * single instantaneous backstop remains for the case where the live signal is * unavailable and the heap is already minutes from aborting. * * The whole module is numeric-only and allocation-light by construction: * * - Every emitted value is a number, and every census site is a FIXED * identifier token, never a path/filename/company/message. Property keys are * additionally shaped to survive the vault's ingest contract: they contain * none of its denied key substrings and end in `_count`/`_bytes`/`_ms`, so * the census attribution is actually stored rather than silently dropped. * - The controller retains only the FIRST sample (the growth baseline) and * the LATEST sample. It never accumulates history — a memory diagnostic * that leaks would regress the very defect it measures. * - All readers, timers, the GC observer, and emit callbacks are injected, so * the module is driven deterministically by tests with no real allocation, * no real heap growth, no wall-clock timing, and no network. * * Diagnostics must never affect sync: a throwing reader, a faulty census * entry, an unavailable GC entry API, or a rejecting emit callback degrades to * silence and never disturbs a watch cycle. */ /** The subset of `v8.getHeapStatistics()` this module reads. */ export interface HeapStatisticsReading { used_heap_size: number; total_heap_size: number; heap_size_limit: number; /** Added in Node 20; may be absent on older runtimes. */ external_memory?: number; } /** The subset of `process.memoryUsage()` this module reads. */ export interface MemoryUsageReading { rss: number; heapTotal: number; heapUsed: number; external: number; arrayBuffers: number; } /** * Injected readers. Production passes `v8.getHeapStatistics`, * `process.memoryUsage`, and `performance.now`; tests pass deterministic fakes. */ export interface HeapReaders { getHeapStatistics: () => HeapStatisticsReading; memoryUsage: () => MemoryUsageReading; /** Monotonic clock in milliseconds (e.g. `performance.now`). */ monotonicNowMs: () => number; } /** A census reader returns the live entry count of one long-lived structure. */ export type CensusReader = () => number; export interface CensusSite { site: string; count: number; } /** * A tiny registry of long-lived structures. Each structure registers once, * under a fixed token, a reader that reports only its current entry count. The * registry itself is bounded by the fixed token set — registering the same * token twice overwrites, so it can never grow without bound. */ export declare class HeapCensus { private readonly readers; register(token: string, reader: CensusReader): void; get siteCount(): number; /** Read every registered structure's count. A faulty reader reports 0. */ snapshot(): CensusSite[]; } /** One flat, numeric-only heap sample plus the census taken with it. */ export interface RunnerHeapSample { uptimeMs: number; usedHeapBytes: number; totalHeapBytes: number; heapLimitBytes: number; externalV8Bytes: number; rssBytes: number; externalBytes: number; arrayBuffersBytes: number; census: CensusSite[]; censusTotal: number; } /** Read the process heap and census once into a numeric-only record. */ export declare function sampleRunnerHeap(deps: { readers: HeapReaders; census: HeapCensus; sessionStartedAtMs: number; }): RunnerHeapSample; /** * The observation-cadence counters carried by every emitted cloud record. They * measure whether the work-driven checkpoint seam is keeping the governor fed * while the 60 s interval timer is starved by a long synchronous pass. Every key * already ends in `_count`/`_ms`, so all four survive the ingestion redaction, * and they are always inserted BEFORE the per-census-site keys so the * 40-property insertion-order cap can never evict them. */ export interface ObservationCounters { /** Total observations from every source (interval ticks + checkpoints). */ heapObservationsCount: number; /** Observations driven by the 60 s idle interval timer. */ heapIntervalObservationsCount: number; /** Observations driven by a work-driven checkpoint (emitter / pass / wait). */ heapCheckpointObservationsCount: number; /** Monotonic gap since the previous observation from any source. */ msSincePreviousObservationMs: number; } /** * Flatten a sample into numeric telemetry properties. Raw heap figures carry a * `heap_`/`rss_`/`external_` prefix; each census site becomes * `census__count`. Every value is a number AND every key ends in * `_count`/`_bytes`/`_ms` and contains none of the vault ingest contract's * denied substrings, so the record is both accepted by the local sanitizer and * actually STORED by ingest (unlike the pre-fix `census_` spelling, * which ingest silently dropped, so the attribution never reached the store). */ export declare function heapSampleCloudProperties(sample: RunnerHeapSample, counters?: ObservationCounters): Record; export interface HeapPressureSite { site: string; count: number; growth: number; } /** The escalation record emitted once when the heap crosses the threshold. */ export interface HeapPressureRecord { usedHeapBytes: number; /** V8's reported `heap_size_limit`, kept for provenance. */ heapLimitBytes: number; /** * The denominator every fraction is computed against: * `min(declaredCeilingBytes, heap_size_limit)` — or the reported limit when no * `--max-old-space-size` was declared. V8's reported limit sits ABOVE the * declared ceiling on Node 25, so a fraction of the reported limit is * unreachable near the top; dividing by the effective limit is what makes the * trigger real. Equal to `heapLimitBytes` when nothing was declared. */ effectiveLimitBytes: number; /** The declared `--max-old-space-size` in bytes, or 0 when none was resolved. */ declaredCeilingBytes: number; /** `usedHeapBytes / effectiveLimitBytes`. */ usedFraction: number; thresholdFraction: number; rssBytes: number; externalBytes: number; uptimeMs: number; /** Census sites ranked by growth since the session's first sample. */ topSites: HeapPressureSite[]; } /** Flatten a pressure record into numeric-only telemetry properties. */ export declare function heapPressureCloudProperties(record: HeapPressureRecord, counters?: ObservationCounters): Record; /** A compact, numeric-only stderr line that survives when the vault is down. */ export declare function heapPressureStderrLine(record: HeapPressureRecord): string; /** * Which detection tier produced a recycle verdict: * - `"live"` — the primary post-GC live path (fraction held across the * sustain window past the uptime floor). Preserved from the shipped governor. * - `"pinned"` — a NEW instantaneous tier: the used fraction pinned at or * above the pinned floor across the pinned sustain window, for the starved * synchronous stretch where no post-GC reading is ever taken. * - `"emergency"` — a single instantaneous observation at or above the * emergency fraction, abort-adjacent, bypassing the window and the floor. * - `"footprint"` — the RESIDENT-FOOTPRINT tier. Denominated in an absolute * rss BUDGET (bytes), NOT a fraction of the V8 old-space cap, so a non-heap * (external/ArrayBuffer) burst that leaves `used_heap_size` quiet is still * caught before the desktop's whole-tree footprint backstop pre-empts. Like * the emergency tier it fires on a SINGLE observation, bypassing the sustain * window and the uptime floor — production footprint bursts run 83-1142 MB/s, * so no window can be crossed in time. It only ever ADDS a verdict where the * four heap tiers are silent; it never suppresses or re-denominates one. */ export type HeapRecycleTrigger = "live" | "pinned" | "emergency" | "footprint"; /** * How a verdict is actuated: * - `"cooperative"` — the design-centre exit. The verdict is LATCHED and the * watch loop honours it at the next pass boundary (the in-flight pass runs to * completion, then the loop returns the benign recycle code). Every `"live"`, * `"pinned"`, and `"emergency"` verdict is cooperative first. * - `"latch_timeout"` — a cooperative latch the loop never honoured (the * in-flight pass never returned) aged past the latch deadline, so a later * observation hard-exits (writeSync-then-exit) instead of waiting for a pass * boundary that will never arrive. * - `"hard_fraction"` — a single instantaneous observation at or above the * hard-exit line, too close to the abort/pre-empt boundary to risk waiting * for a pass boundary, so it hard-exits immediately. For a heap verdict the * line is the hard-exit FRACTION; for a `"footprint"` verdict it is the * footprint hard-exit BYTE budget (same single-observation actuation, so the * reason token is shared — the `trigger` distinguishes which line was crossed). * * The `"footprint"` tier reuses all three actuations verbatim (cooperative latch, * aged-latch `latch_timeout`, single-observation `hard_fraction`); it adds a * trigger, never a new actuation. */ export type HeapRecycleReason = "cooperative" | "latch_timeout" | "hard_fraction"; /** * The governor verdict, emitted at most once per session. Where a pressure * record is a diagnostic breadcrumb, a recycle is ACTED ON: the watch loop * stops admitting new passes, lets the in-flight pass finish, and exits so the * desktop supervisor respawns a fresh runner BEFORE V8 aborts the current one. * * It fires on one of two triggers: * - `"live"` — the primary path. Read on an observed MAJOR GC, the post-GC * live fraction has held at or above `recycleLiveFraction` (default 0.60) * across at least two observations spanning `sustainWindowMs` (default * 120 s), AND the process has outlived `minUptimeMs` (default 35 min, above * hq-desktop-app's 30-minute HEALTHY_RUN_WINDOW so a recycle clears the * crash streak rather than pacing respawn backoff). * - `"emergency"` — an instantaneous `used_heap_size` sample at or above * `emergencyFraction` (default 0.90). At that reading V8 is minutes from * aborting, so this path bypasses the sustain window AND the uptime floor: * a clean exit-1 before the floor is still strictly better than a SIGABRT, * which counts the same crash episode and files a Sentry capture. */ export interface HeapRecycleRecord { /** The detection tier that produced this verdict. */ trigger: HeapRecycleTrigger; /** How the verdict is actuated (cooperative / latch_timeout / hard_fraction). */ reason: HeapRecycleReason; /** Instantaneous `used_heap_size` at the verdict. */ usedHeapBytes: number; /** The governing heap figure: the post-GC live set for `"live"`, else used. */ liveHeapBytes: number; /** V8's reported `heap_size_limit`, kept for provenance. */ heapLimitBytes: number; /** `min(declaredCeilingBytes, heap_size_limit)` — the fraction denominator. */ effectiveLimitBytes: number; /** The declared `--max-old-space-size` in bytes, or 0 when none was resolved. */ declaredCeilingBytes: number; /** `usedHeapBytes / effectiveLimitBytes`. */ usedFraction: number; /** The governing fraction: `liveHeapBytes / effectiveLimitBytes`. */ liveFraction: number; /** Configured post-GC live floor a `"live"` recycle requires. */ recycleLiveFraction: number; /** Configured instantaneous floor the NEW `"pinned"` tier requires. */ pinnedFraction: number; /** Configured instantaneous fraction the `"emergency"` tier requires. */ emergencyFraction: number; /** Configured instantaneous fraction the `hard_fraction` backstop requires. */ hardExitFraction: number; /** * Configured resident-footprint cooperative recycle budget in BYTES — the rss * at or above which the `"footprint"` tier cooperatively recycles on a single * observation. `0` when the footprint tier is disabled (kill switch or the * footprint-specific disable), so a reader can tell an off tier from a live one. */ footprintRecycleBytes: number; /** * Configured resident-footprint HARD-EXIT budget in BYTES — the rss at or above * which the `"footprint"` tier hard-exits (synchronous write + exit) rather than * waiting for a pass boundary. `0` when the footprint tier is disabled. */ footprintHardExitBytes: number; /** Configured minimum span between the two qualifying live observations. */ sustainWindowMs: number; /** Actual span observed across the sustain window (0 for `"emergency"`). */ observedSustainMs: number; /** Configured uptime floor (ms) a floored tier could not fire before. */ minUptimeMs: number; /** Observations since the cooperative latch armed; 0 unless `latch_timeout`. */ postLatchObservations: number; uptimeMs: number; rssBytes: number; externalBytes: number; arrayBuffersBytes: number; /** RSS/external/arrayBuffers growth since the session's first sample, so the * next occurrence distinguishes V8 object retention from native retention. */ rssGrowthBytes: number; externalGrowthBytes: number; arrayBuffersGrowthBytes: number; censusTotal: number; /** Census sites ranked by growth since the session's first sample. */ topSites: HeapPressureSite[]; } /** Flatten a recycle record into numeric-only telemetry properties. */ export declare function heapRecycleCloudProperties(record: HeapRecycleRecord, counters?: ObservationCounters): Record; /** * A compact, numeric-only stderr line for the recycle verdict. It is worded so * it matches NONE of hq-desktop-app's fatal stderr classifiers (no "javascript * heap out of memory", "fatal error", "uncaught exception", "panicked at", * "assertion failed", a leading "#", the npm "npm error "/"npm ERR! " prefixes, * or "ENOSPC"), so a deliberate recycle can never be re-read as a crash by * text. `sync-runner-heap-telemetry.test.ts` pins this against that literal * marker list. */ export declare function heapRecycleStderrLine(record: HeapRecycleRecord): string; export interface RunnerHeapTelemetryOptions { readers: HeapReaders; census: HeapCensus; sessionStartedAtMs: number; /** Fraction of `heap_size_limit` that arms the escalation. Default 0.75. */ pressureThresholdFraction: number; /** How many census sites the escalation ranks. Default 8. */ topSiteCount?: number; /** * Whether the self-recycling governor is armed. Default true; a kill switch * (`HQ_SYNC_RUNNER_HEAP_RECYCLE_ENABLED=0`) sets this false and restores the * exact base behaviour — diagnostics only, no recycle verdict ever produced. */ recycleEnabled?: boolean; /** * Post-GC live fraction of `heap_size_limit` at or above which the PRIMARY * recycle arms. Default 0.60. Derived from the recorded aborting session: its * post-GC live floor was <= 0.51 at 36.7 min and V8 aborted at 0.90 of the * reported limit at 55.3 min, so 0.60 trips roughly 16 minutes ahead of the * abort while every healthy runner in the same day's telemetry sampled below * 0.06. Taken against V8's reported `heap_size_limit`, so it self-calibrates * to whatever `--max-old-space-size` the desktop supplied. */ recycleLiveFraction?: number; /** * Minimum span (ms) the live fraction must hold at or above the floor, across * at least two major-GC observations, before the primary recycle arms. * Default 120 s, so a single post-GC reading taken mid-allocation cannot trip * it. */ recycleSustainWindowMs?: number; /** * Instantaneous `used_heap_size` fraction at or above which the EMERGENCY * backstop recycles on a single sample, bypassing the sustain window and the * uptime floor. Default 0.90 — at that reading V8 is minutes from aborting * and the anti-spike guard is actively harmful. */ recycleEmergencyFraction?: number; /** * Process-uptime floor (ms) below which a PRIMARY (live) recycle can never * fire, however high the heap climbs. Default 35 min — strictly above * hq-desktop-app's 30-minute HEALTHY_RUN_WINDOW — so an ordinary recycle * always clears the crash streak via should_reset_after_recovery instead of * pacing respawn backoff. The emergency backstop deliberately ignores it. */ recycleMinUptimeMs?: number; /** * The declared `--max-old-space-size` in bytes, or null/undefined when none * was supplied. Every fraction — pressure, live, pinned, emergency, hard — * divides `usedHeapBytes` by `effectiveLimitBytes = min(declaredCeilingBytes, * heap_size_limit)`. On Node 25 V8's reported `heap_size_limit` (~2233 MB) * sits ABOVE the desktop-declared 2048 MB ceiling the aborts actually respect, * so dividing by the reported limit put every real trigger ~9% higher in bytes * than its designed value and pushed the 0.90 emergency line onto the 1995-2064 * MB observed abort band. When absent (dev / bare node), the effective limit IS * the reported limit and every fraction keeps its exact prior meaning. */ declaredCeilingBytes?: number | null; /** * Instantaneous fraction of `effectiveLimitBytes` at or above which the NEW * pinned tier arms once it has held across {@link recyclePinnedSustainWindowMs}. * Default 0.84 — above the max healthy instantaneous evidence (~0.816 of * declared) and below the legacy abort floor (1788/2048 = 0.873, ~68 MB * margin). Unlike the live tier this reads INSTANTANEOUS used, so it observes * the starved synchronous stretch where no post-GC reading is ever taken; it * never feeds the live tier's post-GC sustain window. */ recyclePinnedFraction?: number; /** * Minimum span (ms) the instantaneous fraction must stay at or above the pinned * floor, across at least two gated observations, before the pinned tier arms. * Default 20 s. Any single below-floor observation resets the anchor, so a * healthy sawtooth (a functioning GC collapses used within seconds) never * sustains it. */ recyclePinnedSustainWindowMs?: number; /** * Instantaneous fraction of `effectiveLimitBytes` at or above which a SINGLE * observation HARD-EXITS immediately (reason `hard_fraction`), for a climb too * fast for any cooperative pass boundary. Default 0.955 (1956 MB on the fatal * host) — above the 0.90 emergency line and below the post-#471 observed abort * floor (1995/2048 = 0.974, ~39 MB margin). Bypasses the sustain window and the * uptime floor, like a SIGABRT it preempts. */ hardExitFraction?: number; /** * Monotonic ms after an unhonoured cooperative latch before the runner * HARD-EXITS (reason `latch_timeout`). Default 60 s (~= one desktop * respawn-backoff window and one sampler interval). Bounds actuation when an * in-flight pass never returns to the loop to honour the latch. */ latchHardExitMs?: number; /** * Post-latch observation count after which the runner HARD-EXITS even before * the time deadline. Default 240 (= 60 s at the 250 ms checkpoint cadence, so * it is coherent with {@link latchHardExitMs} in production). A backstop for the * case where observations arrive faster than wall-clock; the deterministic * tests set it small with a large time deadline to age the latch by count. */ latchHardExitObservations?: number; /** * Whether the RESIDENT-FOOTPRINT tier is armed. Default true. Disabled by a * footprint-specific kill switch (`HQ_SYNC_RUNNER_FOOTPRINT_RECYCLE_ENABLED=0`) * OR by the global {@link recycleEnabled} switch — either one silences it and * restores exact base behaviour, so the footprint tier can be turned off on its * own without touching the four heap tiers. */ footprintRecycleEnabled?: boolean; /** * Resident footprint (`memoryUsage().rss`) in BYTES at or above which the * footprint tier cooperatively recycles on a SINGLE observation. Default * 4096 MB. Derived from this cluster's production traces and hq-desktop-app's * published constants: the desktop pre-empts on ONE whole-TREE sample at * WATCHER_FOOTPRINT_HARD_CEILING_MB = 5120, and the npx launcher sibling * measured <= 84 MB, so the runner must recycle its OWN rss strictly below * 5120 - 84 = 5036 MB; 4096 MB sits ~940 MB below that pre-empt line and * ~473 MB above the highest healthy cold-ramp peak (3623 MB tree), so healthy * large syncs never churn. Unlike the heap tiers this is an ABSOLUTE budget, * never a fraction of the old-space cap — that cap is exactly what a non-heap * burst outruns. */ footprintRecycleBytes?: number; /** * Resident footprint (rss) in BYTES at or above which the footprint tier * HARD-EXITS on a single observation (synchronous fd-1/fd-2 write + exit) * instead of waiting for a pass boundary a fast burst would outrun. Default * 4864 MB — above the cooperative budget and still ~172 MB below the 5036 MB * pre-empt line, so even the fastest observed burst (1142 MB/s) hard-exits * before the desktop backstop fires. */ footprintHardExitBytes?: number; } /** * Owns the sampling state machine. It keeps the baseline and latest sample, the * pressure escalation, and the two recycle paths: * - {@link observeMajorGc}, the PRIMARY path, driven by the GC observer: each * (throttled) call reads the post-GC live heap and tracks the sustain * window that a live recycle requires. * - {@link evaluate} and the emergency arm of {@link sample}, the instantaneous * BACKSTOP: they recycle on a single sample at/above the emergency fraction * even if the GC signal never arrives or the sampler timer is starved. * Every fraction is derived from the injected `heap_size_limit`, so the governor * self-calibrates to whatever `--max-old-space-size` the desktop supplied. */ /** The two verdict channels an observation can produce, at most one non-null. */ export interface GovernorVerdict { /** A cooperative recycle the loop honours at the next pass boundary. */ recycle: HeapRecycleRecord | null; /** A hard exit (writeSync-then-exit) when cooperation cannot be honoured. */ hardExit: HeapRecycleRecord | null; } export declare class RunnerHeapTelemetry { private readonly readers; private readonly census; private readonly sessionStartedAtMs; private readonly threshold; private readonly topSiteCount; private readonly recycleEnabled; private readonly declaredCeilingBytes; private readonly recycleLiveFraction; private readonly recycleSustainWindowMs; private readonly recycleEmergencyFraction; private readonly recycleMinUptimeMs; private readonly pinnedFraction; private readonly pinnedSustainWindowMs; private readonly hardExitFraction; private readonly latchHardExitMs; private readonly latchHardExitObservations; private readonly footprintRecycleEnabled; private readonly footprintRecycleBytes; private readonly footprintHardExitBytes; private baseline; private latest; private pressureSignalled; private recycleSignalled; private hardExitSignalled; /** Uptime (ms) of the first live observation at/above the floor in the * current consecutive run; null when the last live reading dipped below it. */ private liveAnchorUptimeMs; /** Uptime (ms) of the first instantaneous observation at/above the pinned * floor in the current consecutive run; null when a reading dipped below it. */ private pinnedAnchorUptimeMs; /** Consecutive instantaneous observations at/above the pinned floor. */ private pinnedObservationCount; /** Uptime (ms) at which the FIRST cooperative latch (any tier) armed. */ private latchedAtUptimeMs; /** The tier that produced the cooperative latch, carried onto a hard exit. */ private latchedTrigger; /** Observations seen since the cooperative latch armed (drives latch_timeout). */ private postLatchObservations; constructor(options: RunnerHeapTelemetryOptions); /** Whether the resident-footprint tier is active (both switches on). */ private footprintActive; /** * The one denominator every fraction is computed against: the declared * `--max-old-space-size` when known (it is the real V8 ceiling the aborts * respect), else V8's reported `heap_size_limit`. Never larger than reported. */ private effectiveLimit; private uptimeNow; /** * Capture the growth baseline when sampling starts, before the first interval, * WITHOUT emitting or arming any signal. Without this, a runner that is already * above the threshold at its first scheduled sample would make that same high * sample its own baseline, so a crossing record would report zero growth for * every site and lose the attribution. */ primeBaseline(): RunnerHeapSample; /** * The interval sampler tick. Emits a sample and (at most once per rising * crossing) a pressure record, then runs the instantaneous tiers (pinned, * emergency, hard). The primary post-GC live verdict comes from * {@link observeMajorGc}; this instantaneous path catches the starved * synchronous stretch where no post-GC reading is ever taken. */ sample(): { sample: RunnerHeapSample; pressure: HeapPressureRecord | null; recycle: HeapRecycleRecord | null; hardExit: HeapRecycleRecord | null; }; /** * The PRIMARY governor path, driven by an observed MAJOR GC. The caller * ({@link nodeMajorGcObserver}) only invokes this for a GC whose reading it * could capture PROMPTLY — a callback delayed by a blocked event loop is * skipped upstream, because `getHeapStatistics()` read late would be an * instantaneous sawtooth value (live set plus everything allocated since the * GC), not the post-GC live floor. * * The anchor is maintained on EVERY observed major GC via a cheap * `getHeapStatistics()` read (no throttle), so a post-GC reading that dips * below the floor ALWAYS invalidates the sustain run — a throttle that skipped * such a reading would let a stale anchor fake a continuous run. The fuller * sample (census snapshot + memory deltas) is taken only at the verdict, so a * GC storm never costs more than the O(1) statistics read here. The fraction * is denominated in the EFFECTIVE limit (min(declared, reported)) — the only * change from the shipped live-tier logic. */ observeMajorGc(): GovernorVerdict; /** * The shipped post-GC live/emergency/hard tiers, byte-for-byte. Extracted from * {@link observeMajorGc} so the footprint tier can run after it without altering * a single heap-tier trigger, reason, or fraction. Only called post-guard. */ private observeMajorGcHeapTiers; /** * The loop-top / work-driven BACKSTOP against timer starvation (the observed * 11-minute sampler hole). Reads the instantaneous heap and runs the * instantaneous tiers (hard, emergency, pinned). An instantaneous reading can * never confirm the post-GC live floor, so it never feeds the live tier's * sustain window. Cheap: an O(1) statistics read, sampling the census only if * a verdict actually fires. */ evaluate(): GovernorVerdict; /** * The shared instantaneous pipeline (interval tick, loop-top, and the * work-driven checkpoint all reach it). The heap tiers run FIRST and unchanged, * then — only if they were silent — the resident-footprint tier may add a * verdict. Keeping that order is what guarantees the footprint tier never * suppresses, delays, or re-denominates a heap verdict. The full sample is built * only at a verdict, via `buildVerdictSample`. */ private observeInstant; /** * The shipped instantaneous heap tiers, byte-for-byte. Order: T4 hard * (hard-fraction, then an aged-out latch) → T3 emergency (single observation, * bypasses window+floor) → T2 pinned (instantaneous fraction sustained across * the pinned window past the uptime floor, with any dip resetting the anchor). * Extracted from {@link observeInstant} so the footprint tier can run after it * without changing a single heap trigger. Only called post-guard. */ private observeInstantHeapTiers; /** * The RESIDENT-FOOTPRINT tier. Governed by the process's own rss (an absolute * BYTE budget), not a fraction of the V8 old-space cap — that cap is exactly * what a non-heap (external/ArrayBuffer) burst outruns while the heap stays * quiet, so no heap-denominated tier can see it. It fires on a SINGLE qualifying * observation (production bursts run 83-1142 MB/s, faster than any sustain * window), and it only ADDS a verdict: the caller runs it after the heap tiers, * and the shared one-shot latches keep at most one recycle and one hard exit per * session. Two thresholds, hard first: * - rss >= footprintHardExitBytes → hard exit (trigger `"footprint"`, reason * `"hard_fraction"`): abort/pre-empt-adjacent, take the synchronous write + * exit path even if a cooperative latch is already pending. * - rss >= footprintRecycleBytes → cooperative latch (trigger `"footprint"`), * which the watch loop honours at the next pass boundary. * Any non-finite/non-positive rss, or either switch off, degrades to silence. */ private checkFootprint; /** * Return `sample` with its `rssBytes` replaced by the value that actually * crossed the footprint budget, so the emitted record can never claim an rss * that disagrees with the verdict it triggered. */ private withCheckedRss; /** * T4 hard-exit evaluation, run at EVERY observation point. Fires at most once * per session (independent of the cooperative latch), on either arm: * (a) `hard_fraction` — the instantaneous fraction is at/above the hard line; * (b) `latch_timeout` — a cooperative latch aged past its time OR observation * deadline without the loop honouring it. * The `latch_timeout` observation counter advances on every later observation, * never on the observation that armed the latch (that call sees a still-false * `recycleSignalled` before the cooperative tier runs). */ private checkHardExit; private buildSample; /** Arm the one cooperative latch (from any tier) and build its verdict. */ private armCooperativeLatch; /** * Arm the one hard exit and build its verdict (`hard_fraction`/`latch_timeout`). * `triggerOverride` lets the footprint tier stamp its own trigger on a hard exit * that fires with no prior cooperative latch; without it the trigger falls back * to the latched tier (a footprint latch that ages out already carries * `"footprint"` via {@link latchedTrigger}), else the instantaneous * `"emergency"` family — exact shipped behaviour for the heap tiers. */ private armHardExit; private buildRecord; private detectPressure; private topSitesByGrowth; /** Test accessor: the retained growth baseline. */ get baselineSample(): RunnerHeapSample | null; /** Test accessor: the most recent sample. */ get latestSample(): RunnerHeapSample | null; } /** Timer seam so tests drive ticks without real wall-clock timers. */ export interface IntervalScheduler { setInterval: (handler: () => void, ms: number) => IntervalHandle; clearInterval: (handle: IntervalHandle) => void; } export interface IntervalHandle { unref?: () => void; } /** * Major-GC observer seam. Production wires {@link nodeMajorGcObserver}; tests * inject a manual observer they fire by hand. `start` registers a callback * invoked once per observed MAJOR GC; `stop` disconnects it. Both are wrapped * so an unavailable or mis-shaped GC entry API degrades to silence and can * never throw into the watch loop. */ export interface GcObserver { start(onMajorGc: () => void): void; stop(): void; } /** * The maximum age (ms) of a major-GC PerformanceEntry, at the moment its * observer callback runs, for the post-GC heap read to be trustworthy. GC * entries are delivered ASYNCHRONOUSLY, so when the event loop was blocked the * callback fires long after the collection and a `getHeapStatistics()` read then * reflects the live set PLUS everything allocated since — an instantaneous * sawtooth value, not the post-GC live floor. A reading older than this is * skipped rather than fed to the sustain window, so only promptly-captured * (genuinely post-GC) readings can arm a live recycle. */ export declare const MAX_FRESH_GC_READING_MS = 250; /** * Whether a major-GC reading taken at `nowMs` is fresh enough to treat as a * post-GC live-heap snapshot, given the entry's end time (`entry.startTime + * entry.duration`). Exported for deterministic testing of the staleness gate. */ export declare function isFreshGcReading(gcEndMs: number, nowMs: number, maxAgeMs?: number): boolean; /** * Production major-GC observer over `perf_hooks`. Everything is wrapped so a * runtime without the GC PerformanceEntry API, or one whose entries omit * `detail.kind`, degrades to a silent no-op rather than throwing into the * runner — in that degraded case the instantaneous emergency backstop and the * pressure diagnostics remain active, so the governor is never worse than base. * * It only invokes the callback for a major GC whose reading it can capture * PROMPTLY ({@link isFreshGcReading}): a callback delayed by a blocked event * loop would read an inflated instantaneous heap rather than the post-GC live * floor, so it is skipped. */ export declare function nodeMajorGcObserver(): GcObserver; export interface RunnerHeapSamplingOptions extends RunnerHeapTelemetryOptions { intervalMs: number; /** * Minimum monotonic ms between two full observations driven by the work-driven * checkpoint seam. A checkpoint that lands closer than this to the previous * observation FROM ANY SOURCE is a single clock compare and returns. `0` * disables checkpoints entirely — the interval timer and the loop-top * `evaluate()` are then the only observers, i.e. exact base behaviour. Default * 250. */ checkpointMs?: number; onSample: (sample: RunnerHeapSample, counters: ObservationCounters) => void; onPressure: (record: HeapPressureRecord, counters: ObservationCounters) => void; /** * Called at most once, when a cooperative tier (live/pinned/emergency) decides * the process should recycle at the next pass boundary. Optional so the * diagnostics-only wiring (and every existing caller) needs no change; a faulty * callback is swallowed exactly like the others. */ onRecycle?: (record: HeapRecycleRecord, counters: ObservationCounters) => void; /** * Called at most once, when a hard-exit tier (reason `hard_fraction` or * `latch_timeout`) fires. The production wiring writes the NDJSON + stderr * records synchronously and exits the process; a test captures the record * instead. When a hard exit and a cooperative recycle would fire on the same * observation, ONLY the hard exit is delivered — the process is leaving now, so * the cooperative latch would be moot. A faulty callback is swallowed. */ onHardExit?: (record: HeapRecycleRecord, counters: ObservationCounters) => void; scheduler?: IntervalScheduler; /** * Major-GC observer that drives the primary live-heap path. Optional: when * absent, only the instantaneous tiers and pressure diagnostics run. Production * wires {@link nodeMajorGcObserver}; tests inject a manual one. */ gcObserver?: GcObserver; log?: (message: string) => void; } export interface RunnerHeapSamplingHandle { stop: () => void; /** * The loop-top backstop: run one ungated instantaneous check and fire * `onRecycle`/`onHardExit` if it trips. Cheap and safe to call every watch-loop * iteration; a fault degrades to silence. Runs regardless of `checkpointMs`. */ evaluate: () => void; /** * The work-driven checkpoint seam. The watch loop wires it to the protocol- * event emitter, pass boundaries, and wait resume so the governor keeps * observing while a long synchronous pass starves the interval timer. Gated to * at most one full observation per `checkpointMs`; below the gate it is a * single monotonic clock compare. Never emits a per-observation cloud sample. */ maybeObserve: () => void; readonly telemetry: RunnerHeapTelemetry; } /** * Start periodic sampling and, when a GC observer is supplied, the primary * live-heap path. The interval timer is `unref`'d so it can never hold the * process open, and `stop()` clears it and disconnects the GC observer on every * shutdown path. The interval tick, the work-driven checkpoint, and the loop-top * `evaluate()` share ONE observation pipeline, one last-observation timestamp, * and one set of cadence counters. Each observation — and every emit callback it * drives — is wrapped so a fault degrades to silence and never affects the watch * cycle. */ export declare function startRunnerHeapSampling(options: RunnerHeapSamplingOptions): RunnerHeapSamplingHandle; export interface HeapHardExitIo { writeStdout: (line: string) => void; writeStderr: (line: string) => void; exit: (code: number) => void; } /** * Perform a deliberate hard exit for a `hard_fraction`/`latch_timeout` verdict. * Writes the additive `heap-recycle` NDJSON record to stdout and the compact * numeric stderr line — through SYNCHRONOUS injected writers, so both records * land even when the streams are buffered or blocked — and only THEN exits. The * exit code and the writers are injected so the exact write-before-exit ordering * is unit-testable and the code stays the runner's single benign-exit constant. */ export declare function performHeapHardExit(record: HeapRecycleRecord, io: HeapHardExitIo, exitCode: number): void; /** Env knob names, exported so the runner and its tests agree on the strings. */ export declare const HEAP_SAMPLE_INTERVAL_ENV = "HQ_SYNC_RUNNER_HEAP_SAMPLE_MS"; export declare const HEAP_PRESSURE_FRACTION_ENV = "HQ_SYNC_RUNNER_HEAP_PRESSURE_FRACTION"; export declare const HEAP_RECYCLE_ENABLED_ENV = "HQ_SYNC_RUNNER_HEAP_RECYCLE_ENABLED"; export declare const HEAP_RECYCLE_LIVE_FRACTION_ENV = "HQ_SYNC_RUNNER_HEAP_RECYCLE_LIVE_FRACTION"; export declare const HEAP_RECYCLE_SUSTAIN_WINDOW_MS_ENV = "HQ_SYNC_RUNNER_HEAP_RECYCLE_SUSTAIN_WINDOW_MS"; export declare const HEAP_RECYCLE_EMERGENCY_FRACTION_ENV = "HQ_SYNC_RUNNER_HEAP_RECYCLE_EMERGENCY_FRACTION"; export declare const HEAP_RECYCLE_MIN_UPTIME_MS_ENV = "HQ_SYNC_RUNNER_HEAP_RECYCLE_MIN_UPTIME_MS"; export declare const HEAP_CHECKPOINT_MS_ENV = "HQ_SYNC_RUNNER_HEAP_CHECKPOINT_MS"; export declare const HEAP_PINNED_FRACTION_ENV = "HQ_SYNC_RUNNER_HEAP_PINNED_FRACTION"; export declare const HEAP_PINNED_SUSTAIN_MS_ENV = "HQ_SYNC_RUNNER_HEAP_PINNED_SUSTAIN_MS"; export declare const HEAP_HARD_EXIT_FRACTION_ENV = "HQ_SYNC_RUNNER_HEAP_HARD_EXIT_FRACTION"; export declare const HEAP_RECYCLE_LATCH_HARD_EXIT_MS_ENV = "HQ_SYNC_RUNNER_HEAP_RECYCLE_LATCH_HARD_EXIT_MS"; export declare const DEFAULT_HEAP_SAMPLE_INTERVAL_MS = 60000; export declare const DEFAULT_HEAP_PRESSURE_FRACTION = 0.75; export declare const DEFAULT_HEAP_RECYCLE_LIVE_FRACTION = 0.6; export declare const DEFAULT_HEAP_RECYCLE_SUSTAIN_WINDOW_MS = 120000; export declare const DEFAULT_HEAP_RECYCLE_EMERGENCY_FRACTION = 0.9; export declare const DEFAULT_HEAP_RECYCLE_MIN_UPTIME_MS: number; export declare const DEFAULT_HEAP_CHECKPOINT_MS = 250; export declare const DEFAULT_HEAP_PINNED_FRACTION = 0.84; export declare const DEFAULT_HEAP_PINNED_SUSTAIN_MS = 20000; export declare const DEFAULT_HEAP_HARD_EXIT_FRACTION = 0.955; export declare const DEFAULT_HEAP_RECYCLE_LATCH_HARD_EXIT_MS = 60000; export declare const DEFAULT_HEAP_RECYCLE_LATCH_HARD_EXIT_OBSERVATIONS = 240; export declare const HEAP_FOOTPRINT_RECYCLE_ENABLED_ENV = "HQ_SYNC_RUNNER_FOOTPRINT_RECYCLE_ENABLED"; export declare const HEAP_FOOTPRINT_RECYCLE_MB_ENV = "HQ_SYNC_RUNNER_FOOTPRINT_RECYCLE_MB"; export declare const HEAP_FOOTPRINT_HARD_EXIT_MB_ENV = "HQ_SYNC_RUNNER_FOOTPRINT_HARD_EXIT_MB"; export declare const DEFAULT_HEAP_FOOTPRINT_RECYCLE_BYTES: number; export declare const DEFAULT_HEAP_FOOTPRINT_HARD_EXIT_BYTES: number; /** * Parse the sampling-interval env knob (whole milliseconds). Rejects the ENTIRE * string: a value like "60s" must fall back to the default, not silently parse * to a 60 ms interval that would fire ~17 samples/second. `Number.parseInt` * accepts a numeric prefix, so it is deliberately NOT used here. */ export declare function resolveHeapSampleIntervalMs(raw: string | undefined): number; /** * Parse the pressure-fraction env knob; falls back to the default on garbage. * Rejects the entire string (e.g. "0.5x" falls back) rather than accepting a * numeric prefix. */ export declare function resolveHeapPressureFraction(raw: string | undefined): number; /** * The governor kill switch. The recycle is ON by default; it is disabled ONLY * by an explicit, whole-string off value ("0", "false", "no", "off", * case-insensitive). Any other value — including garbage — keeps the governor * armed, so a typo can never silently disable crash protection. */ export declare function resolveHeapRecycleEnabled(raw: string | undefined): boolean; /** * Parse the primary live-fraction knob; falls back to the default (0.60) on * garbage or out-of-range input. Rejects the entire string (e.g. "0.6x" falls * back) rather than accepting a numeric prefix. */ export declare function resolveHeapRecycleLiveFraction(raw: string | undefined): number; /** * Parse the emergency-fraction knob; falls back to the default (0.90) on garbage * or out-of-range input. Rejects the entire string rather than a numeric prefix. */ export declare function resolveHeapRecycleEmergencyFraction(raw: string | undefined): number; /** * Parse the sustain-window knob (whole milliseconds, >= 0). 0 is a VALID value * here — it removes the window so two qualifying observations at the same uptime * suffice, which the deterministic tests rely on — so only a non-integer or * negative value falls back to the 120-second default. */ export declare function resolveHeapRecycleSustainWindowMs(raw: string | undefined): number; /** * Parse the minimum-uptime-floor knob (whole milliseconds, >= 0). 0 is a VALID * value here — it disables the floor, which the deterministic tests rely on — so * only a non-integer or negative value falls back to the 35-minute default. */ export declare function resolveHeapRecycleMinUptimeMs(raw: string | undefined): number; /** * Parse the checkpoint-gate knob (whole milliseconds, >= 0). 0 is a VALID value * — it disables the work-driven checkpoints, leaving the interval timer and the * loop-top backstop as the only observers (exact base behaviour) — so only a * non-integer or negative value falls back to the 250 ms default. */ export declare function resolveHeapCheckpointMs(raw: string | undefined): number; /** * Parse the pinned-fraction knob; falls back to the default (0.84) on garbage or * out-of-range input. Rejects the entire string rather than a numeric prefix. */ export declare function resolveHeapPinnedFraction(raw: string | undefined): number; /** * Parse the pinned-sustain-window knob (whole milliseconds, >= 0). 0 is a VALID * value — it removes the window so two qualifying observations at the same uptime * suffice, which the deterministic tests rely on — so only a non-integer or * negative value falls back to the 20-second default. */ export declare function resolveHeapPinnedSustainMs(raw: string | undefined): number; /** * Parse the hard-exit-fraction knob; falls back to the default (0.955) on garbage * or out-of-range input. Rejects the entire string rather than a numeric prefix. */ export declare function resolveHeapHardExitFraction(raw: string | undefined): number; /** * Parse the latch-hard-exit knob (whole milliseconds, >= 0). 0 is a VALID value * — it makes the first post-latch observation hard-exit — so only a non-integer * or negative value falls back to the 60-second default. */ export declare function resolveHeapRecycleLatchHardExitMs(raw: string | undefined): number; /** * The footprint-tier kill switch, mirroring {@link resolveHeapRecycleEnabled}: * ON by default, disabled ONLY by an explicit whole-string off value ("0", * "false", "no", "off", case-insensitive). Any other value — including garbage — * keeps the tier armed, so a typo can never silently disable footprint protection. * It is independent of the global recycle kill switch: either one alone silences * the footprint tier. */ export declare function resolveHeapFootprintRecycleEnabled(raw: string | undefined): boolean; /** * Parse the footprint cooperative-recycle budget (whole MB → bytes). Rejects the * ENTIRE string (e.g. "4096x" or "4gb" falls back), never a numeric prefix, and * a zero/negative/garbage value degrades to the 4096 MB default — an unparsable * footprint value can never silently lower the budget or disable the tier. */ export declare function resolveHeapFootprintRecycleBytes(raw: string | undefined): number; /** * Parse the footprint hard-exit budget (whole MB → bytes), same whole-string, * fail-soft contract as {@link resolveHeapFootprintRecycleBytes}; falls back to * the 4864 MB default on anything unparsable. */ export declare function resolveHeapFootprintHardExitBytes(raw: string | undefined): number; /** * Resolve the declared V8 old-space ceiling (bytes) the runner is actually * running under, mirroring how hq-desktop-app hands it to the npx-spawned * runner: a `--max-old-space-size` flag on `process.execArgv` wins over one in * `NODE_OPTIONS`, and within each source the LAST occurrence wins (V8's own * precedence). Both the `=value` and adjacent-arg forms are accepted, values may * carry one pair of surrounding quotes, and only a whole positive integer of MB * is honoured — anything else yields null, so the governor falls back to V8's * reported `heap_size_limit`. Never throws. */ export declare function resolveDeclaredHeapCeilingBytes(execArgv: readonly string[] | undefined, nodeOptions: string | undefined): number | null; /** * Production readers over the real runtime. Kept here so the wiring site stays * a one-liner and the `v8` import lives in exactly one place. */ export declare function nodeHeapReaders(v8: { getHeapStatistics: () => HeapStatisticsReading; }): HeapReaders; //# sourceMappingURL=sync-runner-heap-telemetry.d.ts.map