Files
zclaw_openfang/docs/superpowers/specs/2026-04-15-heartbeat-unified-design.md
iven 215c079d29
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix(intelligence): Heartbeat 统一健康系统 — 6处断链修复 + 健康面板 + SaaS自动恢复
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行, 单文件版是活跃代码)
2026-04-15 23:19:24 +08:00

15 KiB
Raw Blame History

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 保存只到 localStoragehandleSave() 不调用 updateConfig()Rust 后端永远用 App.tsx 的硬编码默认值。
  3. 动态间隔修改无效tokio::time::interval 创建后不可变,update_config 改了值但不生效。
  4. Config 不持久化 — VikingStorage 只存 history 和 last_interactionconfig 重启后丢失。
  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<AppHandle> 全局单例(与 MEMORY_STATS_CACHE 等 OnceLock 模式一致)。heartbeat_init Tauri 命令从参数中拿到 app: AppHandle,写入全局 HEARTBEAT_APP_HANDLE: OnceLock<AppHandle>。后台 spawned task 通过全局读取 emit。

项目已有先例:stream:chunkchat.rs:403hand-execution-completehand.rs:302pipeline-completediscovery.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;
    // 可中断的 sleepstop_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<Mutex<Vec<HeartbeatResult>>> 访问告警历史,不需要 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 数据结构

#[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.ts1476 行单文件版)。已被 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 storeuseState 管理,组件卸载即释放。

3.4.3 UI 布局

系统健康                            [刷新]
├── Agent 心跳卡片(运行状态/间隔/上次检查/告警数)
├── 连接状态卡片(模式/连接/SaaS可达
├── SaaS 设备卡片(注册/上次心跳/连续失败)
├── 记忆管道卡片(条目/存储/上次提取)
└── 最近告警列表(紧急度/时间/标题,最多 100 条)

3.4.4 状态指示器

状态 指示 条件
绿灯 正常运行
黄灯 降级/暂停
灰灯 已禁用/空白
红灯 断开/错误

3.4.5 刷新策略

进入面板时调用一次,手动刷新按钮重新调用。告警列表额外订阅 heartbeat:alert Tauri event 实时追加新告警(组件卸载时 unlisten其他区域不自动轮询。

3.4.6 导航入口

SettingsLayout.tsxadvanced 分组中添加 { 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! + Notifystop 立即唤醒
SaaS 探测持续失败 长时间不可达时每 2 分钟探测浪费资源 指数退避,最长 10 分钟间隔
删除 intelligence-client.ts 影响未知导入 如果有文件显式导入 .ts 后缀 实施前全局 grep 确认所有 import 路径Vite/TypeScript 目录解析优先于文件
HealthPanel 告警列表内存 长时间打开面板可能积累大量实时告警 组件内限制最大 100 条,超出丢弃最早的