Agent Platform · Detail Design

RunState 状态机

把 Run 的运行轨迹从「散落字段 + Job.status 四值」收敛为 11 态严格状态机:所有失败、拒绝、取消都必经 TRACING 收口,Job.status 仅作为旧系统的只读投影。

返回文档目录查看 Harness 内核

11 个显式状态

覆盖创建、准备、执行、审批、收口四阶段,禁止"未知中间态"

终态必经 TRACING

SUCCEEDED / REJECTED / FAILED / CANCELLED 任何一条路径都先收口审计再落终态

Job.status 只读投影

RunState 是唯一真实状态源,Job.status 单向同步、不反向驱动 Harness

Core Flow Diagram
Happy Path
CREATED→PREPARING→PLANNING→POLICY_CHECK→EXECUTING→TRACING→SUCCEEDED
Reject
POLICY_CHECK / HITL→REJECTED→TRACING→CLOSED_REJECTED
Failure
EXECUTING→FAILED / TIMEOUT→TRACING→CLOSED_FAILED

详细设计说明

DESIGN DOCUMENT · RUNSTATE STATE MACHINE

用 11 态状态机让 Run 的真实运行轨迹可观测、可恢复、可审计

这份文档解决执行内核中最容易腐化的问题:状态散落在多个布尔字段和旧 Job.status 四值里,导致谁都说不清一个 Run 此刻"是在等审批、被拒了、还是死掉了"。RunState 把所有运行可能性收敛到 11 个显式状态、一张转移表,并通过强制收敛到 TRACING 解决"异常路径漏审计"。

设计主张

RunState 是 Harness 内核的唯一真实状态源。Policy / HITL / Tool 控制器以及失败 / 取消 / 超时 路径都读 RunState 做判断,Job.status 只作为旧系统兼容投影。任何 Run 在任何时刻都必须落在 11 个显式状态之一,禁止"未知中间态"。

Single Source of TruthTrace BarrierExplicit TerminalsLegacy ProjectionZombie Detection

评审关注点

  • Harness 是否还有路径反向读 Job.status
  • 所有终态前是否必经 TRACING 收口
  • 中间态停留超阈值是否能被巡检识别
  • AWAITING_HITL 与 FAILED 的恢复语义是否清晰
  • 异常落到哪个显式态,是否存在"漏定义"路径
D1 显式 11 态禁止未知中间态
D2 终态必经 TRACING异常路径不得绕过审计
D3 终态唯一4 个 CLOSED_* 不可逆
D4 Job.status 只投影不反向驱动状态机
D5 僵尸 Run 巡检中间态停留超阈值强终

1. 定位与概念

SECTION GOAL

把 11 个状态、4 个阶段、3 类终态在一张图里看清楚,回答 "RunState 是什么 / 谁拥有它 / 为什么严格"。本章是阅读其余章节的入口。

1.1 总体架构图

下图按四个阶段把 11 个状态拆开:阶段 1 主路径推进(CREATED → ... → EXECUTING)、阶段 2 收口 TRACING(任何路径必须穿过)、阶段 3 中间分支(FAILED / TIMEOUT / REJECTED / CANCELLED 是瞬时态,必须再走 TRACING)、阶段 4 最终关闭态(SUCCEEDED 与 3 个 CLOSED_* 不可逆)。

RunState 11 态分阶段视图
阶段 1 · 主路径 推进 5 步 FORWARD 阶段 2 · 收口 必经审计 TRACING 阶段 3 · 中间分支 瞬时态 TRANSIENT 阶段 4 · 最终关闭 不可逆 TERMINAL CREATED 入口 · run_id 写入 PREPARING 上下文 · Skill 装配 PLANNING LLM 分解步骤 POLICY_CHECK 规则 · 风险评估 AWAITING_HITL 等审批 · 可挂起 TRACING 所有路径汇聚 · 写 RunTrace / AuditLog REJECTED Policy / HITL 拒绝 FAILED Tool / LLM 异常 TIMEOUT Step / Run / HITL 超时 CANCELLED 用户主动取消 EXECUTING Tool 调用 · 可重试 SUCCEEDED 主路径终态 Job.status = done CLOSED_REJECTED 拒绝终态 Job.status = failed CLOSED_FAILED 失败终态 Job.status = failed CLOSED_CANCELLED 取消终态 Job.status = failed PATH LEGEND 主路径推进 通过 → 终态 拒绝路径 失败 / 超时
11 个状态分布在 4 个阶段:阶段 1 主路径 6 态(含 AWAITING_HITL / EXECUTING)+ 阶段 2 唯一收口态 TRACING + 阶段 3 四个瞬时态(REJECTED / FAILED / TIMEOUT / CANCELLED) + 阶段 4 四个最终态。所有非主路径都必须先回到 TRACING 再落最终态,没有路径可以绕过 TRACING 直接到 CLOSED_*。

