# Work Graph 节点并行执行——需求文档 **日期**: 2026-08-04(2026-08-05 更新:已确认,标记为核心优先级,进入详细设计阶段) **性质**: 需求文档(BRD/PRD 层面),不含详细技术设计。回应对 `docs/reference/Graph Engineering with Claude_ 14-Step roadmap from 0 to graph architect (Full Course).diff-with-graphlemon.md` 开放问题第 1 条的讨论——"现在的每节点独立子进程设计,断点续跑收益是否值得放弃 fan-out 并行?能否两者兼得?"。详细设计(SDD 层面:`topological_layers()` 的具体实现、线程池调度器接口、锁的粒度)留到需求确认后单独产出。 **状态**: ✅ 已确认。验收口径固化为一句话(2026-08-05 用户原话):"只要 Work Graph 规划了 fan-out、fan-in 的并行节点,就可以在节点触发的时候并行执行"——这与本文档 FR-1/FR-2 一致,无需改动功能需求本身。下一步进入详细设计阶段,见 `docs/todo/graph-engineering-open-items.md` 条目 G。 ## 1. 背景 `src/work_graph_orchestrate.py::run_graph()` 目前用 Kahn 算法把 `graph_manifest` 拍平成一条扁平的拓扑序,用纯 Python `for` 循环逐个节点执行——即使两个节点在 `graph_manifest` 里互相没有 `depends_on` 关系(本可以并行),现在也是严格排队执行,跑完一个才开始下一个。 这是一个记录在案的、有意为之的权衡(见该模块 docstring):早先方案(`docs/graphlemon_architecture.md` §10.1)设想直接复用 Claude Code 原生动态工作流的 `parallel()`/`pipeline()` 来跑整个 Work Graph,但那要求把整张图塞进一次不透明的 `claude` 子进程调用,Python 侧在执行过程中对每个节点的完成状态完全不可见——这跟 REQ-06"进程重启后从已完成节点继续、不重跑"的断点续跑硬需求根本冲突。所以最终选择了"每个节点一次独立的 `claude -p` 子进程调用 + Python 层持久化每个节点的状态",代价是放弃了图内并行。 随着 Work Graph 规模变大(例如 Planner 阶段 PRD/SDD 的多场景/多方案 fan-out 候选,`docs/graphlemon_architecture.md` §6.1 已经设想过、但目前从未真正在生成的 `graph_manifest` 里出现过),纯串行执行会让原本可以同时进行的工作被迫排队,拖长任务的墙钟时间。本需求要解决的问题:**在不牺牲现有断点续跑能力的前提下,让 `graph_manifest` 里互不依赖的节点能够真正并发执行**。 ## 2. 目标 1. `graph_manifest` 中没有依赖关系(无论是直接 `depends_on` 还是通过其他节点间接依赖)的节点集合,能够在同一时间窗口内并发执行,而不是被迫排队。 2. 现有的断点续跑语义(进程重启后,已经落库为 `done` 的节点不重跑,从未完成的节点继续)在并行改造后必须继续成立,不能因为改成并发就丢失这个能力。 3. 现有的每节点独立 worktree 隔离(self-target 场景无条件强制)在并发场景下继续正确工作,不出现多个节点互相踩写文件。 4. 现有的单节点验证-重试机制(`verify_fn`:验证失败则原地重跑一次,验证通过才允许下游节点开始)语义保持不变。 5. 并发规模可控,不因为一次任务的 `graph_manifest` 节点数变多就无限制地同时拉起等量的 `claude` 子进程,导致失控的 token/账单开销或系统资源耗尽。 ## 3. 非目标(本轮明确不做) 1. **不做完整的"随时有依赖满足就调度"的数据流式调度器**。本轮只做"按拓扑层分批 + 层内并发、层间屏障"的粗粒度并行——层边界由 Kahn 算法的天然分层给出,不追求"某个节点的依赖一满足就立刻插队执行,不必等同层其他节点跑完"这种更细粒度的最优调度。理由:现在的 Work Graph 规模是个位数到十几个节点,层级屏障和真正的最优调度之间的墙钟差异预计很小,不值得为此引入更复杂的调度器和更难验证的正确性。 2. **不改变"每个节点是一次独立 `claude -p` 子进程调用"这个既有设计**。本需求只改变"什么时候触发这次调用"(从严格串行改成同层并发触发),不改变每次调用本身的机制(仍然是单节点 workflow 脚本 + `--json-schema` 校验),也不重新尝试把整图塞进一次 Claude 原生 `parallel()`/`pipeline()` 编排脚本。 3. **不引入按节点选模型的策略**。`_render_node_script` 已经预留的 `model` 参数、"哪个节点该用哪个模型"这层策略是独立的开放问题(见另一份对照笔记开放问题第 3 条),本需求不涉及。 4. **不改变 Org-level Loop 的三轮上限、loop-until-dry 机制**。本需求只影响单次 `run_graph()` 调用内部的节点调度,不涉及跨 Org Graph 的重试/收敛逻辑,也不试图让 `graph_manifest` 支持图内有环(`topological_order` 目前对有环图直接报错,这个限制维持不变)。 5. **不承诺降低 token 成本**。并行改造换来的是墙钟时间缩短,节点总数、每个节点的调用量不变,token 总消耗预计基本持平(详见「7. 风险与已知权衡」)。 ## 4. 功能需求 | # | 需求描述 | 优先级 | |---|---|---| | FR-1 | `run_graph()` 在拿到 `graph_manifest` 后,应能识别出可以并发执行的节点分组(同一拓扑层内、彼此无依赖关系的节点集合),而不是像现在这样拍平成一条无结构信息的顺序列表。 | 必须 | | FR-2 | 同一分组内的多个节点应能真正并发触发各自的 `claude -p` 子进程调用,而不是等前一个节点完全结束才开始下一个。 | 必须 | | FR-3 | 并发执行期间,每个节点各自的持久化状态写入(节点行创建、状态流转、执行事件日志)必须保持数据完整性——不能因为多线程同时写同一个 SQLite 连接而出现"database is locked"报错、脏写、或状态丢失。 | 必须 | | FR-4 | 并发执行期间,多个节点各自需要创建 git worktree 时(`needs_worktree=True` 场景)不能相互冲突,每个节点仍然拿到一个独立、干净的工作区。 | 必须 | | FR-5 | 若同一分组内某个节点执行失败(含配额限制 `UsageLimitReachedError`、一般执行错误、`verify_fn` 验证失败且重试后仍不通过),已经在并发执行中的其他兄弟节点应当被允许自然跑完,不因为一个节点失败就中途取消其他正在消耗资源的调用;该分组内全部节点结束后再统一判定整个 `run_graph()` 调用的最终结果(成功/失败/需要升级)。 | 必须 | | FR-6 | 并发度必须可配置且有合理的默认上限,防止一次任务的 `graph_manifest` 节点数暴涨时同时拉起等量的 `claude` 子进程。 | 必须 | | FR-7 | 进程在某个分组执行到一半时中断重启后,恢复执行应能正确识别"这个分组里哪些节点已经 `done`、哪些还没跑完",只重新执行未完成的部分,不重跑已完成节点——这个语义必须和现在的单节点断点续跑完全等价,只是作用范围从"单个节点"扩展到了"一个分组内的多个节点"。 | 必须 | | FR-8 | `verify_fn` 的语义保持不变:一个节点的输出必须先通过验证(或验证失败重试后通过),该节点的结果才能进入下游节点可读取的 `outputs`;依赖该节点的下游节点所在的分组,必须等到这一验证真正完成后才能开始执行,不能提前读取未经验证的中间结果。 | 必须 | | FR-9 | 现有调用方(`e2e_pipeline.py` 等)对 `run_graph()` 的调用方式和返回值格式(`{node_id: output_payload}`)应保持兼容,不需要因为内部改成并行调度而修改调用方代码。 | 应该 | | FR-10 | 并行执行的分组信息(哪些节点被并发调度、每组的起止时间)应当可观测——至少体现在现有的 `execution_event`/日志机制里,方便事后排查"某次运行到底并发了多少节点、并发是否生效"。 | 应该 | ## 5. 验收标准 1. 给定一个 `graph_manifest`,其中包含至少一组"两个节点互不依赖、都依赖同一个上游节点"的结构(例如 A→{B, C}→D),跑一次 `run_graph()`,能观测到 B 和 C 的 `claude -p` 子进程调用在时间上有重叠(不是 B 完全结束后 C 才开始)。 2. 用 mock 的 `run_fn`(不真实调用 `claude`,返回固定输出,可注入人为延迟)写单元测试:断言同一分组内的节点确实并发触发(例如用一个共享计数器/时间戳断言重叠),且分组之间保持严格先后顺序(下一层的节点不会在上一层未全部完成前开始)。 3. 模拟"进程在某个分组执行到一半时中断"(例如分组内两个节点,mock 让其中一个成功落库为 `done`、另一个抛异常模拟中断),重新调用 `run_graph()`,断言已完成的节点不会被重新执行(`run_fn` 没有被再次调用),只有未完成的节点被重新调度。 4. 并发场景下跑一轮包含多个节点写入 SQLite 的测试,不出现 `sqlite3.OperationalError: database is locked` 或类似的并发写入异常。 5. 模拟 `needs_worktree=True` 场景下同一分组内多个节点并发创建 worktree,断言每个节点拿到的 worktree 路径互不相同、互不冲突,且 `git worktree list` 能看到全部预期数量的 worktree。 6. 模拟同一分组内一个节点抛 `UsageLimitReachedError`、另一个节点正常完成,断言正常完成的节点的输出被正确保留(不因为兄弟节点失败而丢失),同时整体调用仍然正确地向上抛出/传播配额限制信号。 7. 现有 `src/test_work_graph_orchestrate.py`、`src/test_work_graph_execute.py`、`src/test_e2e_pipeline.py` 中的既有测试用例在改造后无需修改断言即可继续通过(验证行为兼容性),除非该用例本身就是在断言"严格串行"这一将被改变的实现细节(这类用例需要明确识别出来,逐一确认是否要更新)。 ## 6. 约束与假设 - 复用 `src/backend_server.py::_Server` 里已经验证过的模式:共享一个 `sqlite3.connect(..., check_same_thread=False)` 连接 + `threading.Lock()` 保护并发写入,不引入连接池或每线程独立连接这类更重的方案。 - 复用现有的 `work_node_run.py` 断点续跑机制("已 `done` 的节点短路,直接返回持久化输出"),并行改造不重新设计这一层,只是让触发多个节点执行这件事本身从串行变成并发。 - 复用现有的每节点 worktree 隔离机制(`work_node_run.py::create_worktree`),如果发现并发创建 worktree 本身有 git 层面的竞态,允许在"创建 worktree"这一步单独加锁串行化,不要求整个节点执行都串行——真正耗时的 `claude -p` 调用部分仍然并发。 - 默认并发上限取一个保守值(具体数字留给设计阶段结合实测确定,不在需求阶段写死),避免一次运行同时拉起过多 `claude` 子进程。 ## 7. 风险与已知权衡 - **不降低 token 成本,只缩短墙钟时间**:并行改造让节点调用"同时发生"而不是排队,但总的节点数量、每个节点的 token 消耗不变。如果验收标准或后续讨论里出现"并行后应该更省钱"的预期,需要提前澄清纠正。 - **层级屏障不是最优调度**:如果某一层里有一个节点明显比同层其他节点慢很多,下一层要等这个最慢的节点跑完才能开始,即使下一层里有些节点其实只依赖同层里已经跑完的那些节点。本需求接受这个次优性(见「3. 非目标」第 1 条),如果未来图规模变大、这个次优性变成真实瓶颈,需要另立需求评估数据流式调度器。 - **SQLite 并发写入的锁粒度需要在设计阶段仔细确定**:过粗的锁(整个 `run_graph()` 调用期间持锁)会退化成事实上的串行,失去并行的意义;过细的锁需要更仔细地审视哪些写入操作之间存在真正的竞态。这是详细设计阶段需要重点验证的部分,不是需求阶段能定论的。 - **worktree 创建的 git 层面竞态目前只是推测,未经验证**:需要在设计/实现阶段用真实的 `git worktree add` 并发调用做实测,确认是否真的需要额外加锁,还是 git 本身已经能安全处理并发创建。 ## 8. 开放问题(留给详细设计阶段回答) - [ ] `topological_layers()` 的具体实现:是在现有 `topological_order()` 旁边新增一个函数,还是重构 `topological_order()` 本身让它返回分层结构,由调用方按需拍平? - [ ] 并发上限(`max_workers`)默认值定多少?是否需要按 Org Agent 类型(Planner 偏文档生成、Coder 偏代码修改)给不同的默认上限?是否需要暴露成可配置项(管理后台/环境变量)? - [ ] FR-5 的"该分组内全部节点结束后再统一判定整体结果"具体的异常聚合/优先级规则是什么(比如同组里既有配额限制又有一般执行错误,该抛出哪一个、还是都记录?)? - [ ] 是否需要在 `execution_event` 里新增一种事件类型来标记"分组开始/分组结束",还是复用现有的 `node_start`/`node_completed` 事件、靠时间戳重叠反推并发情况就够用? - [ ] worktree 创建竞态如果实测确认存在,加锁的粒度是"整个 `create_worktree` 函数"还是可以更细(比如只锁 `git worktree add` 这一条命令的调用)?