Agent 应用开发会员账号
← 返回研究目录

RUNTIME

长任务怎么恢复:从宕机现场到可运行 Harness

从检查点与租约深入到人工审批、取消和未知结果核对;给出可运行代码、宕机复现和 Java 服务边界。

01 · 先看失败现场:为什么“重试一次”会生成两份报告

假设你要做一个公司内部技术研究助手。它读取两份获准材料,生成带来源 ID 的摘要,然后调用报告服务保存。任务可能运行数十分钟,调度消息可能重复投递,模型请求也可能超时。业务真正关心的是:已做完的工作不丢失,未知状态能解释,同一份报告不被重复写入。

时间 Worker A 的动作 可靠事实
T0 读取材料,生成摘要 只有内存结果,进程退出即可丢失
T1 报告服务保存成功 远端已有报告 R1
T2 回执尚未写进任务库,进程被终止 本地仍显示 publish 未完成
T3 Worker B 接管,重试 publish 若每次生成新操作键,远端可能新增 R2

这里的问题与模型是否聪明无关。即使输出完全确定,T1 到 T2 之间仍然有跨系统提交空隙。本地事务只能约束本地数据库,不能把另一个服务已经发生的写入回滚。解决时必须分别回答两件事:运行时从哪里恢复;业务写入重复到达时如何处理。

本篇结论: 用检查点确定下一步,用租约代次拒绝旧执行者写回,用稳定操作键和远端持久回执处理重试。三个机制各自承担不同职责,缺一个就有对应的故障窗口。

02 · 一个可验收的案例:四步、两个数据库、明确交付物

本次统一案例是固定四步研究流程。它用于验证 Agent 的执行外壳(Harness):调度、状态、工具结果和恢复边界。默认 draft 使用确定性替身,便于复现;可选切换 HTTP 模型。它没有让模型动态规划工具路线,因此不能用这套结果证明自主 Agent 的规划能力。

步骤 输入 → 输出 允许重做吗
collect 任务内冻结的两段材料 + 有效记忆 → 上下文快照 允许;纯本地读取
draft 上下文快照 → summary / citations / language 已提交则跳过;未提交重做可能再次产生模型费用
verify 草稿 + 允许来源集合 → 结构与引用 ID 校验结果 允许;未通过禁止进入 publish
publish 已提交的草稿 → 模拟报告服务回执 请求可以重试;同操作键仅保留一份业务记录

运行后得到 lab.sqlite(任务、步骤、事件和记忆)及 lab.sqlite.publisher.sqlite(模拟外部报告服务)。刻意用两个独立数据库,让远端写入与本地检查点无法共享事务。网页展示的恢复结果就是从这两个库实际读取,材料内容是合成样例。

同一套案例,三篇文章共用

Python 3.10+ · 标准库 · 默认离线 · 含源代码、36 项测试和运行记录

下载完整实验包 ZIP运行说明

  1. 新建空目录,解压下载包;进入包含 cli.py 的目录。
  2. 执行下面命令。默认无需 pip install、账户或 API Key。
  3. 用 inspect 检查 4 个检查点,再用 evaluate.py 查看机械验收结果。

第一轮:正常路径

python3 cli.py memory-put
python3 cli.py submit
python3 cli.py run
python3 cli.py inspect
python3 evaluate.py
python3 -m unittest discover -s . -p test_lab.py -v

预期 run 输出 succeeded。评分 JSON 中 passed 为 true,而 semantic_support 与 model_quality 为 not_scored。后两个字段刻意保留:引用 ID 存在不等于语义正确,离线替身也不能代表真实模型质量。

03 · 数据库设计:每个字段都对应一个故障问题

runtime.py · 实际使用的表结构

