# Heartbeat 统一健康系统设计 > 日期: 2026-04-15 > 状态: Draft > 范围: Intelligence Heartbeat 断链修复 + 统一健康面板 + SaaS 自动恢复 ## 1. 问题诊断 ### 1.1 五个心跳系统现状 ZCLAW 有 5 个独立心跳系统,各自运行,互不交互: | 系统 | 触发机制 | 监测对象 | 状态 | |------|----------|----------|------| | Intelligence Heartbeat | Rust tokio timer (30min) | Agent 智能健康 | 设计完整,6处断链 | | WebSocket Ping/Pong | 前端 JS setInterval (30s) | TCP 连接活性 | 完整 | | SaaS Device Heartbeat | 前端 saasStore (5min) | 设备在线状态 | 完整,降级无恢复 | | StreamBridge SSE | 服务端 async_stream (15s) | SSE 流保活 | 完整(纯服务端) | | A2A Heartbeat | 无(枚举占位) | Agent 间协议 | 空壳,暂不需要 | **核心发现:5 个系统之间没有运行时交互,也不需要交互。** 它们各自监测完全不同的东西。不存在"统一协调"的实际需求,需要的是"断链修复 + 统一可见性"。 ### 1.2 Intelligence Heartbeat 的 6 处断链 1. **告警无法实时送达前端** — Rust `broadcast::Sender` 有发送者但零订阅者,`subscribe()` 是 dead code。告警只存入 history,用户无法实时感知。 2. **HeartbeatConfig 保存只到 localStorage** — `handleSave()` 不调用 `updateConfig()`,Rust 后端永远用 App.tsx 的硬编码默认值。 3. **动态间隔修改无效** — `tokio::time::interval` 创建后不可变,`update_config` 改了值但不生效。 4. **Config 不持久化** — VikingStorage 只存 history 和 last_interaction,config 重启后丢失。 5. **重复 client 实现** — `intelligence-client.ts` 单文件版被 `intelligence-client/` 目录版遮蔽,是死代码。 6. **Memory stats 依赖前端推送** — 如果前端同步失败,检查只能产出"缓存为空"警告。 ### 1.3 SaaS 降级无恢复 `saasStore.ts` 在 SaaS 连续 3 次心跳失败后从 `saas` 模式降级到 `tauri` 模式,但不会自动恢复。用户必须手动切换回去。 ## 2. 设计决策 ### 2.1 为什么不做后台协调器 5 个系统不需要状态协调: - WebSocket 断了不影响 Intelligence Heartbeat 继续检查任务积压 - SaaS 不可达不影响 WebSocket ping/pong - 每个系统的触发者、消费者、检测对象完全不同 "统一可见性"通过按需查询实现,不需要常驻后台任务。 ### 2.2 核心策略 **断链修复 + 按需查询 > 后台协调器** | 改动类型 | 内容 | |----------|------| | 修复 | heartbeat.rs 6 处断链 | | 新增 | `health_snapshot` Tauri 命令(按需查询) | | 新增 | `HealthPanel.tsx` 前端组件 | | 修复 | SaaS 自动恢复 | | 清理 | 删除重复 client 文件 | ## 3. 详细设计 ### 3.1 Rust 后端断链修复 **文件**: `desktop/src-tauri/src/intelligence/heartbeat.rs` #### 3.1.1 告警实时推送 **方案选择**:使用 `OnceLock` 全局单例(与 `MEMORY_STATS_CACHE` 等 OnceLock 模式一致)。`heartbeat_init` Tauri 命令从参数中拿到 `app: AppHandle`,写入全局 `HEARTBEAT_APP_HANDLE: OnceLock`。后台 spawned task 通过全局读取 emit。 项目已有先例:`stream:chunk`(chat.rs:403)、`hand-execution-complete`(hand.rs:302)、`pipeline-complete`(discovery.rs:173)都在 Tauri 命令中直接使用 `app: AppHandle` 参数。HeartbeatEngine 的特殊性在于它运行在 `tokio::spawn` 后台任务中,无法直接获得命令参数,因此使用全局单例传递。 ```rust // 全局声明(与 MEMORY_STATS_CACHE 同层级,在 heartbeat.rs 顶部) static HEARTBEAT_APP_HANDLE: OnceLock = OnceLock::new(); // heartbeat_init 命令中注入 pub async fn heartbeat_init( app: tauri::AppHandle, // Tauri 自动注入 agent_id: String, config: Option, state: tauri::State<'_, HeartbeatEngineState>, ) -> Result<(), String> { if let Err(_) = HEARTBEAT_APP_HANDLE.set(app) { tracing::warn!("[heartbeat] APP_HANDLE already set (multiple init calls)"); } // ... 现有 init 逻辑 } // execute_tick() 末尾,告警生成后 if !alerts.is_empty() { if let Some(app) = HEARTBEAT_APP_HANDLE.get() { if let Err(e) = app.emit("heartbeat:alert", &alerts) { tracing::warn!("[heartbeat] Failed to emit alert: {}", e); } } } ``` 前端用 `safeListen('heartbeat:alert', callback)` 接收(`desktop/src/lib/safe-tauri.ts:76` 已有封装),回调中调 `toast(alert.content, urgencyToType(alert.urgency))` 弹出通知。 #### 3.1.2 动态 Interval 替换 `tokio::time::interval()` 为 `tokio::time::sleep` + 每次重读 config。使用 `tokio::select!` + `tokio::sync::Notify` 实现可中断的 sleep,确保 stop 信号能立即响应: ```rust // HeartbeatEngine 结构体新增字段(在 heartbeat.rs 约 116-122 行) pub struct HeartbeatEngine { agent_id: String, config: Arc>, running: Arc>, stop_notify: Arc, // 新增 alert_sender: broadcast::Sender, history: Arc>>, } // HeartbeatEngine::new() 中初始化 stop_notify: Arc::new(Notify::new()), // start() 中的 loop loop { let sleep_secs = config.lock().await.interval_minutes * 60; // 可中断的 sleep:stop_notify 信号到达时立即醒来 tokio::select! { _ = tokio::time::sleep(Duration::from_secs(sleep_secs)) => {}, _ = stop_notify.notified() => { break; } }; if !*running_clone.lock().await { break; } if is_quiet_hours(&*config.lock().await) { continue; } let result = execute_tick(&agent_id, &config, &alert_sender).await; // ... history + persist } // stop() 方法 pub async fn stop(&self) { *self.running.lock().await = false; self.stop_notify.notify_one(); // 立即唤醒 sleep } ``` 每次循环重新读取 `config.interval_minutes`,修改立即生效。`stop()` 通过 `Notify` 立即中断 sleep,无需等待下一周期。 #### 3.1.3 Config 持久化 - `update_config()` 改完后写 VikingStorage key `heartbeat:config:{agent_id}` - `heartbeat_init()` 恢复时优先读 VikingStorage,无记录才用传入的默认值 - 前端 App.tsx 不再需要传 config,让 Rust 侧自己恢复 #### 3.1.4 Memory Stats 查询 Fallback 检查函数中,如果 `MEMORY_STATS_CACHE` 为空,fallback 直接查 VikingStorage 统计 entry 数量和存储大小。 #### 3.1.5 清理 Dead Code - 删除 `subscribe()` — `health_snapshot` 通过 `history: Arc>>` 访问告警历史,不需要 broadcast receiver。`broadcast::Sender` 仅用于内部告警传递,不再暴露 subscribe API。 - 移除 `HeartbeatCheckFn` type alias — 当前未使用且设计方向已明确为硬编码 5 检查 - `is_running()` 暴露为 Tauri 命令(`health_snapshot` 需要查询引擎运行状态) ### 3.2 Health Snapshot 端点 **新文件**: `desktop/src-tauri/src/intelligence/health_snapshot.rs`(~120 行) #### 3.2.1 数据结构 ```rust #[derive(Serialize)] pub struct HealthSnapshot { pub timestamp: String, pub intelligence: IntelligenceHealth, pub memory: MemoryHealth, } #[derive(Serialize)] pub struct IntelligenceHealth { pub engine_running: bool, pub config: HeartbeatConfig, pub last_tick: Option, pub alert_count_24h: usize, pub total_checks: usize, // 固定值 5(内置检查项总数) } #[derive(Serialize)] pub struct MemoryHealth { pub total_entries: usize, pub storage_size_bytes: u64, pub last_extraction: Option, } ``` 只包含 Rust 侧能查询的状态。连接状态和 SaaS 状态由前端各自 store 管理,不绕道 Rust。 #### 3.2.2 Tauri 命令 ```rust #[tauri::command] pub async fn health_snapshot( agent_id: String, heartbeat_state: tauri::State<'_, HeartbeatEngineState>, ) -> Result ``` #### 3.2.3 注册 在 `intelligence/mod.rs` 添加 `pub mod health_snapshot;`,在 `lib.rs` builder 中注册命令和 re-export。 ### 3.3 前端修复 #### 3.3.1 HeartbeatConfig 保存接通后端 **文件**: `desktop/src/components/HeartbeatConfig.tsx` `handleSave()` 在写 localStorage 之后同时调用 `intelligenceClient.heartbeat.updateConfig()` 推送到 Rust 后端。保留 localStorage 作为离线 fallback。 错误处理策略:`updateConfig` 调用用 try/catch 包裹,失败时仅 `log.warn`(不阻塞 UI 更新),用户看到"已保存"反馈。浏览器模式下 `fallbackHeartbeat.updateConfig()` 是纯内存操作,不会失败。 ```typescript const handleSave = useCallback(async () => { localStorage.setItem('zclaw-heartbeat-config', JSON.stringify(config)); localStorage.setItem('zclaw-heartbeat-checks', JSON.stringify(checkItems)); try { await intelligenceClient.heartbeat.updateConfig('zclaw-main', config); } catch (err) { log.warn('[HeartbeatConfig] Backend sync failed:', err); } setHasChanges(false); }, [config, checkItems]); ``` #### 3.3.2 App.tsx 启动读持久化 Config **文件**: `desktop/src/App.tsx` 优先从 localStorage 读用户保存的配置,无记录才用默认值。Rust 侧 `heartbeat_init` 也会从 VikingStorage 恢复,形成双层恢复。 #### 3.3.3 告警监听 + Toast 展示 **文件**: `desktop/src/App.tsx` 在 heartbeat start 之后注册 `safeListen('heartbeat:alert', callback)`,回调中对每条告警调 `toast()`。使用项目已有的 `safeListen` 封装和 Toast 系统。 #### 3.3.4 SaaS 自动恢复 **文件**: `desktop/src/store/saasStore.ts` 降级后启动周期探测,复用现有 `saasClient.deviceHeartbeat(DEVICE_ID)` 调用本身作为探测(它已经能证明 SaaS 可达性)。使用指数退避:初始 2 分钟,最长 10 分钟(2min → 3min → 4.5min → 6.75min → 10min cap)。恢复后自动切回 `saas` 模式 + toast 通知用户 + 停止探测。 **探测启动点**:在 saasStore.ts 的 `DEGRADE_AFTER_FAILURES` 降级逻辑的同一个 catch 块中启动(`saasStore.ts` 约 694-700 行)。降级代码执行后紧接着调用 `startRecoveryProbe()`。 ```typescript // saasStore.ts 现有降级逻辑中追加 if (_consecutiveFailures >= DEGRADE_AFTER_FAILURES) { set({ saasReachable: false, connectionMode: 'tauri' }); saveConnectionMode('tauri'); startRecoveryProbe(); // ← 新增 } ``` #### 3.3.5 清理重复文件 删除 `desktop/src/lib/intelligence-client.ts`(1476 行单文件版)。已被 `intelligence-client/` 目录版遮蔽,是死代码。 ### 3.4 HealthPanel 健康面板 **新文件**: `desktop/src/components/HealthPanel.tsx`(~300 行) #### 3.4.1 定位 设置页中的一个选项卡,只读展示所有子系统健康状态 + 历史告警浏览。不做配置(配置由 HeartbeatConfig 选项卡负责)。 #### 3.4.2 数据来源 | 区域 | 数据源 | |------|--------| | Agent 心跳 | `health_snapshot` invoke | | 连接状态 | `useConnectionStore`(已有) | | SaaS 状态 | `useSaasStore`(已有) | | 记忆状态 | `health_snapshot` invoke | | 历史告警 | `intelligenceClient.heartbeat.getHistory()` | 不新建 Zustand store,用 `useState` 管理,组件卸载即释放。 #### 3.4.3 UI 布局 ``` 系统健康 [刷新] ├── Agent 心跳卡片(运行状态/间隔/上次检查/告警数) ├── 连接状态卡片(模式/连接/SaaS可达) ├── SaaS 设备卡片(注册/上次心跳/连续失败) ├── 记忆管道卡片(条目/存储/上次提取) └── 最近告警列表(紧急度/时间/标题,最多 100 条) ``` #### 3.4.4 状态指示器 | 状态 | 指示 | 条件 | |------|------|------| | 绿灯 | `●` | 正常运行 | | 黄灯 | `●` | 降级/暂停 | | 灰灯 | `○` | 已禁用/空白 | | 红灯 | `●` | 断开/错误 | #### 3.4.5 刷新策略 进入面板时调用一次,手动刷新按钮重新调用。告警列表额外订阅 `heartbeat:alert` Tauri event 实时追加新告警(组件卸载时 unlisten),其他区域不自动轮询。 #### 3.4.6 导航入口 在 `SettingsLayout.tsx` 的 `advanced` 分组中添加 `{ id: 'health', label: '系统健康' }`,与 `heartbeat` 选项卡并列。 ## 4. 改动清单 | 文件 | 操作 | 行数 | |------|------|------| | `intelligence/heartbeat.rs` | 修改(6处修复) | ~80 行改动 | | `intelligence/health_snapshot.rs` | 新建 | ~120 行 | | `intelligence/mod.rs` | 修改(添加模块声明) | ~3 行 | | `lib.rs` | 修改(注册命令 + re-export) | ~5 行 | | `components/HealthPanel.tsx` | 新建 | ~300 行 | | `components/HeartbeatConfig.tsx` | 修改(保存逻辑) | ~10 行 | | `components/Settings/SettingsLayout.tsx` | 修改(添加导航) | ~3 行 | | `App.tsx` | 修改(读配置 + 告警监听) | ~17 行 | | `store/saasStore.ts` | 修改(自动恢复) | ~25 行 | | `lib/intelligence-client.ts` | 删除 | -1476 行 | | **合计** | | ~-913 行净变化 | ## 5. 不做的事 - 不建后台协调器或事件总线 - 不替代 WebSocket ping/pong - 不替代 SaaS device heartbeat 的 HTTP POST 机制 - 不实现 A2A Heartbeat(仍是枚举占位) - 不建新的 Zustand store - 不设自动轮询刷新 ## 6. 验证标准 ### 6.1 功能验证 - [ ] 修改 HeartbeatConfig 保存后,Rust 后端立即生效(interval 变更在下次 tick 体现) - [ ] 告警实时弹出 Toast(不高于 proactivity_level 过滤) - [ ] 重启应用后配置自动恢复(VikingStorage + localStorage 双层) - [ ] SaaS 降级后恢复连接,自动切回 + Toast 通知 - [ ] HealthPanel 展示所有 4 个子系统状态 + 历史告警 - [ ] Memory stats 前端同步失败时 fallback 到 VikingStorage 直接查询 ### 6.2 回归验证 - [ ] `cargo check --workspace --exclude zclaw-saas` 通过 - [ ] `cd desktop && pnpm tsc --noEmit` 通过 - [ ] `cd desktop && pnpm vitest run` 通过 - [ ] 现有 WebSocket ping/pong 不受影响 - [ ] 现有 SSE StreamBridge 不受影响 ## 7. 风险 | 风险 | 影响 | 缓解 | |------|------|------| | AppHandle 全局单例时序 | `heartbeat_init` 必须在 Tauri app 初始化后调用 | 实际在 App.tsx bootstrap Step 4.5 调用,此时 Tauri 已就绪;`OnceLock::set` 失败仅 log warn | | stop 信号在长 sleep 期间延迟 | 用户点击停止后需要等待 sleep 结束 | 使用 `tokio::select!` + `Notify`,stop 立即唤醒 | | SaaS 探测持续失败 | 长时间不可达时每 2 分钟探测浪费资源 | 指数退避,最长 10 分钟间隔 | | 删除 intelligence-client.ts 影响未知导入 | 如果有文件显式导入 `.ts` 后缀 | 实施前全局 grep 确认所有 import 路径;Vite/TypeScript 目录解析优先于文件 | | HealthPanel 告警列表内存 | 长时间打开面板可能积累大量实时告警 | 组件内限制最大 100 条,超出丢弃最早的 |