Agent 执行多步计划前,先检查依赖图
两份资料分别读取、抽取,再汇总。计划里只要一个依赖拼错,后面的顺序就可能失真。
1. 先把资料汇总的前后关系写清楚
假设一个 Agent 要整理两份资料:一份 API 说明、一份变更笔记。它先分别读取,再分别抽取要点,最后合成摘要。模型可能一次交回五个步骤,看起来都合理,但其中一个依赖拼错了,或者摘要步骤反过来成了读取步骤的前置条件。等工具开始运行后才发现这些问题,会把计划错误混进执行错误。
这里把检查放在整份计划进入执行层之前。输入是一份候选步骤列表,输出是已经校验过的依赖图;列表不合约定时直接返回错误。读完后,你可以在空目录里复现重复 ID、缺失依赖和环的拒绝过程,还能观察一条分支先完成时,后继怎样提早就绪。
候选计划可以来自模型,也可以手写。本文固定使用手写的合成计划,没有调用模型或运行任何工具节点。read_api 这样的名字只用于标识图中的节点,不对应真实文件、网络请求或 SDK。
图上箭头按“前置 → 后继”来读,代码却以“节点 → 前驱列表”存储。比如 compose_digest 的值是 ["extract_api", "extract_notes"]。把方向写反,也可能得到一张无环图,只是运行顺序完全错了。
这个检查填补的是整份计划的结构验证。逐轮 Agent Loop 可以等上一轮结果再决定下一步;当上游已经给出多阶段计划时,执行入口仍需要确认引用和依赖是否成立。并发配额、工具超时、消息重启恢复属于后续执行过程,下面的代码不承担这些工作。
2. 为什么还需要自己检查列表
每个步骤只有两个字段:id 和 depends_on。计划必须是非空列表,ID 必须是非空白字符串,依赖必须是这类字符串组成的列表。本例要求字段恰好匹配,重复依赖也拒绝。这个小合同让实验容易看清;它没有实现工具参数、输出类型或资源声明。
先保留列表形态很重要。若直接用 {step["id"]: step["depends_on"] for step in plan} 转成字典,同名 ID 可能被后来的记录覆盖。等字典建好,再检查重复已经来不及。因此第一遍逐条收集 ID 并拒绝重复,第二遍才检查引用。这样允许步骤提前引用后面才声明的 ID,不必强迫模型按拓扑顺序输出。
缺失引用也不能交给排序库兜底。下面这段独立探针会得到 ["typo", "b"]:
from graphlib import TopologicalSorter
print(list(TopologicalSorter({"b": ["typo"]}).static_order()))
TopologicalSorter 把仅出现在依赖里的名字补成无前驱节点。这对于通用图输入很方便,但业务计划里的 typo 没有对应步骤。我们的第二遍检查要求所有引用都出现在原始 ID 集合内,拼错名称会在建图前被拒绝。程序不自动改拼写,也不猜哪个步骤最接近。
通过前两遍后,再建立排序器并调用 prepare()。只要发现环,就把异常转成 PlanError,不返回排序器。即使另有一条独立分支可以推进,本例也拒绝整份计划。这个“整份退回”是本文的应用选择;库本身允许在检出环后继续取出未受环阻塞的节点。
错误中的 cycle witness 是一条用于定位问题的环,例如 a → b → a。有多个环时不要把这一条当成完整诊断。修复后重新提交整个列表,检查仍可能发现别的环。
3. 把校验器和合成计划保存下来
以下文件只依赖 Python 标准库。本次实际运行版本为 Python 3.12.14;2026 年 10 月 7 日核对的 Python 3.12 官方文档页面标为 3.12.15。这里使用并实测的是两个版本共有的接口,没有采用其他大版本新增的行为。
lint() 返回的排序器已经调用过 prepare(),调用者不要再准备一遍,也不要尝试往图里添加节点。若需要改计划,应重新调用 lint() 建立新图;本文不讨论执行到一半如何改图。
waves() 用来预览层次,每次假设本波全部完成后才查看下一波。这种预览能帮助人工核对图,不包含执行时间信息。simulate() 则按预先写好的模拟完成事件推进,稍后会用它解释分支提前就绪和失败不放行。
保存为 plan_lab.py:
"""Offline dependency-plan lint. No model or tool execution."""
from graphlib import TopologicalSorter, CycleError
class PlanError(ValueError):
pass
def lint(plan):
if not isinstance(plan, list) or not plan:
raise PlanError('plan must be a nonempty list')
ids = set()
for step in plan:
if not isinstance(step, dict) or set(step) != {'id', 'depends_on'}:
raise PlanError('step needs exactly id and depends_on')
name, deps = step['id'], step['depends_on']
if not isinstance(name, str) or not name.strip():
raise PlanError('invalid id')
if name in ids:
raise PlanError(f'duplicate id: {name}')
ids.add(name)
if not isinstance(deps, list) or any(not isinstance(d, str) or not d.strip() for d in deps):
raise PlanError('depends_on must be a list of nonempty strings')
if len(set(deps)) != len(deps):
raise PlanError(f'duplicate dependency: {name}')
for step in plan:
missing = set(step['depends_on']) - ids
if missing:
raise PlanError(f"missing dependency for {step['id']}: {sorted(missing)}")
sorter = TopologicalSorter({s['id']: tuple(s['depends_on']) for s in plan})
try:
sorter.prepare()
except CycleError as error:
# Application policy: reject whole plan before returning any ready work.
raise PlanError(f'cycle witness: {error.args[1]}') from error
return sorter
def waves(plan):
"""Preview only: assume each whole wave succeeds; do not execute any step."""
ts = lint(plan)
batches = []
while ts.is_active():
ready = ts.get_ready()
batches.append(sorted(ready))
ts.done(*ready)
return batches
def step(name, *deps):
return {'id': name, 'depends_on': list(deps)}
# Synthetic local-reference digest. IDs describe proposed steps, not actual reads.
PLAN = [step('read_api'), step('read_notes'),
step('extract_api', 'read_api'), step('extract_notes', 'read_notes'),
step('compose_digest', 'extract_api', 'extract_notes')]
def simulate(events):
"""Consume synthetic completion events; execute no plan nodes.
Contract: events are in-memory (node, success, error) triples.
success must be True and error must be None to call done().
Stop on the first failed event; no retry or recovery is implemented.
"""
ts = lint(PLAN)
issued = set()
log = []
def ready():
names = sorted(ts.get_ready())
issued.update(names)
log.append({'ready': names, 'active': ts.is_active()})
ready()
# A second read demonstrates that issued nodes are not reissued.
ready()
for node, success, error in events:
if node not in issued:
raise PlanError(f'event not for an outstanding node: {node}')
if success is not True or error is not None:
log.append({'rejected_event': node, 'success': success,
'error': error, 'done_called': False})
log.append({'ready': sorted(ts.get_ready()),
'active': ts.is_active(), 'stopped': True})
return log
ts.done(node)
issued.remove(node)
log.append({'simulated_done': node})
ready()
log.append({'events_exhausted': True, 'active': ts.is_active(),
'outstanding': sorted(issued)})
return log
# These booleans are fixture inputs, not evidence that any real task succeeded.
SUCCESS_EVENTS = [
('read_api', True, None),
('extract_api', True, None),
('read_notes', True, None),
('extract_notes', True, None),
('compose_digest', True, None),
]
FAILURE_EVENTS = [('read_api', False, 'synthetic read failure')]
ID 比较区分大小写,代码只用 strip() 判断是否全为空白,并不自动修剪 ID。"read_api" 和 " read_api " 在这个合同里是两个不同名称。输入是程序内已有的 Python 列表;网络 JSON 的体积、解析和输入来源校验不在本例范围内。
4. ready 发出后需要等待什么
开始时,两条读取步骤都没有前驱,因此第一次 get_ready() 会发出它们。立刻再调用一次,结果为空:库不会因为你多问一次就重新发出同一批节点。这时 is_active() 仍然为真,因为还有已经发出、尚未标记完成的节点。
所以,空的 ready 列表不能单独解释成“计划结束”或“图有环”。它可能表示要等已经发出的节点产生完成结果。在真正的执行器中,这个结果应来自任务完成事件;若写成一个不断调用 get_ready() 的紧循环,只会反复拿到空列表。本文的模拟器直接消费预置事件,不建立轮询器。
事件按 read_api、extract_api、read_notes、extract_notes、compose_digest 排列。第一条模拟完成后,extract_api 已可发出;此时 read_notes 还没有完成。随后 extract_api 完成,摘要仍不能开始,因为 extract_notes 尚未完成。这就是依赖交汇处的等待条件。
如果把实际调度写成“同一波全部等完,再开下一波”,会人为增加一道整批等待。waves() 采用这一假设,只是为了给出易读的图层预览。图 3 表示逻辑先后,没有计时测量,也没有承诺吞吐量收益。
5. 失败事件为什么不能调用 done
done(node) 接收节点名,不接收工具返回值,也不验收结果。它会改变依赖状态,使满足条件的后继在之后被发出。如果一个读取失败,你为了清空状态也调用 done("read_api"),图就会把 extract_api 放出来,即使没有可供抽取的内容。
模拟器把这个边界写成一条很窄的规则:事件中的 success is True 且 error is None,才调用 done()。成功标记为 False、整数 1,或者同时带着错误,都不会放行。事件还必须对应已经发出但尚未完成的节点;陌生节点、尚未就绪节点和重复完成事件会被拒绝。
这些布尔值是测试夹具,不是可信执行凭据。实际接工具时,应用还需要定义如何验收输出,以及谁能提交完成事件。不能把模型自称成功直接视作这里的完成条件。本文没有实现这层验收,也没有实现失败后的重试、取消、跳过或恢复。
遇到第一个失败事件,演示立即返回,保留未完成状态。因此失败输出里会同时出现 stopped: true 和 active: true:前者表示模拟演示停了,后者表示依赖图还有未完成节点。它们描述不同层面的状态。不会为了得到一个好看的结束状态而伪造完成。
6. 在空目录运行并核对固定输出
把上面的 plan_lab.py、下面的 demo.py、下一节的 test_plan_lab.py 保存到同一个空目录即可运行,不需要安装包。命令中的 python 应指向 Python 3.12。下方完整固定输出可保存为 expected-output.json,便于逐项核对。
保存为 demo.py:
"""Print deterministic synthetic traces; no tool execution or file reads."""
import json
from plan_lab import PLAN, waves, simulate, SUCCESS_EVENTS, FAILURE_EVENTS
if __name__ == '__main__':
result = {
'preview_waves_assuming_success': waves(PLAN),
'simulated_success_events': simulate(SUCCESS_EVENTS),
'simulated_failure_event': simulate(FAILURE_EVENTS),
}
print(json.dumps(result, ensure_ascii=False, indent=2))
python --version
python -B demo.py
python -B -m unittest -v
下面是演示的固定标准输出。只排序每次返回的 ready 名称,目的是稳定展示;名称的字母顺序不表示任务优先级。
保存为 expected-output.json:
{
"preview_waves_assuming_success": [
[
"read_api",
"read_notes"
],
[
"extract_api",
"extract_notes"
],
[
"compose_digest"
]
],
"simulated_success_events": [
{
"ready": [
"read_api",
"read_notes"
],
"active": true
},
{
"ready": [],
"active": true
},
{
"simulated_done": "read_api"
},
{
"ready": [
"extract_api"
],
"active": true
},
{
"simulated_done": "extract_api"
},
{
"ready": [],
"active": true
},
{
"simulated_done": "read_notes"
},
{
"ready": [
"extract_notes"
],
"active": true
},
{
"simulated_done": "extract_notes"
},
{
"ready": [
"compose_digest"
],
"active": true
},
{
"simulated_done": "compose_digest"
},
{
"ready": [],
"active": false
},
{
"events_exhausted": true,
"active": false,
"outstanding": []
}
],
"simulated_failure_event": [
{
"ready": [
"read_api",
"read_notes"
],
"active": true
},
{
"ready": [],
"active": true
},
{
"rejected_event": "read_api",
"success": false,
"error": "synthetic read failure",
"done_called": false
},
{
"ready": [],
"active": true,
"stopped": true
}
]
}
三波预览依次是两个 read、两个 extract、最后 compose。事件轨迹里却能看到 extract_api 在 read_notes 模拟完成之前就绪,这两份输出并不矛盾:前者假设整波完成,后者逐事件推进。
完整事件结束时 active 为假,表示这张合成图已全部标记完成。它不表示真的生成了一份摘要。失败事件输出没有 simulated_done,也没有释放 extract_api。如果事件列表提前耗尽,模拟器会把剩余 outstanding 节点返回;它不会把缺失事件当成自动成功。
7. 让反例约束这个检查器
测试围绕计划的具体行为组织。先检查两分支汇总和前向引用,再检查重复 ID、缺失引用、自环与多节点环;独立分支加环的用例确认整份计划会退回。状态测试检查 ready 不会重发、一支完成会释放后继、汇总必须等待两边。最后用失败、错误和重复事件约束模拟器。
其中两个反例尤其值得保留。一个直接调用库,证实它确实会补出未声明前驱;另一个删掉语义上必要的依赖,确认无环检查照样通过。这能防止后续维护时把校验器的能力说得过满。
保存为 test_plan_lab.py:
import unittest
from unittest.mock import patch
from graphlib import TopologicalSorter
from plan_lab import (PlanError, lint, waves, step, PLAN, simulate,
SUCCESS_EVENTS, FAILURE_EVENTS)
class Tests(unittest.TestCase):
def test_waves(self):
self.assertEqual(waves(PLAN), [['read_api', 'read_notes'], ['extract_api', 'extract_notes'], ['compose_digest']])
def test_forward_reference(self):
self.assertEqual(waves([step('b','a'), step('a')]), [['a'], ['b']])
def test_duplicate_id(self):
with self.assertRaisesRegex(PlanError,'duplicate id'): lint([step('a'),step('a')])
def test_missing_dependency(self):
with self.assertRaisesRegex(PlanError,'missing dependency'): lint([step('b','typo')])
def test_self_cycle(self):
with self.assertRaisesRegex(PlanError,'cycle'): lint([step('a','a')])
def test_multi_cycle(self):
with self.assertRaisesRegex(PlanError,'cycle'): lint([step('a','b'),step('b','a')])
def test_cycle_rejects_whole_plan(self):
with self.assertRaisesRegex(PlanError,'cycle'): lint([step('independent'),step('a','b'),step('b','a')])
def test_duplicate_dependency(self):
with self.assertRaisesRegex(PlanError,'duplicate dependency'): lint([step('a'),step('b','a','a')])
def test_schema(self):
for plan in [[],{},[{'id':'a','depends_on':'b'}],[step(' ')],[{'id':'a'}]]:
with self.subTest(plan=plan), self.assertRaises(PlanError): lint(plan)
def test_library_silently_adds_predecessor(self):
self.assertEqual(list(TopologicalSorter({'b':['typo']}).static_order()), ['typo','b'])
def test_ready_not_reissued(self):
ts = lint(PLAN)
self.assertEqual(set(ts.get_ready()), {'read_api','read_notes'})
self.assertEqual(ts.get_ready(), ())
self.assertTrue(ts.is_active())
def test_one_finished_branch_unblocks_without_barrier(self):
ts = lint(PLAN); ts.get_ready(); ts.done('read_api')
self.assertEqual(ts.get_ready(), ('extract_api',))
def test_join_waits_for_both_branches(self):
ts = lint(PLAN); ts.get_ready(); ts.done('read_api'); ts.get_ready()
ts.done('extract_api'); self.assertEqual(ts.get_ready(), ())
ts.done('read_notes'); self.assertEqual(ts.get_ready(), ('extract_notes',))
ts.done('extract_notes'); self.assertEqual(ts.get_ready(), ('compose_digest',))
def test_done_is_not_success_validator(self):
ts = lint([step('read'),step('compose','read')]); ts.get_ready()
ts.done('read') # API accepts no result or success evidence.
self.assertEqual(ts.get_ready(), ('compose',))
def test_unready_done_rejected(self):
ts = lint(PLAN)
with self.assertRaises(ValueError): ts.done('compose_digest')
def test_repeated_done_rejected(self):
ts = lint(PLAN); ts.get_ready(); ts.done('read_api')
with self.assertRaises(ValueError): ts.done('read_api')
def test_topology_cannot_spot_missing_semantic_edge(self):
self.assertEqual(waves([step('read'),step('compose')]), [['compose','read']])
class EventTests(unittest.TestCase):
def test_complete_trace(self):
trace = simulate(SUCCESS_EVENTS)
self.assertEqual(trace[-1], {'events_exhausted': True,
'active': False, 'outstanding': []})
self.assertEqual(trace[3]['ready'], ['extract_api'])
self.assertEqual(trace[5]['ready'], [])
def test_false_event_does_not_call_done(self):
with patch.object(TopologicalSorter, 'done') as mark_done:
trace = simulate(FAILURE_EVENTS)
mark_done.assert_not_called()
self.assertFalse(trace[2]['done_called'])
self.assertTrue(trace[-1]['active'])
self.assertEqual(trace[-1]['ready'], [])
def test_error_blocks_even_with_true_success(self):
with patch.object(TopologicalSorter, 'done') as mark_done:
trace = simulate([('read_api', True, 'synthetic error')])
mark_done.assert_not_called()
self.assertFalse(trace[2]['done_called'])
self.assertEqual(trace[-1]['ready'], [])
def test_non_boolean_success_rejected(self):
with patch.object(TopologicalSorter, 'done') as mark_done:
trace = simulate([('read_api', 1, None)])
mark_done.assert_not_called()
self.assertFalse(trace[2]['done_called'])
def test_unknown_event_rejected(self):
with self.assertRaisesRegex(PlanError, 'outstanding'):
simulate([('ghost', True, None)])
def test_unready_event_rejected(self):
with self.assertRaisesRegex(PlanError, 'outstanding'):
simulate([('compose_digest', True, None)])
def test_duplicate_event_rejected(self):
with self.assertRaisesRegex(PlanError, 'outstanding'):
simulate([('read_api', True, None), ('read_api', True, None)])
def test_exhausted_events_can_leave_active_graph(self):
trace = simulate([])
self.assertTrue(trace[-1]['active'])
self.assertEqual(trace[-1]['outstanding'], ['read_api', 'read_notes'])
if __name__ == '__main__':
unittest.main(verbosity=2)
本次使用 Python 3.12.14 实际运行,25 项单元测试通过。原有的 17 项图结构与状态实验保留在同一测试文件中,另外补了 8 项事件行为测试。这个结果验证的是列出的合成案例与代码分支,不包含真实模型计划质量、工具返回内容、网络故障或并行资源冲突。
8. 图通过之后还要人工检查什么
回到最开始的汇总任务。若候选计划写成 [step("read_api"), step("compose_digest")],两个节点都没有前驱,检查器会允许它们同时就绪。这张图完全无环,引用也没有拼错,但摘要缺了输入关系。拓扑算法只能检查已声明的边,无法知道任务语义中遗漏了哪条边。
检查候选计划时,可以沿着具体产物反向看:摘要要消费哪份抽取结果?抽取要消费哪个读取结果?代码里 depends_on 只是前后约束,没有携带产物本身,也没有验证两个步骤输出输入是否兼容。若某条必需关系根本没写出来,仍需由计划生成、人工审阅或另外的业务规则发现。
同样,ready 只说明当前声明的前驱已被标记完成。两个就绪步骤可能写入同一份文档,可能共享只有一个名额的客户端,也可能尚未获得操作授权。是否发给执行器、能否并发以及分配多少资源,必须另外决定。这个实验不会把 ready 翻译成“可以安全并行执行”。
你可以先把这段代码接在候选计划的输出端,只做拒绝或预览,并保留原始列表用于纠错。若后续真的接入执行层,首先需要补清完成验收和失败处理;动态改图、持久化调度、租约恢复和生产级运行都超出本文已验证的边界。
9. 参考与复现范围
Python 3.12 graphlib 官方文档说明了节点映射到前驱的输入方式,以及 prepare()、get_ready()、done() 与 is_active() 的接口约定。本文的列表合同、整份拒绝策略和模拟事件结构均为本例自行定义。
核对日期:2026 年 10 月 7 日。在线文档显示 Python 3.12.15;本地实测 Python 3.12.14。演示仅运行标准库依赖图和单元测试;没有模型调用、真实工具节点、外部服务或用户资料读取。三张图是依据本文合成计划绘制的原创示意图。
10. 2026-10-13 补测:一批回执报错,不等于整批没处理
如果把完成回执攒成一批,其中一项重复,捕获异常后能否原样重试?在本次 CPython 3.12.14 实验里,答案是不能据此假定整批尚未处理:done("read_a", "read_a", "read_b") 已经完成第一项,才在第二项抛出 ValueError。
前文的 simulate() 一次校验一个事件,waves() 传入的也是刚发出的合法节点。这次另写批次实验,考察含错误项的多参数调用,不改变前文的代码与结论。
每个案例都新建同一张图,再用 get_ready() 发出两个根节点。节点只代表合成步骤,没有读取资料或调用工具:
read_a → extract_a → compose
read_b → extract_b → compose
首次 get_ready():("read_a", "read_b")
| 情况 | 传给 done 的节点 | 结果 | 随后 get_ready 发出 |
|---|---|---|---|
| 两个合法根节点 | ('read_a', 'read_b') | 成功 | ('extract_a', 'extract_b') |
| 第二项重复 | ('read_a', 'read_a', 'read_b') | ValueError | ('extract_a',) |
| 第二项不存在 | ('read_a', 'missing', 'read_b') | ValueError | ('extract_a',) |
| 第一项不存在 | ('missing', 'read_a', 'read_b') | ValueError | () |
| 第二项尚未发出 | ('read_a', 'extract_a') | ValueError | ('extract_a',) |
| 空批次 | () | 成功 | () |
11. 先分清已完成、已就绪和已发出
错误放在第一项时,两条分支都没有推进;放在合法的 read_a 后面时,下一次 get_ready() 会发出 extract_a。后者也解释了 ("read_a", "extract_a") 为什么报错:第一项让后继满足前驱条件,但后继还没有被 get_ready() 发出,不能在同一批里直接标记完成。
get_ready() 本身会改变发出状态。表中每行都只在开头和批次调用之后各调用一次,并记录返回节点;不能把它当作可以随意重复的只读状态查询。
另一个独立案例连续提交两次同样的重复批次,中间不调用 get_ready()。两次都报错,随后只发出 extract_a。测试再单独标记 read_b,才能发出另一条分支:
{
"after_explicit_read_b_completion": [
"extract_b"
],
"after_replay": [
"extract_a"
],
"errors": [
"ValueError",
"ValueError"
]
}3.12 官方接口文档规定了已发出节点的完成与错误条件,没有承诺整批回滚。与实测版本一致的 v3.12.14 源码在同一循环内逐项检查、标记完成并更新后继,再处理下一项。这与本次观察到的合法前缀已经生效一致。
这里单独完成 read_b 只是证明它仍未完成。实验没有真实工作结果,因此不能把这段演示用作重跑工具或恢复生产任务的依据。
12. 整批先验完,再做第一次图修改
补充代码中的 Coordinator 先检查整份事件列表。列表长度限定为 1 到 5;每项恰有 node、success、error 三个字段;节点必须是普通字符串、本批未重复、已经发出且尚未完成;成功标记必须是 True,错误必须是 None。整数 1 不算布尔成功。
全部通过后,才把节点复制为不可变元组,调用一次 done(*batch)。六份错误输入分别是:后项重复、后项未知、首项未知、后继未发出、后项报告失败,以及后项用整数 1 表示成功。它们得到同一组结果:
{
"completed": [],
"done_calls": 0,
"error": "BatchRejected",
"released": []
}修正为两个已经发出的根节点后,六个案例都只调用一次 done,完成集合为 ["read_a", "read_b"],下一次发出两条 extract 分支。完整输入与逐例结果在后面的 JSON 中;这一步修正的是被预检拒绝的合成事件,不是重试一个已经发生不确定修改的原生批次。
预检依赖单一所有者、固定图和一致的记录;检查到应用之间没有 await、回调或其他线程修改状态。它只把这些可预见的输入错误挡在第一次图修改之前,不把 done 变成事务,也不验证回执来源或真实工作是否成功。
13. 调用中途意外出错,就停止使用这份协调记录
测试故意让底层 done 先完成第一个节点,再抛出 RuntimeError。协调器捕获该调用抛出的 Exception,设置 stopped=True,并拒绝后续的 take_ready() 和 complete_batch()。
此时 completed 还是空集合,因为原生调用没有正常返回;它已不能代表图的实际完成状态。测试绕过协调器,直接对底层排序器调用一次 get_ready(),仍得到 ("extract_a",),明确证明先前的修改没有撤销。这个绕过只用于故障实验,不是公开恢复接口。
保护范围仅是 done 调用内抛出的 Exception。它不覆盖 BaseException、进程终止,或原生调用成功后 completed.update 自身失败,也没有线程安全、崩溃原子性或回滚保证。如何凭可信记录重建状态、如何避免真实工具重复执行,仍需要另外设计。
14. 完整源码、固定输出和八种错误实现
用 CPython 3.12.14 在同一目录保存以下三份文件,或下载源码与本次输出。只用标准库,不需要安装依赖:
python3 -B batch_completion_lab.py
python3 -B test_batch_completion_lab.py
python3 -B mutation_check.py
batch_completion_lab.py:固定图、全部输入和两种完成方式
"""One fixed graph, synthetic completion events, no workers or external effects."""
from graphlib import TopologicalSorter
import json
import platform
GRAPH = {
"read_a": (),
"read_b": (),
"extract_a": ("read_a",),
"extract_b": ("read_b",),
"compose": ("extract_a", "extract_b"),
}
def fresh():
sorter = TopologicalSorter(GRAPH)
sorter.prepare()
assert sorted(sorter.get_ready()) == ["read_a", "read_b"]
return sorter
def native_case(nodes):
sorter = fresh()
error = None
try:
sorter.done(*nodes)
except ValueError as exc:
error = type(exc).__name__
# get_ready is a mutating issuance operation, not a passive observation.
released = sorted(sorter.get_ready())
return {
"batch": list(nodes),
"error": error,
"released": released,
"active": sorter.is_active(),
}
class BatchRejected(ValueError):
"""Expected input rejection, before the first call to sorter.done."""
class CoordinatorStopped(RuntimeError):
"""Unexpected sorter failure; no rollback or automatic replay promised."""
class Coordinator:
"""Single-owner teaching ledger for this fixed graph; not a scheduler."""
def __init__(self):
self._sorter = TopologicalSorter(GRAPH)
self._sorter.prepare()
self.issued = set()
self.completed = set()
self.done_calls = 0
self.stopped = False
def take_ready(self):
if self.stopped:
raise CoordinatorStopped("coordinator stopped")
ready = tuple(sorted(self._sorter.get_ready()))
self.issued.update(ready)
return ready
def complete_batch(self, events):
if self.stopped:
raise CoordinatorStopped("coordinator stopped")
if type(events) is not list or not 1 <= len(events) <= len(GRAPH):
raise BatchRejected("expected a nonempty bounded event list")
# Copy validated node strings into an immutable tuple before mutation.
# No await, callbacks, worker threads, or independent sorter owners here.
nodes = []
seen = set()
for event in events:
if type(event) is not dict or set(event) != {"node", "success", "error"}:
raise BatchRejected("event fields")
node = event["node"]
if type(node) is not str:
raise BatchRejected("node type")
if node in seen:
raise BatchRejected("duplicate within batch")
if node not in self.issued or node in self.completed:
raise BatchRejected("not an outstanding issued node")
if event["success"] is not True or event["error"] is not None:
raise BatchRejected("not a successful completion")
seen.add(node)
nodes.append(node)
batch = tuple(nodes)
self.done_calls += 1
try:
self._sorter.done(*batch)
except Exception as exc:
# A bug, unexpected runtime fault, or illicit concurrent mutation
# might leave partial state. Stop rather than claiming rollback.
self.stopped = True
raise CoordinatorStopped("unexpected done failure; inspect state") from exc
self.completed.update(batch)
return batch
def event(node, success=True, error=None):
return {"node": node, "success": success, "error": error}
def guarded_case(events):
coordinator = Coordinator()
first = list(coordinator.take_ready())
error = None
try:
coordinator.complete_batch(events)
except BatchRejected as exc:
error = type(exc).__name__
rejected_state = {
"error": error,
"done_calls": coordinator.done_calls,
"completed": sorted(coordinator.completed),
"released": list(coordinator.take_ready()),
}
# These are corrected synthetic events for already-issued roots, not a
# request to rerun completed workers or blindly replay an uncertain batch.
if error:
coordinator.complete_batch([event("read_a"), event("read_b")])
return {
"initial_ready": first,
"after_attempt": rejected_state,
"after_corrected_batch": {
"done_calls": coordinator.done_calls,
"completed": sorted(coordinator.completed),
"released": list(coordinator.take_ready()),
},
}
def replay_case():
sorter = fresh()
batch = ("read_a", "read_a", "read_b")
errors = []
for _ in range(2):
try:
sorter.done(*batch)
except ValueError as exc:
errors.append(type(exc).__name__)
first_release = sorted(sorter.get_ready())
# Only read_b remains outstanding among the two roots.
sorter.done("read_b")
second_release = sorted(sorter.get_ready())
return {"errors": errors, "after_replay": first_release,
"after_explicit_read_b_completion": second_release}
def run():
native_inputs = {
"valid_roots": ("read_a", "read_b"),
"duplicate_later": ("read_a", "read_a", "read_b"),
"unknown_later": ("read_a", "missing", "read_b"),
"unknown_first": ("missing", "read_a", "read_b"),
"newly_ready_but_unissued": ("read_a", "extract_a"),
"empty_native_batch": (),
}
guarded_inputs = {
"duplicate_later": [event("read_a"), event("read_a"), event("read_b")],
"unknown_later": [event("read_a"), event("missing"), event("read_b")],
"unknown_first": [event("missing"), event("read_a"), event("read_b")],
"unissued_successor": [event("read_a"), event("extract_a")],
"failed_later": [event("read_a"), event("read_b", False, "synthetic failure")],
"integer_success_later": [event("read_a"), event("read_b", 1)],
}
return {
"environment": {"python": platform.python_version(), "implementation": platform.python_implementation()},
"graph": GRAPH,
"guarded_inputs": guarded_inputs,
"native": {name: native_case(nodes) for name, nodes in native_inputs.items()},
"replay": replay_case(),
"guarded": {name: guarded_case(events) for name, events in guarded_inputs.items()},
"scope": "fixed graph, single owner, synthetic events; no real tools, model, threads, network, persistence, or crash-atomicity test",
}
if __name__ == "__main__":
print(json.dumps(run(), ensure_ascii=False, indent=2, sort_keys=True))
test_batch_completion_lab.py:12 个测试方法
import unittest
from unittest.mock import patch
from batch_completion_lab import (BatchRejected, Coordinator, CoordinatorStopped,
event, guarded_case, native_case, replay_case, run)
class BatchCompletionTests(unittest.TestCase):
def test_valid_native_batch_releases_both_branches(self):
self.assertEqual(native_case(("read_a", "read_b")), {
"batch": ["read_a", "read_b"], "error": None,
"released": ["extract_a", "extract_b"], "active": True})
def test_invalid_later_mutates_valid_prefix(self):
for nodes in [("read_a", "read_a", "read_b"),
("read_a", "missing", "read_b"),
("read_a", "extract_a")]:
with self.subTest(nodes=nodes):
result = native_case(nodes)
self.assertEqual(result["error"], "ValueError")
self.assertEqual(result["released"], ["extract_a"])
self.assertTrue(result["active"])
def test_invalid_first_does_not_release_any_successor(self):
result = native_case(("missing", "read_a", "read_b"))
self.assertEqual(result["error"], "ValueError")
self.assertEqual(result["released"], [])
def test_empty_native_batch_does_not_complete_roots(self):
result = native_case(())
self.assertIsNone(result["error"])
self.assertEqual(result["released"], [])
self.assertTrue(result["active"])
def test_replaying_native_failed_batch_does_not_finish_read_b(self):
self.assertEqual(replay_case(), {
"errors": ["ValueError", "ValueError"],
"after_replay": ["extract_a"],
"after_explicit_read_b_completion": ["extract_b"]})
def test_all_guarded_rejections_make_zero_done_calls(self):
for name, result in run()["guarded"].items():
with self.subTest(name=name):
self.assertEqual(result["initial_ready"], ["read_a", "read_b"])
self.assertEqual(result["after_attempt"], {
"error": "BatchRejected", "done_calls": 0,
"completed": [], "released": []})
self.assertEqual(result["after_corrected_batch"], {
"done_calls": 1, "completed": ["read_a", "read_b"],
"released": ["extract_a", "extract_b"]})
def test_success_marker_alone_with_error_is_rejected(self):
result = guarded_case([event("read_a"), event("read_b", True, "failure")])
self.assertEqual(result["after_attempt"]["done_calls"], 0)
def test_batch_shape_and_types_reject_before_mutation(self):
invalid = [(), [], [event("read_a")] * 6, [None],
[{"node": "read_a", "success": True}],
[event(1)], [event("read_a"), event("read_b", "true")]]
for events in invalid:
with self.subTest(events=events):
c = Coordinator()
c.take_ready()
with self.assertRaises(BatchRejected):
c.complete_batch(events)
self.assertEqual(c.done_calls, 0)
self.assertEqual(c.completed, set())
def test_complete_before_issuance_is_rejected(self):
c = Coordinator()
with self.assertRaises(BatchRejected):
c.complete_batch([event("read_a")])
self.assertEqual(c.done_calls, 0)
self.assertEqual(c.take_ready(), ("read_a", "read_b"))
def test_already_completed_event_does_not_complete_new_root(self):
c = Coordinator()
c.take_ready()
c.complete_batch([event("read_a")])
with self.assertRaises(BatchRejected):
c.complete_batch([event("read_b"), event("read_a")])
self.assertEqual(c.done_calls, 1)
self.assertEqual(c.completed, {"read_a"})
self.assertEqual(c.take_ready(), ("extract_a",))
def test_valid_guarded_progress_finishes_fixed_graph(self):
c = Coordinator()
self.assertEqual(c.take_ready(), ("read_a", "read_b"))
c.complete_batch([event("read_a"), event("read_b")])
self.assertEqual(c.take_ready(), ("extract_a", "extract_b"))
c.complete_batch([event("extract_a"), event("extract_b")])
self.assertEqual(c.take_ready(), ("compose",))
c.complete_batch([event("compose")])
self.assertEqual(c.take_ready(), ())
self.assertFalse(c._sorter.is_active())
self.assertEqual(len(c.completed), 5)
def test_unexpected_mutating_failure_stops_without_rollback_claim(self):
c = Coordinator()
c.take_ready()
original = c._sorter.done
def partial_then_fail(*nodes):
original(nodes[0])
raise RuntimeError("injected after first mutation")
with patch.object(c._sorter, "done", side_effect=partial_then_fail):
with self.assertRaises(CoordinatorStopped):
c.complete_batch([event("read_a"), event("read_b")])
self.assertTrue(c.stopped)
self.assertEqual(c.completed, set())
with self.assertRaises(CoordinatorStopped):
c.take_ready()
with self.assertRaises(CoordinatorStopped):
c.complete_batch([event("read_b")])
# Test-only direct inspection proves graph mutation was NOT rolled back.
self.assertEqual(c._sorter.get_ready(), ("extract_a",))
if __name__ == "__main__":
unittest.main(verbosity=2)
mutation_check.py:执行八份故意改错的源码
"""Execute intentionally wrong copies in memory; source files stay unchanged."""
from pathlib import Path
from hashlib import sha256
from types import ModuleType
from unittest.mock import patch
import json
SOURCE = Path(__file__).with_name('batch_completion_lab.py').read_text()
SUCCESS = 'if event["success"] is not True or event["error"] is not None:'
OUTSTANDING = 'if node not in self.issued or node in self.completed:'
def assert_equal(actual, expected, label):
if actual != expected:
raise AssertionError(f'{label}: expected {expected!r}, got {actual!r}')
def rejected_without_mutation(m, kind):
c = m.Coordinator()
c.take_ready()
inputs = {
'duplicate': [m.event('read_a'), m.event('read_a'), m.event('read_b')],
'unknown': [m.event('read_a'), m.event('missing'), m.event('read_b')],
'integer_success': [m.event('read_a'), m.event('read_b', 1)],
'error_with_success': [m.event('read_a'), m.event('read_b', True, 'failure')],
}
if kind == 'already_completed':
c.complete_batch([m.event('read_a')])
assert_equal(c.take_ready(), ('extract_a',), 'first branch')
events = [m.event('read_b'), m.event('read_a')]
else:
events = inputs[kind]
before_calls = c.done_calls
before_completed = set(c.completed)
error_type = None
try:
c.complete_batch(events)
except (m.BatchRejected, m.CoordinatorStopped) as exc:
error_type = type(exc).__name__
assert_equal(error_type, 'BatchRejected', 'input error class')
assert_equal(c.done_calls, before_calls, 'native call count')
assert_equal(c.completed, before_completed, 'completion ledger')
assert_equal(c.stopped, False, 'preflight rejection remains usable')
assert_equal(c.take_ready(), (), 'no successor released')
def stops_after_partial_failure(m):
c = m.Coordinator()
c.take_ready()
native_done = c._sorter.done
def partial_then_fail(*nodes):
native_done(nodes[0])
raise RuntimeError('injected after first mutation')
error_type = None
with patch.object(c._sorter, 'done', side_effect=partial_then_fail):
try:
c.complete_batch([m.event('read_a'), m.event('read_b')])
except m.CoordinatorStopped as exc:
error_type = type(exc).__name__
assert_equal(error_type, 'CoordinatorStopped', 'fault class')
assert_equal(c.stopped, True, 'stopped flag')
assert_equal(c.completed, set(), 'ledger not proof of rollback')
for name, operation in [
('take_ready', c.take_ready),
('complete_batch', lambda: c.complete_batch([m.event('read_b')]))]:
stopped = False
try:
operation()
except m.CoordinatorStopped:
stopped = True
assert_equal(stopped, True, name + ' blocked')
# Deliberately bypass the coordinator only to expose the injected mutation.
assert_equal(c._sorter.get_ready(), ('extract_a',), 'no rollback')
MUTATIONS = [
('drop_duplicate_check', 'if node in seen:', 'if False:', 'duplicate'),
('drop_outstanding_check', OUTSTANDING, 'if False:', 'unknown'),
('forget_completed_check', OUTSTANDING, 'if node not in self.issued:', 'already_completed'),
('accept_truthy_success', SUCCESS, 'if not event["success"] or event["error"] is not None:', 'integer_success'),
('ignore_error_field', SUCCESS, 'if event["success"] is not True:', 'error_with_success'),
('mutate_during_preflight', ' nodes.append(node)', ' nodes.append(node)\n self._sorter.done(node)', 'unknown'),
('omit_stopped_flag', ' self.stopped = True', ' self.stopped = False', 'fault'),
('allow_readiness_after_fault', ' if self.stopped:\n raise CoordinatorStopped("coordinator stopped")\n ready =', ' ready =', 'fault'),
]
def module_from(source, name):
module = ModuleType(name)
exec(compile(source, '<' + name + '>', 'exec'), module.__dict__)
return module
def check(module, kind):
if kind == 'fault':
stops_after_partial_failure(module)
else:
rejected_without_mutation(module, kind)
def run():
baseline = module_from(SOURCE, 'baseline')
for kind in ('duplicate', 'unknown', 'already_completed', 'integer_success', 'error_with_success', 'fault'):
check(baseline, kind)
results = []
for name, old, new, kind in MUTATIONS:
if SOURCE.count(old) != 1:
raise RuntimeError('mutation anchor not unique: ' + name)
mutated = SOURCE.replace(old, new)
module = module_from(mutated, name)
# Only an oracle AssertionError counts. Syntax/import/runtime errors fail
# this script rather than being mislabeled a detected behavior change.
try:
check(module, kind)
except AssertionError as exc:
results.append({'name': name, 'oracle': kind, 'detected': True,
'assertion': str(exc),
'source_sha256': sha256(mutated.encode()).hexdigest()})
else:
raise AssertionError('mutant survived: ' + name)
return {'baseline_oracles_passed': 6, 'mutants_executed': len(results),
'mutants_detected_by_assertion': len(results), 'results': results}
if __name__ == '__main__':
print(json.dumps(run(), ensure_ascii=False, indent=2, sort_keys=True))
本次 12 个 unittest 方法全部通过,其中若干包含多个子例。两次独立进程的完整 JSON 逐字节相同;原文 25 项测试也用原代码重新跑过。这里的数量是测试方法数,不是场景覆盖率。
八份错误实现实际执行后,都触发了指定断言:去掉批内去重、去掉发出状态检查、忘记已完成集合、接受真值而非布尔 True、忽略错误字段、边预检边修改图、漏设停止标志,以及停止后仍发出新节点。突变脚本只改内存中的源码副本;语法、导入或其他运行异常不算成功拦截。
本次完整输入与实际 results.json
{
"environment": {
"implementation": "CPython",
"python": "3.12.14"
},
"graph": {
"compose": [
"extract_a",
"extract_b"
],
"extract_a": [
"read_a"
],
"extract_b": [
"read_b"
],
"read_a": [],
"read_b": []
},
"guarded": {
"duplicate_later": {
"after_attempt": {
"completed": [],
"done_calls": 0,
"error": "BatchRejected",
"released": []
},
"after_corrected_batch": {
"completed": [
"read_a",
"read_b"
],
"done_calls": 1,
"released": [
"extract_a",
"extract_b"
]
},
"initial_ready": [
"read_a",
"read_b"
]
},
"failed_later": {
"after_attempt": {
"completed": [],
"done_calls": 0,
"error": "BatchRejected",
"released": []
},
"after_corrected_batch": {
"completed": [
"read_a",
"read_b"
],
"done_calls": 1,
"released": [
"extract_a",
"extract_b"
]
},
"initial_ready": [
"read_a",
"read_b"
]
},
"integer_success_later": {
"after_attempt": {
"completed": [],
"done_calls": 0,
"error": "BatchRejected",
"released": []
},
"after_corrected_batch": {
"completed": [
"read_a",
"read_b"
],
"done_calls": 1,
"released": [
"extract_a",
"extract_b"
]
},
"initial_ready": [
"read_a",
"read_b"
]
},
"unissued_successor": {
"after_attempt": {
"completed": [],
"done_calls": 0,
"error": "BatchRejected",
"released": []
},
"after_corrected_batch": {
"completed": [
"read_a",
"read_b"
],
"done_calls": 1,
"released": [
"extract_a",
"extract_b"
]
},
"initial_ready": [
"read_a",
"read_b"
]
},
"unknown_first": {
"after_attempt": {
"completed": [],
"done_calls": 0,
"error": "BatchRejected",
"released": []
},
"after_corrected_batch": {
"completed": [
"read_a",
"read_b"
],
"done_calls": 1,
"released": [
"extract_a",
"extract_b"
]
},
"initial_ready": [
"read_a",
"read_b"
]
},
"unknown_later": {
"after_attempt": {
"completed": [],
"done_calls": 0,
"error": "BatchRejected",
"released": []
},
"after_corrected_batch": {
"completed": [
"read_a",
"read_b"
],
"done_calls": 1,
"released": [
"extract_a",
"extract_b"
]
},
"initial_ready": [
"read_a",
"read_b"
]
}
},
"guarded_inputs": {
"duplicate_later": [
{
"error": null,
"node": "read_a",
"success": true
},
{
"error": null,
"node": "read_a",
"success": true
},
{
"error": null,
"node": "read_b",
"success": true
}
],
"failed_later": [
{
"error": null,
"node": "read_a",
"success": true
},
{
"error": "synthetic failure",
"node": "read_b",
"success": false
}
],
"integer_success_later": [
{
"error": null,
"node": "read_a",
"success": true
},
{
"error": null,
"node": "read_b",
"success": 1
}
],
"unissued_successor": [
{
"error": null,
"node": "read_a",
"success": true
},
{
"error": null,
"node": "extract_a",
"success": true
}
],
"unknown_first": [
{
"error": null,
"node": "missing",
"success": true
},
{
"error": null,
"node": "read_a",
"success": true
},
{
"error": null,
"node": "read_b",
"success": true
}
],
"unknown_later": [
{
"error": null,
"node": "read_a",
"success": true
},
{
"error": null,
"node": "missing",
"success": true
},
{
"error": null,
"node": "read_b",
"success": true
}
]
},
"native": {
"duplicate_later": {
"active": true,
"batch": [
"read_a",
"read_a",
"read_b"
],
"error": "ValueError",
"released": [
"extract_a"
]
},
"empty_native_batch": {
"active": true,
"batch": [],
"error": null,
"released": []
},
"newly_ready_but_unissued": {
"active": true,
"batch": [
"read_a",
"extract_a"
],
"error": "ValueError",
"released": [
"extract_a"
]
},
"unknown_first": {
"active": true,
"batch": [
"missing",
"read_a",
"read_b"
],
"error": "ValueError",
"released": []
},
"unknown_later": {
"active": true,
"batch": [
"read_a",
"missing",
"read_b"
],
"error": "ValueError",
"released": [
"extract_a"
]
},
"valid_roots": {
"active": true,
"batch": [
"read_a",
"read_b"
],
"error": null,
"released": [
"extract_a",
"extract_b"
]
}
},
"replay": {
"after_explicit_read_b_completion": [
"extract_b"
],
"after_replay": [
"extract_a"
],
"errors": [
"ValueError",
"ValueError"
]
},
"scope": "fixed graph, single owner, synthetic events; no real tools, model, threads, network, persistence, or crash-atomicity test"
}
两次独立输出的哈希对照
{
"runtime": {
"implementation": "CPython",
"python": "3.12.14"
},
"first_sha256": "4d1d619f7932fcd6d8038360f1b8b9f329929a836fbca027270ce104198c24a6",
"second_sha256": "4d1d619f7932fcd6d8038360f1b8b9f329929a836fbca027270ce104198c24a6",
"byte_identical": true
}
八份错误实现的源码哈希与断言结果
{
"baseline_oracles_passed": 6,
"mutants_detected_by_assertion": 8,
"mutants_executed": 8,
"results": [
{
"assertion": "input error class: expected 'BatchRejected', got 'CoordinatorStopped'",
"detected": true,
"name": "drop_duplicate_check",
"oracle": "duplicate",
"source_sha256": "8c3384a8ba8ac1a8eb07805fc8a6204c5d93a37b76f0bc80583e9501138153a5"
},
{
"assertion": "input error class: expected 'BatchRejected', got 'CoordinatorStopped'",
"detected": true,
"name": "drop_outstanding_check",
"oracle": "unknown",
"source_sha256": "618471de28c918107eae540feab9164d65cfc536a44d86397e485b535a2008d6"
},
{
"assertion": "input error class: expected 'BatchRejected', got 'CoordinatorStopped'",
"detected": true,
"name": "forget_completed_check",
"oracle": "already_completed",
"source_sha256": "3c5f7d440def5ed5dcd0395380908c2bd4764c71997769c5e8f6223c7c0ed234"
},
{
"assertion": "input error class: expected 'BatchRejected', got None",
"detected": true,
"name": "accept_truthy_success",
"oracle": "integer_success",
"source_sha256": "d417a24a50a02df46455047958875d0db8d241f6bd3df3cb77d2baecd3ec0558"
},
{
"assertion": "input error class: expected 'BatchRejected', got None",
"detected": true,
"name": "ignore_error_field",
"oracle": "error_with_success",
"source_sha256": "99533b6329907943632439678a17526369ca1f070590ecf5aabf45e8a36199c7"
},
{
"assertion": "no successor released: expected (), got ('extract_a',)",
"detected": true,
"name": "mutate_during_preflight",
"oracle": "unknown",
"source_sha256": "62025063512134fbe7df476a35dc409e86e9d63f647cc184d63ebf1ee0f2443b"
},
{
"assertion": "stopped flag: expected True, got False",
"detected": true,
"name": "omit_stopped_flag",
"oracle": "fault",
"source_sha256": "18fd58129ad7925946ec639ba329d5aa370600bdad0dcb0310ad68ceee7cd7c0"
},
{
"assertion": "take_ready blocked: expected True, got False",
"detected": true,
"name": "allow_readiness_after_fault",
"oracle": "fault",
"source_sha256": "ff015d4856765389ccb799e9391ad9e9699a1a51ceab382f422674994c61072b"
}
]
}
运行说明与文件范围
graphlib 批量完成回执:可独立复现的补充实验
实测:CPython 3.12.14,仅标准库,无需安装依赖。
在解压后的同一目录运行:
python3 -B batch_completion_lab.py
python3 -B test_batch_completion_lab.py
python3 -B mutation_check.py
原文代码也可单独检查:
cd original-body
python3 -B test_plan_lab.py
batch_completion_lab.py:固定五节点图、原生批量调用、失败批次重试与整批预检。
test_batch_completion_lab.py:12 个测试方法;包含多个子例,不是覆盖率或生产验证。
mutation_check.py:在内存中执行八份故意改错的源码;每份都必须触发指定行为断言。
observed/results.json 与 repeat-results.json:两次独立进程的完整实际输出,逐字节相同。
mutations.json:八份错误源码的哈希、检查名和实际断言结果。
repeat-verification.json:两份输出的字节哈希。
original-body/:从原文代码块提取的 plan_lab.py 和 test_plan_lab.py,25 项原测试通过。
official-sources.json:文档与版本固定源码链接。在线文档版本不等于执行版本。
get_ready() 会发出节点并改变状态。所有“released”都记录该次实际调用的返回值。
预检只是同一所有者、固定图、合成内存事件下的输入检查,不提供事务、并发安全或回滚。
异常保护仅处理原生 done 调用抛出的 Exception;测试中的强制检查底层图只用于证明没有回滚。
不运行图中的任务,不调用模型、网络或外部工具,不实现持久化、重启恢复或真实回执认证。
官方资料核对于 2026-10-13:3.12 文档显示 3.12.15;当前文档入口显示 3.15.0,也保留了相关完成与错误条件。本次只运行 CPython 3.12.14,没有据此声称跨版本实测通过。
补测限于一张固定图、一个所有者和合成内存回执,没有运行模型、真实执行器、并发工作线程、外部服务或持久化恢复。捕获批次异常后,先确认哪些状态已经变化,比直接把整批当作未处理更重要。
检查通过就能并行运行全部 ready 节点吗?
还需要另外检查操作授权、资源配额和读写冲突。ready 只说明已声明的依赖条件满足。
库为什么没有报缺失依赖?
TopologicalSorter 会自动加入仅在前驱列表出现的节点。本例在建图前核对所有依赖是否有对应步骤。
get_ready 为空是不是结束了?
不一定。已经发出但未标 done 的节点仍可能在等待完成,应结合 is_active 和执行层状态判断。
执行失败后能调用 done 吗?
done 会释放满足条件的后继。本例对失败或带错误的模拟事件不调用 done,并立即停止演示。