07 · Heartbeat 调度与运行闭环¶
一句话结论: Heartbeat 不是一个 cron callback,而是一条持久化执行管线:请求先被规范化、抑制、合并和入队,再 claim 为 Run,组装受治理上下文,调用 Adapter,最后以终态、成本、会话、workspace 和 liveness 一起收敛。
1. 两张表回答两个不同问题¶
agent_wakeup_requests¶
回答“为什么系统想唤醒 Agent”:
- company/agent;
- source、trigger/reason、payload;
- issue/run 上下文;
- status;
- coalescing/idempotency;
- 最终关联 heartbeat run。
状态包括:
heartbeat_runs¶
回答“实际发生的这次执行怎样了”:
它还保存 adapter、session、usage、cost、result、log/process metadata、retry 关系、liveness 和 context snapshot。
为什么不合成一张表?一次业务唤醒可能被合并、跳过或推迟,未必创建真正执行;一次执行也可能是 retry/recovery 产生的新尝试。请求身份和尝试身份不同。
2. 唤醒来源¶
标准 invocation source 包括:
timer:定时 heartbeat;assignment:新任务/责任变化;on_demand:人或 API 手动触发;automation:routine、pipeline、completion、recovery 等系统动作。
更细的 reason/payload 还区分 mention、comment、approval resolution、task watchdog、dependency completion、retry 等。来源会影响:
- 是否必须有 actionable issue;
- 是否可被 coalesce;
- 是否允许自动 checkout;
- context 中携带哪些限制;
- toast/activity 如何展示;
- 幂等键如何计算。
3. enqueueWakeup() 先做大量“不要运行”的判断¶
入队路径大致如下:
flowchart TD
IN["Wakeup intent"] --> CTX["规范化 company / agent / issue / responsible user"]
CTX --> SUP{"全局调度抑制?"}
SUP -->|yes| SKIP["持久化 skipped request"]
SUP -->|no| ACTIVE{"company + agent active?"}
ACTIVE -->|no| SKIP
ACTIVE -->|yes| BUDGET{"预算 / invokability 通过?"}
BUDGET -->|no| SKIP
BUDGET -->|yes| ACTION{"timer 是否有 actionable work?"}
ACTION -->|no| SKIP
ACTION -->|yes| HOLD{"issue tree hold?"}
HOLD -->|yes| SKIP
HOLD -->|no| TX["transaction: lock + coalesce + create request/run"]
TX --> QUEUE["start agent queue"]
即使跳过也持久化 request 的好处是:操作者能区分“scheduler 没触发”和“触发了但因预算/暂停/无工作被抑制”。
4. 规范化 Issue 身份¶
唤醒 payload 可能传 Issue UUID 或人类 identifier。入队时会解析为 canonical ID,并加载:
- 当前 assignee;
- execution lock;
- project/workspace policy;
- source trust;
- responsible user;
- tree hold 和 dependency 状态。
这避免把同一 Issue 的不同字符串形式当成两条独立队列键。
5. Coalescing 与排队¶
Agent 已有活动 run 时,新唤醒不总是再启动一个进程。事务内会根据来源和 issue context:
- 与现有请求合并;
- 标为
deferred_issue_execution; - 创建 queued run;
- 在安全时绑定 issue execution lock;
- 用 idempotency key 阻止重复自动化请求。
stateDiagram-v2
[*] --> Requested
Requested --> Skipped: suppressed / inactive / budget / no work
Requested --> Coalesced: equivalent live request exists
Requested --> Deferred: issue execution already occupied
Requested --> Queued: accepted
Deferred --> Queued: execution path becomes available
Queued --> Claimed: agent queue selects it
Claimed --> Completed: run terminalized
Coalescing 不是简单丢消息:相关 reason/context 要被保留或可从 activity/wakeup records 追溯。
6. Agent queue 的职责¶
每个 Agent 的运行要经过统一 queue,而不是各入口直接 executeRun。Claim 阶段检查:
- run 仍为 queued;
- 前置/依赖仍成立;
- Agent 并发槽可用;
- issue execution path 没被其他 run 占用;
- 当前 run 能通过 CAS 从 queued → running。
这解决“两个不同事件同时唤醒同一个 Agent”的竞争。
7. executeRun() 的十四个阶段¶
heartbeat.ts 约 1.9 万行,最有效的阅读方式是把 executeRun 拆成阶段。
阶段 1:再次确认调度条件¶
入队与执行之间可能经过等待,因此运行前重新检查 suppression、Company/Agent 状态、预算与 run status。
阶段 2:Claim run¶
用条件更新把 queued run 变为 running,并注册 active execution。失败说明其他 worker/流程已处理,当前调用安全退出。
阶段 3:加载任务与会话¶
读取:
- Agent 配置与 runtime state;
- Issue、项目、目标祖先和 wake comment;
- task session 及 adapter session state;
- run context snapshot;
- issue adapter/model/runtime override。
阶段 4:可选的自动 checkout¶
由 assignment 等可信 wake 触发,且当前 Issue/Agent 满足规则时,Heartbeat 可调用同一 Issue service 自动 checkout。它不会绕过第 06 章的原子竞争。
阶段 5:解析信任与责任主体¶
合并 input source trust、project/issue preset、low-trust review policy,解析 responsible user,并生成运行授权 envelope。
阶段 6:选择 Workspace / Environment¶
结合 project policy、issue preference、已有 execution workspace 和 trust preset:
- shared / isolated / reuse;
- local / SSH / sandbox / plugin provider;
- 需要时获取 environment lease;
- 低信任来源可被强制放入隔离 workspace。
阶段 7:准备运行时能力¶
包括:
- scratch directory;
- workspace prepare operation;
- runtime services;
- materialized skills;
- secrets 与 tool/MCP config;
- 短期 Paperclip run JWT;
- adapter config 和 instructions bundle。
阶段 8:构造任务 payload¶
上下文不是简单拼接 title:它可能含目标祖先、项目、Issue 线程、wake reason、工作区信息、权限/治理提示、当前 run 身份和 session continuation。
不可信内容在进入 Prompt/环境前执行 redaction/quarantine/边界标注。
阶段 9:调用 Adapter¶
Server 从 registry 取得 adapter module,调用 execute(context)。Context 提供 callbacks:
- log/event/progress;
- process metadata;
- session updates;
- usage/cost;
- runtime status。
阶段 10:流式记录¶
Adapter 输出持续写入 run log store、structured events 和 live event bus;process PID/group/provider reference 也在早期持久化,以便 stop/restart/recovery。
阶段 11:收集结果¶
Adapter result 被规范化为:
- success/failure/interrupted;
- model/usage/cost;
- session state;
- final output / structured metadata;
- provider/runtime diagnostics。
阶段 12:Liveness 分类¶
Run 不只分成功失败,还被分类:
| Liveness | 含义 |
|---|---|
completed |
任务达到合法完成/终态 |
advanced |
有真实进展,仍可能需后续 |
plan_only |
只产出计划,需确认或继续 |
empty_response |
没有可用推进证据 |
blocked |
明确等待外部条件 |
failed |
执行失败 |
needs_followup |
本轮结束但必须续跑/转交 |
阶段 13:Continuation / retry / recovery¶
根据 run outcome、liveness、attempt count、provider failure 分类和 Issue 状态:
- 立即/延迟 retry;
- scheduled retry;
- completion wake;
- manager/reviewer handoff;
- recovery notice/action/issue;
- 释放或保留 Issue lock;
- 更新 Agent 状态。
阶段 14:终态与清理¶
最终保存 run result、usage/cost、session、workspace operation,并清理 lease、runtime service、scratch、active registry,然后启动下一个 queued run。
8. 关键顺序:先终态化 Run,再释放 Lease¶
这是一个细小但重要的不变量:
sequenceDiagram
participant R as Run
participant DB as Database
participant L as Environment Lease
participant Q as Next Queue Item
R->>DB: persist terminal status/result/liveness
DB-->>R: committed
R->>L: finalize workspace + release lease
L-->>R: released/cleanup recorded
R->>Q: start next queued run
如果先释放 lease,再写 run 终态,另一个执行可能复用环境,而 recovery 仍看到旧 run running,造成错误接管或清理。
9. 状态写入使用 CAS 防晚到事件¶
子进程 exit、timeout、cancel 和 provider callback 可能竞相写终态。setRunStatusFromLive 一类操作只允许从 live 状态转换:
UPDATE heartbeat_runs
SET status = :terminal
WHERE id = :id AND status IN ('queued', 'scheduled_retry', 'running')
一旦某个路径先写入 terminal,晚到的“failed”不能覆盖已确认的“cancelled”或“succeeded”。
10. 日志、事件、进度不是同一层¶
Heartbeat 发布多类 live event:
| 类型 | 用途 |
|---|---|
heartbeat.run.queued |
新 run 进入队列 |
heartbeat.run.status |
生命周期状态变化 |
heartbeat.run.progress |
当前工具、摘要、状态消息 |
heartbeat.run.event |
adapter 结构化事件 |
heartbeat.run.log |
高频日志片段 |
agent.status |
Agent 控制状态 |
activity.logged |
业务实体变更 |
UI 对 log 只做局部 transcript 消费,对 status/activity 才进行更广缓存失效,避免事件风暴。
11. Session 如何跨 Heartbeat 延续¶
agent_task_sessions 按 company、agent、adapter、task key 唯一。执行前:
- 找到当前 task session;
- 用 adapter session codec 解码 provider-specific state;
- 若 workspace/cwd 与 session 不兼容,按策略重置或迁移;
- 传入 adapter resume 参数;
- 执行后原子保存新的 session state。
这让 Agent 跨 heartbeat 保留对话,而每轮 run 仍有独立成本、日志和故障边界。
12. Retry 不是一刀切¶
系统会区分:
- provider 暂时失败;
- adapter/config 永久错误;
- process 被中断;
- silent timeout;
- responsible user denial;
- workspace/environment provisioning 失败;
- Agent 有输出但 liveness 不足。
Scheduled retry 保存 retryOfRunId、时间、attempt 和原因。到期后由 promotion tick 转回可 claim 状态,而不是在内存里 setTimeout 几分钟。
13. Outer error 与 inner error¶
executeRun 有两圈错误处理:
- inner:Adapter 已启动后的执行错误,通常已有 process/log/workspace,需正常 finalization;
- outer:在 Adapter 前的 setup/claim/context/workspace 错误,仍必须把 run 终态化并释放已经获取的部分资源。
Finally 按“获得过什么就释放什么”工作,避免 setup 半途失败留下 lease 或 scratch。
14. 服务器重启后的恢复¶
持久化 run/requests 允许启动时:
- adopt 仍活着且身份可验证的本地进程;
- terminalize 停机期间自然完成的进程;
- 标记 lost 并生成恢复路径;
- promote 到期 retry;
- resume queued run;
- 释放无 live run 对应的 issue lock;
- 为 stranded issue 建立 continuation。
仅内存队列无法做到这一点。
15. Heartbeat 的端到端时序¶
sequenceDiagram
participant T as Trigger
participant H as Heartbeat Service
participant DB as PostgreSQL
participant W as Workspace/Environment
participant A as Adapter
participant E as Events/UI
participant R as Recovery
T->>H: enqueueWakeup(intent)
H->>DB: persist request + queued run / coalesce / skip
H->>H: startAgentQueue(agent)
H->>DB: claim queued → running
H->>DB: auto-checkout Issue (optional)
H->>W: realize workspace + acquire lease
H->>A: execute(context, session, secrets, tools)
loop streaming
A->>H: log / progress / event / process meta
H->>DB: persist evidence
H->>E: publish company live event
end
A-->>H: result + usage + session
H->>DB: cost + session + liveness + run terminal
H->>W: finalize/sync + release lease
alt needs continuation
H->>DB: retry / wakeup / recovery action
H->>R: schedule next path
else complete
H->>DB: release locks / final Agent state
end
16. 设计代价¶
优点¶
- 每个决策有 durable evidence;
- 多触发源共享同一执行不变量;
- restart/retry/cancel 都能对账;
- adapter 差异被 contract 吸收;
- liveness 与 exit code 解耦。
代价¶
- 单个 service 极大,阶段之间共享大量局部状态;
- 高频 DB 更新与日志持久化带来写放大;
- coalescing、Issue lock、Agent queue、workspace lease 是多重并发层,测试矩阵庞大;
- 当前进程内 live event 不能提供跨节点回放。
17. 可复用经验¶
- 把 wake intent 和 execution attempt 分表。
- 跳过的调度也要留记录,才能解释“为什么没运行”。
- Queue claim 与 run status 用 CAS,防多 worker 重复执行。
- Session 用于连续性,Run 用于账本和故障边界。
succeeded只说明执行成功;另建 liveness 表达工作是否仍需推进。- 终态写入应早于资源复用,finally 要按已获取资源做幂等清理。
- 延迟重试应持久化时间和来源,不依赖进程内 timer。
源码入口¶
server/src/services/heartbeat.ts :: enqueueWakeup / executeRunpackages/db/src/schema/agent_wakeup_requests.tspackages/db/src/schema/heartbeat_runs.tspackages/db/src/schema/agent_task_sessions.tspackages/shared/src/constants.ts:wakeup/run/liveness/live event 状态server/src/services/run-liveness.tsserver/src/services/recovery/service.tsserver/src/services/hot-restart.ts
下一章打开执行黑盒:08 · Adapter、会话与上下文组装。