SCHEMA = '''
CREATE TABLE IF NOT EXISTS jobs(
 id TEXT PRIMARY KEY, spec TEXT NOT NULL, input_hash TEXT NOT NULL,
 status TEXT NOT NULL, generation INTEGER NOT NULL DEFAULT 0, owner TEXT,
 lease_until REAL NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0,
 available_at REAL NOT NULL DEFAULT 0, scope TEXT NOT NULL,
 memory_rev INTEGER NOT NULL, memory_expires REAL NOT NULL,
 deadline REAL NOT NULL, error TEXT);
CREATE TABLE IF NOT EXISTS checkpoints(
 job_id TEXT NOT NULL REFERENCES jobs(id), step TEXT NOT NULL, payload TEXT NOT NULL,
 PRIMARY KEY(job_id,step));
CREATE TABLE IF NOT EXISTS events(
 seq INTEGER PRIMARY KEY AUTOINCREMENT, job_id TEXT NOT NULL REFERENCES jobs(id),
 at REAL NOT NULL, generation INTEGER NOT NULL, kind TEXT NOT NULL, detail TEXT NOT NULL);
'''

下载完整 runtime.py

字段 / 约束 要解决的问题 遗漏后的表现
jobs.id + input_hash 同任务 ID 重复提交必须是同一输入和作用域 旧检查点套到新问题上
generation + owner + lease_until 识别当前合法执行者,并限制执行期限 旧进程覆盖新进程结果
attempts + available_at + deadline 限制认领次数、退避时间、总执行窗口 重试风暴,任务永不结束
checkpoints 主键(job_id,step) 每个步骤只有一份已提交结果 恢复时无法选定哪个输出
memory_rev + memory_expires 恢复前检测上下文是否已经失效 已撤回的记忆继续影响输出
events.seq + generation 保留可排序、可关联的执行证据 仅凭最终状态无法解释失败

input_hash 由规范化 JSON 计算,包含材料、provider、model、endpoint、流程版本和作用域。重复提交时若摘要不同,直接报错。模型 Key 不进入 spec。恢复已有任务不会自动加载新版材料;需要明确创建新任务 ID。修改步骤语义时提升 report/v1 版本并使用新任务,这是本例的版本迁移规则。

若迁移到大规模服务,不应把大文档直接放进 jobs.spec。将对象存储 URI、内容哈希和授权快照存入任务;取回时核验哈希。此处只有两段合成材料,内联是为了降低复现实验的门槛。

04 · 认领与写回必须成对:只抢锁还不够

runtime.py · 原子认领

    def claim(self, job, owner):
        with transaction(self.db):
            row, now = self.job(job), self.clock()
            ready = (row['status'] in ('queued', 'retry_wait') and row['available_at'] <= now
                     or row['status'] == 'running' and row['lease_until'] <= now)
            if not ready:
                return None
            if row['deadline'] <= now or row['attempts'] >= 3:
                self.db.execute("UPDATE jobs SET status='failed',error='deadline_or_attempt_budget' WHERE id=?", (job,))
                self.event(job, row['generation'], 'failed', 'deadline_or_attempt_budget')
                return None
            generation = row['generation'] + 1
            self.db.execute("UPDATE jobs SET status='running',owner=?,generation=?,lease_until=?,"
                'attempts=attempts+1 WHERE id=?', (owner, generation, now + self.lease_seconds, job))
            self.event(job, generation, 'claimed', owner)
            return generation

下载完整 runtime.py

runtime.py · 写回前的执行者校验

    def guard(self, job, owner, generation):
        row = self.job(job)
        if row['status'] != 'running' or row['owner'] != owner or row['generation'] != generation or row['lease_until'] <= self.clock():
            raise LostLease('stale worker or cancelled task')
        return row

下载完整 runtime.py

SQLite 的 BEGIN IMMEDIATE 在事务开始时申请写事务;同一数据库仍只有一个写者,竞争时可能得到 SQLITE_BUSY。我们的认领事务只读改一条任务并追加事件,绝不在里面等待模型。连接设置 5 秒锁等待,超时后由调用方处理,不能把“用了事务”解释成永远不会争锁。

SQLite · Transaction:写事务的并发限制与 IMMEDIATE 行为来自官方文档。(核对:2026-09-25)

