保存任务状态,让流程暂停和继续
用单任务文件检查点保存报告与确认状态,在独立进程中继续,并核对已写入结果避免盲目重复。
保存任务状态,让流程暂停和继续
等待人工确认时,不必让一个进程永远挂着。可以先把准备好的报告、输入身份和当前阶段保存下来,退出进程;确认后再启动程序,读取记录继续执行。
本篇采用一个固定任务、单写入者的文件检查点。它不是通用持久化工作流引擎,不提供多用户并发、数据库事务或断电一致性保证。范围明确之后,才容易看清恢复为什么需要保存状态和核对副作用。
沿用顺序工作流中的 lesson.ts、model.ts、connection.ts 和练习文件。
保存什么状态
任务分为 awaiting-approval 和 completed。生成报告后保存源码摘要、完整报告和确认令牌。摘要用于发现输入变化,令牌绑定待确认的具体报告。
确认令牌不是账号认证或数字签名,只是教学程序用来关联本次输入和产物的标识。任何能编辑本地状态文件的人都能重算它,不能把它当成权限系统。
创建 src/checkpoint.ts:
import { createHash } from "node:crypto"
import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
import { join } from "node:path"
export type Checkpoint = {
version: 1
inputHash: string
report: string
approvalToken: string
status: "awaiting-approval" | "completed"
}
export const jobDir = join(process.cwd(), ".workflow-demo")
export function digest(text: string) {
return createHash("sha256").update(text).digest("hex")
}
export function tokenFor(inputHash: string, report: string) {
return digest(JSON.stringify({ inputHash, report }))
}
export async function saveCheckpoint(state: Checkpoint) {
await mkdir(jobDir, { recursive: true })
// 单任务、单写入者练习;临时文件与目标同目录,避免读到半份 JSON。
const temp = join(jobDir, "checkpoint.tmp")
await writeFile(temp, JSON.stringify(state, null, 2), "utf8")
await rename(temp, join(jobDir, "checkpoint.json"))
}
export async function loadCheckpoint(): Promise<Checkpoint | undefined> {
let raw: string
try {
raw = await readFile(join(jobDir, "checkpoint.json"), "utf8")
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return undefined
}
throw error
}
const value: unknown = JSON.parse(raw)
if (
typeof value !== "object" ||
value === null ||
!("version" in value) ||
value.version !== 1 ||
!("inputHash" in value) ||
typeof value.inputHash !== "string" ||
!("report" in value) ||
typeof value.report !== "string" ||
!("approvalToken" in value) ||
typeof value.approvalToken !== "string" ||
!("status" in value) ||
(value.status !== "awaiting-approval" && value.status !== "completed")
) {
throw new Error("检查点结构无效")
}
const state = value as Checkpoint
if (state.approvalToken !== tokenFor(state.inputHash, state.report)) {
throw new Error("检查点内容与确认令牌不一致")
}
return state
}
export async function saveReportOnce(report: string) {
const path = join(jobDir, "report.md")
try {
await writeFile(path, report, { encoding: "utf8", flag: "wx" })
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
throw error
}
if ((await readFile(path, "utf8")) !== report) {
throw new Error("已有报告与待保存内容不同,请人工核对")
}
}
}检查点先写同目录临时文件再重命名,避免常规读取时看到半份 JSON。这没有实现日志、fsync 或断电恢复协议;异常损坏时应拒绝继续,而不是猜测任务已经成功。
输出报告使用固定路径和独占创建。若文件已经存在,比较内容是否相同;相同才视作这份产物已经保存,不同则停止并要求人工核对。
创建可重复启动的入口
创建 src/resume-main.ts:
import { readFile } from "node:fs/promises"
import { join } from "node:path"
import {
digest,
jobDir,
loadCheckpoint,
saveCheckpoint,
saveReportOnce,
tokenFor,
} from "./checkpoint.js"
import { readLesson } from "./lesson.js"
import { askModel } from "./model.js"
const command = process.argv[2] || "start"
if (!["start", "approve"].includes(command)) {
throw new Error("命令只能是 start 或 approve")
}
const signal = AbortSignal.timeout(30_000)
const input = await readLesson(signal)
const inputHash = digest(JSON.stringify(input))
let state = await loadCheckpoint()
if (state && state.inputHash !== inputHash) {
throw new Error("源码已变化,不能沿用旧报告与确认;请使用独立的新任务目录")
}
if (!state) {
if (command !== "start") {
throw new Error("任务尚未准备,请先 start")
}
const report = await askModel(`解释以下练习函数:\n${input.source}`, signal)
state = {
version: 1,
inputHash,
report,
approvalToken: tokenFor(inputHash, report),
status: "awaiting-approval",
}
signal.throwIfAborted()
await saveCheckpoint(state)
}
if (state.status === "completed") {
const stored = await readFile(join(jobDir, "report.md"), "utf8")
if (stored !== state.report) {
throw new Error("已完成任务的报告缺失或被改变,请人工核对")
}
console.log("已完成,报告核对一致,没有重新生成或保存。")
} else if (command === "start") {
console.log(state.report)
console.log("等待人工确认。确认令牌:", state.approvalToken)
} else {
if (process.argv[3] !== state.approvalToken) {
throw new Error("确认令牌不匹配,未保存报告")
}
signal.throwIfAborted()
await saveReportOnce(state.report)
if (process.env.DEMO_CRASH_AFTER_WRITE === "true") {
throw new Error("教学模拟:报告已写入,但完成状态还没保存")
}
await saveCheckpoint({ ...state, status: "completed" })
console.log("已保存报告并记录完成。")
}先运行:
node --env-file=.env --import tsx src/resume-main.ts start程序生成报告并保存检查点,然后退出。阅读终端里的报告和确认令牌,将真实令牌替换到下面的命令中:
node --env-file=.env --import tsx src/resume-main.ts approve REPLACE_WITH_PRINTED_TOKEN这已经是第二个进程,不依赖上个进程仍在内存中的变量。报告保存在当前工作目录的 .workflow-demo/report.md;状态位于同目录的 checkpoint.json。
再次执行 start,等待状态下应展示原报告而不重新请求模型;完成后再次启动,应核对现有文件并报告已完成。
检查输入和批准有没有过期
生成之后修改 fixtures/sample.ts,再次运行应拒绝沿用旧报告。不能拿旧输入上的确认直接批准新输入产生的动作。
这份练习只管理一个任务。需要新任务时,使用完整项目的独立副本或明确归档旧任务,不要随手删除检查点后假定旧操作从未发生。
如果报告文件被手工改变,也不能自动覆盖。程序保留冲突并退出,让使用者决定正确产物。
模拟最容易漏掉的恢复窗口
可能出现这样的顺序:报告已经写入,但进程在保存 completed 之前退出。为了复现,在 .env 中设置:
DEMO_CRASH_AFTER_WRITE=true对一份刚准备好的任务执行批准,程序会在写入后故意抛错。关闭这个开关后,重新运行相同批准命令:应核对已有报告,保存完成状态,不重新生成或覆盖文件。
这是明确注入的进程失败场景,不是声称已经测试任意机器崩溃。若独占创建过程中只写出了部分内容,恢复时比较会失败,需要人工核对。
恢复的保证到哪里
这个实现只适合单任务、单写入者和固定报告文件。同一目录同时启动多个进程仍可能竞争,没有锁和提交事务。它也不解决发邮件、付款等外部副作用的幂等性。
若模型请求完成后、检查点写入前进程退出,再次启动可能重新调用模型。这里保证的重点是已保存报告的继续与固定文件结果核对,不是所有动作恰好执行一次。
跨进程开始、批准、重复批准、写后失败恢复、输入变化和输出冲突已通过教学模拟验证。完整持久化运行时需要进一步验证其存储、调度与副作用协议,不应把这一篇的文件例子直接搬成生产任务平台。