/** * health-store.ts — provider/model 健康事件的 7 天 JSONL 持久化。 * * 设计: * - 每次请求成功/失败追加一行到 ~/.pi/agent/roundrobin/health.jsonl * - 多 CLI 共享同一文件;小行 append(O_APPEND)在 POSIX 上对 < PIPE_BUF 原子 * - 读取时按 7 天窗口过滤并聚合:成功率 / 平均 TTFT / 平均总延迟 * - 不持久化 roundrobin 组内 currentIndex/cooldown(那是进程内 failover 状态) */ import { existsSync, statSync, appendFileSync, readFileSync, writeFileSync, mkdirSync, renameSync, openSync, closeSync, ftruncateSync, } from "node:fs"; import { join, dirname } from "node:path"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; /** 保留窗口:7 天 */ export const HEALTH_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; export type HealthEvent = { /** epoch ms */ ts: number; provider: string; model: string; /** true=成功, false=失败/aborted */ ok: boolean; /** true=测速探活产生(非真实请求); smart 排序的可靠性口径应排除 */ probe?: boolean; /** 首字延迟 ms(仅成功且能采到时) */ ttftMs?: number | null; /** 总延迟 ms(仅成功时) */ latencyMs?: number | null; }; export type ProviderHealthAgg = { success: number; fail: number; rate: number | null; avgLatencyMs: number | null; avgTtftMs: number | null; lastFailAt: number | null; lastSuccessAt: number | null; }; export type ProviderHealthSnapshot = { providers: Record; /** 聚合窗口说明 */ windowMs: number; /** 事件条数(窗口内) */ eventCount: number; /** 数据源路径 */ path: string; }; function healthPath(): string { return join(getAgentDir(), "roundrobin", "health.jsonl"); } function ensureParent(file: string): void { const dir = dirname(file); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); } /** 序列化一行事件(无换行)。 */ export function serializeEvent(e: HealthEvent): string { const row: Record = { ts: e.ts, provider: e.provider, model: e.model, ok: e.ok, }; if (e.ttftMs != null && Number.isFinite(e.ttftMs)) row.ttftMs = Math.round(e.ttftMs); if (e.latencyMs != null && Number.isFinite(e.latencyMs)) row.latencyMs = Math.round(e.latencyMs); if (e.probe === true) row.probe = true; return JSON.stringify(row); } /** 解析一行;坏行返回 null(容错)。 */ export function parseEventLine(line: string): HealthEvent | null { const s = line.trim(); if (!s) return null; try { const o = JSON.parse(s) as Partial; if (typeof o.ts !== "number" || !Number.isFinite(o.ts)) return null; if (typeof o.provider !== "string" || !o.provider) return null; if (typeof o.model !== "string" || !o.model) return null; if (typeof o.ok !== "boolean") return null; const ev: HealthEvent = { ts: o.ts, provider: o.provider, model: o.model, ok: o.ok, }; if (typeof o.ttftMs === "number" && Number.isFinite(o.ttftMs)) ev.ttftMs = o.ttftMs; if (typeof o.latencyMs === "number" && Number.isFinite(o.latencyMs)) ev.latencyMs = o.latencyMs; // 旧记录无 probe 字段 → undefined, 按“真实请求”对待(向后兼容) if (o.probe === true) ev.probe = true; return ev; } catch { return null; } } /** * 追加一条事件。多进程安全依赖 OS 对 O_APPEND 小写的原子性。 * 失败静默(不影响主对话路径)。 */ export function appendHealthEvent(e: HealthEvent): void { try { const path = healthPath(); ensureParent(path); appendFileSync(path, serializeEvent(e) + "\n", "utf-8"); } catch { // ignore IO errors } } // loadEvents 缓存: 面板每 3s 轮询 getHealthSnapshot → loadEvents 全量读盘+逐行 parse, // 7 天事件增长后阻塞事件循环。按 mtime+size 缓存原始解析结果(未过滤), // 命中后按调用方的 now/retention 过滤 — append 会变 size、覆盖/重写会变 mtime, // 缓存自动失效; 多 CLI 共享文件同理(他人 append 改 stat → 失效重读)。 let eventsCache: { mtimeMs: number; size: number; events: HealthEvent[] } | null = null; /** 从磁盘读出窗口内事件;可选 now 便于测试。stat 未变时用缓存。 */ export function loadEvents(now = Date.now(), retentionMs = HEALTH_RETENTION_MS): HealthEvent[] { const path = healthPath(); if (!existsSync(path)) return []; let stat: { mtimeMs: number; size: number }; try { const s = statSync(path); stat = { mtimeMs: s.mtimeMs, size: s.size }; } catch { return []; } let parsed: HealthEvent[]; if (eventsCache && eventsCache.mtimeMs === stat.mtimeMs && eventsCache.size === stat.size) { parsed = eventsCache.events; } else { let raw: string; try { raw = readFileSync(path, "utf-8"); } catch { return []; } parsed = []; for (const line of raw.split("\n")) { const ev = parseEventLine(line); if (ev) parsed.push(ev); } eventsCache = { ...stat, events: parsed }; } const cutoff = now - retentionMs; return parsed.filter((ev) => ev.ts >= cutoff); } /** 聚合为 provider/model → stats(与旧 API 字段兼容)。 * opts.realOnly=true 时排除 probe(测速探活)事件 — smart 可靠性口径用。 */ export function aggregateEvents(events: HealthEvent[], opts?: { realOnly?: boolean }): Record { type Acc = { success: number; fail: number; totalLatencyMs: number; totalTtftMs: number; ttftCount: number; lastFailAt: number | null; lastSuccessAt: number | null; }; const map = new Map(); for (const e of events) { if (opts?.realOnly && e.probe === true) continue; const key = `${e.provider}/${e.model}`; let a = map.get(key); if (!a) { a = { success: 0, fail: 0, totalLatencyMs: 0, totalTtftMs: 0, ttftCount: 0, lastFailAt: null, lastSuccessAt: null, }; map.set(key, a); } if (e.ok) { a.success += 1; a.lastSuccessAt = a.lastSuccessAt == null ? e.ts : Math.max(a.lastSuccessAt, e.ts); if (typeof e.latencyMs === "number" && e.latencyMs >= 0) a.totalLatencyMs += e.latencyMs; if (typeof e.ttftMs === "number" && e.ttftMs >= 0) { a.totalTtftMs += e.ttftMs; a.ttftCount += 1; } } else { a.fail += 1; a.lastFailAt = a.lastFailAt == null ? e.ts : Math.max(a.lastFailAt, e.ts); } } const out: Record = {}; for (const [key, a] of map) { const total = a.success + a.fail; out[key] = { success: a.success, fail: a.fail, rate: total > 0 ? Math.round((a.success / total) * 100) : null, avgLatencyMs: a.success > 0 ? Math.round(a.totalLatencyMs / a.success) : null, avgTtftMs: a.ttftCount > 0 ? Math.round(a.totalTtftMs / a.ttftCount) : null, lastFailAt: a.lastFailAt, lastSuccessAt: a.lastSuccessAt, }; } return out; } export function getProviderHealthSnapshot(now = Date.now()): ProviderHealthSnapshot { const path = healthPath(); const events = loadEvents(now); return { providers: aggregateEvents(events), windowMs: HEALTH_RETENTION_MS, eventCount: events.length, path, }; } /** * 清空全部健康事件。 * 用 open+ftruncate 尽量兼容并发 append;失败则 writeFile 空串。 */ export function resetProviderHealth(): void { const path = healthPath(); try { ensureParent(path); const fd = openSync(path, "a"); try { ftruncateSync(fd, 0); } finally { closeSync(fd); } } catch { try { ensureParent(path); writeFileSync(path, "", "utf-8"); } catch { // ignore } } } /** * 把窗口外的旧事件从文件中剪掉,控制体积。 * 并发下用临时文件 + rename;失败无害(下次再试)。 * 返回保留条数。 */ export function pruneHealthFile(now = Date.now(), retentionMs = HEALTH_RETENTION_MS): number { const path = healthPath(); if (!existsSync(path)) return 0; let raw: string; let sizeAtRead: number; try { const s = statSync(path); sizeAtRead = s.size; raw = readFileSync(path, "utf-8"); } catch { return 0; } const cutoff = now - retentionMs; const kept: string[] = []; let changed = false; for (const line of raw.split("\n")) { if (!line.trim()) continue; const ev = parseEventLine(line); if (!ev) { changed = true; // drop corrupt lines continue; } if (ev.ts < cutoff) { changed = true; continue; } kept.push(serializeEvent(ev)); } if (!changed) return kept.length; try { // rename 前比 size: 读→写 tmp 期间其他 CLI append 过(影响 smart 可靠性口径的 // 真实事件)就放弃本次 prune — 改天再剪, 不能静默覆盖丢事件。 const sizeNow = statSync(path).size; if (sizeNow !== sizeAtRead) return kept.length; const tmp = `${path}.tmp.${process.pid}`; writeFileSync(tmp, kept.length ? kept.join("\n") + "\n" : "", "utf-8"); renameSync(tmp, path); } catch { // ignore race / IO } return kept.length; } /** key helper(与旧 map 一致) */ export function providerHealthKey(provider: string, model: string): string { return `${provider}/${model}`; }