Maka Runtime Resume Phase 0 崩溃契约:基于已提交 RuntimeEvent 前缀的确定性回放安全
【免费下载链接】makaApache Maka (Incubating) is a high-performance agent workspace that keeps a complete record of everything it did.项目地址: https://gitcode.com/GitHub_Trending/mak/maka
Phase 0 是 Maka(Apache Maka, Incubating)崩溃恢复体系的第一道闸门:它只回答一个问题——当进程在任意时刻被杀死后,仅凭已经完整落盘的RuntimeEvent前缀,系统能否安全地把这段历史重放给模型。本文以 docs/architecture/runtime-resume-phase0-crash-contract.md 为骨架,结合 packages/runtime/src/runtime-resume.ts 与 packages/runtime/src/tests/runtime-resume-crash.test.ts 的源码实现,完整解析 P0–P11 稳定故障注入点、四条已提交前缀的判定规则、真实子进程 SIGKILL 测试方法论,以及 Phase 0 明确不承诺的能力边界。读完本文,你将掌握 Maka 如何在"崩溃后工具副作用状态未知"这一最危险场景下做到确定性、可测试、fail-closed 的恢复决策。
为什么需要 Phase 0:崩溃后"缺失结果"至少有四种解释
当一个 Agent 正在调用工具(例如通过Bash执行touch marker)时进程崩溃,重启后系统面对一个缺失的工具结果,至少有四种互斥的解释:
- 工具从未启动;
- 工具启动了但没有写入任何东西;
- 副作用已经完成(文件已写入),但结果事务未提交;
- 文件被写入后又被人或其他进程改掉了。
盲目重试会重复第 3 种情况下的副作用;盲目宣布成功则会给模型一个在第 1、2、4 种情况下完全虚假的历史。因此,恢复必须基于不可变的事实而非猜测。Phase 0 的全部工作就是:只读取已提交的RuntimeEvent前缀,把它投影成ToolOperation,再产出一个ResumePlan——要么safe_replay,要么blocked。
与完整恢复体系的关系:Phase 0 不恢复执行、不调和工具副作用、也不引入未来的 SQLite 工具日志(tool journal)。这些属于后续阶段,见 docs/architecture/runtime-resume-architecture.md 中 Phase 0–4 的阶段划分。
生产 API:一条纯函数链路
Phase 0 的生产 API 是纯的(pure),即同样的输入永远得到同样的输出,且投影过程不修改任何持久化数据:
committed RuntimeEvent prefix -> ToolOperation projection -> ResumePlan -> safe_replay or blocked这条链路在源码中的落点非常清晰:
projectToolOperationsFromRuntimeEvents(events)先将事件交给resolveRuntimeRecovery得到恢复决策,再投影出ToolOperation[];buildResumePlanFromRuntimeEvents(events, options)聚合投影、诊断、拒绝原因,最终计算出disposition: 'safe_replay' | 'blocked'(见 packages/runtime/src/runtime-resume.ts);- 决策的底层依据来自
RecoveryResolver(resolveRuntimeRecovery,见 packages/runtime/src/recovery-resolver.ts),它把RuntimeEvent前缀解释为completed / parked / definitely_not_dispatched / indeterminate / corruption五类工具状态。
ResumePlan的关键字段(源码中定义于 packages/runtime/src/runtime-resume.ts):
| 字段 | 含义 |
|---|---|
disposition | safe_replay或blocked |
operations | 每个工具调用的投影结果(succeeded/failed/indeterminate/not_dispatched/parked/corruption) |
diagnostics | 人类可读的诊断码与消息 |
rejectionReasons | 机器可读的拒绝原因(如dangling_tool_state、runtime_offset_mismatch) |
requiresVerification | 是否存在未解决的工具副作用需要人工核验 |
sourceRuntimeEventHighWater | 该计划覆盖的不可变日志水位 |
replayRuntimeEvents | 重放给 provider 的合法事件子集 |
稳定故障注入点:P0–P11 目录
RUNTIME_RESUME_FAILPOINTS是机器可读的权威事实来源,其 TypeScript 常量定义于 packages/runtime/src/runtime-resume.ts。其中committedPrefix列表示崩溃后最后一个完整提交的RuntimeEvent前缀。它刻意不假装未来的 T1/T2 日志已经存在——崩溃发生在哪个阶段,就只承认哪个阶段的事实。
| ID | 注入边界 | 最后完整提交的 RuntimeEvent 前缀 |
|---|---|---|
| P0 | 工具准备(T1)之前 | before_function_call |
| P1 | function_call 已提交,prepared journal 未提交 | after_function_call |
| P2 | prepared journal 已提交,实现未开始 | after_function_call |
| P3 | 工具实现进行中 | after_function_call |
| P4 | 副作用完成,outcome 事务(T2)未提交 | after_function_call |
| P5 | function_response 已提交,outcome journal 未提交 | after_function_response |
| P6 | outcome 已提交,结果未送达模型 | after_function_response |
| P7 | 结果已送达,下一步 provider 调用未开始 | after_function_response |
| P8 | terminal RuntimeEvent 提交 | after_function_response |
| P9 | terminal run-header 提交 | after_terminal_event |
| P10 | 恢复决策提交 | after_terminal_event |
| P11 | continuation-run 创建 | after_terminal_event |
关于 P8 需要特别注意:Phase 0 只对 terminal append之前的前缀做推理,terminal 之后的合法前缀由 P9 表示。一条撕裂(torn)的 JSON 行属于存储损坏(storage corruption),不是合法的已提交前缀,绝不能把它升级为恢复事实——这正是"fail-closed"的第一道体现。
测试中对该目录的稳定性有专门断言:runtime-resume.test.ts验证RUNTIME_RESUME_FAILPOINTS恰好包含 P0–P11 十二个 ID,且去重后的 committedPrefix 恰好是before_function_call、after_function_call、after_function_response、after_terminal_event四种(见 packages/runtime/src/tests/runtime-resume.test.ts)。
必选决策:四种前缀如何判定
Phase 0 对每种已提交前缀给出确定性的判定结果:
| 前缀 | 期望结果 |
|---|---|
before_function_call | safe_replay;不存在任何工具操作 |
after_function_call | blocked;操作状态为indeterminate;拒绝原因为dangling_tool_state;未解决的调用不出现在 provider 重放历史中 |
after_function_response | safe_replay;操作状态为succeeded或failed;调用与响应在 provider 重放中保持配对 |
after_terminal_event | 工具判定与前缀相同;terminal 事实保留在 canonical ledger 中 |
此外,若重新打开的已提交前缀与期望的 RuntimeEvent 高水位(high-water)不一致,一律以runtime_offset_mismatch拒绝。源码中collectResumeDiagnostics会在expectedRuntimeEventHighWater !== events.length时产出该诊断(见 packages/runtime/src/runtime-resume.ts)。
源码视角:这些决策是如何算出来的
buildResumePlanFromRuntimeEvents的判定逻辑(见 packages/runtime/src/runtime-resume.ts)可以概括为:
disposition = safe_replay 当且仅当 rejectionReasons 为空 且 无 requiresVerification(没有 indeterminate 操作) 且 无 recovery.hasCorruption 否则 = blocked拒绝原因dangling_tool_state由deriveRejectionReasons统一归并(见 packages/runtime/src/runtime-resume.ts):以下任何诊断都会映射到它——pending_tool_result(function_call 无匹配的已提交 function_response)、tool_not_dispatched、tool_recovery_parked、tool_recovery_corruption、tool_ledger_corruption、duplicate_event_id、semantic_lane_conflict、protocol_marker_invalid、unmatched_tool_result、tool_name_mismatch。也就是说,"悬空工具状态"不是单一情形,而是一整类不确定状态的共同落点,任何一类出现都会让重放被阻断。
provider 重放历史如何构建
buildResumeReplayRuntimeEvents(见 packages/runtime/src/runtime-resume.ts)按三条规则裁剪重放事件:
- 丢弃
partial事件(流式中间态); - 丢弃
modelVisibility === 'hidden'的事件(如 T1 dispatch 等系统内部事实); - 只保留已配对的
function_call与function_response——未解决的调用绝不会被喂回 provider。
这与契约表完全一致:after_function_call前缀重放时只包含 user 事件,调用被排除;after_function_response前缀重放时 user/call/response 三者保持配对。
进程级测试 Harness:真实 SIGKILL 下的语义验证
Phase 0 的崩溃测试必须使用真实文件后端的RuntimeEventStore(createWorkspaceRuntimeStore),而不是内存 mock。契约规定的九个步骤在 packages/runtime/src/tests/runtime-resume-crash.test.ts 中被完整实现:
- 创建临时工作区(
mkdtemp); - 启动一个子 Node.js 进程(
spawn(process.execPath, ...),以环境变量MAKA_RUNTIME_RESUME_CRASH_CHILD=1进入崩溃子进程模式); - 通过
RuntimeEventStore.appendRuntimeEvent写入该 failpoint 的完整前缀; - 只有当所有 append promise resolve 之后,子进程才向 stdout 输出
READY通知父进程; - 父进程用
SIGKILL终止子进程(Windows 下经terminateChildProcessTree处理进程树); - 断言
finally清理标记文件(child-finally-ran)未被写入——这证明 kill 发生在追加全部完成之后、任何清理之前,正是"已提交前缀"的精确位置; - 用新的
RuntimeEventStore实例重新打开工作区,读回事件并断言与提交前缀逐 ID 一致; - 对重开的前缀投影两次,要求两次的
ResumePlan深度相等(assert.deepEqual(second, first)),验证确定性; - 再次读取 ledger,断言投影没有改动持久化数据(
assert.deepEqual(await reopened.readRuntimeEvents(...), recoveredEvents))。
该 harness 在 Windows、macOS、Linux 三个平台上覆盖全部十二个稳定 failpoint ID。它验证的是进程崩溃恢复语义,而不是断电持久性或文件系统fsync保证——后者需要单独的硬件与文件系统层测试。
针对每种前缀,assertResumePlanForPrefix(见 packages/runtime/src/tests/runtime-resume-crash.test.ts)还做了行为断言:
after_function_call:disposition === 'blocked'、操作status === 'indeterminate'、rejectionReasons === ['dangling_tool_state']、重放事件仅['user'];before_function_call:safe_replay、无操作;after_function_response/after_terminal_event:safe_replay、操作status === 'succeeded'。
Phase 边界:Phase 0 不做什么
Phase 0不改变任何工具执行行为。以下能力明确超出范围:
- 自动续跑(automatic continuation);
- 工作区恢复(workspace restoration);
- T1/T2 事务化工具边界;
- 副作用对账(side-effect reconciliation);
- 幂等工具重执行;
- 以 SQLite 作为 canonical 的 RuntimeEvent 与工具日志存储。
这些能力依赖后续阶段。Phase 0 的定位是:仅基于当前可得证据,让恢复决策变得确定且 fail-closed。也就是说,它在"证据不足"与"证据充分"之间的任何模糊地带,一律选择阻断(blocked/park),而不是猜测"应该没事"。
与后续阶段的关系
从 docs/architecture/runtime-resume-architecture.md 可以看到,Phase 0 是五阶段恢复路线的第一环:Phase 0 解释已提交历史 → Phase 1 在完整安全边界上创建新执行(RuntimeContinuationPlanner)→ Phase 2 用 SQLite 的 T1/T2 收窄副作用窗口 → Phase 3A 原子化提交恢复事实 → 后续 Phase 3/4 做工具级证据与工作区检查点。Phase 2 不会取代 Phase 0/1,它只是给这些闸门提供更精确的"是否跨过工具派发边界"的证据。
相关延伸资料:
- Runtime Resume 总体架构
- Phase 1 安全边界契约
- RecoveryResolver ADR
- RecoveryResolver 实现
- Phase 0 单元与契约测试
- Phase 0 进程崩溃 harness 测试
小结:Phase 0 一句话契约
Phase 0 的价值不在于"崩溃后能自动继续",而在于让恢复决策本身变得可证明、可测试、可复现:
- 只承认完整提交的
RuntimeEvent前缀,撕裂行永远不算数; - 只有四种合法前缀,每种前缀的
ResumePlan结果完全确定; - 任何悬空工具状态一律
blocked并给出稳定的机器可读原因(dangling_tool_state等); - 投影是纯函数:重复投影结果一致,且绝不改写持久化 ledger;
- 用真实子进程 + SIGKILL + 文件后端 store 在三大平台验证语义,且明确不把"进程崩溃"与"断电持久性"混为一谈。
这是整个 Maka 恢复体系"fail-closed"的基石:先证明安全,才允许重放。
【免费下载链接】makaApache Maka (Incubating) is a high-performance agent workspace that keeps a complete record of everything it did.项目地址: https://gitcode.com/GitHub_Trending/mak/maka
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考