/** * Host-overload alerting: watch the machine's 15-minute load average and memory * pressure, and fire a single Feishu DM to the bot owner when the host crosses * into an overloaded state (and one more when it recovers). * * Motivation: botmux already samples load/cpu/mem for the dashboard * (dashboard/resource-monitor-service.ts), but that path is purely passive — it * only feeds charts and never warns anyone. A cold-start that misses the 15s * readiness window (see the "bare-shell" false-death path) usually traces back * to the machine being overloaded, not to a config bug. This module turns the * already-collected signal into an actionable heads-up. * * Design: * - Pure decision function {@link evaluateOverload} maps a reading + previous * state to a next state and an optional alert action. No I/O, no timers, no * Node globals — trivially unit-testable. * - Hysteresis: enter at `load15 > cpuCount * enterLoadRatio` (or mem/swap over * their enter thresholds); only clear once BELOW a lower exit threshold so a * load hovering around the line can't flap and spam the owner. * - Edge-triggered: exactly one "entered" alert per overload episode and one * "recovered" alert when it ends. Steady-state (still overloaded / still * healthy) produces no action. * - A minimum re-alert interval guards against a long episode that dips just * under the exit line and back repeatedly within a short window. */ export interface OverloadThresholds { /** Logical CPU count used to normalise load average (os.cpus().length). */ cpuCount: number; /** Enter overload when load15 > cpuCount * this. Default 1.5. */ enterLoadRatio: number; /** Leave overload only when load15 <= cpuCount * this. Default 1.0. Must be <= enterLoadRatio. */ exitLoadRatio: number; /** Enter overload when used memory fraction (0..1) >= this. Default 0.92. */ enterMemUsedFrac: number; /** Leave overload only when used memory fraction <= this. Default 0.85. */ exitMemUsedFrac: number; /** Enter overload when swap-used fraction (0..1) >= this. Default 0.5. 0 disables. */ enterSwapUsedFrac: number; /** Leave overload only when swap-used fraction <= this. Default 0.25. */ exitSwapUsedFrac: number; /** * Minimum ms between two "entered" alerts. Even if the state machine leaves * and re-enters overload, suppress a fresh entered-alert within this window. * Default 15 min. The recovered-alert is never rate-limited (it ends noise). */ minReAlertMs: number; } export declare const DEFAULT_OVERLOAD_THRESHOLDS: Omit; /** Parse a finite float from a raw env string; blank/garbage/failing-`isValid` * → `fallback`. `isValid` defaults to non-negative (right for exit lines + * minReAlertMs, where 0 is a legal "off"/"immediate" value); the enter * thresholds pass stricter predicates (load must be > 0, mem must be in (0,1]) * so a degenerate `enter=0` can't sneak in — at enter=0 the 95% hysteresis * clamp is still 0, breaking "exit strictly below enter" and (for mem) flapping * entered/recovered every tick. Shared by {@link computeOverloadThresholds} so * env-override precedence is testable without a live `process.env`. */ export declare function parseOverloadEnvFloat(raw: string | undefined, fallback: number, isValid?: (n: number) => boolean): number; /** Validity predicates for the two enter dimensions — exported so tests + the * config/applier layers can share the exact same rule (single source of truth). */ export declare const isValidEnterLoadRatio: (n: number) => boolean; export declare const isValidEnterMemUsedFrac: (n: number) => boolean; /** * Should THIS daemon sample host pressure and own the overload alert? * * The alert is machine-level: the global `hostOverloadAlert` config names ONE * notifier bot, and only that bot's own daemon samples + advances the state * machine + DMs (it runs on this host, so no cross-daemon delivery is needed). * Every other daemon must no-op AND reset its local state, so that when the * target later switches TO this bot the state machine starts clean (no stale * "already overloaded" edge from a previous ownership). Pure predicate over the * config + this daemon's identity — the reset is the caller's responsibility. * * FAIL-CLOSED on apiOnly: a core-only (apiOnly) bot has no Feishu transport, so * it can never DM an admin. Even if a hand-edited config or a pre-`apiOnly`-aware * migration names an apiOnly bot as the target, this daemon must NOT sample — * it would advance the state machine and then silently drop every alert. The * migration also excludes apiOnly candidates; this is the runtime backstop for * configs that bypass it. * * @param alertCfg the global `hostOverloadAlert` block (or {} when unset). * @param self this daemon's identity: `larkAppId` + whether it's apiOnly. */ export declare function isOverloadAlertTarget(alertCfg: { enabled?: boolean; targetBotAppId?: string; } | undefined, self: { larkAppId: string; apiOnly?: boolean; } | string): boolean; /** Raw inputs for {@link computeOverloadThresholds}: the host CPU count, the * operator's enter thresholds from global config (already-parsed numbers or * undefined), and the relevant `BOTMUX_OVERLOAD_*` env strings. Kept as plain * data so the priority + hysteresis logic is pure and unit-testable. */ export interface OverloadThresholdInputs { cpuCount: number; configEnterLoadRatio?: number; configEnterMemUsedFrac?: number; env?: { enterLoadRatio?: string; exitLoadRatio?: string; enterMemUsedFrac?: string; exitMemUsedFrac?: string; minReAlertMs?: string; }; /** Optional sink for the hysteresis-clamp warning (daemon passes logger.warn). */ warn?: (message: string) => void; } /** * Resolve the effective {@link OverloadThresholds} from env > global config > * built-in default for the ENTER lines, deriving the EXIT lines with hysteresis. * * Precedence (enter load/mem): a valid `BOTMUX_OVERLOAD_ENTER_*` env wins; else * a sane positive config value; else the built-in default. Config values are * re-validated defensively here (finite, positive, mem ≤ 1) so a bad persisted * value can't leak past the resolver. * * Hysteresis: the recover (exit) line MUST sit strictly below the enter line, or * a reading pinned at the threshold flaps entered/recovered every tick (enter * uses `>=`, recover uses `<=`). If a misconfigured exit ≥ enter, clamp it to * 95% of enter and warn. Swap thresholds + minReAlertMs pass through (env or * default). Pure: no `os`, no `process.env`, no clock. */ export declare function computeOverloadThresholds(inputs: OverloadThresholdInputs): OverloadThresholds; /** A single sampled reading of host pressure. */ export interface HostReading { /** 15-minute load average (os.loadavg()[2]). */ load15: number; /** Total physical memory in bytes (os.totalmem()). */ memTotalBytes: number; /** Free physical memory in bytes (os.freemem()). */ memFreeBytes: number; /** * Swap total/used in bytes, if known. macOS/Node has no built-in swap read; * pass undefined to skip the swap dimension entirely. */ swapTotalBytes?: number; swapUsedBytes?: number; } /** Which dimension(s) tripped the enter threshold — used for the alert copy. */ export type OverloadReason = 'load' | 'memory' | 'swap'; export interface OverloadState { overloaded: boolean; /** ms timestamp of the last "entered" alert we emitted (0 = never). */ lastEnteredAlertAt: number; } export declare const INITIAL_OVERLOAD_STATE: OverloadState; export interface OverloadAlertAction { kind: 'entered' | 'recovered'; reasons: OverloadReason[]; reading: HostReading; /** Derived, human-friendly numbers for the alert copy. */ metrics: { load15: number; loadPerCpu: number; cpuCount: number; memUsedFrac: number; swapUsedFrac: number | undefined; }; } export interface OverloadEvaluation { nextState: OverloadState; /** Present only on a state edge that should notify the owner. */ action?: OverloadAlertAction; } /** * Core state-machine step. Given the previous state, the current reading and * thresholds (plus `now` for rate-limiting), return the next state and, on a * notable edge, the alert action to deliver. * * Transitions: * - healthy → overloaded: any enter reason trips. Emit `entered` UNLESS a prior * entered-alert fired within `minReAlertMs` (then flip state silently). * - overloaded → healthy: only once `fullyRecovered` (all dims under exit bar). * Always emit `recovered`. * - no change: no action. */ export declare function evaluateOverload(prev: OverloadState, reading: HostReading, thresholds: OverloadThresholds, now: number): OverloadEvaluation; /** Build the Feishu text body for an alert action. Kept here so it's testable. */ export declare function formatOverloadAlert(action: OverloadAlertAction, hostLabel?: string): string; /** action.value.action strings emitted by the alert card buttons; the daemon's * card handler matches on these. Exported so the handler and tests share the * exact literals (typo-proof). */ export declare const OVERLOAD_ACTION_CLEAN_STOPPED = "overload_clean_stopped"; export declare const OVERLOAD_ACTION_SUSPEND_IDLE = "overload_suspend_idle"; /** Restart a specific browser (bundleId carried on the button value). One action * literal for all browsers; the per-browser one-shot claim keys on bundleId. */ export declare const OVERLOAD_ACTION_RESTART_BROWSER = "overload_restart_browser"; /** Fail-safe action on a disabled (already-run) button — clients that don't * suppress disabled callbacks just get a harmless toast, no side effect. */ export declare const OVERLOAD_ACTION_NOOP = "overload_noop"; /** * Per-button state carried on the card so any daemon can rebuild it after a * click without external storage. `done`/`n` record whether an action already * ran and how many sessions it affected (−1 = not yet run). Counts are the * machine-wide candidate totals shown in the button labels before clicking. */ export interface OverloadCardState { nonce: string; /** metrics for the summary line (kept compact). */ load15: number; cpu: number; mem: number; swap?: number; reasons: OverloadReason[]; /** machine-wide candidate counts (refreshed on every rebuild). */ stopped: number; idle: number; /** result of each action once run; -1 = not run yet. */ cleanedN: number; suspendedN: number; /** Browsers currently running + holding memory on this host, in display order. * Empty ⇒ no browser buttons (nothing to reclaim). */ browsers?: OverloadCardBrowser[]; /** bundleIds whose restart button has already been clicked (✓ done). */ restartedBrowsers?: string[]; } /** A per-browser restart button descriptor carried on the card. */ export interface OverloadCardBrowser { bundleId: string; label: string; memMB: number; } /** Seed initial card state from an `entered` action + counts + nonce. */ export declare function initialOverloadCardState(action: OverloadAlertAction, counts: { stopped: number; idle: number; }, nonce: string, browsers?: OverloadCardBrowser[]): OverloadCardState; /** * Build the interactive Feishu overload card from card state (returns a JSON * string). This one builder renders BOTH the initial alert and every post-click * rebuild: it always shows two buttons so clicking one never removes the other. * * - Not-yet-run button → live callback, label shows the candidate count * (「🧹 清僵尸会话 (N)」). Carries the full `st` so the handler can rebuild. * - Already-run button → disabled + a `✓ 已清理 X 个` label (with a `noop` * fail-safe action for clients that don't suppress disabled callbacks). * * Each button also carries its own nonce claim (one-shot per action), so the * owner can click both buttons on one card, but neither twice. */ export declare function buildOverloadAlertCard(st: OverloadCardState, hostLabel?: string): string; /** Display-only card for the `recovered` edge (no buttons). */ export declare function buildOverloadRecoveredCard(action: OverloadAlertAction, hostLabel?: string): string; /** Grey "this alert card has expired" card (daemon restart lost the nonce, etc.). */ export declare function buildOverloadExpiredCard(detail?: string): string; /** * Failure card for a browser-restart click that could not complete (quit failed, * refused to quit within the window, or quit-but-relaunch-failed). Rendered as a * FULL patchable alert card (not a toast): the browser-restart handler can run up * to ~12s, past the 2.5s card-ACK window, after which a toast-only result is * dropped by the dispatcher — so the failure must ride a card the dispatcher can * patch in. Carries the rebuilt alert `st` so every still-live button (including * this browser's, whose claim was released for retry) stays clickable. */ export declare function buildOverloadBrowserFailureCard(st: OverloadCardState, label: string, detail: string, hostLabel?: string): string; //# sourceMappingURL=host-overload-alert.d.ts.map