1.2 三个典型时序场景

下图按时间从上到下画出三种最常见的运行轨迹:成功路径、Policy 拒绝、HITL 等待并超时。左侧四条 lifeline 头部色块可点击跳转到对应章节(状态定义 / 转移规则 / 终态收敛)。

RunState · 三个典型时序场景
StateMachine CH2 · 状态定义 Transition Guard CH3 · 合法转移 Trace Recorder CH4 · 终态收敛 Job Projector CH5 · 投影 Scenario A · 成功路径 CREATED → PREPARING → PLANNING → POLICY_CHECK(allow) → EXECUTING → TRACING → SUCCEEDED transition(CREATED → PREPARING) → PLANNING → POLICY_CHECK guard pass · risk=low → EXECUTING(双守门员通行) → TRACING · write RunTrace + AuditLog(run_succeeded) → SUCCEEDED · Job.status = done(单向投影) Scenario B · Policy 拒绝路径 CREATED → ... → POLICY_CHECK(deny) → REJECTED → TRACING → CLOSED_REJECTED → PREPARING → PLANNING → POLICY_CHECK guard fail · cost > budget · 命中黑名单 → REJECTED(瞬时态 · 不直接终止) → TRACING · write PolicyDecisionTrace + AuditLog(policy_rejected) → CLOSED_REJECTED · Job.status = failed(无 rejected 投影值) 关键不变量:拒绝事件先入 TRACING 才进终态;Job.status 不引入 rejected 枚举值,按对照表投到 failed。 Scenario C · HITL 等待 + 超时拒绝路径 POLICY_CHECK(high) → AWAITING_HITL → TIMEOUT → TRACING → CLOSED_REJECTED → POLICY_CHECK · risk=high → AWAITING_HITL(挂起 · Job.status 仍为 running) HITL 倒计时 无审批响应 → TIMEOUT(默认拒绝 · 不是失败) → TRACING · write HITLDecisionTrace(timeout=true) → CLOSED_REJECTED(HITL 超时默认拒绝 ≠ 执行失败)
三个场景共用同一组组件 lifeline,共用同一份转移规则,共用同一道 TRACING 收口。Scenario C 的关键差异:HITL 超时 = 默认拒绝,落 CLOSED_REJECTED 而不是 CLOSED_FAILED;Job.status 仍按对照表投到 failed(不为兼容旧字段而扩枚举)。

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 OSRunState 的唯一拥有者StateMachine.transition 是 Run.state 的唯一写入入口;跨 Run 协作链路(如 @团队展开后多个 Run)也只读 RunState 做调度
Tier 3 · Infrastructure不感知 RunStateLLM API / Skill SDK / Sandbox 只接收输入返回输出,不读不写 Run.state

这条边界保证了:旧页面继续看 Job.status 不会坏;新内核的状态语义不被旧字段污染;执行层换 LLM 或换沙箱时不会触发状态模型改造。


2. 11 个状态的定义

SECTION GOAL

逐一定义 11 个状态:进入条件、典型持续时长、允许的退出转移。本章是后续转移表、终态收敛、僵尸检测的依据。

2.1 主路径状态(6 个)

状态进入条件典型持续允许退出转移
CREATEDRun 管理器收到 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_HITLPolicy 判定 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 个)

状态进入条件典型持续允许退出转移
REJECTEDPOLICY_CHECK 命中黑名单 / 超预算 / 越权;或 AWAITING_HITL 收到拒绝决议毫秒级→ TRACING(强制)
FAILEDPREPARING / PLANNING / EXECUTING 阶段异常或重试耗尽毫秒级→ TRACING(强制)
TIMEOUTStep / Run 总耗时超阈值,或 AWAITING_HITL 倒计时到期毫秒级→ TRACING(强制)
CANCELLED用户主动停止(chat stop / API cancel)毫秒级→ TRACING(强制)