核心竞态可以手工推演:A 认领 generation=1;A 暂停到租约过期;B 认领 generation=2;A 恢复并提交。此时唯一检查点索引可能仍为空,所以单靠唯一键拦不住 A。guard 同时核验运行状态、owner、generation 和租约,A 的提交被拒绝。取消任务也递增 generation,让正在运行的旧工作者失去写回资格。

本例每步开始续租,不包含后台心跳。若一个模型调用超过 30 秒租约,即使结果成功返回也可能被拒收。这是明确的失败边界。接入生产时增加周期续租,并在续租失败后停止后续工具调用;外部工具还需自己的幂等或 fencing 协议。本地 guard 不能阻止已飞出的远端请求。

05 · 事务边界:工具在事务外,结果与事件在事务内

runtime.py · 提交结果与完成状态

    def commit_step(self, job, owner, generation, step, payload):
        with transaction(self.db):
            row = self.guard(job, owner, generation)
            self.validate_memory(row)
            if row['deadline'] <= self.clock():
                raise PermanentError('deadline exceeded')
            self.db.execute('INSERT INTO checkpoints VALUES (?,?,?)', (job, step, encode(payload)))
            self.event(job, generation, 'step_committed', step)
            if step == 'publish':
                self.db.execute("UPDATE jobs SET status='succeeded',owner=NULL,error=NULL WHERE id=?", (job,))

下载完整 runtime.py

一次 commit_step 同时保存步骤结果与 step_committed 事件。最后一步还把任务改为 succeeded,保证不会出现“任务成功但 publish 检查点没有落盘”的本地状态。恢复逻辑先读取已存在的检查点,只执行缺失步骤;它不要求模型根据一段日志猜测下一步。

宕机位置 重启可观察状态 下一步
draft 请求发出前 collect 已提交;draft 缺失 继续 draft
模型响应后,draft 提交前 仍只有 collect 可能再次调用模型;无法保证模型费用只发生一次
draft 提交后 draft 结果存在 复用原草稿,不重新生成
最后一步事务提交后 publish 与 succeeded 同时存在 重复投递直接返回 succeeded

LangGraph 的持久化文档描述了线程与检查点等机制。无论采用框架还是自行实现,都需要检查底层存储是否持久、哪些步骤会被回放、外部副作用如何去重。下面的实现属于本站设计,不是 LangGraph 内部源码。

LangGraph · Persistence:用于核对线程、检查点及持久化机制。(核对:2026-09-25)

06 · 故障实验 A:真正退出进程,再从 collect 之后恢复

复现命令

python3 cli.py submit --db crash.sqlite
python3 cli.py run --db crash.sqlite --lease-seconds 2 --fault after_collect
# 预期退出码 75;独立执行上面这条命令
python3 cli.py inspect --db crash.sqlite
# 等待至少 2 秒,让旧租约到期
python3 cli.py run --db crash.sqlite
python3 evaluate.py --db crash.sqlite

fault 分支在 collect 检查点提交后触发,CLI 调用 os._exit(75) 真正结束进程。它不会执行 finally 清理,也不会把任务改成 failed。因此首次 inspect 应看到 running 与 collect,而不是优雅异常处理后的理想状态。

检查项 第一次中断后 恢复后
任务状态 running succeeded
generation 1 2
已提交步骤 collect collect、draft、verify、publish
collect 提交次数 1 1

如果第二次 run 仍输出 running,先看 lease_until:旧租约可能尚未到期,不要删除任务行强行恢复。若拖过默认 600 秒 deadline,任务会终止;使用新的 job ID 重新提交。本例认领次数上限为 3,首次执行也计数,不是“额外重试三次”。

07 · 故障实验 B:外部已成功,本地却没有回执

