LinOnward / Agent TutorialAgent Tutorial
章节17 / 18
教程整体目标

通过逐章可运行的增量,理解 Agent 的模型、上下文、任务状态、规划、工具、Skills 与 Agent Loop 等关键组件,并从零构建一个由 Harness 约束、不依赖 Agent 框架的可用 Agent。

16

长程任务恢复

通过事件日志、原子检查点和幂等工具语义,让中断任务安全续跑。

本页目录
本章任务在工具副作用后注入崩溃,恢复同一 runId,并确保副作用只发生一次。
章节目标让 Agent 在进程崩溃、机器重启或部署切换后,从最后一个一致状态恢复,并避免重复执行有副作用的工具。
开始之前

上一章已经能生成带事件范围与校验和的 CompactionSnapshot,Agent Loop 的每个重要状态转换都有事件;下一章将用故障注入统一验收恢复链路。

本章涉及文件
  • src/run-store.ts
  • src/checkpoint.ts
  • src/recovery.ts
  • src/agent-loop.ts
  • src/index.ts
  • tests/recovery.test.ts

上下文压缩解决“一次运行能思考多久”,长程恢复解决“运行进程消失后还能不能正确继续”。只把 AgentState 写进 JSON 不够:崩溃可能发生在写文件、执行命令、提交工具结果或更新计划之间。恢复器必须知道最后一个已提交事件、是否存在执行到一半的副作用,以及哪些外部资源仍可信。

本章使用 append-only event log + 原子 checkpoint + 单写者 lease。Checkpoint 加速加载,事件日志提供审计与重放;二者共同保证恢复的不是“看起来相似的会话”,而是同一个任务状态机。

Step 1区分可持久化状态与运行时依赖

不要序列化整个 AgentLoopOptions。函数、连接池、AbortSignal、工具实现、API Key 和打开的文件句柄都只能在恢复时重新注入。

typescript
export interface DurableAgentState {  runId: string;  task: string;  cwd: string;  status: AgentState["status"];  plan: TaskPlan;  activeStepId?: string | undefined;  planHistory: AgentState["planHistory"];  messages: AgentMessage[];  contextSources: PersistedContextSource[];  skills: PersistedSkillState;  compaction: CompactionState;  changedFiles: string[];  changedFileHashes: Record<string, string>;  mutationRevision: number;  validations: ValidationRecord[];  requiredCriterionIds: string[];  budget: AgentBudget;  pendingUserInput?: UserInputRequest | undefined;  goalVersion: number;  constraints: string[];  failedAttempts: string[];  events: AgentEvent[];  nextEventSequence: number;  stopReason: string | undefined;  providerCursor?:    | { provider: string; model: string; previousResponseId: string }    | undefined;}export interface AgentRuntime {  model: ModelDriver & {    canResume?(cursor: DurableAgentState["providerCursor"]): Promise<boolean>;  };  planner: Planner;  tools: ToolRegistry;  trace: TraceSink;  store: RunStore;  clock: Clock;  skillsDirectory: string;  toolTimeoutMs: number;  signal?: AbortSignal;}

budget 保存上限和已消费计数,恢复时不能重置;验证记录必须和 mutationRevision、文件 hash 一起恢复,不能只保留文件名。pendingUserInput 与 waiting 状态也必须成对存在。持久化 providerCursor 只是优化提示。恢复时必须确认 Provider、模型、保留策略和游标仍兼容;否则从应用快照、权威任务状态与原始尾部启动一个新 response。previous_response_id 不能替代事件日志,因为它不保存你的本地文件状态、计划事务和工具副作用。

Secret 永远只保存引用,例如 credentialRef: "openai/default",不把真实值写进事件或 checkpoint。

Step 2定义带版本的事件与检查点

所有记录必须有单调 sequence、schema 版本和完整性校验。事件 payload 使用可辨识联合类型:

typescript
export type DurableEvent =  | { type: "run_started"; task: string }  | { type: "plan_updated"; plan: TaskPlan }  | { type: "model_completed"; responseId: string; turn: PersistedModelTurn }  | { type: "tool_intent"; call: PersistedToolCall; idempotencyKey: string }  | { type: "tool_result"; callId: string; idempotencyKey: string; output: string }  | { type: "compaction_completed"; snapshot: CompactionSnapshot }  | { type: "run_waiting"; reason: string }  | { type: "run_stopped"; reason: StopReason };export interface EventRecord {  schemaVersion: 1;  runId: string;  sequence: number;  eventId: string;  recordedAt: string;  event: DurableEvent;  checksum: string;}export interface RunCheckpoint {  schemaVersion: 1;  runId: string;  throughSequence: number;  savedAt: string;  state: DurableAgentState;  checksum: string;}