这四个状态是瞬时态而不是终态:唯一作用是把"为什么收口"的语义带进 TRACING,让审计能区分四类原因。新页面可以观察到 Run 短暂经过,旧 Job.status 此刻仍显示 running(它只在最终态才更新)。

2.4 最终关闭态(4 个)

状态进入条件典型持续允许退出转移
SUCCEEDEDTRACING 来自 EXECUTING 成功路径永久无(不可逆)
CLOSED_REJECTEDTRACING 来自 REJECTED 或 HITL 超时(默认拒绝)永久无(不可逆)
CLOSED_FAILEDTRACING 来自 FAILED 或执行类 TIMEOUT永久无(不可逆)
CLOSED_CANCELLEDTRACING 来自 CANCELLED永久无(不可逆)

四个最终态都不可逆。重试 Run 必须通过新建 RunRequest 完成(产生新 run_id),不允许把 CLOSED_FAILED 的 Run 改回 PREPARING。


3. 合法转移规则

SECTION GOAL

把"哪些转移合法 / 谁触发 / 什么守卫条件"写成一张完整的转移表。任何代码路径触发的转移如果不在此表,等同于状态机异常。

3.1 转移表(按 from 状态分组)

fromto触发方守卫条件
CREATEDPREPARINGRun 管理器RunRequest 字段完整
PREPARINGPLANNING上下文装配器memory 召回成功 + Skill schema 加载完毕 + token 不超预算
PREPARINGFAILED上下文装配器memory 召回异常 / 压缩失败仍超预算 / 跨 tenant 串数据被拦截
PLANNINGPOLICY_CHECK计划执行器LLM 返回有效 Plan
PLANNINGFAILED计划执行器LLM 重试耗尽 / Plan 解析失败
PLANNINGTIMEOUT计划执行器LLM 调用超出 Step 超时
POLICY_CHECKEXECUTINGHITL 控制器risk ∈ {low, medium} 自动通过
POLICY_CHECKAWAITING_HITLHITL 控制器risk = high,已发出审批请求
POLICY_CHECKREJECTEDHITL 控制器命中黑名单 / 越权 / 单 Run 成本超预算
AWAITING_HITLEXECUTINGHITL 控制器审批人决议 = approve
AWAITING_HITLREJECTEDHITL 控制器审批人决议 = reject
AWAITING_HITLTIMEOUTHITL 控制器倒计时到期且无决议(默认拒绝语义)
AWAITING_HITLCANCELLED用户入口用户撤销请求
EXECUTINGTRACING工具调用控制器所有步骤执行成功,Plan 终止
EXECUTINGFAILED工具调用控制器Tool / LLM 异常重试耗尽
EXECUTINGTIMEOUT工具调用控制器Run 总耗时超 600s(执行类超时)
EXECUTINGCANCELLED用户入口用户主动停止
REJECTEDTRACINGStateMachine 自动无(强制收口,不可绕过)
FAILEDTRACINGStateMachine 自动无(强制收口)
TIMEOUTTRACINGStateMachine 自动无(强制收口)
CANCELLEDTRACINGStateMachine 自动无(强制收口)
TRACINGSUCCEEDED审计追踪器入口为 EXECUTING 成功 + Trace 写入完成
TRACINGCLOSED_REJECTED审计追踪器入口为 REJECTED 或 HITL TIMEOUT
TRACINGCLOSED_FAILED审计追踪器入口为 FAILED 或执行类 TIMEOUT
TRACINGCLOSED_CANCELLED审计追踪器入口为 CANCELLED

3.2 不允许的转移(举例)

StateMachine.transition 实现层做白名单校验:传入 (from, to) 不在转移表则抛 InvalidTransitionError,并写 AuditLog 记录非法转移尝试,便于追查。

3.3 触发方与守卫的边界

触发方说明"谁调用 transition",守卫条件说明"调用前必须满足什么"。守卫不通过时,触发方有责任改走异常分支(FAILED / REJECTED / TIMEOUT),不允许悄悄不转移——这是僵尸 Run 的主要来源之一。


4. 终态收敛

SECTION GOAL

说明为什么要"必经 TRACING"、终态唯一性如何保证、异常如何映射到显式终态。这是 RunState 与普通 enum 字段最本质的区别。