runtime.py · 独立服务的幂等协议

    def publish(self, job, payload):
        # Independent database simulates an external service's durable idempotency contract.
        db = sqlite3.connect(self.path + '.publisher.sqlite', isolation_level=None, timeout=5)
        try:
            db.execute('CREATE TABLE IF NOT EXISTS receipts(op TEXT PRIMARY KEY, payload_hash TEXT NOT NULL, receipt TEXT NOT NULL)')
            op, payload_hash = digest([job, 'publish/v1']), digest(payload)
            with transaction(db):
                old = db.execute('SELECT payload_hash,receipt FROM receipts WHERE op=?', (op,)).fetchone()
                if old:
                    if old[0] != payload_hash:
                        raise PermanentError('idempotency key reused with different payload')
                    return {'receipt': old[1], 'deduplicated': True}
                receipt = 'report-' + op[:12]
                db.execute('INSERT INTO receipts VALUES (?,?,?)', (op, payload_hash, receipt))
                return {'receipt': receipt, 'deduplicated': False}
        finally:
            db.close()

下载完整 runtime.py

操作键使用 SHA-256(job_id, publish/v1),同一逻辑发布在重试时保持不变。服务保存 payload_hash 和 receipt;同键同内容返回旧回执,同键不同内容报错。不要把 generation 或重试次数放进操作键,否则每次接管都会绕过去重。

复现跨系统空隙

python3 cli.py submit --db effect.sqlite
python3 cli.py run --db effect.sqlite --lease-seconds 2 --fault after_effect
# 等待至少 2 秒
python3 cli.py run --db effect.sqlite
python3 cli.py inspect --db effect.sqlite

本次实际结果: 中断时有 collect / draft / verify 三个检查点;恢复后有四个。模拟发布库始终只有 1 条 receipts,恢复回执 deduplicated=true。查看原始运行快照。

这条测试验证的是:具有持久幂等契约的服务在本次故障下能返回同一个结果。它没有模拟网络分区、远端灾备、幂等键过期或服务实现错误。若接入的报告接口不支持幂等键,要先增加查询 operation_id 的能力;既不能查询也不能去重时,将超时记为 unknown,进入核对流程,不做盲目自动重试。

08 · 重试、取消与截止时间:把停止规则写成代码

事件 本例处理 接入真实系统时的补充
HTTP 429 / 5xx / 网络错误 retry_wait;按认领次数等待 2 秒、4 秒;第 3 次失败停止 调度器到时重新投递,加入抖动与 Retry-After 处理
401 / 403 / 非法输出 / 未知来源 failed;保留失败原因 按明确分类告警或交人工修正
取消请求 写 cancelled 并递增 generation 外部已完成操作用业务补偿,不宣称撤销成功
超过 deadline 认领或步骤边界终止 为每个外部调用设置真正的总截止时间
未知进程错误 / 强制退出 保留运行状态,租约过期后可接管 记录基础设施告警,防止无限故障循环

这里的 2 秒、4 秒只是可复现实验参数,不是生产推荐值。实际退避可采用 full jitter:在 0 到 min(cap, base×2^attempt) 内随机取值;同时满足业务 deadline。仅增加重试次数可能提高表面成功率,也可能提高费用和重复业务影响。必须同时查看失败原因与最终业务记录。

API 适配器的 timeout=20 是 socket 层超时,不是严格的整个调用墙钟上限。慢速分块响应可能使调用时间更长;租约没有后台心跳时会进一步触发写回拒绝。若业务真的要求 60 秒内结束,需要可取消的 HTTP 客户端、整体 deadline 与任务调度协同。

09 · 接入千问或兼容接口:只替换 draft,保留执行边界

默认替身跑通后,才切换 provider=api。控制台中的模型 ID、地域、业务空间与 Key 必须匹配。适配器读取 LLM_API_KEY 环境变量,向你提供的 HTTPS 地址发送材料与有效记忆;它不把密钥写入任务数据库,也不跟随重定向。

先配置 LLM_API_KEY,再替换占位符执行

python3 cli.py submit --db api.sqlite --job api-001 \
  --provider api --model YOUR_MODEL_ID \
  --endpoint https://YOUR_TRUSTED_HOST/compatible-mode/v1/chat/completions
