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

361 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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_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: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<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 信号能立即响应:
```rust
// 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 数据结构
```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<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 命令
```rust
#[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()` 是纯内存操作,不会失败。
```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 条,超出丢弃最早的 |