4.1 必经 TRACING 的工程含义

TRACING 不是"建议步骤"而是路径漏斗。任何路径直达 CLOSED_* 都视为状态机异常,触发一票否决。这条约束带来三个工程收益:

4.2 异常如何落显式终态

"漏定义路径"是普通 enum 状态机的常见 bug。RunState 通过下表保证任何异常都有归属:

异常类别瞬时态最终态关键 Trace
Memory 装配失败 / 跨 tenant 串数据FAILEDCLOSED_FAILEDStepTrace(memory_retrieve, error)
LLM 异常 / Plan 解析失败FAILEDCLOSED_FAILEDToolInvocationTrace(llm, error)
Plan 阶段超时TIMEOUTCLOSED_FAILEDToolInvocationTrace(llm, timeout)
Policy 命中黑名单 / 超预算 / 越权REJECTEDCLOSED_REJECTEDPolicyDecisionTrace(rejected)
HITL 拒绝REJECTEDCLOSED_REJECTEDHITLDecisionTrace(reject)
HITL 超时(默认拒绝)TIMEOUTCLOSED_REJECTEDHITLDecisionTrace(timeout=true)
Tool 调用失败 / 重试耗尽FAILEDCLOSED_FAILEDToolInvocationTrace(error, retry_count)
Run 总耗时超 600sTIMEOUTCLOSED_FAILEDRunTrace(timeout)
用户主动停止CANCELLEDCLOSED_CANCELLEDAuditLog(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 = ? 的乐观锁,保证:


5. Job.status 投影

SECTION GOAL

把 11 个 RunState 单向映射到旧 Job.status 的 4 值,定义只读投影规则,明确 Job.status 不再扩展枚举。

5.1 投影对照表

RunStateJob.status(legacy)语义解释
CREATED / PREPARING / PLANNING / POLICY_CHECKpending尚未开始执行任何副作用
EXECUTING / AWAITING_HITL / TRACINGrunning正在跑或等待审批,对旧系统是"还没结束"
REJECTED / FAILED / TIMEOUT / CANCELLED(瞬时)running瞬时态,旧系统看到的是"还在跑",下次轮询才转 failed
SUCCEEDEDdone主路径成功
CLOSED_REJECTED / CLOSED_FAILED / CLOSED_CANCELLEDfailed三种关闭原因都映射到旧字段的 failed(旧字段不区分原因)

5.2 单向同步规则

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 检测

SECTION GOAL

定义"在某个中间态停留太久"的识别规则、巡检触发条件、强制终态的处理策略。这是把状态机从"纸面契约"变成"运行时保险"的关键。

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。识别条件:

6.3 强制终态处理流程

巡检发现僵尸 Run 后按以下顺序处理:

  1. 记录 AuditLog 关键事件 zombie_detected,包含 run_id / 当前状态 / 停留时长 / 阈值
  2. 用乐观锁尝试把 Run.state 推到对应瞬时态(已被并发写入则放弃)
  3. 瞬时态走 → TRACING → CLOSED_* 的常规收口路径
  4. 巡检任务自身也写 AuditLog 关键事件 zombie_recovered,标记 forced_by=zombie_sweeper

这条机制保证:即使 Harness 内部异常退出 / Celery worker 被 kill / DB 连接断 / 死锁导致 transition 没完成,最终也会有巡检兜底。Job.status 不会永远卡在 running。

6.4 与执行类超时的区别

EXECUTING 阶段的超时有两层防护:

正常情况下第一层就能处理,第二层只在第一层故障时兜底。两层都写各自的 AuditLog,便于区分"正常超时"还是"巡检兜底"。


7. 恢复语义

SECTION GOAL

定义两类可恢复场景的处理边界: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。如果业务需要重试,重试方有责任:

7.3 取消后的恢复

CLOSED_CANCELLED 同样不可恢复,与 FAILED 的重试边界一致:用户撤销后想再跑必须新建 Run。这条规则避免"撤销 → 反悔 → 把 Run 拉回来跑"导致状态机变复杂。

7.4 与 Memory 写回的耦合

恢复语义和 Memory 写回有一条隐含约束:失败 / 取消 / 拒绝路径不写长期 memory。原因是:

因此 Memory 写回钩子只挂在 SUCCEEDED 的 TRACING 阶段。与 RunState 终态四态一一对应,使写回路径无歧义。