python3 cli.py run --db api.sqlite
python3 cli.py inspect --db api.sqlite

完整实现见 provider.py。请求固定 max_tokens=800、temperature=0,严格解析裸 JSON。若模型返回 Markdown 围栏,不做猜测性修复,而是显式失败,便于后续按错误样本调整提示词或采用服务支持的结构化输出。temperature=0 也不构成跨服务版本完全确定的承诺。

阿里云百炼 · Chat Completions:请求格式及地域地址以官方文档与控制台为准,文档列有业务空间专属域名。(核对:2026-09-25)

已用模拟 HTTP 成功响应和 429 响应验证适配器;未调用真实模型,也未测试供应商可用性。接入后仍需人工核对摘要是否受到原文支持。现在的 verify 只管结构与来源 ID。

10 · 放进已有 Java 系统:改哪些类、事务放在哪里

本例位置 Java 系统中的位置 实现约束
cli.submit / jobs TaskApplicationService + task 表 复用请求幂等 ID;认证层提供租户与用户
claim / before_step 调度器 + 独立 ClaimService 短事务认领,记录 owner、generation、lease_until
provider.draft ModelClient / HTTP 客户端 事务外调用;只读合成材料先验收
commit_step CheckpointService 在同一事务校验执行者、写检查点、写事件
publish ReportClient + 原业务服务 服务端持久操作键,禁止随机新键绕过去重
evaluate / tests CI 回归阶段 保存失败轨迹和业务回执,限制对真实服务副作用

Java 接入顺序

// 结构示意;不是可直接编译的完整 Java 项目
var claim = claimService.claim(taskId); // 独立短事务
if (claim == null) return;
var input = checkpointService.loadInput(claim);
var output = modelClient.generate(input); // 不持有数据库事务
checkpointService.commit(claim, output); // 校验 generation + lease

不要在一个标了事务注解的大方法里完成“查任务 → 调模型 → 写结果”。长网络调用会长时间占用连接和锁。将短事务方法放入独立服务,通过真实事务边界调用;不要假定同对象内部方法调用必然触发框架代理。数据库引擎不同,认领 SQL 需要针对性实现和竞争测试。

若使用 PostgreSQL,可评估 FOR UPDATE SKIP LOCKED 的队列认领方式。它跳过锁定行,适合多个消费者争抢待处理任务;它不提供严格按创建时间完成的保证。你若要求某账户、某项目必须顺序处理,要另加分区串行约束,不能把 SKIP LOCKED 当 FIFO。

PostgreSQL · SELECT:官方说明 SKIP LOCKED 可用于多个消费者访问队列表,但不适合一般一致性视图。(核对:2026-09-25)

11 · 排障手册与上线前仍需补齐的部分

现场症状 先看哪里 根因与处理
任务一直 running lease_until、最后一个 step_started 调用阻塞或工作者退出;到期接管,核查调用总超时
出现 LostLease owner、generation、最后续租时间 旧工作者迟到;停止写回,不放宽校验
database is locked 事务时长和写并发 短事务、降低本地写争用;规模扩大再迁移数据库
重复报告 操作键与远端 receipts 重试生成新键、幂等键过期,或服务并未持久去重
修改输入后还是旧结果 job_id 与 input_hash 应使用新任务或显式迁移;禁止手工覆盖检查点
记忆更改后失败 memory_rev 与任务快照 符合失效阻断;新建任务重算,不恢复旧草稿

这份代码可以直接运行并复现故障,但没有 HTTP 服务、身份认证、生产队列、数据库迁移和监控部署;第三版补充了本地审批实验,尚非真实审批后台。生产前按具体业务补上这些部分,并测试磁盘满、远端成功后超时、网络分区、幂等记录保留期、数据库恢复以及权限变更。不要把基础 18 项通过写成生产可用率。

