import { z } from 'zod/v4' import { getFeatureValue_CACHED_WITH_REFRESH } from '../services/analytics/growthbook.js' import { lazySchema } from '../utils/lazySchema.js' import { DEFAULT_POLL_CONFIG, type PollIntervalConfig, } from './pollConfigDefaults.js' // .min(100) on the seek-work intervals restores the old Math.max(..., 100) // defense-in-depth floor against fat-fingered GrowthBook values. Unlike a // clamp, Zod rejects the whole object on violation — a config with one bad // field falls back to DEFAULT_POLL_CONFIG entirely rather than being // partially trusted. // // The at_capacity intervals use a 0-or-≥100 refinement: 0 means "disabled" // (heartbeat-only mode), ≥100 is the fat-finger floor. Values 1–99 are // rejected so unit confusion (ops thinks seconds, enters 10) doesn't poll // every 10ms against the VerifyEnvironmentSecretAuth DB path. // // The object-level refines require at least one at-capacity liveness // mechanism enabled: heartbeat OR the relevant poll interval. Without this, // the hb=0, atCapMs=0 drift config (ops disables heartbeat without // restoring at_capacity) falls through every throttle site with no sleep — // tight-looping /poll at HTTP-round-trip speed. const zeroOrAtLeast100 = { message: 'must be 0 (disabled) or ≥100ms', } const pollIntervalConfigSchema = lazySchema(() => z .object({ poll_interval_ms_not_at_capacity: z.number().int().min(100), // 0 = no at-capacity polling. Independent of heartbeat — both can be // enabled (heartbeat runs, periodically breaks out to poll). poll_interval_ms_at_capacity: z .number() .int() .refine(v => v === 0 || v >= 100, zeroOrAtLeast100), // 0 = disabled; positive value = heartbeat at this interval while at // capacity. Runs alongside at-capacity polling, not instead of it. // Named non_exclusive to distinguish from the old heartbeat_interval_ms // (either-or semantics in pre-#22145 clients). .default(0) so existing // GrowthBook configs without this field parse successfully. non_exclusive_heartbeat_interval_ms: z.number().int().min(0).default(0), // Multisession (bridgeMain.ts) intervals. Defaults match the // single-session values so existing configs without these fields // preserve current behavior. multisession_poll_interval_ms_not_at_capacity: z .number() .int() .min(100) .default( DEFAULT_POLL_CONFIG.multisession_poll_interval_ms_not_at_capacity, ), multisession_poll_interval_ms_partial_capacity: z .number() .int() .min(100) .default( DEFAULT_POLL_CONFIG.multisession_poll_interval_ms_partial_capacity, ), multisession_poll_interval_ms_at_capacity: z .number() .int() .refine(v => v === 0 || v >= 100, zeroOrAtLeast100) .default(DEFAULT_POLL_CONFIG.multisession_poll_interval_ms_at_capacity), // .min(1) matches the server's ge=1 constraint (work_v1.py:230). reclaim_older_than_ms: z.number().int().min(1).default(5000), session_keepalive_interval_v2_ms: z .number() .int() .min(0) .default(120_000), }) .refine( cfg => cfg.non_exclusive_heartbeat_interval_ms > 0 || cfg.poll_interval_ms_at_capacity > 0, { message: 'at-capacity liveness requires non_exclusive_heartbeat_interval_ms > 0 or poll_interval_ms_at_capacity > 0', }, ) .refine( cfg => cfg.non_exclusive_heartbeat_interval_ms > 0 || cfg.multisession_poll_interval_ms_at_capacity > 0, { message: 'at-capacity liveness requires non_exclusive_heartbeat_interval_ms > 0 or multisession_poll_interval_ms_at_capacity > 0', }, ), ) /** * Fetch the bridge poll interval config from GrowthBook with a 5-minute * refresh window. Validates the served JSON against the schema; falls back * to defaults if the flag is absent, malformed, or partially-specified. * * Shared by bridgeMain.ts (standalone) and replBridge.ts (REPL) so ops * can tune both poll rates fleet-wide with a single config push. */ export function getPollIntervalConfig(): PollIntervalConfig { const raw = getFeatureValue_CACHED_WITH_REFRESH( 'tengu_bridge_poll_interval_config', DEFAULT_POLL_CONFIG, 5 * 60 * 1000, ) const parsed = pollIntervalConfigSchema().safeParse(raw) return parsed.success ? parsed.data : DEFAULT_POLL_CONFIG }