以后修改结构时新增迁移函数,按 schemaVersion 逐级升级;不知道的未来版本必须拒绝加载,不能用类型断言强行继续。eventId 用于去重,sequence 用于排序和发现缺口,checksum 用于发现截断或损坏。

Step 3先追加事件,再原子替换 Checkpoint

定义一个与存储介质无关的接口:

typescript
export interface RunStore {  append(input: {    runId: string;    expectedSequence: number;    ownerId: string;    epoch: number;    event: DurableEvent;  }): Promise<EventRecord>;  loadCheckpointHistory(runId: string, limit: number): Promise<RunCheckpoint[]>;  saveCheckpoint(checkpoint: RunCheckpoint, lease: RunLease): Promise<void>;  readEvents(runId: string, afterSequence: number): Promise<EventRecord[]>;  acquireLease(runId: string, ownerId: string, ttlMs: number): Promise<RunLease>;  renewLease(lease: RunLease, ttlMs: number): Promise<RunLease>;  releaseLease(lease: RunLease): Promise<void>;}

本地文件实现不能只覆盖唯一的 checkpoint.json,否则损坏后没有可回退版本。将每版写到 checkpoints/<throughSequence>.json,完成 fsync 后原子更新只含 currentprevious 的 manifest,并至少保留最后两个已校验版本。单个 checkpoint 的原子写入如下:

typescript
export async function atomicWriteJson(path: string, value: unknown): Promise<void> {  const directory = dirname(path);  const temporary = join(directory, `.checkpoint-${crypto.randomUUID()}.tmp`);  const handle = await open(temporary, "wx", 0o600);  try {    await handle.writeFile(`${canonicalJson(value)}\n`, "utf8");    await handle.sync(); // fsync file contents  } finally {    await handle.close();  }  await rename(temporary, path);  const directoryHandle = await open(directory, "r");  try {    await directoryHandle.sync(); // fsync directory entry  } finally {    await directoryHandle.close();  }}

事件先落盘,状态机再应用事件,最后异步或按边界写 checkpoint。即使最新 checkpoint 损坏,loadCheckpointHistory 仍能返回上一份完整版本并重放后续事件。

expectedSequence 防止事件交错,ownerId + epoch 则提供 fencing。Store 的每一次 append 与 checkpoint 保存都必须在事务边界比较当前 lease;旧 worker 即使暂停后重新运行,也会因 epoch 过期被拒绝。外部系统未必理解本地 epoch,因此其副作用仍必须依靠幂等键或事后 verifier,不能把 lease 当成跨系统事务。

Step 4用意图与结果包围工具副作用

最危险的窗口是:工具已经执行成功,但进程在写入 observation 前崩溃。恢复后盲目重跑可能重复发消息、重复扣款或覆盖文件。

typescript
export async function executeDurably(options: {  state: DurableAgentState;  call: PersistedToolCall;  tool: ToolDefinition;  store: RunStore;  lease: RunLease;}): Promise<string> {  const idempotencyKey = `${options.state.runId}:${options.call.callId}`;  await options.store.append({    runId: options.state.runId,    expectedSequence: nextSequence(options.state),    ownerId: options.lease.ownerId,    epoch: options.lease.epoch,    event: { type: "tool_intent", call: options.call, idempotencyKey },  });  const output = await options.tool.execute(options.call.arguments, { idempotencyKey });  await options.store.append({    runId: options.state.runId,    expectedSequence: nextSequence(options.state),    ownerId: options.lease.ownerId,    epoch: options.lease.epoch,    event: { type: "tool_result", callId: options.call.callId, idempotencyKey, output },  });  return output;}

为工具声明恢复策略:

typescript
export type RecoveryPolicy =  | { kind: "replay_safe" }  | { kind: "idempotent"; keyLocation: "header" | "argument" }  | { kind: "verify_then_continue"; verify: ToolStateVerifier }  | { kind: "manual_reconciliation" };

纯读取通常是 replay_safe;支持 idempotencyKey 的外部 API 可以安全重试;文件写入应比较预期 hash 或读取目标状态;无法查询结果且没有幂等键的副作用必须暂停,要求人工核对。绝不能因为“多数时候没事”就把未知调用标记完成。

Step 5校验 Checkpoint 并重放后续事件

恢复器先拿 lease,再从新到旧选择第一份 checksum 和 schema 都有效的 checkpoint,随后重放事件:

typescript
export async function restoreRun(options: {  runId: string;  ownerId: string;  store: RunStore;}): Promise<{ state: DurableAgentState; lease: RunLease; inFlight: PersistedToolCall[] }> {  const lease = await options.store.acquireLease(options.runId, options.ownerId, 30_000);  const checkpointHistory = await options.store.loadCheckpointHistory(options.runId, 2);  const checkpoint = firstValidCheckpoint(checkpointHistory);  if (!checkpoint) throw new Error("no_valid_checkpoint");  let state = migrateCheckpoint(checkpoint).state;  const eventsThroughCheckpoint = await options.store.readEvents(options.runId, 0);  const records = await options.store.readEvents(options.runId, checkpoint.throughSequence);  assertContiguousSequences(records, checkpoint.throughSequence + 1);  for (const record of records) {    verifyEventChecksum(record);    state = reduceDurableEvent(state, record.event);  }  return {    state,    lease,    inFlight: findToolIntentsWithoutResults([      ...eventsThroughCheckpoint.filter((record) => record.sequence <= checkpoint.throughSequence),      ...records,    ]),  };}

事件 reducer 必须是纯函数;对同一 checkpoint 与事件序列重复执行,结果必须完全一致。这里从完整事件历史计算未配对的 tool_intent,因此不会漏掉已经包含进 checkpoint、但尚无 tool_result 的调用。更高效的实现可以把 open intents 作为 checkpoint 的权威字段,但仍要用事件回放测试它。遇到 sequence 缺口、重复但内容不同的 event、所有 checkpoint 都损坏或未知 schema 时停止恢复并保留原文件供调查。

Checkpoint 的 cwd 也需要重新验证:路径存在、仓库身份匹配、关键基线 hash 没有意外漂移。环境漂移不代表一定失败,但必须转成显式 observation,触发重新调查或计划修订。

Step 6协调未完成调用并续跑 Loop

恢复入口先处理 in-flight 工具,再调用已有 Loop:

typescript
export async function resumeAgentRun(  runId: string,  runtime: AgentRuntime,): Promise<AgentResult> {  const restored = await restoreRun({    runId,    ownerId: crypto.randomUUID(),    store: runtime.store,  });  startLeaseHeartbeat(restored.lease, runtime.clock, runtime.signal);  for (const call of restored.inFlight) {    await reconcileInFlightTool(call, restored.state, runtime);  }  const canReuseProviderCursor = await runtime.model.canResume?.(    restored.state.providerCursor,  );  if (!canReuseProviderCursor) restored.state.providerCursor = undefined;  return runAgentLoopFromState(restored.state, runtime);}

runAgentLoopFromState 不重新创建任务或计划,也不把计数器归零。它从已有 activeStepId、预算、CompactionSnapshot 与 raw tail 继续;如果 Provider cursor 无效,就用重建上下文调用 model.start,而不是伪造一次 continue

CLI 明确区分新运行与恢复:

typescript
const [command, value] = process.argv.slice(2);if (command === "run") {  await startAgentRun(value, runtime);} else if (command === "resume") {  await resumeAgentRun(value, runtime);} else {  throw new Error("usage: agent run <task> | agent resume <run-id>");}

Lease 要定期续期,并在正常停止时释放。Lease 过期只允许新 worker 抢占,不代表旧 worker 一定死亡;所有 append 仍要比较 owner/epoch,防止暂停后的旧进程恢复运行并写入分叉历史。

Step 7在每个提交边界注入崩溃

恢复逻辑不能只测“保存后立即读取”。Fake Store 应允许在每个 await 后抛出模拟断电:

typescript
it.each([  "after_tool_intent",  "after_tool_effect",  "after_tool_result",  "before_checkpoint_rename",  "after_checkpoint_rename",])("recovers safely from %s", async (crashPoint) => {  const fixture = createCrashFixture(crashPoint);  await expect(fixture.start()).rejects.toThrow("simulated_crash");  const result = await resumeAgentRun(fixture.runId, fixture.restartedRuntime());  expect(result.status).toBe("completed");  expect(fixture.externalEffectCount()).toBe(1);  expect(fixture.eventSequences()).toEqual(fixture.contiguousSequences());});

继续覆盖:两个 worker 争抢 lease、旧 epoch 写入被拒绝、损坏 checkpoint 回退到上一版本、event sequence 缺口、schema migration、无幂等能力的 in-flight 工具转人工核对、Provider cursor 失效后从快照重启,以及恢复后仍遵守原 maxStepsmaxToolCalls