源码中的 scope 在 CLI 固定生成,默认信任本地操作者;绝不能直接映射为开放 API 请求体里的 tenant_id。真实写操作必须通过原业务权限服务,审批凭证应绑定动作类型、资源 ID、内容摘要、有效期与申请人。内容变化即重新审批,不能让模型拿旧批准执行新动作。

验收顺序建议为:先复现本页两个故障;再运行并发认领和旧代次测试;最后替换只读模型与报告沙箱接口。每一步保存任务库快照和远端回执。先能解释一次失败,再谈扩大并发。

12 · 审批要批准什么:不能只有一个 approved=true

继续用报告发布案例,把 publish 改成必须经过人工批准。最小实现很容易写成“用户点通过 → approved=true → 继续”。问题是人看过的草稿、实际发送的内容、所属项目或目标服务可能已经变化;布尔值没有记录批准范围,恢复进程也就无从核对。

必须绑定的字段 本例中的值 / 来源 防止的错误
job_id + input_hash 任务 ID 与冻结输入摘要 把旧任务的批准套到新输入
action + target report.publish/v1 + local-report-store 批准保存报告,却被改成另一种操作
scope + requester 任务内的租户、用户、项目与申请人 跨项目复用审批或自己批准自己
payload_hash 已提交 draft 的规范化 JSON 摘要 批准后静默改写内容
memory_rev / memory_expires 任务记忆快照版本与期限 更正或撤回后继续使用旧上下文
expires_at / decided_by 请求有效期、审批人 无限期使用旧决定或无法追溯

approval_runtime.py · 真正参与审批摘要计算的字段

    def envelope(self, job, report):
        row = self.job(job)
        spec = json.loads(row['spec'])
        return {'job_id': job, 'action': 'report.publish/v1',
                'target': spec.get('report_target', 'local-report-store'),
                'scope': row['scope'], 'requester': json.loads(row['scope'])[1],
                'input_hash': row['input_hash'], 'payload_hash': digest(report),
                'memory_rev': row['memory_rev'], 'memory_expires': row['memory_expires']}

下载完整 approval_runtime.py

binding_hash 是上述动作描述的摘要,页面必须同时展示具体动作、目标和完整草稿。只显示一串 hash 让人批准,没有可理解的审查对象。Hash 也不是签名或登录凭证;操作者身份仍必须来自可信认证层。

OWASP · Transaction Authorization:授权应在服务端执行,重要交易数据应可供用户核对,并与执行阶段绑定。本文审批模型为独立教学实现。(核对:2026-09-25)

本例的 Principal 和 --actor 是本地测试夹具,不提供真实登录。开放成 HTTP 接口时,不允许浏览器提交 roles 或 allowed_scopes;这些字段应由认证会话和权限系统计算。local-report-store 是模拟目标标签,没有对接真实报告服务。

13 · 实验 D:保存草稿 → 等待审批 → 批准后继续

第三版使用独立入口 approval_cli.py,复用原 runtime.py 的采集、草稿、校验与检查点。基础 cli.py 仍保持原行为,方便比较是否增加审批。先下载首页第三版实验包,解压后运行:

建立待审批任务

python3 approval_cli.py submit
python3 approval_cli.py run
python3 approval_cli.py inspect

run 输出 waiting_approval。inspect 中 collect、draft、verify 已提交,publish 不存在。先读 checkpoints.draft 与 approval.action_json,再复制 approval.binding_hash,替换以下占位符。默认审批有效期 120 秒,任务总截止时间 600 秒;实验用短时间帮助观察过期,并非生产建议值。

确认同一份动作再恢复

python3 approval_cli.py approve --actor reviewer --hash REVIEWED_BINDING_HASH
python3 approval_cli.py run
python3 approval_cli.py inspect
阶段 任务状态 检查点 / 外部结果
首次 run waiting_approval 3 个检查点,0 条报告记录
approve queued 只记录审批决定,不调用发布接口
第二次 run succeeded 复用原草稿,新增 publish;只有 1 条报告记录

