11 个显式状态
覆盖创建、准备、执行、审批、收口四阶段,禁止"未知中间态"
终态必经 TRACING
SUCCEEDED / REJECTED / FAILED / CANCELLED 任何一条路径都先收口审计再落终态
Job.status 只读投影
RunState 是唯一真实状态源,Job.status 单向同步、不反向驱动 Harness
详细设计说明
用 11 态状态机让 Run 的真实运行轨迹可观测、可恢复、可审计
这份文档解决执行内核中最容易腐化的问题:状态散落在多个布尔字段和旧 Job.status 四值里,导致谁都说不清一个 Run 此刻"是在等审批、被拒了、还是死掉了"。RunState 把所有运行可能性收敛到 11 个显式状态、一张转移表,并通过强制收敛到 TRACING 解决"异常路径漏审计"。
设计主张
RunState 是 Harness 内核的唯一真实状态源。Policy / HITL / Tool 控制器以及失败 / 取消 / 超时 路径都读 RunState 做判断,Job.status 只作为旧系统兼容投影。任何 Run 在任何时刻都必须落在 11 个显式状态之一,禁止"未知中间态"。
评审关注点
- Harness 是否还有路径反向读 Job.status
- 所有终态前是否必经 TRACING 收口
- 中间态停留超阈值是否能被巡检识别
- AWAITING_HITL 与 FAILED 的恢复语义是否清晰
- 异常落到哪个显式态,是否存在"漏定义"路径
1. 定位与概念
把 11 个状态、4 个阶段、3 类终态在一张图里看清楚,回答 "RunState 是什么 / 谁拥有它 / 为什么严格"。本章是阅读其余章节的入口。
1.1 总体架构图
下图按四个阶段把 11 个状态拆开:阶段 1 主路径推进(CREATED → ... → EXECUTING)、阶段 2 收口 TRACING(任何路径必须穿过)、阶段 3 中间分支(FAILED / TIMEOUT / REJECTED / CANCELLED 是瞬时态,必须再走 TRACING)、阶段 4 最终关闭态(SUCCEEDED 与 3 个 CLOSED_* 不可逆)。
1.2 三个典型时序场景
下图按时间从上到下画出三种最常见的运行轨迹:成功路径、Policy 拒绝、HITL 等待并超时。左侧四条 lifeline 头部色块可点击跳转到对应章节(状态定义 / 转移规则 / 终态收敛)。
1.3 为什么必须严格状态机
不严格的痛点不是抽象的"代码混乱",而是具体到每天值班时会遇到的真实问题:
| 不严格的现状 | 引发的问题 | RunState 的解法 |
|---|---|---|
状态散落在 Job.status 四值 + started / finished 多个布尔字段 | "Job.status=running 但实际线程已死"靠监控告警才能发现 | 显式 11 态,任何时刻 RunState 必落其一,巡检发现停留超阈值即识别僵尸 |
| 异常路径直接抛错,没写 Trace 就退出 | 线上失败定位只能翻日志,无法回放决策链 | 所有失败先入 REJECTED / FAILED / TIMEOUT 中间态,再必经 TRACING 收口 |
| HITL 超时和执行失败混用同一个终态 | 合规报表分不清"被人拒绝"与"系统挂了" | 显式区分 CLOSED_REJECTED / CLOSED_FAILED / CLOSED_CANCELLED 三种终态 |
| 没有"中间失败可见态" | 失败瞬间 Trace 还没写完,监控就只能看到 final,无法分辨阶段 | FAILED / TIMEOUT / REJECTED 是瞬时态,新页面可看到 Run 短暂经过 |
| 取消路径没有显式态,Job.status 直接打 failed | 用户主动停 = 系统失败,统计口径污染 | 显式 CANCELLED → CLOSED_CANCELLED,单独统计取消率 |
1.4 三层职责分工
RunState 由 Tier 2 拥有,Tier 1 / Tier 3 各自看到不同投影:
| 层 | 对 RunState 的角色 | 具体行为 |
|---|---|---|
| Tier 1 · Agent Platform | 消费 Job.status 投影(旧)+ 可选读 Run.state(新页面) | 列表页、状态徽章、Celery 重试判断都基于 Job.status;新调试页可读 Run.state 实时值 |
| Tier 2 · Agentic OS | RunState 的唯一拥有者 | StateMachine.transition 是 Run.state 的唯一写入入口;跨 Run 协作链路(如 @团队展开后多个 Run)也只读 RunState 做调度 |
| Tier 3 · Infrastructure | 不感知 RunState | LLM API / Skill SDK / Sandbox 只接收输入返回输出,不读不写 Run.state |
这条边界保证了:旧页面继续看 Job.status 不会坏;新内核的状态语义不被旧字段污染;执行层换 LLM 或换沙箱时不会触发状态模型改造。
2. 11 个状态的定义
逐一定义 11 个状态:进入条件、典型持续时长、允许的退出转移。本章是后续转移表、终态收敛、僵尸检测的依据。
2.1 主路径状态(6 个)
| 状态 | 进入条件 | 典型持续 | 允许退出转移 |
|---|---|---|---|
CREATED | Run 管理器收到 RunRequest 后立即写入 | 毫秒级 | → PREPARING(自动) |
PREPARING | 开始装配上下文(memory + system + history)和加载 Skill schema | ≤ 2 秒 | → PLANNING(成功)/ → FAILED(装配异常) |
PLANNING | 调用 LLM 把任务分解为步骤序列 | ≤ 30 秒 | → POLICY_CHECK(产出 Plan)/ → FAILED(LLM 异常 / 超时) |
POLICY_CHECK | 对 Plan 中的工具调用做权限 / 成本 / 风险评估 | 毫秒级 | → EXECUTING(low / medium 自动通过)/ → AWAITING_HITL(high)/ → REJECTED(命中黑名单 / 超预算) |
AWAITING_HITL | Policy 判定 high,需要人工审批 | 分钟到小时(可挂起) | → EXECUTING(批准)/ → REJECTED(拒绝)/ → TIMEOUT(默认拒绝)/ → CANCELLED(用户撤销) |
EXECUTING | 双守门员通行,开始调用工具 | 秒到分钟 | → TRACING(成功收口)/ → FAILED(工具异常 / LLM 超时)/ → TIMEOUT(Run 总耗时超阈值)/ → CANCELLED(用户停止) |
主路径的关键不变量:POLICY_CHECK 永远在 EXECUTING 之前,副作用调用都受双守门员约束。AWAITING_HITL 是唯一可以"挂起小时级"的状态,其余主路径状态都是秒级。
2.2 收口状态(1 个)
| 状态 | 进入条件 | 典型持续 | 允许退出转移 |
|---|---|---|---|
TRACING | 来自任意主路径或瞬时态,开始写 RunTrace / AuditLog / 关键事件 | ≤ 1 秒(异步落库) | → SUCCEEDED / CLOSED_REJECTED / CLOSED_FAILED / CLOSED_CANCELLED 之一(按入口决定) |
TRACING 是所有路径的唯一收口。不允许任何状态跳过 TRACING 直接到最终关闭态。Trace 写入必须在事务内完成或采用 outbox 模式保证至少一次落库,否则视为状态机异常(一票否决)。
2.3 瞬时分支态(4 个)
| 状态 | 进入条件 | 典型持续 | 允许退出转移 |
|---|---|---|---|
REJECTED | POLICY_CHECK 命中黑名单 / 超预算 / 越权;或 AWAITING_HITL 收到拒绝决议 | 毫秒级 | → TRACING(强制) |
FAILED | PREPARING / PLANNING / EXECUTING 阶段异常或重试耗尽 | 毫秒级 | → TRACING(强制) |
TIMEOUT | Step / Run 总耗时超阈值,或 AWAITING_HITL 倒计时到期 | 毫秒级 | → TRACING(强制) |
CANCELLED | 用户主动停止(chat stop / API cancel) | 毫秒级 | → TRACING(强制) |
这四个状态是瞬时态而不是终态:唯一作用是把"为什么收口"的语义带进 TRACING,让审计能区分四类原因。新页面可以观察到 Run 短暂经过,旧 Job.status 此刻仍显示 running(它只在最终态才更新)。
2.4 最终关闭态(4 个)
| 状态 | 进入条件 | 典型持续 | 允许退出转移 |
|---|---|---|---|
SUCCEEDED | TRACING 来自 EXECUTING 成功路径 | 永久 | 无(不可逆) |
CLOSED_REJECTED | TRACING 来自 REJECTED 或 HITL 超时(默认拒绝) | 永久 | 无(不可逆) |
CLOSED_FAILED | TRACING 来自 FAILED 或执行类 TIMEOUT | 永久 | 无(不可逆) |
CLOSED_CANCELLED | TRACING 来自 CANCELLED | 永久 | 无(不可逆) |
四个最终态都不可逆。重试 Run 必须通过新建 RunRequest 完成(产生新 run_id),不允许把 CLOSED_FAILED 的 Run 改回 PREPARING。
3. 合法转移规则
把"哪些转移合法 / 谁触发 / 什么守卫条件"写成一张完整的转移表。任何代码路径触发的转移如果不在此表,等同于状态机异常。
3.1 转移表(按 from 状态分组)
| from | to | 触发方 | 守卫条件 |
|---|---|---|---|
CREATED | PREPARING | Run 管理器 | RunRequest 字段完整 |
PREPARING | PLANNING | 上下文装配器 | memory 召回成功 + Skill schema 加载完毕 + token 不超预算 |
PREPARING | FAILED | 上下文装配器 | memory 召回异常 / 压缩失败仍超预算 / 跨 tenant 串数据被拦截 |
PLANNING | POLICY_CHECK | 计划执行器 | LLM 返回有效 Plan |
PLANNING | FAILED | 计划执行器 | LLM 重试耗尽 / Plan 解析失败 |
PLANNING | TIMEOUT | 计划执行器 | LLM 调用超出 Step 超时 |
POLICY_CHECK | EXECUTING | HITL 控制器 | risk ∈ {low, medium} 自动通过 |
POLICY_CHECK | AWAITING_HITL | HITL 控制器 | risk = high,已发出审批请求 |
POLICY_CHECK | REJECTED | HITL 控制器 | 命中黑名单 / 越权 / 单 Run 成本超预算 |
AWAITING_HITL | EXECUTING | HITL 控制器 | 审批人决议 = approve |
AWAITING_HITL | REJECTED | HITL 控制器 | 审批人决议 = reject |
AWAITING_HITL | TIMEOUT | HITL 控制器 | 倒计时到期且无决议(默认拒绝语义) |
AWAITING_HITL | CANCELLED | 用户入口 | 用户撤销请求 |
EXECUTING | TRACING | 工具调用控制器 | 所有步骤执行成功,Plan 终止 |
EXECUTING | FAILED | 工具调用控制器 | Tool / LLM 异常重试耗尽 |
EXECUTING | TIMEOUT | 工具调用控制器 | Run 总耗时超 600s(执行类超时) |
EXECUTING | CANCELLED | 用户入口 | 用户主动停止 |
REJECTED | TRACING | StateMachine 自动 | 无(强制收口,不可绕过) |
FAILED | TRACING | StateMachine 自动 | 无(强制收口) |
TIMEOUT | TRACING | StateMachine 自动 | 无(强制收口) |
CANCELLED | TRACING | StateMachine 自动 | 无(强制收口) |
TRACING | SUCCEEDED | 审计追踪器 | 入口为 EXECUTING 成功 + Trace 写入完成 |
TRACING | CLOSED_REJECTED | 审计追踪器 | 入口为 REJECTED 或 HITL TIMEOUT |
TRACING | CLOSED_FAILED | 审计追踪器 | 入口为 FAILED 或执行类 TIMEOUT |
TRACING | CLOSED_CANCELLED | 审计追踪器 | 入口为 CANCELLED |
3.2 不允许的转移(举例)
- 任何状态 → CREATED:禁止重置(重试需新 run_id)
- FAILED → EXECUTING:禁止"原地重试"(重试需通过新 Run)
- EXECUTING → CLOSED_FAILED:禁止跳过 FAILED + TRACING
- POLICY_CHECK → SUCCEEDED:禁止跳过 EXECUTING + TRACING
- CLOSED_* → 任何状态:终态不可逆
StateMachine.transition 实现层做白名单校验:传入 (from, to) 不在转移表则抛 InvalidTransitionError,并写 AuditLog 记录非法转移尝试,便于追查。
3.3 触发方与守卫的边界
触发方说明"谁调用 transition",守卫条件说明"调用前必须满足什么"。守卫不通过时,触发方有责任改走异常分支(FAILED / REJECTED / TIMEOUT),不允许悄悄不转移——这是僵尸 Run 的主要来源之一。
4. 终态收敛
说明为什么要"必经 TRACING"、终态唯一性如何保证、异常如何映射到显式终态。这是 RunState 与普通 enum 字段最本质的区别。
4.1 必经 TRACING 的工程含义
TRACING 不是"建议步骤"而是路径漏斗。任何路径直达 CLOSED_* 都视为状态机异常,触发一票否决。这条约束带来三个工程收益:
- 审计完整性:成功、被拒、失败、取消四条路径在 RunTrace + AuditLog 里都有完整记录,事后可回放
- 对账可行:业务统计 SUCCEEDED 数与 AuditLog 的 run_succeeded 事件数必须严格一致;任何不一致即说明有路径绕过 TRACING
- 关键事件不丢:policy_rejected / hitl_timeout / tool_call_failed 等关键事件由 TRACING 阶段统一落库,避免散落在多个写入点导致漏写
4.2 异常如何落显式终态
"漏定义路径"是普通 enum 状态机的常见 bug。RunState 通过下表保证任何异常都有归属:
| 异常类别 | 瞬时态 | 最终态 | 关键 Trace |
|---|---|---|---|
| Memory 装配失败 / 跨 tenant 串数据 | FAILED | CLOSED_FAILED | StepTrace(memory_retrieve, error) |
| LLM 异常 / Plan 解析失败 | FAILED | CLOSED_FAILED | ToolInvocationTrace(llm, error) |
| Plan 阶段超时 | TIMEOUT | CLOSED_FAILED | ToolInvocationTrace(llm, timeout) |
| Policy 命中黑名单 / 超预算 / 越权 | REJECTED | CLOSED_REJECTED | PolicyDecisionTrace(rejected) |
| HITL 拒绝 | REJECTED | CLOSED_REJECTED | HITLDecisionTrace(reject) |
| HITL 超时(默认拒绝) | TIMEOUT | CLOSED_REJECTED | HITLDecisionTrace(timeout=true) |
| Tool 调用失败 / 重试耗尽 | FAILED | CLOSED_FAILED | ToolInvocationTrace(error, retry_count) |
| Run 总耗时超 600s | TIMEOUT | CLOSED_FAILED | RunTrace(timeout) |
| 用户主动停止 | CANCELLED | CLOSED_CANCELLED | AuditLog(run_cancelled, by=user_id) |
HITL 超时的特殊性值得单独强调:从用户视角是"我没批准但也没拒绝,系统替我决定",从合规视角是"默认拒绝 ≠ 系统失败"。因此映射到 CLOSED_REJECTED 而不是 CLOSED_FAILED,合规报表能正确归类。
4.3 终态唯一性
同一 run_id 在生命周期内只能有一个最终态记录。StateMachine.transition 在落终态时使用 WHERE state IN (transient_or_tracing) AND run_id = ? 的乐观锁,保证:
- 并发触发(如同一 Run 同时被 user cancel 和 HITL timeout)只有一个胜出
- 胜出方写终态 + Trace;落败方读到当前 state 已是 CLOSED_*,直接放弃自己的转移
- 双方决议都进 AuditLog,便于事后审计"为什么是 CANCELLED 不是 TIMEOUT"
5. Job.status 投影
把 11 个 RunState 单向映射到旧 Job.status 的 4 值,定义只读投影规则,明确 Job.status 不再扩展枚举。
5.1 投影对照表
| RunState | Job.status(legacy) | 语义解释 |
|---|---|---|
| CREATED / PREPARING / PLANNING / POLICY_CHECK | pending | 尚未开始执行任何副作用 |
| EXECUTING / AWAITING_HITL / TRACING | running | 正在跑或等待审批,对旧系统是"还没结束" |
| REJECTED / FAILED / TIMEOUT / CANCELLED(瞬时) | running | 瞬时态,旧系统看到的是"还在跑",下次轮询才转 failed |
| SUCCEEDED | done | 主路径成功 |
| CLOSED_REJECTED / CLOSED_FAILED / CLOSED_CANCELLED | failed | 三种关闭原因都映射到旧字段的 failed(旧字段不区分原因) |
5.2 单向同步规则
- RunStateProjector 是唯一写入方:在 transition 提交事务时,由 Projector 把新 Run.state 按对照表写到 Job.status
- Harness 内核不读 Job.status:Policy / HITL / Tool / 计划执行器代码路径都禁止
if job.status == ...,违反即一票否决 - Job.status 不扩展枚举:保持 pending / running / done / failed 四值;新出现的精细状态通过 RunState 表达
- 可选字段
job.run_state_snapshot:旧表加一列镜像 Run.state 实时值,给新页面 / 调试接口看精细状态,不影响旧 Job.status 语义
5.3 旧消费者影响评估
| 旧消费者 | 当前行为 | RunState 引入后 |
|---|---|---|
| 旧前端列表 | 读 Job.status 显示徽章 | 语义不变(pending / running / done / failed),徽章色和文案保持 |
| 旧 Celery 重试器 | 读 Job.status 判断是否重启 Job | 语义不变;瞬时态被 Projector 暂留为 running,重试逻辑不会误触发 |
| 监控告警 | 按 Job.status = failed 出告警 | 语义不变;告警量可能略升(取消也算 failed),可在告警侧用 run_state_snapshot 细分 |
| 旧接口 GET /jobs/<id> | 返回 Job.status 字段 | 字段语义不变;可选附加 run_state_snapshot 给新版前端读取 |
6. 僵尸 Run 检测
定义"在某个中间态停留太久"的识别规则、巡检触发条件、强制终态的处理策略。这是把状态机从"纸面契约"变成"运行时保险"的关键。
6.1 中间态停留阈值
| 状态 | 正常持续 | 僵尸阈值 | 强制终态 |
|---|---|---|---|
| CREATED | 毫秒级 | > 30 秒 | FAILED → CLOSED_FAILED |
| PREPARING | ≤ 2 秒 | > 60 秒 | FAILED → CLOSED_FAILED |
| PLANNING | ≤ 30 秒 | > 180 秒 | TIMEOUT → CLOSED_FAILED |
| POLICY_CHECK | 毫秒级 | > 30 秒 | FAILED → CLOSED_FAILED |
| AWAITING_HITL | 由 HITL 配置决定(默认 24h) | 超出 HITL 配置 | TIMEOUT → CLOSED_REJECTED(默认拒绝) |
| EXECUTING | 秒到分钟 | > 600 秒(Run 总耗时) | TIMEOUT → CLOSED_FAILED |
| TRACING | ≤ 1 秒 | > 60 秒 | FAILED → CLOSED_FAILED(视为 Trace 写入异常) |
| REJECTED / FAILED / TIMEOUT / CANCELLED | 毫秒级 | > 30 秒 | 强制 → TRACING → CLOSED_*(按瞬时态对应类型) |
6.2 巡检触发
独立的巡检任务(建议放在 Celery beat 或 K8s CronJob)每 30 秒扫一次中间态 Run。识别条件:
- Run.state ∈ 中间态集合
- now() - Run.last_state_change_at > 该状态阈值
- 不在 AWAITING_HITL 的合法挂起窗口内
6.3 强制终态处理流程
巡检发现僵尸 Run 后按以下顺序处理:
- 记录 AuditLog 关键事件
zombie_detected,包含 run_id / 当前状态 / 停留时长 / 阈值 - 用乐观锁尝试把 Run.state 推到对应瞬时态(已被并发写入则放弃)
- 瞬时态走 → TRACING → CLOSED_* 的常规收口路径
- 巡检任务自身也写 AuditLog 关键事件
zombie_recovered,标记 forced_by=zombie_sweeper
这条机制保证:即使 Harness 内部异常退出 / Celery worker 被 kill / DB 连接断 / 死锁导致 transition 没完成,最终也会有巡检兜底。Job.status 不会永远卡在 running。
6.4 与执行类超时的区别
EXECUTING 阶段的超时有两层防护:
- 第一层:工具调用控制器内部计时器到 600s 自动触发 transition(EXECUTING → TIMEOUT)
- 第二层:巡检任务发现 Run 还在 EXECUTING 但已超阈值,强制触发同样的 transition
正常情况下第一层就能处理,第二层只在第一层故障时兜底。两层都写各自的 AuditLog,便于区分"正常超时"还是"巡检兜底"。
7. 恢复语义
定义两类可恢复场景的处理边界:AWAITING_HITL 的恢复(继续原 Run)与 FAILED 的重试(产生新 Run)。明确"什么是恢复 / 什么是重试"。
7.1 AWAITING_HITL 的恢复
AWAITING_HITL 是唯一允许长时间挂起的状态,恢复时继续原 run_id。这背后有三条约束:
| 约束 | 原因 | 实现要求 |
|---|---|---|
| Run 上下文必须持久化 | 挂起期间 worker 可能重启,内存上下文会丢 | 进入 AWAITING_HITL 前把 RunContext 序列化到 DB;恢复时反序列化 |
| 恢复后跳过已完成步骤 | 不能重跑已写过 Trace 的 Plan / Policy 步骤 | RunContext 携带 already_done 标记,工具调用控制器从下一个步骤开始 |
| 审批决议必须先落库再驱动状态机 | 避免决议丢失导致用户以为批了但 Run 还卡着 | HITL 控制器先写 HITLDecisionTrace 再调 transition |
7.2 FAILED 的重试边界
FAILED 不是可恢复状态,必须通过 TRACING 落到 CLOSED_FAILED。如果业务需要重试,重试方有责任:
- 新建 RunRequest(产生新 run_id)
- 可选携带 retry_of=<原 run_id> 元数据,便于 Trace 关联
- 不允许把 CLOSED_FAILED 的 Run 改回 PREPARING / PLANNING
- 不允许复用原 run_id(保证终态唯一性 + 审计可追溯)
7.3 取消后的恢复
CLOSED_CANCELLED 同样不可恢复,与 FAILED 的重试边界一致:用户撤销后想再跑必须新建 Run。这条规则避免"撤销 → 反悔 → 把 Run 拉回来跑"导致状态机变复杂。
7.4 与 Memory 写回的耦合
恢复语义和 Memory 写回有一条隐含约束:失败 / 取消 / 拒绝路径不写长期 memory。原因是:
- FAILED 的 Run 还没产生有效经验,写入会污染 user_memory / agent_memory
- CANCELLED 的 Run 用户主动放弃,结论可能本身就是错的
- REJECTED 的 Run 是被拦截的危险操作,绝不能写"危险操作经验"
因此 Memory 写回钩子只挂在 SUCCEEDED 的 TRACING 阶段。与 RunState 终态四态一一对应,使写回路径无歧义。