Agent 执行多步计划前,先检查依赖图

两份资料分别读取、抽取,再汇总。计划里只要一个依赖拼错,后面的顺序就可能失真。

1. 先把资料汇总的前后关系写清楚

假设一个 Agent 要整理两份资料:一份 API 说明、一份变更笔记。它先分别读取,再分别抽取要点,最后合成摘要。模型可能一次交回五个步骤,看起来都合理,但其中一个依赖拼错了,或者摘要步骤反过来成了读取步骤的前置条件。等工具开始运行后才发现这些问题,会把计划错误混进执行错误。

这里把检查放在整份计划进入执行层之前。输入是一份候选步骤列表,输出是已经校验过的依赖图;列表不合约定时直接返回错误。读完后,你可以在空目录里复现重复 ID、缺失依赖和环的拒绝过程,还能观察一条分支先完成时,后继怎样提早就绪。

候选计划可以来自模型,也可以手写。本文固定使用手写的合成计划,没有调用模型或运行任何工具节点。read_api 这样的名字只用于标识图中的节点,不对应真实文件、网络请求或 SDK。

read_api 指向 extract_api;read_notes 指向 extract_notes;两个 extract 都指向 compose_digest
图 1 汇总需要两份抽取结果;两条读取分支之间没有声明依赖。

图上箭头按“前置 → 后继”来读,代码却以“节点 → 前驱列表”存储。比如 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 集合内,拼错名称会在建图前被拒绝。程序不自动改拼写,也不猜哪个步骤最接近。

重复 ID、缺失引用、依赖环分别导致整份计划被拒绝
图 2 保留原始列表,才能在覆盖或自动补节点之前发现计划问题。

通过前两遍后,再建立排序器并调用 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 尚未完成;compose_digest 仍等待另一分支
图 3 按完成事件推进时,一条分支无需等待另一条分支的同层步骤。

事件按 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',)
空批次()成功()
批次 read_a、read_a、read_b:第一项已完成,第二项重复报错,第三项未处理;接着 get_ready 发出 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,并立即停止演示。