等待期间没有工作线程一直 sleep,也没有数据库事务跨越人的审批时间。状态已经持久化,CLI 可以退出。审批决定用短事务写入,再把任务置为 queued;后续由本例第二次 run 或生产调度器接管。等待本身不占新的认领次数,首次执行与批准后恢复仍各算一次认领。

LangGraph 的 interrupt 也能持久化暂停并用同一线程恢复;但恢复时中断所在节点会从开头重跑,所以在 interrupt 之前的副作用仍需处理幂等。框架提供暂停机制,不等于替应用定义了审批范围或授权规则。

LangGraph · Interrupts:核对暂停、持久化、线程恢复以及节点重新执行的行为。(核对:2026-09-25)

14 · 审批回调要防什么:越权、重复、过期与内容变化

复现操作 本例预期 需要保留的证据
approve --actor requester 拒绝自审批,状态仍 pending 操作者与申请人不同的规则
approve --actor outsider / viewer 作用域或角色不符,拒绝 可信角色与授权作用域
传入其他 binding_hash 拒绝,不改变审批 用户审查的内容摘要
相同 reviewer 重复提交相同决定 有效期内返回原决定,不重复授权 只出现一次 approval_approved 事件
两个进程同时 approve / reject 一方成功,另一方得到冲突 只有一项决定,状态没有混合
批准后修改 draft 或记忆 invalidated / failed,禁止发送 最新内容摘要与记忆修订号
批准后等到 expires_at 再 run expired / failed,禁止发送 最后一次授权检查时间

approve 和 reject 都需要同一个已审阅摘要。拒绝是终止决定,不能随后用旧请求再批准。需要修改内容时建立新任务,重新生成、审查;但如果旧任务已经发出,必须先核对它的结果,不能换新任务 ID 直接重复操作。

审批成功后、真正发出请求前,还要再核验一次。检查的不是“曾经批准过”,而是“此刻同一动作、同一内容、同一上下文仍然被允许”。验证和写入 dispatching 标记在同一个短事务中;下一节解释为什么这个标记需要独立状态。

approval_runtime.py · 发出前再次核验并持久化边界

    def publish(self, job, payload):
        if not self.execution or self.execution[0] != job:
            raise PermissionError('publish requires the guarded execution path')
        _, owner, generation = self.execution
        with transaction(self.db):
            row = self.guard(job, owner, generation)
            request = self.approval(job)
            if request is None or request['status'] != 'approved':
                raise PermanentError('approval required')
            self.validate_binding(job, request, payload)
            if request['expires_at'] <= self.clock() or row['deadline'] <= self.clock():
                raise PermanentError('approval_expired_before_dispatch')
            # Authorization cutoff: after this durable marker, outcome may be unknown.
            self.db.execute("UPDATE approvals SET status='dispatching' WHERE job_id=?", (job,))
            self.event(job, generation, 'dispatch_started', request['operation_key'])
        if self.fault == 'before_effect':
            raise InjectedCrash('dispatch marker committed; no service call was made')
        return super().publish(job, payload)

下载完整 approval_runtime.py

15 · 最难的窗口:请求已经发出,批准却过期了

考虑这个时序:审查通过 → 持久化 dispatching → 外部服务保存成功 → 本地进程退出 → 审批到期。恢复时若仅看到过期就标记“未执行”,会隐藏已经发生的业务结果;若重新申请批准再发送,也可能重复执行。授权是否允许新的写入,与过去的写入是否已经发生,必须分开回答。

可观察状态 允许做什么 不能推断什么
approved 且尚未 dispatching 到期或取消可阻止新的发送 批准不代表已经执行
dispatching,无本地回执 按稳定操作键查询远端回执 没有回执不代表没有发出
查到相同 payload_hash 的回执 补记已存在的业务效果 这不构成新授权,也不自动撤销业务
查不到回执 保持 reconciling,等待或人工核对 不自动换键、换任务或再次发送
远端回执内容摘要不一致 报冲突并停止 不能拿不匹配回执当成功

