fix(intelligence): Heartbeat 统一健康系统 — 6处断链修复 + 健康面板 + SaaS自动恢复
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

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行, 单文件版是活跃代码)
This commit is contained in:
iven
2026-04-15 23:19:24 +08:00
parent 043824c722
commit 215c079d29
19 changed files with 1184 additions and 1678 deletions

View File

@@ -0,0 +1,360 @@
# 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 条,超出丢弃最早的 |