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行, 单文件版是活跃代码)
361 lines
15 KiB
Markdown
361 lines
15 KiB
Markdown
# 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<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;
|
||
// 可中断的 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<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 条,超出丢弃最早的 |
|