实验 E:审批后的外部结果核对

python3 approval_cli.py submit --db approved-effect.sqlite
python3 approval_cli.py run --db approved-effect.sqlite
python3 approval_cli.py inspect --db approved-effect.sqlite
python3 approval_cli.py approve --db approved-effect.sqlite --hash REVIEWED_BINDING_HASH
python3 approval_cli.py run --db approved-effect.sqlite --lease-seconds 2 --fault after_effect
# 故障命令退出 75;等待至少 2 秒
python3 approval_cli.py reconcile --db approved-effect.sqlite
python3 approval_cli.py inspect --db approved-effect.sqlite

结果含义: effect_confirmed 表示确认了过去的写入;publish.fresh_write_performed=false 表示恢复只查询,没有发起新的发布。它不同于普通 succeeded,也不表示取消请求已回滚报告。查看本次原始记录。

将故障点换为 before_effect,会在 dispatching 提交之后、调用服务之前退出。测试注入器知道请求没发出,但恢复进程只看到 dispatching 和“无回执”,所以它停在 reconciling。这个保守选择牺牲自动继续的便利,换来不把“未知”错误当成“安全重发”。真实系统若有可靠的服务端幂等重试契约,可以另行设计恢复协议;本扩展只实现只读核对。

没有回执的情况要配置人工核对或远端终态查询,不能无限停着无人处理。当前代码没有后台核对器、告警或人工结案接口。生产中结案也必须保留证据,禁止运维直接把 dispatching 改回 queued。

16 · 取消的分界线:停止后续动作,不伪造回滚结果

取消发生点 任务状态变化 外部业务影响
等待审批 / 已批准但未发送 cancelled,审批失效 本例尚未发布
dispatching 之后 reconciling,记录 cancel_requested_after_dispatch 请求可能仍在执行,不能保证无写入
核对时查到报告回执 effect_confirmed,保留取消意图 报告已经存在,撤销需要单独业务操作
核对时没有回执 仍为 reconciling 继续核对,不向用户承诺已撤销

派发前的最后授权检查是本例的分界线。取消与 dispatching 更新竞争同一数据库短事务:取消先提交则工作者失去资格;dispatching 先提交则进入结果未知区域。外部请求不处在本地事务中,因此不能提供“点取消后绝无业务影响”的全局保证。

补偿不是恢复代码的通用反向函数。删除草稿、撤销发布、退款、冲正,分别有不同业务规则。接入 Java 业务服务时应由原服务提供明确补偿命令、独立审批和审计,不让模型根据自然语言随意构造反向 SQL。

17 · Java 接入:把审批、派发和核对拆成明确服务边界

服务 / 表 事务内工作 事务外工作
ApprovalService / approvals 校验操作者、scope、摘要、版本、状态;记录决定 不调用模型或发布接口
DispatchService 校验有效批准与执行租约;记录 dispatching 调用支持稳定 operation_id 的业务服务
ReceiptReconciler 记录核对结果与审计;必要时更新任务终态 按 operation_id 查询,不创建新业务记录
CheckpointService 同时写 publish 检查点、任务状态与回执 不通过重生成草稿修补回执
PermissionGateway 读取可信身份与服务端政策 每次真实业务访问重新执行授权

推荐 API 合同是 POST /approvals/{id}/decision,请求体只包含 decision、reviewed_hash 和客户端请求幂等 ID。actor 从会话读取,权限与租户范围从服务端计算。返回 409 表示版本或决定冲突,403 表示无权审批,410 可用于已过期请求;具体状态码是本页建议,当前实验没有 HTTP API。

本次扩展仍是本地审批状态机与身份夹具,没有实现登录后台。迁移时先用现有登录和角色服务接入 read-only 业务沙箱,再验证:过期批准不可发送、内容变更重新审查、取消后业务效果如实显示、回执不明不创建第二份操作。

来源与验证

官方资料用于核对具体机制;表结构、程序与实验为本站独立设计。

验证记录 · 审批恢复证据