通过 orchestrator 每 60 秒发送心跳、超过 75 秒无响应则判定为不健康,实现了一种无需被监控进程主动写入的分布式健康检测机制,避免了进程崩溃后状态不更新的问题。
崩溃的 Coding Agent 无法上报自己的崩溃。在我的工单追踪器里,orchestrator 在 Agent 工作时每 60 秒发送一次心跳,而"working"状态如果上一次心跳超过 75 秒就会在所有界面上显示为红色,且不需要任何人写入数据。
我叫 Panth,在 Oizom 带领软件团队。这个追踪器叫 ticket-tracker,是我的开源看板,每个工单都会运行一个独立的无头 Claude Code 会话。这篇文章讲的是其中很小的一块:看板如何知道一个 Agent 还活着。
最直观的设计是在工单上存储一个状态:working,然后 done。Agent 启动时写入 working,结束时写入 done。
如果进程在此期间崩溃,什么都不会写入。工单会一直显示 working,直到有人去查看。存储的状态的真实性取决于最后一次成功写入它的那个进程,而一个已经崩溃的进程什么都写不了。
心跳由 orchestrator 发送,而非 Agent 自己。只需要一个调用:
POST /v1/heartbeat
{ ticket?: KEY, // omit = an agent-level beat, with no ticket
state: 'working' | 'idle' | 'done' | 'error',
message?: string, // "Running tests (3/12)", up to 200 characters
progress?: number } // 0 to 1
在 Agent 工作期间每分钟发送一次,工作结束时再发一次。message 来自 Agent 的任务列表:orchestrator 每隔五次心跳读取一次列表,中间四次重复上一条消息。
心跳从不触碰工单文档。它为每个 Agent 和工单维护自己的一条小记录,所以每分钟一次的心跳不会触发工单的搜索索引,也不会让所有监听该工单的界面重新渲染。
存储的状态有四个值。屏幕上的健康状态有六个,多出来的两个来自时钟:
export const HEARTBEAT_INTERVAL_MS = 60_000;
export const HEARTBEAT_STALE_MS = 75_000;
export function deriveAgentHealth(status, now, staleAfterMs = HEARTBEAT_STALE_MS) {
if (!status) return 'none';
if (status.state !== 'working') return status.state;
return now - status.lastBeatAt > staleAfterMs ? 'stale' : 'working';
}
75 秒是一次心跳间隔加一点缓冲。
只有 working 可以变为 stale。idle、done 和 error 是陈述性状态,下次心跳之前始终为真。working 是一个需要持续续约的声明。
这一个函数放在 shared 包里。卡片、工单头部、人员与角色页面和 Agent 页面都调用它,所以它们不可能对一个 Agent 的状态产生分歧。当多个 Agent 在同一个工单上时,卡片显示最严重的状态:error 优先,然后是无信号,然后是 working、idle 和 finished。
派生值只在有东西重新计算它时才会改变。如果没有新的心跳到达,也就没有新数据到达,所以界面需要自己的时钟:
export const HEALTH_TICK_MS = 15_000;
export const healthClock = readable(Date.now(), (set) => {
const id = setInterval(() => set(Date.now()), HEALTH_TICK_MS);
return () => clearInterval(id);
});
15 秒是心跳间隔的四分之一,所以一个圆点最多只会错误 15 秒。这是一个所有界面上所有圆点共享的间隔,当最后一个订阅者离开时它就停止。
第一个版本把每个心跳流存为 Firestore 文档。我测量了 2026 年 9 月 23 日到 26 日项目的账单,心跳在上面出现了两次:
每个 Agent 每天 1,440 次写入,每次都是经由一个函数发起 Firestore 事务,然后每个打开的标签页会读一次。
每分钟运行一次的去扫(sweep)用来发现静默:三天内运行了 4,261 次。
为此做了两处改动。
心跳移到了 Realtime Database,后者按带宽计费而非按操作次数计费。一次心跳现在大约 100 字节。数据库之上所有层面的读取形状保持不变,所以没有任何界面需要改动。
去扫不再查找静默。发现静默从来不需要服务器,因为 stale 就是 now - lastBeatAt,在读取心跳的地方计算。现在去扫每三分钟运行一次,只做一件真正需要服务器的事:如果一个 working 的 Agent 已经静默了五分钟,就通知它的 Owner,每个静默周期只通知一次,因为可能没有人开着看板。下一条心跳会重新启用它。
存储事实:Agent 最后一次声明的状态以及声明的时间。在读取时、在所有界面共享的那一个函数里,派生判断它是活着还是死了。然后给界面一个时钟,否则这个判断永远不会更新。
你的 Agent 设置是如何告诉你一次运行已经死掉的:服务器上的超时,还是根据它最后说的话来推断?