Rust 后端 (heartbeat.rs): - 告警实时推送: OnceLock<AppHandle> + Tauri emit heartbeat:alert - 动态间隔: tokio::select! + Notify 替代不可变 interval - Config 持久化: update_config 写入 VikingStorage - heartbeat_init 从 VikingStorage 恢复 config - 移除 dead code (subscribe, HeartbeatCheckFn) - Memory stats fallback 分层处理 新增 health_snapshot.rs: - HealthSnapshot Tauri 命令 — 按需查询引擎/记忆状态 - 注册到 lib.rs invoke_handler 前端修复: - HeartbeatConfig handleSave 同步到 Rust 后端 - App.tsx 读 localStorage 持久化配置 + heartbeat:alert 监听 + toast - saasStore 降级后指数退避探测恢复 + saas-recovered 事件 - 新增 HealthPanel.tsx 只读健康面板 (4卡片 + 告警列表) - SettingsLayout 添加 health 导航入口 清理: - 删除 intelligence-client/ 目录版 (9文件 -1640行, 单文件版是活跃代码)
15 KiB
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 处断链
- 告警无法实时送达前端 — Rust
broadcast::Sender有发送者但零订阅者,subscribe()是 dead code。告警只存入 history,用户无法实时感知。 - HeartbeatConfig 保存只到 localStorage —
handleSave()不调用updateConfig(),Rust 后端永远用 App.tsx 的硬编码默认值。 - 动态间隔修改无效 —
tokio::time::interval创建后不可变,update_config改了值但不生效。 - Config 不持久化 — VikingStorage 只存 history 和 last_interaction,config 重启后丢失。
- 重复 client 实现 —
intelligence-client.ts单文件版被intelligence-client/目录版遮蔽,是死代码。 - 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<AppHandle> 全局单例(与 MEMORY_STATS_CACHE 等 OnceLock 模式一致)。heartbeat_init Tauri 命令从参数中拿到 app: AppHandle,写入全局 HEARTBEAT_APP_HANDLE: OnceLock<AppHandle>。后台 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 后台任务中,无法直接获得命令参数,因此使用全局单例传递。
// 全局声明(与 MEMORY_STATS_CACHE 同层级,在 heartbeat.rs 顶部)
static HEARTBEAT_APP_HANDLE: OnceLock<tauri::AppHandle> = OnceLock::new();
// heartbeat_init 命令中注入
pub async fn heartbeat_init(
app: tauri::AppHandle, // Tauri 自动注入
agent_id: String,
config: Option<HeartbeatConfig>,
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 信号能立即响应:
// HeartbeatEngine 结构体新增字段(在 heartbeat.rs 约 116-122 行)
pub struct HeartbeatEngine {
agent_id: String,
config: Arc<Mutex<HeartbeatConfig>>,
running: Arc<Mutex<bool>>,
stop_notify: Arc<Notify>, // 新增
alert_sender: broadcast::Sender<HeartbeatAlert>,
history: Arc<Mutex<Vec<HeartbeatResult>>>,
}
// 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 keyheartbeat: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<Mutex<Vec<HeartbeatResult>>>访问告警历史,不需要 broadcast receiver。broadcast::Sender仅用于内部告警传递,不再暴露 subscribe API。 - 移除
HeartbeatCheckFntype alias — 当前未使用且设计方向已明确为硬编码 5 检查 is_running()暴露为 Tauri 命令(health_snapshot需要查询引擎运行状态)
3.2 Health Snapshot 端点
新文件: desktop/src-tauri/src/intelligence/health_snapshot.rs(~120 行)
3.2.1 数据结构
#[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<String>,
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<String>,
}
只包含 Rust 侧能查询的状态。连接状态和 SaaS 状态由前端各自 store 管理,不绕道 Rust。
3.2.2 Tauri 命令
#[tauri::command]
pub async fn health_snapshot(
agent_id: String,
heartbeat_state: tauri::State<'_, HeartbeatEngineState>,
) -> Result<HealthSnapshot, String>
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() 是纯内存操作,不会失败。
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()。
// 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 条,超出丢弃最早的 |