Commit 6543f525973e61221a8be923fbb4aecc16073306
1 parent
e696cd48
U-031: wire c_work_graph_plan/execute/run_work_node together + U-024.5 WS reconnect refetch
U-031: closes a structural integration gap found during the REQ-01~14 regression audit -- plan_work_graph/execute_work_graph/run_work_node were each individually tested but never actually called together in production code (e2e_pipeline.py's only validated closed loop bypassed all three via flat org_node_trigger calls). Adds work_graph_orchestrate.py (topological sort over graph_manifest + one one-node dynamic-workflow agent() call per Work Node, needed for REQ-06's per-node breakpoint recovery), rewires e2e_pipeline.py's plan_documents/ implement_code onto the real chain, and fixes two bugs a real API run surfaced: graph_manifest's id/node_id/depends_on normalization (work_graph_plan._normalize_graph_manifest) and a missing repo_path in run_work_node's cwd resolution. work_node_run.py also now marks a node 'blocked' instead of leaving it stuck 'running' on any unretryable run_fn exception. U-024.5: the regression audit also found REQ-12's reconnect boundary case (refetch a snapshot after a dropped WS reconnects) was never implemented -- only the dashboard reacted to onStatusChange, and even it never refetched. Fixes mountDashboard/mountTaskBoard/mountTaskDetail (which embeds v_graph_visualizer) to refetch their current snapshot on reconnect. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
13 changed files
with
770 additions
and
92 deletions
docs/development-plan.md
| ... | ... | @@ -237,6 +237,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 |
| 237 | 237 | - **U-024.5(2026-07-30 新增)[Value Path]** REQ-12 完整验收(事件多样性联调) |
| 238 | 238 | - 依赖:U-024, U-015, U-016, U-018, U-019, U-020, U-021(需要 PH2 大部分 Unit 已上线,事件类型覆盖足够丰富才能真正验证 boundary/failure 场景——例如 usage-limit 等待、护栏拦截、Skill 回滚各自产生的 `execution_event` 都要真实出现过) |
| 239 | 239 | - Done:REQ-12 全部 happy/boundary/failure 用例通过;PH3 Phase 验收里的 REQ-12 以本 Unit 为准,U-024 本身只解决"链路通不通" |
| 240 | + - **实现补充(2026-08-01)——U-031 回归审计中发现的真实缺口**:核对本 Unit 是否真的 Done 时,重新对照 06 节 REQ-12 的 boundary 用例原文——"重连后能补齐断线期间遗漏的关键状态(通过重新拉取 task/work_graph_run 当前快照兜底)"——grep 了 `frontend/app.js` 里所有 `onStatusChange` 用法,发现只有 `v_dashboard` 做了一点反应(重连时把断线提示 banner 隐藏),而且连它自己也没有真的重新拉取数据;`v_task_board`/`v_task_detail`(含内嵌的 `v_graph_visualizer`)的 `onStatusChange` 全是空函数 `() => {}`。也就是说这条 boundary 用例此前从未被真正实现过,U-024/U-024.5 之前的"REQ-12 完整通过"是不准确的。修复:`mountDashboard`/`mountTaskBoard`/`mountTaskDetail` 三处的 `connectEventSocket` 都加了 `wasDisconnected` 标志,在状态从 `disconnected` 转回 `connected` 时调用各自页面已有的刷新函数(`refreshTasksAndOrgGraph`+`refreshBudget` / `refresh` / `refresh`)重新拉取当前快照。`v_artifact_browser` 的 `onStatusChange` 保持空——REQ-12 命名的验收范围只到 `v_dashboard`/`v_graph_visualizer`/`v_task_detail`,artifact 浏览器不在其中,不属于这次修复范围。浏览器扩展本次会话不可用,验证方式:`node --check` 过语法 + 全量 `unittest` 回归(361 tests);WS 重连本身的行为逻辑此前已被 `src/test_backend_server.py`/Node 原生 WebSocket 端到端验证过(U-024/U-027),这次只是前端消费侧新增的刷新调用,未改动服务端重连协议本身。 | |
| 240 | 241 | - 覆盖:REQ-12(完整) |
| 241 | 242 | |
| 242 | 243 | - **U-025 [Value Path]** `v_dashboard` + `v_task_board` + `v_task_detail` |
| ... | ... | @@ -288,6 +289,15 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 |
| 288 | 289 | |
| 289 | 290 | - **U-031 [Risk Gate]** REQ-01~14 全量自动化回归套件 |
| 290 | 291 | - 依赖:PH1/PH2/PH3 全部 Unit |
| 292 | + - Done:06 节全部 14 条 REQ 的全部用例通过一次完整回归(原文档缺 Done/覆盖行,2026-08-01 补上) | |
| 293 | + - **实现补充(2026-08-01)——发现并修复一个结构性集成缺口,这是本 Unit 目前为止最重要的产出**:逐条核对 REQ-01~14 验收用例对应的测试覆盖时,发现 REQ-02/REQ-03 验收锚点明确列出的控制器覆盖(`c_work_graph_plan`/`c_work_graph_execute`/`c_work_node_run`)——也就是"Org Graph 触发 → Work Graph 规划 → Work Graph 执行 → 每个 Work Node 独立运行"这套两层架构——虽然在 U-011/U-012/U-013/U-020/U-021 各自都建好、也各自做过完整单元测试(U-013/U-021 甚至做过真实 git worktree 验证),但 `plan_work_graph()`/`execute_work_graph()`/`run_work_node()` 这三个函数彼此之间、以及和唯一真正跑通验证过的闭环(U-014 的 `e2e_pipeline.py`,snake-game 那次真实生成)之间,从未被真正接起来过——`e2e_pipeline.py` 走的是完全独立、更简单的路径(对 Planner/Coder/Validator 各打一次 `org_node_trigger.trigger_org_node()` 直连调用),完全绕开了这套两层架构。**这个决策已跟用户确认(AskUserQuestion),选择立即补上集成而不是把现状订正进文档了事**,因为 REQ-06(断点恢复)的验收要求"重启后不重跑已完成节点",只有 `work_node_run.py` 的按节点 Python 层持久化状态能做到,这和"整个 Work Graph 包在一次不透明的 `claude -p` 调用里"(`execute_work_graph()` 原本设想的用法)根本互斥,所以这个缺口不是"锦上添花",是 REQ-06 能不能算真正满足的前提。 | |
| 294 | + - **设计**:新增 `src/work_graph_orchestrate.py`,每个 Work Node 的真实执行走**它自己单独的一次动态工作流调用**(`write_workflow_script` + `execute_work_graph`),不是把整张图塞进一次调用——这是刻意的取舍,牺牲了 03-C §3.5 原本"多节点编排摊薄到一次调用里,编排本身不消耗 token"的效率立意,换来 REQ-06 要求的按节点级持久化/断点恢复。`topological_order()` 对 `graph_manifest` 做拓扑排序,同时读 `depends_on` 和 `edges` 并合并(原因见下一条发现)。`build_prompt(node, outputs)` 在拓扑序上逐节点调用,`outputs` 里已经有该节点全部上游依赖的真实产出内容,天然支持"prd 的 prompt 需要嵌入 brd 的真实内容"这种链式依赖,也天然支持断点恢复(`run_work_node` 对已 `done` 节点直接返回持久化结果,恢复执行时上游内容照样能取到)。 | |
| 295 | + - **`work_node_run.py` 补了一个真实 bug**:原来只捕获 `_UsageLimitSignal`,任何其他异常(比如新集成会遇到的 `ClassifierBlockedError`)会直接从 `run_work_node()` 冒泡出去,`work_node_execution.status` 永远停在 `running`,没有任何"为什么卡住了"的记录。改成任何非 usage-limit 异常都先把节点标记 `blocked` 再重新抛出。 | |
| 296 | + - **真实调用验证时连续发现两个集成 bug,都已修复**:① 第一次真实跑 `plan_work_graph()` 时,模型返回的 `graph_manifest` 每个节点用的是 `id` 而不是文档要求的 `node_id`,而且完全没填任何节点的 `depends_on`(依赖关系只体现在 `edges` 里)——`plan_work_graph()` 本身从来没做过 schema 校验(`--json-schema` 配 `--agent` 不生效,05 §5.4.1.1 早就记录过),这是真实模型输出会偏离文档契约的活例子。新增 `work_graph_plan._normalize_graph_manifest()`,在 parse 完、持久化前统一把 `id`→`node_id`、从 `edges` 反推缺失的 `depends_on`——这样不只是我这个新集成受益,`v_task_detail` 的 to-do 树、`v_graph_visualizer` 的 DAG 分层以后遇到真实(而不是测试里手写整齐的)manifest 时也不会因为字段名对不上而显示错误。② 修好第一个 bug 后,第二次真实跑又炸了:`work_graph_orchestrate.run_graph()` 调 `run_work_node()` 时忘了传 `repo_path`,`cwd` 解析成 `None`,写工作流脚本直接崩——两次真实调用暴露的都是"单元测试全绿但没人真正把这几个函数接起来跑过"的典型症状,修完重跑,真实 4 篇文档(BRD→PRD→SDD→Benchmark)全部生成成功、血缘链正确、内容长度合理。 | |
| 297 | + - **`agentType` + `schema` 能否在动态工作流的 `agent()` 调用里正确共存,是这次集成最大的未知数,做了两次独立真实验证**:先用一个最小 spike(一个只有一次 `agent({agentType:'planner', schema:{...}})` 调用的一节点工作流)验证——不仅 `structured_output` 正确返回,追问角色身份时模型明确自称"Planning Agent (PL)",说明迁移后的 SOUL.md 人设真的通过 `agentType` 正确加载了(不是走到某个通用 agent);后续完整 `plan_documents()` 真实跑通又在真实的四节点场景下再次确认。记录在 05 §5.4.1.5。 | |
| 298 | + - **Coder 阶段的范围决策**:REQ-03 的验收锚点只写了 `c_work_node_run`,没写 `c_work_graph_plan`/`c_work_graph_execute`——因为"规划一个真正的多模块代码图"是另一个完全不同的 LLM 能力,这次没有人要求建,也没有 SDD 规定怎么建,属于超出这次集成范围的新能力,不是"补上就完事"的小活。`implement_code()` 因此只用一个合成的单节点 manifest 走 `work_graph_orchestrate.run_graph()`,让 `c_work_node_run` 对 Coder 也真正生效,但没有尝试造一个"Coder 图规划器"。`needs_worktree` 参数留在 `run_graph()` 里但暂时两处调用都没启用——worktree 隔离要求 `.claude/agents/<org_node>.md` 已经**提交**到分支上(`git worktree add` 只看已提交内容,`deploy_agent_definitions()` 目前只写工作区文件不 commit),这个"部署后要不要顺手提交"的问题这次没有一并解决,留作后续发现,不是遗漏是有意不在这次范围内展开。真实验证也只覆盖了 Planner 全流程(Coder/Validator 走同一套机制,机制本身已经被 Planner 的真实跑通证明,没有再花真钱跑一次完整 Coder 会话,跟 `test_e2e_pipeline_e2e.py` 一贯的成本节制原则一致)。 | |
| 299 | + - **Validator 阶段未改动**:REQ-04 的覆盖从来没提过 Work Graph 控制器(只有 `c_bug_record_create`/`p_validator_root_cause_trace`),`validate_implementation()` 原来的直连调用已经如实对应它自己的 REQ,不需要动。 | |
| 300 | + - 覆盖:01-M `work_graph_run`/`work_node_execution`(真正被生产代码写入,不再只是测试 fixture);03-C `c_work_graph_plan`/`c_work_graph_execute`/`c_work_node_run`(首次真正接通);REQ-01~14 全部 | |
| 291 | 301 | |
| 292 | 302 | - **U-032 [Risk Gate]** 多 Goal 并发(`org_concurrency_config`)+ 长时间运行稳定性压测 |
| 293 | 303 | - 依赖:U-031 | ... | ... |
docs/sdd/05-实施约束与依赖.md
| ... | ... | @@ -61,6 +61,13 @@ |
| 61 | 61 | - **应对方式**:参照 `create_server()` 已有的 `db_path` 参数模式,加了 `skill_repo_path` 参数:默认值是 `skill_query._REPO_ROOT`(graphlemon 真实仓库根,生产环境下这就是唯一正确的行为——Skill 变更本来就应该落到这里),但可以在构造 server 时覆盖;测试统一传一个 `tempfile` 建的临时仓库。**这个参数只在 Python 层(进程启动/测试构造)可设,绝不通过 HTTP 请求体/query string 暴露给客户端**——否则等于开了一个"客户端指定任意目录、服务端替它跑 git commit"的口子,属于路径注入/任意写入范畴。 |
| 62 | 62 | - 这不是"发现"的约束(不是实测跑出来的意外行为),是设计阶段就该考虑到、写代码时主动做的防护,记录下来是为了以后改这块代码的人不要图省事把 `skill_repo_path` 从请求里读出来。 |
| 63 | 63 | |
| 64 | +## 5.4.1.5 动态工作流内部 `agent()` 的 `agentType`+`schema` 组合已实测可用(发现,2026-08-01,U-031) | |
| 65 | + | |
| 66 | +- 背景:U-031 补 Work Graph 三模块集成(`work_graph_orchestrate.py`)时,每个 Work Node 的真实执行都要用一份一次性的动态工作流脚本,脚本内唯一的一次 `agent()` 调用需要同时做到两件事——① 加载目标 org_node(Planner/Coder/Validator)迁移后的人设(`agentType: '<org_node>'`,对应 `.claude/agents/<org_node>.md`),② 拿到结构化输出(`schema: {...}`)。这和 §5.4.1.1 记录的"顶层 `--agent` + `--json-schema` 不能同时生效"表面上是同一类组合,但机制完全不同:§5.4.1.1 是 CLI 顶层参数 `claude --agent ... --json-schema ...`;这里是动态工作流脚本**内部** JS DSL 的 `agent(prompt, {agentType, schema})` 调用,二者互不影响,不能假设前者的限制也适用于后者——必须单独验证,不能凭直觉套用。 | |
| 67 | +- **实测确认(两次独立验证)**:① 先用一个最小 spike(仅一个一节点工作流,唯一动作是 `agent({agentType:'planner', schema:{type:'object',...}})`)验证——`structured_output` 正确按 schema 返回,且额外追问角色身份时模型明确自称"Planning Agent (PL)",证明迁移后的 SOUL.md 人设确实通过 `agentType` 正确加载(不是退化成某个通用 agent 后蒙对了格式);② 后续 `e2e_pipeline.plan_documents()` 完整四节点(BRD→PRD→SDD→Benchmark)真实跑通,再次在真实多节点场景下确认这个组合稳定可用。 | |
| 68 | +- **实际用法**:`work_graph_orchestrate._render_node_script()` 渲染的脚本固定形如 `const result = await agent(prompt, { agentType: <org_node>, schema: <output_schema> })`,`execute_work_graph()` 直接把这个一次性脚本文件写到 `.claude/workflows/` 后调用 `claude -p 'Run /<name>...'` 执行,不复用同一个脚本名跑第二个节点(每个 `node_exec_id` 生成自己独立的脚本文件名,避免并发/重试之间互相覆盖)。 | |
| 69 | +- 同 §5.4.1.1/§5.4.1.2:这条结论是实测发现的,官方文档没有专门说明动态工作流 `agent()` 调用里 `agentType`+`schema` 的组合行为,不排除后续 Claude Code 版本变化后需要重新验证。 | |
| 70 | + | |
| 64 | 71 | ## 5.4.2 Claude Code plan 额度等待重试参数(架构文档 7.5 节决策,2026-07-29) |
| 65 | 72 | |
| 66 | 73 | - 退避轮询间隔(5→10→20→30 分钟,上限 30 分钟)、超时兜底阈值(session 8 小时、weekly 9 天)是 MVP 阶段的硬编码默认值,不在管理后台开放用户配置,和 `log_retention_config` 的"保留天数可配、清理频率不可配"是同一个 MVP 简化原则 | ... | ... |
docs/sdd/unit_matrix.json
| ... | ... | @@ -39,7 +39,7 @@ |
| 39 | 39 | {"id": "U-022", "phase": "PH3", "sub_phase": "PH3-S1", "name": "v_project_setup + c_pick_folder 全平台(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-007", "U-008"], "coverage": {"model": [], "view": ["v_project_setup"], "controller": ["c_pick_folder", "c_project_create"], "process": [], "requirement": ["REQ-11"]}, "done": "REQ-11 里 v_project_setup/c_pick_folder/c_project_create 部分通过;c_pick_folder 补齐 Windows/Linux;c_project_create 首次接上 HTTP 路由;REQ-11 完整 happy(含建 task)随 U-023 累计满足"}, |
| 40 | 40 | {"id": "U-023", "phase": "PH3", "sub_phase": "PH3-S1", "name": "v_goal_composer + v_bug_feedback(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-022"], "coverage": {"model": [], "view": ["v_goal_composer", "v_bug_feedback"], "controller": ["c_task_create", "c_bug_report_create"], "process": [], "requirement": []}, "done": "能通过 UI 创建 task 和人工 bug_record;补上 c_bug_report_create 的 project_id 必填缺口(03-C input_contract 遗漏);related_task_id 简化为文本输入,等 U-025 的 c_task_list 落地后换真下拉"}, |
| 41 | 41 | {"id": "U-024", "phase": "PH3", "sub_phase": "PH3-S2", "name": "c_ws_subscribe + execution_event 实时推送(最小连通版,见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-012", "U-013"], "coverage": {"model": ["execution_event"], "view": [], "controller": ["c_ws_subscribe"], "process": [], "requirement": ["REQ-12"]}, "done": "推送链路本身连通,≤2 秒延迟达成;REQ-12 happy 用例通过(boundary/failure 留给 U-024.5);手写 RFC 6455 握手+帧编解码(无第三方 websockets 依赖),轮询式推送(无单一写路径可插桩),Node 原生 WebSocket 客户端验证过真实互操作性"}, |
| 42 | - {"id": "U-024.5", "phase": "PH3", "sub_phase": "PH3-S2", "name": "REQ-12 完整验收:事件多样性联调(2026-07-30 新增)", "classification": "Value Path", "depends_on": ["U-024", "U-015", "U-016", "U-018", "U-019", "U-020", "U-021"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": ["REQ-12"]}, "done": "REQ-12 全部 happy/boundary/failure 用例通过;PH3 Phase 验收里的 REQ-12 以本 Unit 为准"}, | |
| 42 | + {"id": "U-024.5", "phase": "PH3", "sub_phase": "PH3-S2", "name": "REQ-12 完整验收:事件多样性联调(见 development-plan.md 2026-08-01 实现补充)", "classification": "Value Path", "depends_on": ["U-024", "U-015", "U-016", "U-018", "U-019", "U-020", "U-021"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": ["REQ-12"]}, "done": "REQ-12 全部 happy/boundary/failure 用例通过;PH3 Phase 验收里的 REQ-12 以本 Unit 为准。boundary 用例(重连补齐断线期间遗漏状态)此前从未真正实现,仅 v_dashboard 有 UI banner 切换、无数据重拉——2026-08-01 于 U-031 回归审计中发现并补上:v_dashboard/v_task_board/v_task_detail(含内嵌的 v_graph_visualizer)的 connectEventSocket onStatusChange 均已在 disconnected→connected 转换时重新拉取当前快照;v_artifact_browser 不在 REQ-12 命名范围内,未改动"}, | |
| 43 | 43 | {"id": "U-025", "phase": "PH3", "sub_phase": "PH3-S2", "name": "v_dashboard + v_task_board + v_task_detail(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": [], "view": ["v_dashboard", "v_task_board", "v_task_detail"], "controller": ["c_task_list", "c_task_get", "c_org_loop_route"], "process": [], "requirement": []}, "done": "三个页面可正常展示实时数据;补上此前无 Unit 认领的 c_task_list/c_task_get HTTP 路由,c_org_loop_route 首次接上 HTTP;v_graph_visualizer/v_log_viewer 子模块占位等 U-026/U-027"}, |
| 44 | 44 | {"id": "U-026", "phase": "PH3", "sub_phase": "PH3-S2", "name": "v_graph_visualizer(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": [], "view": ["v_graph_visualizer"], "controller": [], "process": [], "requirement": []}, "done": "Work Graph DAG 分层视图含 waiting_for_quota 信息态;Org Graph 层复用 v_task_detail 已有的 org-strip 面板,不重复渲染"}, |
| 45 | 45 | {"id": "U-027", "phase": "PH3", "sub_phase": "PH3-S3", "name": "execution_log_file + 日志查看器(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": ["execution_log_file", "log_retention_config"], "view": ["v_log_viewer"], "controller": ["c_execution_log_get", "c_ws_log_tail"], "process": ["p_log_retention_cleanup"], "requirement": ["REQ-13"]}, "done": "REQ-13 happy/boundary/failure 通过;补上此前从未存在的 append_log 写路径;顺带修了 U-024 就存在的 server_close() 并发 segfault 隐患(conn_lock 未覆盖)"}, |
| ... | ... | @@ -47,7 +47,7 @@ |
| 47 | 47 | {"id": "U-029", "phase": "PH3", "sub_phase": "PH3-S3", "name": "v_skill_library + v_skill_audit_log(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-018"], "coverage": {"model": [], "view": ["v_skill_library", "v_skill_audit_log"], "controller": ["c_skill_diff", "c_skill_list", "c_skill_get", "c_skill_rollback"], "process": [], "requirement": []}, "done": "Skill 库与变更审计日志可查看;c_skill_rollback 首次接上 HTTP(skill_repo_path 可注入,测试绝不碰真实仓库)"}, |
| 48 | 48 | {"id": "U-030", "phase": "PH3", "sub_phase": "PH3-S4", "name": "并发/预算/日志保留配置页(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-019", "U-021"], "coverage": {"model": ["org_concurrency_config"], "view": ["v_concurrency_settings", "v_budget_settings", "v_log_retention_settings"], "controller": ["c_concurrency_config_update", "c_budget_config_update", "c_log_retention_config_update"], "process": [], "requirement": []}, "done": "三个配置页可用且改动生效;PH3 至此全部完成"}, |
| 49 | 49 | |
| 50 | - {"id": "U-031", "phase": "PH4", "sub_phase": "PH4-S1", "name": "REQ-01~14 全量自动化回归", "classification": "Risk Gate", "depends_on": ["U-014", "U-017", "U-020", "U-021", "U-022", "U-023", "U-024.5", "U-025", "U-026", "U-027", "U-028", "U-029", "U-030"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": ["REQ-01", "REQ-02", "REQ-03", "REQ-04", "REQ-05", "REQ-06", "REQ-07", "REQ-08", "REQ-09", "REQ-10", "REQ-11", "REQ-12", "REQ-13", "REQ-14"]}, "done": "06 节全部 14 条 REQ 的全部用例通过一次完整回归"}, | |
| 50 | + {"id": "U-031", "phase": "PH4", "sub_phase": "PH4-S1", "name": "REQ-01~14 全量自动化回归(见 development-plan.md 2026-08-01 实现补充)", "classification": "Risk Gate", "depends_on": ["U-014", "U-017", "U-020", "U-021", "U-022", "U-023", "U-024.5", "U-025", "U-026", "U-027", "U-028", "U-029", "U-030"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": ["REQ-01", "REQ-02", "REQ-03", "REQ-04", "REQ-05", "REQ-06", "REQ-07", "REQ-08", "REQ-09", "REQ-10", "REQ-11", "REQ-12", "REQ-13", "REQ-14"]}, "done": "06 节全部 14 条 REQ 的全部用例通过一次完整回归;回归审计中发现 c_work_graph_plan/c_work_graph_execute/c_work_node_run 三个模块彼此隔离测试通过但从未被生产代码真正串联(唯一验证过的闭环 e2e_pipeline.py 绕开了三者,直接用 org_node_trigger),已按用户决策现场补上集成:新增 src/work_graph_orchestrate.py(拓扑排序 + 每节点一份独立动态工作流脚本调用 run_work_node),e2e_pipeline.py 的 plan_documents/implement_code 改为真调用 plan_work_graph→run_graph;真实 API 验证过程中发现并修复两个真实 bug(graph_manifest 的 id/node_id 字段名及 depends_on 缺失见 work_graph_plan._normalize_graph_manifest;run_work_node 缺 repo_path 导致 cwd=None)"}, | |
| 51 | 51 | {"id": "U-032", "phase": "PH4", "sub_phase": "PH4-S2", "name": "多 Goal 并发 + 长时间运行稳定性压测", "classification": "Risk Gate", "depends_on": ["U-031"], "coverage": {"model": ["org_concurrency_config"], "view": [], "controller": [], "process": [], "requirement": []}, "done": "并发数调高后多个独立 Goal 可同时稳定运行"}, |
| 52 | 52 | {"id": "U-033", "phase": "PH4", "sub_phase": "PH4-S3", "name": "A-2 假设收口", "classification": "Risk Gate", "depends_on": ["U-004"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": [], "assumption": ["A-2"]}, "done": "A-2 状态确认为 confirmed 或记录实际偏差"}, |
| 53 | 53 | ... | ... |
frontend/app.js
| ... | ... | @@ -548,6 +548,7 @@ function mountDashboard() { |
| 548 | 548 | refreshBudget(); |
| 549 | 549 | eventTimeline.innerHTML = '<div class="hint">暂无事件</div>'; |
| 550 | 550 | |
| 551 | + let wasDisconnected = false; | |
| 551 | 552 | const socket = connectEventSocket(projectId, null, { |
| 552 | 553 | onEvent: (event) => { |
| 553 | 554 | prependEvent(event); |
| ... | ... | @@ -556,6 +557,16 @@ function mountDashboard() { |
| 556 | 557 | }, |
| 557 | 558 | onStatusChange: (status) => { |
| 558 | 559 | wsBanner.style.display = status === "disconnected" ? "block" : "none"; |
| 560 | + if (status === "disconnected") { | |
| 561 | + wasDisconnected = true; | |
| 562 | + } else if (status === "connected" && wasDisconnected) { | |
| 563 | + // REQ-12 boundary: reconnect doesn't replay every missed event, but | |
| 564 | + // must at least catch up on current state via a fresh snapshot | |
| 565 | + // fetch (03-C §3.14's own documented reconnect story). | |
| 566 | + wasDisconnected = false; | |
| 567 | + refreshTasksAndOrgGraph(); | |
| 568 | + refreshBudget(); | |
| 569 | + } | |
| 559 | 570 | }, |
| 560 | 571 | }); |
| 561 | 572 | registerRouteCleanup(() => socket.close()); |
| ... | ... | @@ -637,11 +648,20 @@ function mountTaskBoard() { |
| 637 | 648 | orgNodeEl.addEventListener("change", refresh); |
| 638 | 649 | refresh(); |
| 639 | 650 | |
| 651 | + let wasDisconnected = false; | |
| 640 | 652 | const socket = connectEventSocket(projectId, null, { |
| 641 | 653 | onEvent: (event) => { |
| 642 | 654 | if (event.event_type === "task_status_changed") refresh(); |
| 643 | 655 | }, |
| 644 | - onStatusChange: () => {}, | |
| 656 | + onStatusChange: (status) => { | |
| 657 | + if (status === "disconnected") { | |
| 658 | + wasDisconnected = true; | |
| 659 | + } else if (status === "connected" && wasDisconnected) { | |
| 660 | + // REQ-12 boundary: catch up via a fresh snapshot on reconnect. | |
| 661 | + wasDisconnected = false; | |
| 662 | + refresh(); | |
| 663 | + } | |
| 664 | + }, | |
| 645 | 665 | }); |
| 646 | 666 | registerRouteCleanup(() => socket.close()); |
| 647 | 667 | } |
| ... | ... | @@ -969,9 +989,18 @@ function mountTaskDetail(taskId) { |
| 969 | 989 | |
| 970 | 990 | refresh(); |
| 971 | 991 | |
| 992 | + let wasDisconnected = false; | |
| 972 | 993 | const socket = connectEventSocket(projectId, taskId, { |
| 973 | 994 | onEvent: () => refresh(), |
| 974 | - onStatusChange: () => {}, | |
| 995 | + onStatusChange: (status) => { | |
| 996 | + if (status === "disconnected") { | |
| 997 | + wasDisconnected = true; | |
| 998 | + } else if (status === "connected" && wasDisconnected) { | |
| 999 | + // REQ-12 boundary: catch up via a fresh snapshot on reconnect. | |
| 1000 | + wasDisconnected = false; | |
| 1001 | + refresh(); | |
| 1002 | + } | |
| 1003 | + }, | |
| 975 | 1004 | }); |
| 976 | 1005 | registerRouteCleanup(() => { |
| 977 | 1006 | socket.close(); | ... | ... |
src/e2e_pipeline.py
| ... | ... | @@ -5,51 +5,82 @@ benchmark/tests and produces a root-cause-attributed verdict). Criteria 4-7 |
| 5 | 5 | (Org-level Loop, breakpoint recovery, ...) are explicitly out of this Unit's |
| 6 | 6 | scope -- PH2 (U-017/U-021). |
| 7 | 7 | |
| 8 | +**Rewired 2026-08-01 (U-031 regression audit)**: `plan_documents` used to | |
| 9 | +call `org_node_trigger.trigger_org_node()` directly, bypassing | |
| 10 | +`work_graph_plan.plan_work_graph()`/`work_graph_execute.execute_work_graph()` | |
| 11 | +/`work_node_run.run_work_node()` entirely -- those three modules were each | |
| 12 | +fully unit-tested (and even real-API-validated) in isolation since PH1/PH2, | |
| 13 | +but nothing in production ever called any of them; this was the one real, | |
| 14 | +validated closed loop, and it used a completely separate, simpler mechanism. | |
| 15 | +REQ-02's own coverage line names `c_work_graph_plan`/`c_work_graph_execute` | |
| 16 | +explicitly, so that gap is now closed: `plan_documents` calls | |
| 17 | +`plan_work_graph()` for a real graph_manifest, then | |
| 18 | +`work_graph_orchestrate.run_graph()` executes each node for real (see that | |
| 19 | +module's docstring for why each node gets its own one-node dynamic-workflow | |
| 20 | +script rather than one big multi-node script -- REQ-06 breakpoint recovery | |
| 21 | +needs it). `implement_code` (Coder) goes through the same per-node | |
| 22 | +mechanism with a synthetic single-node manifest, since REQ-03's own | |
| 23 | +coverage only names `c_work_node_run`, not graph planning -- there is no | |
| 24 | +real "plan a Coder module graph" LLM capability built (a much bigger, | |
| 25 | +separate undertaking; planning docs and planning code modules are | |
| 26 | +different problems), so a one-node graph is the honest minimum that makes | |
| 27 | +`c_work_node_run` genuinely load-bearing for Coder too. | |
| 28 | +`validate_implementation` (Validator) is unchanged -- REQ-04's own | |
| 29 | +coverage never named the Work Graph controllers at all. | |
| 30 | + | |
| 8 | 31 | Status-transition bookkeeping (task.status, precondition guards) stays |
| 9 | 32 | U-010's job (org_node_lifecycle.trigger_org_node_full) -- called once per |
| 10 | -phase with a lightweight "confirm ready" prompt. Content generation is | |
| 11 | -separate, plain org_node_trigger.trigger_org_node calls (no status | |
| 12 | -side-effects) so each document/step's real text is available to write as an | |
| 13 | -artifact -- trigger_org_node_full's own contract (a status dict, no text) | |
| 14 | -isn't changed to accommodate this. | |
| 33 | +phase with a lightweight "confirm ready" prompt. | |
| 15 | 34 | """ |
| 16 | 35 | |
| 36 | +import json | |
| 17 | 37 | import os |
| 18 | 38 | import subprocess |
| 39 | +import uuid | |
| 19 | 40 | |
| 20 | -from src import artifact_write, org_node_lifecycle, org_node_trigger, task_create | |
| 41 | +from src import ( | |
| 42 | + artifact_write, org_node_lifecycle, org_node_trigger, task_create, | |
| 43 | + work_graph_orchestrate, work_graph_plan, | |
| 44 | +) | |
| 21 | 45 | |
| 22 | 46 | _READY_PROMPT = "Confirm you're ready to begin. Take no other action yet." |
| 23 | 47 | |
| 48 | +_DOC_INPUT_SCHEMA = {"type": "object", "required": ["node_id"]} | |
| 49 | +_DOC_OUTPUT_SCHEMA = {"type": "object", "required": ["content"]} | |
| 50 | + | |
| 24 | 51 | _DOC_PROMPTS = { |
| 25 | 52 | "brd": ( |
| 26 | 53 | "Write a BRD (Business Requirement Document) for this goal, following your own " |
| 27 | 54 | "SOUL.md methodology: {goal_text}\n" |
| 28 | - "Output ONLY the BRD content in Markdown -- no preamble, no meta-commentary about " | |
| 29 | - "what you're about to do." | |
| 55 | + "Reply with the document content (Markdown) via the required schema -- no preamble, " | |
| 56 | + "no meta-commentary about what you're about to do." | |
| 30 | 57 | ), |
| 31 | 58 | "prd": ( |
| 32 | 59 | "Here is the approved BRD for goal '{goal_text}':\n\n{brd_content}\n\n" |
| 33 | 60 | "Write the PRD (Product Requirement Document) based on this BRD. " |
| 34 | - "Output ONLY the PRD content in Markdown." | |
| 61 | + "Reply with the document content (Markdown) via the required schema." | |
| 35 | 62 | ), |
| 36 | 63 | "sdd": ( |
| 37 | 64 | "Here is the approved PRD for goal '{goal_text}':\n\n{prd_content}\n\n" |
| 38 | 65 | "Write the SDD (System Design Document) based on this PRD, including a Development " |
| 39 | - "Unit breakdown. Output ONLY the SDD content in Markdown." | |
| 66 | + "Unit breakdown. Reply with the document content (Markdown) via the required schema." | |
| 40 | 67 | ), |
| 41 | 68 | "benchmark": ( |
| 42 | 69 | "Here is the approved SDD for goal '{goal_text}':\n\n{sdd_content}\n\n" |
| 43 | 70 | "Write the Benchmark/acceptance-test document based on this SDD: concrete test " |
| 44 | 71 | "scenarios (happy/boundary/failure) with observable pass criteria. " |
| 45 | - "Output ONLY the Benchmark content in Markdown." | |
| 72 | + "Reply with the document content (Markdown) via the required schema." | |
| 46 | 73 | ), |
| 47 | 74 | } |
| 48 | 75 | |
| 76 | +_CODE_INPUT_SCHEMA = {"type": "object", "required": ["node_id"]} | |
| 77 | +_CODE_OUTPUT_SCHEMA = {"type": "object", "required": ["response"]} | |
| 78 | + | |
| 49 | 79 | _CODER_PROMPT = ( |
| 50 | 80 | "Implement this goal per the approved SDD below. Write real code and real tests, run the " |
| 51 | 81 | "tests yourself, and only stop once they pass (or once you've exhausted reasonable attempts " |
| 52 | - "and can explain why not). Goal: {goal_text}\n\nSDD:\n\n{sdd_content}" | |
| 82 | + "and can explain why not). Reply via the required schema with a `response` field describing " | |
| 83 | + "what you did and the real test commands/output. Goal: {goal_text}\n\nSDD:\n\n{sdd_content}" | |
| 53 | 84 | ) |
| 54 | 85 | |
| 55 | 86 | _VALIDATOR_PROMPT = ( |
| ... | ... | @@ -61,42 +92,86 @@ _VALIDATOR_PROMPT = ( |
| 61 | 92 | ) |
| 62 | 93 | |
| 63 | 94 | |
| 64 | -def plan_documents(conn, task_id, run_id, goal_text, target_folder_path, timeout=300): | |
| 65 | - """Architecture §11 criterion 1: real Planner calls producing BRD->PRD->SDD | |
| 66 | - ->Benchmark, each written via c_artifact_write with correct lineage. | |
| 67 | - Returns {"brd": artifact_id, "prd": ..., "sdd": ..., "benchmark": ...}. | |
| 95 | +def _planner_prompt_builder(goal_text, node_artifact_types): | |
| 96 | + """node_artifact_types: dict[node_id] -> artifact_type, read off the | |
| 97 | + real graph_manifest before running it (build_prompt only ever sees one | |
| 98 | + node/outputs-so-far pair at a time, not the whole manifest).""" | |
| 99 | + def build_prompt(node, outputs): | |
| 100 | + node_id = node["node_id"] | |
| 101 | + artifact_type = node_artifact_types.get(node_id, node_id) | |
| 102 | + content_kwargs = {} | |
| 103 | + for dep_id in (node.get("depends_on") or []): | |
| 104 | + dep_output = outputs.get(dep_id) | |
| 105 | + if dep_output is not None: | |
| 106 | + dep_type = node_artifact_types.get(dep_id, dep_id) | |
| 107 | + content_kwargs[dep_type + "_content"] = dep_output["content"] | |
| 108 | + return _DOC_PROMPTS[artifact_type].format(goal_text=goal_text, **content_kwargs) | |
| 109 | + return build_prompt | |
| 110 | + | |
| 111 | + | |
| 112 | +def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300): | |
| 113 | + """Architecture §11 criterion 1: a real Planner Work Graph | |
| 114 | + (c_work_graph_plan -> per-node c_work_graph_execute/c_work_node_run via | |
| 115 | + work_graph_orchestrate.run_graph) producing BRD->PRD->SDD->Benchmark, | |
| 116 | + each written via c_artifact_write with correct lineage. | |
| 117 | + Returns {"run_id": ..., "artifact_ids": {artifact_type: artifact_id}}. | |
| 68 | 118 | """ |
| 119 | + plan_result = work_graph_plan.plan_work_graph( | |
| 120 | + conn, "planner", task_id, goal_text, target_folder_path, timeout=timeout | |
| 121 | + ) | |
| 122 | + run_id = plan_result["run_id"] | |
| 123 | + graph_manifest = plan_result["graph_manifest"] | |
| 124 | + node_artifact_types = {n["node_id"]: n.get("artifact_type", n["node_id"]) for n in graph_manifest["nodes"]} | |
| 125 | + | |
| 126 | + outputs = work_graph_orchestrate.run_graph( | |
| 127 | + conn, run_id, "planner", "planner-doc-writing", target_folder_path, graph_manifest, | |
| 128 | + _planner_prompt_builder(goal_text, node_artifact_types), _DOC_INPUT_SCHEMA, _DOC_OUTPUT_SCHEMA, | |
| 129 | + ) | |
| 130 | + | |
| 69 | 131 | artifact_ids = {} |
| 70 | - contents = {} | |
| 71 | - prev_type = None | |
| 72 | - for artifact_type in ("brd", "prd", "sdd", "benchmark"): | |
| 73 | - prompt = _DOC_PROMPTS[artifact_type].format( | |
| 74 | - goal_text=goal_text, **{k + "_content": v for k, v in contents.items()} | |
| 75 | - ) | |
| 76 | - content = org_node_trigger.trigger_org_node("planner", prompt, target_folder_path, timeout=timeout) | |
| 77 | - deps = [artifact_ids[prev_type]] if prev_type else [] | |
| 132 | + for node in work_graph_orchestrate.topological_order(graph_manifest): | |
| 133 | + node_id = node["node_id"] | |
| 134 | + artifact_type = node_artifact_types[node_id] | |
| 135 | + deps = [ | |
| 136 | + artifact_ids[node_artifact_types[dep_id]] | |
| 137 | + for dep_id in (node.get("depends_on") or []) | |
| 138 | + if node_artifact_types.get(dep_id) in artifact_ids | |
| 139 | + ] | |
| 78 | 140 | result = artifact_write.write_artifact( |
| 79 | 141 | conn, target_folder_path, artifact_type, task_id, run_id, |
| 80 | - ".graphlemon/runs/{}/{}.md".format(task_id, artifact_type), content, | |
| 142 | + ".graphlemon/runs/{}/{}.md".format(task_id, artifact_type), outputs[node_id]["content"], | |
| 81 | 143 | depends_on_artifact_ids=deps, |
| 82 | 144 | ) |
| 83 | 145 | artifact_ids[artifact_type] = result["artifact_id"] |
| 84 | - contents[artifact_type] = content | |
| 85 | - prev_type = artifact_type | |
| 86 | - return artifact_ids | |
| 146 | + return {"run_id": run_id, "artifact_ids": artifact_ids} | |
| 87 | 147 | |
| 88 | 148 | |
| 89 | -def implement_code(conn, task_id, run_id, sdd_content, goal_text, target_folder_path, timeout=1200): | |
| 90 | - """Architecture §11 criterion 2: real Coder call, then registers whatever | |
| 91 | - the Coder actually committed as code_diff (the diff itself) and | |
| 92 | - test_report (the Coder's own response text, which per its migrated SOUL.md | |
| 93 | - is required to include real test commands + real output) artifacts. | |
| 149 | +def implement_code(conn, task_id, sdd_content, goal_text, target_folder_path, timeout=1200): | |
| 150 | + """Architecture §11 criterion 2: a real Coder call via c_work_node_run | |
| 151 | + (work_graph_orchestrate.run_graph with a synthetic single-node | |
| 152 | + manifest -- see module docstring for why there's no real Coder graph | |
| 153 | + planner), then registers whatever the Coder actually committed as | |
| 154 | + code_diff (the diff itself) and test_report (the Coder's own response | |
| 155 | + text, which per its migrated SOUL.md is required to include real test | |
| 156 | + commands + real output) artifacts. | |
| 94 | 157 | """ |
| 95 | 158 | before_sha = _current_commit(target_folder_path) |
| 96 | - response = org_node_trigger.trigger_org_node( | |
| 97 | - "coder", _CODER_PROMPT.format(goal_text=goal_text, sdd_content=sdd_content), | |
| 98 | - target_folder_path, timeout=timeout, | |
| 159 | + run_id = str(uuid.uuid4()) | |
| 160 | + manifest = {"nodes": [{"node_id": "implement", "depends_on": []}], "edges": []} | |
| 161 | + conn.execute( | |
| 162 | + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) " | |
| 163 | + "VALUES (?, ?, 'coder', ?, 'running')", | |
| 164 | + (run_id, task_id, json.dumps(manifest)), | |
| 165 | + ) | |
| 166 | + conn.commit() | |
| 167 | + | |
| 168 | + outputs = work_graph_orchestrate.run_graph( | |
| 169 | + conn, run_id, "coder", "coder-implementation", target_folder_path, manifest, | |
| 170 | + lambda node, outputs: _CODER_PROMPT.format(goal_text=goal_text, sdd_content=sdd_content), | |
| 171 | + _CODE_INPUT_SCHEMA, _CODE_OUTPUT_SCHEMA, timeout=timeout, | |
| 99 | 172 | ) |
| 173 | + response = outputs["implement"]["response"] | |
| 174 | + | |
| 100 | 175 | diff = subprocess.run( |
| 101 | 176 | ["git", "diff", before_sha, "HEAD"], cwd=target_folder_path, capture_output=True, text=True |
| 102 | 177 | ).stdout |
| ... | ... | @@ -115,7 +190,11 @@ def implement_code(conn, task_id, run_id, sdd_content, goal_text, target_folder_ |
| 115 | 190 | def validate_implementation(task_id, benchmark_content, goal_text, target_folder_path, timeout=1200): |
| 116 | 191 | """Architecture §11 criterion 3: real Validator call, parses the |
| 117 | 192 | 'VERDICT: PASS|FAIL|INCONCLUSIVE' line this Unit's prompt asks for. |
| 118 | - Returns {"verdict", "raw_response"}. | |
| 193 | + Returns {"verdict", "raw_response"}. Unchanged by the 2026-08-01 | |
| 194 | + rewiring -- REQ-04's own coverage never named the Work Graph | |
| 195 | + controllers (c_bug_record_create/p_validator_root_cause_trace only), | |
| 196 | + so the simpler flat trigger_org_node call is already faithful to what | |
| 197 | + this REQ actually requires. | |
| 119 | 198 | """ |
| 120 | 199 | response = org_node_trigger.trigger_org_node( |
| 121 | 200 | "validator", |
| ... | ... | @@ -145,30 +224,19 @@ def run_end_to_end(conn, target_folder_path, goal_text): |
| 145 | 224 | validate_implementation. Returns a dict with every intermediate result, |
| 146 | 225 | for inspection/debugging rather than hiding it behind a single verdict. |
| 147 | 226 | """ |
| 148 | - project_id = conn.execute("SELECT project_id FROM project WHERE target_folder_path = ?", (target_folder_path,)).fetchone()[0] | |
| 227 | + project_id = conn.execute( | |
| 228 | + "SELECT project_id FROM project WHERE target_folder_path = ?", (target_folder_path,) | |
| 229 | + ).fetchone()[0] | |
| 149 | 230 | task_id = task_create.create_task(conn, project_id, goal_text)["task_id"] |
| 150 | 231 | |
| 151 | 232 | org_node_lifecycle.trigger_org_node_full(conn, "planner", task_id, target_folder_path, _READY_PROMPT) |
| 152 | - # trigger_org_node_full itself doesn't create a work_graph_run row (that's | |
| 153 | - # U-011's plan_work_graph, a separate call this pipeline doesn't invoke, | |
| 154 | - # since REQ-02's graph_manifest and this pipeline's actual document | |
| 155 | - # content are orthogonal concerns) -- artifact.produced_by_run_id is a | |
| 156 | - # real FK, so a row has to exist; this one just records that the e2e | |
| 157 | - # pipeline (not a real planned Work Graph) produced these artifacts | |
| 158 | - run_id = "e2e-" + task_id | |
| 159 | - conn.execute( | |
| 160 | - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) " | |
| 161 | - "VALUES (?, ?, 'planner', '{}', 'running')", | |
| 162 | - (run_id, task_id), | |
| 163 | - ) | |
| 164 | - conn.commit() | |
| 165 | - | |
| 166 | - artifact_ids = plan_documents(conn, task_id, run_id, goal_text, target_folder_path) | |
| 233 | + plan_result = plan_documents(conn, task_id, goal_text, target_folder_path) | |
| 234 | + artifact_ids = plan_result["artifact_ids"] | |
| 167 | 235 | sdd_content = _read_artifact_content(conn, target_folder_path, artifact_ids["sdd"]) |
| 168 | 236 | benchmark_content = _read_artifact_content(conn, target_folder_path, artifact_ids["benchmark"]) |
| 169 | 237 | |
| 170 | 238 | org_node_lifecycle.trigger_org_node_full(conn, "coder", task_id, target_folder_path, _READY_PROMPT) |
| 171 | - code_artifact_ids = implement_code(conn, task_id, run_id, sdd_content, goal_text, target_folder_path) | |
| 239 | + code_artifact_ids = implement_code(conn, task_id, sdd_content, goal_text, target_folder_path) | |
| 172 | 240 | |
| 173 | 241 | org_node_lifecycle.trigger_org_node_full(conn, "validator", task_id, target_folder_path, _READY_PROMPT) |
| 174 | 242 | verdict = validate_implementation(task_id, benchmark_content, goal_text, target_folder_path) | ... | ... |
src/test_e2e_pipeline.py
| ... | ... | @@ -6,6 +6,19 @@ correctly, not what a real Planner/Coder/Validator would actually produce. |
| 6 | 6 | Real coverage (as much of it as is safe/affordable to run) is in |
| 7 | 7 | src/test_e2e_pipeline_e2e.py (RUN_REAL_AGENT_E2E=1). |
| 8 | 8 | |
| 9 | +**Rewired 2026-08-01 (U-031 regression audit)**: plan_documents/ | |
| 10 | +implement_code now go through work_graph_plan.plan_work_graph + | |
| 11 | +work_graph_orchestrate.run_graph (which calls work_graph_execute. | |
| 12 | +execute_work_graph per node) instead of a flat org_node_trigger. | |
| 13 | +trigger_org_node call -- see e2e_pipeline.py's module docstring for why. | |
| 14 | +plan_work_graph is mocked at its own subprocess boundary (same pattern as | |
| 15 | +test_work_graph_plan.py) so its real graph_manifest-parsing logic and its | |
| 16 | +real work_graph_run INSERT both still run for real; execute_work_graph is | |
| 17 | +mocked directly (one level higher than its own subprocess boundary) since | |
| 18 | +this file's job is testing e2e_pipeline's wiring, not re-proving | |
| 19 | +execute_work_graph's own mechanics (covered in test_work_graph_execute.py) | |
| 20 | +or work_graph_orchestrate's own mechanics (test_work_graph_orchestrate.py). | |
| 21 | + | |
| 9 | 22 | Run: python3 -m unittest src.test_e2e_pipeline -v |
| 10 | 23 | """ |
| 11 | 24 | |
| ... | ... | @@ -15,27 +28,33 @@ import tempfile |
| 15 | 28 | import unittest |
| 16 | 29 | from unittest.mock import patch |
| 17 | 30 | |
| 18 | -from src import e2e_pipeline, project_scaffold, schema | |
| 31 | +from src import e2e_pipeline, org_node_trigger, project_scaffold, schema, work_graph_execute | |
| 19 | 32 | |
| 20 | 33 | _FAKE_DOCS = {"brd": "# BRD\nfake", "prd": "# PRD\nfake", "sdd": "# SDD\nfake", "benchmark": "# Benchmark\nfake"} |
| 21 | 34 | |
| 35 | +_FAKE_GRAPH_MANIFEST = { | |
| 36 | + "nodes": [ | |
| 37 | + {"node_id": "brd", "artifact_type": "brd", "depends_on": []}, | |
| 38 | + {"node_id": "prd", "artifact_type": "prd", "depends_on": ["brd"]}, | |
| 39 | + {"node_id": "sdd", "artifact_type": "sdd", "depends_on": ["prd"]}, | |
| 40 | + {"node_id": "benchmark", "artifact_type": "benchmark", "depends_on": ["sdd"]}, | |
| 41 | + ], | |
| 42 | + "edges": [ | |
| 43 | + {"from": "brd", "to": "prd", "edge_type": "needs_judgment"}, | |
| 44 | + {"from": "prd", "to": "sdd", "edge_type": "needs_judgment"}, | |
| 45 | + {"from": "sdd", "to": "benchmark", "edge_type": "needs_judgment"}, | |
| 46 | + ], | |
| 47 | +} | |
| 48 | + | |
| 49 | + | |
| 50 | +def _fake_plan_subprocess_run(*args, **kwargs): | |
| 51 | + payload = {"is_error": False, "result": "```json\n" + json.dumps(_FAKE_GRAPH_MANIFEST) + "\n```"} | |
| 52 | + return subprocess.CompletedProcess(args=["claude"], returncode=0, stdout=json.dumps(payload), stderr="") | |
| 22 | 53 | |
| 23 | -def _fake_trigger(org_node, prompt, target_folder_path, timeout=300): | |
| 24 | - if prompt == e2e_pipeline._READY_PROMPT: | |
| 25 | - return "Ready." | |
| 26 | - if "BRD (Business" in prompt: | |
| 27 | - return _FAKE_DOCS["brd"] | |
| 28 | - if "PRD (Product" in prompt: | |
| 29 | - return _FAKE_DOCS["prd"] | |
| 30 | - if "SDD (System" in prompt: | |
| 31 | - return _FAKE_DOCS["sdd"] | |
| 32 | - if "Benchmark/acceptance-test" in prompt: | |
| 33 | - return _FAKE_DOCS["benchmark"] | |
| 34 | - if prompt.startswith("Implement this goal"): | |
| 35 | - return "Implemented. Ran `pytest`: 3 passed." | |
| 36 | - if prompt.startswith("Independently verify"): | |
| 37 | - return "VERDICT: PASS\nAll benchmark scenarios reproduced and passing." | |
| 38 | - raise AssertionError("unexpected prompt: " + prompt[:80]) | |
| 54 | + | |
| 55 | +def _fake_ready_trigger(org_node, prompt, target_folder_path, timeout=300): | |
| 56 | + assert prompt == e2e_pipeline._READY_PROMPT | |
| 57 | + return "Ready." | |
| 39 | 58 | |
| 40 | 59 | |
| 41 | 60 | class PipelineTestBase(unittest.TestCase): |
| ... | ... | @@ -58,17 +77,18 @@ class PlanDocumentsTest(PipelineTestBase): |
| 58 | 77 | "VALUES ('task-1', (SELECT project_id FROM project), '实现贪吃蛇游戏', 'goal', " |
| 59 | 78 | "'planning_brd_prd_sdd', 'planner')" |
| 60 | 79 | ) |
| 61 | - self.conn.execute( | |
| 62 | - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) " | |
| 63 | - "VALUES ('run-1', 'task-1', 'planner', '{}', 'running')" | |
| 64 | - ) | |
| 65 | 80 | self.conn.commit() |
| 66 | 81 | |
| 67 | - with patch("src.org_node_trigger.trigger_org_node", side_effect=_fake_trigger): | |
| 68 | - artifact_ids = e2e_pipeline.plan_documents( | |
| 69 | - self.conn, "task-1", "run-1", "实现贪吃蛇游戏", self.tmp.name | |
| 70 | - ) | |
| 82 | + with patch("src.work_graph_plan.subprocess.run", side_effect=_fake_plan_subprocess_run), \ | |
| 83 | + patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 84 | + patch.object(work_graph_execute, "execute_work_graph") as mock_execute: | |
| 85 | + mock_execute.side_effect = [ | |
| 86 | + {"content": _FAKE_DOCS["brd"]}, {"content": _FAKE_DOCS["prd"]}, | |
| 87 | + {"content": _FAKE_DOCS["sdd"]}, {"content": _FAKE_DOCS["benchmark"]}, | |
| 88 | + ] | |
| 89 | + plan_result = e2e_pipeline.plan_documents(self.conn, "task-1", "实现贪吃蛇游戏", self.tmp.name) | |
| 71 | 90 | |
| 91 | + artifact_ids = plan_result["artifact_ids"] | |
| 72 | 92 | self.assertEqual(set(artifact_ids), {"brd", "prd", "sdd", "benchmark"}) |
| 73 | 93 | prd_deps = json.loads( |
| 74 | 94 | self.conn.execute( |
| ... | ... | @@ -83,11 +103,25 @@ class PlanDocumentsTest(PipelineTestBase): |
| 83 | 103 | ) |
| 84 | 104 | self.assertEqual(sdd_deps, [artifact_ids["prd"]]) |
| 85 | 105 | |
| 106 | + # plan_work_graph's own real work_graph_run INSERT actually ran (not bypassed) | |
| 107 | + run_row = self.conn.execute( | |
| 108 | + "SELECT org_node, status FROM work_graph_run WHERE run_id = ?", (plan_result["run_id"],) | |
| 109 | + ).fetchone() | |
| 110 | + self.assertEqual(run_row, ("planner", "planning")) | |
| 111 | + | |
| 86 | 112 | |
| 87 | 113 | class RunEndToEndTest(PipelineTestBase): |
| 88 | 114 | def test_full_pipeline_wiring(self): |
| 89 | - with patch("src.org_node_lifecycle.trigger_org_node", side_effect=_fake_trigger), \ | |
| 90 | - patch("src.org_node_trigger.trigger_org_node", side_effect=_fake_trigger): | |
| 115 | + with patch("src.org_node_lifecycle.trigger_org_node", side_effect=_fake_ready_trigger), \ | |
| 116 | + patch("src.work_graph_plan.subprocess.run", side_effect=_fake_plan_subprocess_run), \ | |
| 117 | + patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 118 | + patch.object(work_graph_execute, "execute_work_graph") as mock_execute, \ | |
| 119 | + patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS\nAll scenarios pass."): | |
| 120 | + mock_execute.side_effect = [ | |
| 121 | + {"content": _FAKE_DOCS["brd"]}, {"content": _FAKE_DOCS["prd"]}, | |
| 122 | + {"content": _FAKE_DOCS["sdd"]}, {"content": _FAKE_DOCS["benchmark"]}, | |
| 123 | + {"response": "Implemented. Ran `pytest`: 3 passed."}, | |
| 124 | + ] | |
| 91 | 125 | result = e2e_pipeline.run_end_to_end(self.conn, self.tmp.name, "实现贪吃蛇游戏") |
| 92 | 126 | |
| 93 | 127 | self.assertEqual(result["verdict"]["verdict"], "PASS") |
| ... | ... | @@ -104,6 +138,14 @@ class RunEndToEndTest(PipelineTestBase): |
| 104 | 138 | ).fetchone()[0] |
| 105 | 139 | self.assertEqual(all_artifacts, 6) # brd, prd, sdd, benchmark, code_diff, test_report |
| 106 | 140 | |
| 141 | + # planner and coder each got their own work_graph_run (not sharing one) | |
| 142 | + run_org_nodes = { | |
| 143 | + row[0] for row in self.conn.execute( | |
| 144 | + "SELECT org_node FROM work_graph_run WHERE task_id = ?", (result["task_id"],) | |
| 145 | + ).fetchall() | |
| 146 | + } | |
| 147 | + self.assertEqual(run_org_nodes, {"planner", "coder"}) | |
| 148 | + | |
| 107 | 149 | |
| 108 | 150 | class ValidateImplementationTest(unittest.TestCase): |
| 109 | 151 | def test_parses_pass_verdict(self): | ... | ... |
src/test_e2e_pipeline_e2e.py
| ... | ... | @@ -29,14 +29,12 @@ class RealPlanDocumentsTest(unittest.TestCase): |
| 29 | 29 | agent_profile.deploy_agent_definitions(tmp) |
| 30 | 30 | project_id = conn.execute("SELECT project_id FROM project").fetchone()[0] |
| 31 | 31 | task_id = task_create.create_task(conn, project_id, "实现贪吃蛇游戏")["task_id"] |
| 32 | - conn.execute( | |
| 33 | - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) " | |
| 34 | - "VALUES ('run-1', ?, 'planner', '{}', 'running')", | |
| 35 | - (task_id,), | |
| 36 | - ) | |
| 37 | - conn.commit() | |
| 38 | 32 | |
| 39 | - artifact_ids = e2e_pipeline.plan_documents(conn, task_id, "run-1", "实现贪吃蛇游戏", tmp) | |
| 33 | + # plan_documents (2026-08-01 rewiring) creates its own real | |
| 34 | + # work_graph_run via plan_work_graph() -- no manual seed row | |
| 35 | + # needed anymore. | |
| 36 | + plan_result = e2e_pipeline.plan_documents(conn, task_id, "实现贪吃蛇游戏", tmp) | |
| 37 | + artifact_ids = plan_result["artifact_ids"] | |
| 40 | 38 | |
| 41 | 39 | for artifact_type, artifact_id in artifact_ids.items(): |
| 42 | 40 | row = conn.execute( | ... | ... |
src/test_work_graph_orchestrate.py
0 → 100644
| 1 | +"""U-031 tests: work_graph_orchestrate.py -- the graph_manifest -> per-node | |
| 2 | +execution bridge closed during the REQ-01~14 regression audit. Real sqlite, | |
| 3 | +run_fn mocked at the claude subprocess boundary (execute_work_graph is | |
| 4 | +tested for real elsewhere, in test_work_graph_execute.py / the 2026-08-01 | |
| 5 | +spike -- this module's own job is orchestration, not re-proving that). | |
| 6 | + | |
| 7 | +Run: python3 -m unittest src.test_work_graph_orchestrate -v | |
| 8 | +""" | |
| 9 | + | |
| 10 | +import unittest | |
| 11 | +from unittest.mock import patch | |
| 12 | + | |
| 13 | +from src import schema, work_graph_execute, work_graph_orchestrate as wgo | |
| 14 | + | |
| 15 | + | |
| 16 | +class TopologicalOrderTest(unittest.TestCase): | |
| 17 | + def test_linear_chain_via_depends_on(self): | |
| 18 | + manifest = { | |
| 19 | + "nodes": [ | |
| 20 | + {"node_id": "brd", "depends_on": []}, | |
| 21 | + {"node_id": "prd", "depends_on": ["brd"]}, | |
| 22 | + {"node_id": "sdd", "depends_on": ["prd"]}, | |
| 23 | + ], | |
| 24 | + "edges": [], | |
| 25 | + } | |
| 26 | + self.assertEqual([n["node_id"] for n in wgo.topological_order(manifest)], ["brd", "prd", "sdd"]) | |
| 27 | + | |
| 28 | + def test_linear_chain_via_edges_only(self): | |
| 29 | + # real LLM output might populate edges but not each node's own | |
| 30 | + # depends_on -- must still order correctly | |
| 31 | + manifest = { | |
| 32 | + "nodes": [{"node_id": "a"}, {"node_id": "b"}, {"node_id": "c"}], | |
| 33 | + "edges": [{"from": "a", "to": "b"}, {"from": "b", "to": "c"}], | |
| 34 | + } | |
| 35 | + self.assertEqual([n["node_id"] for n in wgo.topological_order(manifest)], ["a", "b", "c"]) | |
| 36 | + | |
| 37 | + def test_diamond_fan_out_fan_in(self): | |
| 38 | + manifest = { | |
| 39 | + "nodes": [ | |
| 40 | + {"node_id": "n1", "depends_on": []}, | |
| 41 | + {"node_id": "n2", "depends_on": ["n1"]}, | |
| 42 | + {"node_id": "n3", "depends_on": ["n1"]}, | |
| 43 | + {"node_id": "n4", "depends_on": ["n2", "n3"]}, | |
| 44 | + ], | |
| 45 | + "edges": [], | |
| 46 | + } | |
| 47 | + order = [n["node_id"] for n in wgo.topological_order(manifest)] | |
| 48 | + self.assertEqual(order[0], "n1") | |
| 49 | + self.assertEqual(order[-1], "n4") | |
| 50 | + self.assertEqual(set(order[1:3]), {"n2", "n3"}) | |
| 51 | + | |
| 52 | + def test_cycle_raises(self): | |
| 53 | + manifest = { | |
| 54 | + "nodes": [{"node_id": "x", "depends_on": ["y"]}, {"node_id": "y", "depends_on": ["x"]}], | |
| 55 | + "edges": [], | |
| 56 | + } | |
| 57 | + with self.assertRaises(ValueError): | |
| 58 | + wgo.topological_order(manifest) | |
| 59 | + | |
| 60 | + def test_independent_nodes_ordered_deterministically(self): | |
| 61 | + manifest = {"nodes": [{"node_id": "b"}, {"node_id": "a"}], "edges": []} | |
| 62 | + self.assertEqual([n["node_id"] for n in wgo.topological_order(manifest)], ["a", "b"]) | |
| 63 | + | |
| 64 | + | |
| 65 | +class RunGraphTestBase(unittest.TestCase): | |
| 66 | + def setUp(self): | |
| 67 | + self.conn = schema.connect(":memory:") | |
| 68 | + schema.create_schema(self.conn) | |
| 69 | + self.conn.execute( | |
| 70 | + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " | |
| 71 | + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')" | |
| 72 | + ) | |
| 73 | + self.conn.execute( | |
| 74 | + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " | |
| 75 | + "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'coding', 'planner')" | |
| 76 | + ) | |
| 77 | + self.conn.execute( | |
| 78 | + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) " | |
| 79 | + "VALUES ('run-1', 'task-1', 'planner', '{}', 'running')" | |
| 80 | + ) | |
| 81 | + self.conn.commit() | |
| 82 | + | |
| 83 | + def tearDown(self): | |
| 84 | + self.conn.close() | |
| 85 | + | |
| 86 | + | |
| 87 | +_OUT_SCHEMA = {"type": "object", "required": ["content"]} | |
| 88 | +_IN_SCHEMA = {"type": "object", "required": ["node_id"]} | |
| 89 | + | |
| 90 | + | |
| 91 | +class RunGraphHappyPathTest(RunGraphTestBase): | |
| 92 | + def test_runs_nodes_in_order_and_returns_outputs(self): | |
| 93 | + manifest = { | |
| 94 | + "nodes": [ | |
| 95 | + {"node_id": "brd", "artifact_type": "brd", "depends_on": []}, | |
| 96 | + {"node_id": "prd", "artifact_type": "prd", "depends_on": ["brd"]}, | |
| 97 | + ], | |
| 98 | + "edges": [], | |
| 99 | + } | |
| 100 | + calls = [] | |
| 101 | + | |
| 102 | + def build_prompt(node, outputs): | |
| 103 | + calls.append((node["node_id"], dict(outputs))) | |
| 104 | + return "prompt for " + node["node_id"] | |
| 105 | + | |
| 106 | + with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 107 | + patch.object(work_graph_execute, "execute_work_graph") as mock_execute: | |
| 108 | + mock_execute.side_effect = [{"content": "brd text"}, {"content": "prd text"}] | |
| 109 | + outputs = wgo.run_graph( | |
| 110 | + self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, | |
| 111 | + build_prompt, _IN_SCHEMA, _OUT_SCHEMA, | |
| 112 | + ) | |
| 113 | + | |
| 114 | + self.assertEqual(outputs, {"brd": {"content": "brd text"}, "prd": {"content": "prd text"}}) | |
| 115 | + self.assertEqual(calls[0], ("brd", {})) | |
| 116 | + self.assertEqual(calls[1], ("prd", {"brd": {"content": "brd text"}})) | |
| 117 | + | |
| 118 | + def test_creates_work_node_execution_rows_and_placeholder_skill(self): | |
| 119 | + manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []} | |
| 120 | + with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 121 | + patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "x"}): | |
| 122 | + wgo.run_graph( | |
| 123 | + self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, | |
| 124 | + lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, | |
| 125 | + ) | |
| 126 | + row = self.conn.execute( | |
| 127 | + "SELECT status, subagent_type, skill_ref FROM work_node_execution WHERE run_id = 'run-1' AND node_id = 'brd'" | |
| 128 | + ).fetchone() | |
| 129 | + self.assertEqual(row, ("done", "planner", "planner-doc-writing")) | |
| 130 | + skill_row = self.conn.execute("SELECT owner_org_node FROM skill WHERE skill_id = 'planner-doc-writing'").fetchone() | |
| 131 | + self.assertEqual(skill_row[0], "planner") | |
| 132 | + | |
| 133 | + def test_resumes_without_rerunning_done_nodes(self): | |
| 134 | + manifest = { | |
| 135 | + "nodes": [ | |
| 136 | + {"node_id": "brd", "depends_on": []}, | |
| 137 | + {"node_id": "prd", "depends_on": ["brd"]}, | |
| 138 | + ], | |
| 139 | + "edges": [], | |
| 140 | + } | |
| 141 | + calls = [] | |
| 142 | + | |
| 143 | + def build_prompt(node, outputs): | |
| 144 | + return "p-" + node["node_id"] | |
| 145 | + | |
| 146 | + with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 147 | + patch.object(work_graph_execute, "execute_work_graph") as mock_execute: | |
| 148 | + mock_execute.side_effect = lambda cwd, name, args, schema, timeout=600: calls.append(name) or {"content": "c-" + name} | |
| 149 | + wgo.run_graph( | |
| 150 | + self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, | |
| 151 | + build_prompt, _IN_SCHEMA, _OUT_SCHEMA, | |
| 152 | + ) | |
| 153 | + self.assertEqual(len(calls), 2) | |
| 154 | + | |
| 155 | + # simulate a restart: run_graph called again for the same run_id | |
| 156 | + with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 157 | + patch.object(work_graph_execute, "execute_work_graph") as mock_execute2: | |
| 158 | + mock_execute2.side_effect = AssertionError("should not be called -- both nodes are already done") | |
| 159 | + outputs = wgo.run_graph( | |
| 160 | + self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, | |
| 161 | + build_prompt, _IN_SCHEMA, _OUT_SCHEMA, | |
| 162 | + ) | |
| 163 | + self.assertEqual(set(outputs.keys()), {"brd", "prd"}) | |
| 164 | + | |
| 165 | + | |
| 166 | +class RunGraphUsageLimitTest(RunGraphTestBase): | |
| 167 | + def test_usage_limit_reached_translates_to_quota_wait(self): | |
| 168 | + manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []} | |
| 169 | + attempts = {"n": 0} | |
| 170 | + | |
| 171 | + def fake_execute(cwd, name, args, output_schema, timeout=600): | |
| 172 | + attempts["n"] += 1 | |
| 173 | + if attempts["n"] == 1: | |
| 174 | + raise work_graph_execute.UsageLimitReachedError("hit your session limit", "session") | |
| 175 | + return {"content": "recovered"} | |
| 176 | + | |
| 177 | + sleeps = [] | |
| 178 | + with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 179 | + patch.object(work_graph_execute, "execute_work_graph", side_effect=fake_execute): | |
| 180 | + outputs = wgo.run_graph( | |
| 181 | + self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, | |
| 182 | + lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, | |
| 183 | + sleep=lambda s: sleeps.append(s), now=lambda: 0.0, | |
| 184 | + ) | |
| 185 | + self.assertEqual(outputs["brd"], {"content": "recovered"}) | |
| 186 | + self.assertEqual(sleeps, [5 * 60]) | |
| 187 | + | |
| 188 | + | |
| 189 | +class RunGraphClassifierBlockedTest(RunGraphTestBase): | |
| 190 | + def test_classifier_blocked_marks_node_blocked_and_propagates(self): | |
| 191 | + manifest = {"nodes": [{"node_id": "brd", "depends_on": []}], "edges": []} | |
| 192 | + with patch.object(work_graph_execute, "write_workflow_script"), \ | |
| 193 | + patch.object( | |
| 194 | + work_graph_execute, "execute_work_graph", | |
| 195 | + side_effect=work_graph_execute.ClassifierBlockedError("blocked by classifier"), | |
| 196 | + ): | |
| 197 | + with self.assertRaises(work_graph_execute.ClassifierBlockedError): | |
| 198 | + wgo.run_graph( | |
| 199 | + self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, | |
| 200 | + lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, | |
| 201 | + ) | |
| 202 | + node_exec_id = self.conn.execute( | |
| 203 | + "SELECT node_exec_id FROM work_node_execution WHERE run_id = 'run-1' AND node_id = 'brd'" | |
| 204 | + ).fetchone()[0] | |
| 205 | + status = self.conn.execute( | |
| 206 | + "SELECT status FROM work_node_execution WHERE node_exec_id = ?", (node_exec_id,) | |
| 207 | + ).fetchone()[0] | |
| 208 | + self.assertEqual(status, "blocked") | |
| 209 | + | |
| 210 | + | |
| 211 | +if __name__ == "__main__": | |
| 212 | + unittest.main() | ... | ... |
src/test_work_graph_plan.py
| ... | ... | @@ -72,6 +72,63 @@ class ExtractGraphManifestTest(unittest.TestCase): |
| 72 | 72 | self.assertIsNone(work_graph_plan._extract_graph_manifest("sorry, I can't do that")) |
| 73 | 73 | |
| 74 | 74 | |
| 75 | +class NormalizeGraphManifestTest(unittest.TestCase): | |
| 76 | + """U-031 regression: a real Planner call (2026-08-01) came back using | |
| 77 | + `id` instead of the documented `node_id`, and never populated any | |
| 78 | + node's own `depends_on` -- dependencies existed only as `edges`. This | |
| 79 | + is a real, observed deviation, not a hypothetical one; the fixture | |
| 80 | + below is the actual shape captured from that call (fields trimmed to | |
| 81 | + what normalization touches).""" | |
| 82 | + | |
| 83 | + def test_already_well_formed_manifest_untouched(self): | |
| 84 | + manifest = { | |
| 85 | + "nodes": [ | |
| 86 | + {"node_id": "brd", "artifact_type": "brd", "depends_on": []}, | |
| 87 | + {"node_id": "prd", "artifact_type": "prd", "depends_on": ["brd"]}, | |
| 88 | + ], | |
| 89 | + "edges": [{"from": "brd", "to": "prd", "edge_type": "needs_judgment"}], | |
| 90 | + } | |
| 91 | + result = work_graph_plan._normalize_graph_manifest(manifest) | |
| 92 | + self.assertEqual(result["nodes"][1]["depends_on"], ["brd"]) | |
| 93 | + | |
| 94 | + def test_id_key_normalized_to_node_id(self): | |
| 95 | + manifest = { | |
| 96 | + "nodes": [{"id": "brd", "artifact_type": "brd"}, {"id": "prd", "artifact_type": "prd"}], | |
| 97 | + "edges": [{"from": "brd", "to": "prd", "edge_type": "needs_judgment"}], | |
| 98 | + } | |
| 99 | + result = work_graph_plan._normalize_graph_manifest(manifest) | |
| 100 | + self.assertEqual([n["node_id"] for n in result["nodes"]], ["brd", "prd"]) | |
| 101 | + | |
| 102 | + def test_depends_on_derived_from_edges_when_absent(self): | |
| 103 | + manifest = { | |
| 104 | + "nodes": [{"id": "brd"}, {"id": "prd"}, {"id": "sdd"}, {"id": "benchmark"}], | |
| 105 | + "edges": [ | |
| 106 | + {"from": "brd", "to": "prd", "edge_type": "needs_judgment"}, | |
| 107 | + {"from": "prd", "to": "sdd", "edge_type": "needs_judgment"}, | |
| 108 | + {"from": "sdd", "to": "benchmark", "edge_type": "needs_judgment"}, | |
| 109 | + ], | |
| 110 | + } | |
| 111 | + result = work_graph_plan._normalize_graph_manifest(manifest) | |
| 112 | + by_id = {n["node_id"]: n for n in result["nodes"]} | |
| 113 | + self.assertEqual(by_id["brd"]["depends_on"], []) | |
| 114 | + self.assertEqual(by_id["prd"]["depends_on"], ["brd"]) | |
| 115 | + self.assertEqual(by_id["sdd"]["depends_on"], ["prd"]) | |
| 116 | + self.assertEqual(by_id["benchmark"]["depends_on"], ["sdd"]) | |
| 117 | + | |
| 118 | + def test_does_not_duplicate_dependency_already_present(self): | |
| 119 | + manifest = { | |
| 120 | + "nodes": [{"node_id": "brd", "depends_on": []}, {"node_id": "prd", "depends_on": ["brd"]}], | |
| 121 | + "edges": [{"from": "brd", "to": "prd", "edge_type": "needs_judgment"}], | |
| 122 | + } | |
| 123 | + result = work_graph_plan._normalize_graph_manifest(manifest) | |
| 124 | + self.assertEqual(result["nodes"][1]["depends_on"], ["brd"]) | |
| 125 | + | |
| 126 | + def test_edge_referencing_unknown_node_id_ignored(self): | |
| 127 | + manifest = {"nodes": [{"id": "brd"}], "edges": [{"from": "brd", "to": "no-such-node"}]} | |
| 128 | + result = work_graph_plan._normalize_graph_manifest(manifest) | |
| 129 | + self.assertEqual(result["nodes"][0]["depends_on"], []) | |
| 130 | + | |
| 131 | + | |
| 75 | 132 | class HappyPathTest(PlanWorkGraphTestBase): |
| 76 | 133 | def test_returns_run_id_and_graph_manifest(self): |
| 77 | 134 | with patch("src.work_graph_plan.subprocess.run") as run: | ... | ... |
src/test_work_node_run.py
| ... | ... | @@ -109,6 +109,24 @@ class HappyPathTest(WorkNodeRunTestBase): |
| 109 | 109 | final_status = self.conn.execute("SELECT status FROM execution_log_file").fetchone()[0] |
| 110 | 110 | self.assertEqual(final_status, "closed") |
| 111 | 111 | |
| 112 | + def test_unretryable_run_fn_failure_marks_node_blocked_not_stuck_running(self): | |
| 113 | + # any exception from run_fn other than a recognized usage-limit | |
| 114 | + # signal (e.g. CLASSIFIER_BLOCKED surfacing from | |
| 115 | + # work_graph_execute.execute_work_graph, 03-C §3.5 point 6) must | |
| 116 | + # not leave the row stuck at status='running' with no record of why | |
| 117 | + class SomeUnretryableError(Exception): | |
| 118 | + pass | |
| 119 | + | |
| 120 | + def boom(cwd, model=None): | |
| 121 | + raise SomeUnretryableError("classifier blocked or similar") | |
| 122 | + | |
| 123 | + with self.assertRaises(SomeUnretryableError): | |
| 124 | + work_node_run.run_work_node(self.conn, "node-1", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA, run_fn=boom) | |
| 125 | + status = self.conn.execute( | |
| 126 | + "SELECT status FROM work_node_execution WHERE node_exec_id = 'node-1'" | |
| 127 | + ).fetchone()[0] | |
| 128 | + self.assertEqual(status, "blocked") | |
| 129 | + | |
| 112 | 130 | |
| 113 | 131 | class RetryOnSchemaFailureTest(WorkNodeRunTestBase): |
| 114 | 132 | def test_retries_then_succeeds(self): | ... | ... |
src/work_graph_orchestrate.py
0 → 100644
| 1 | +"""Bridges `plan_work_graph()`'s `graph_manifest` to real per-node execution | |
| 2 | +via `execute_work_graph()` + `run_work_node()` -- the missing integration | |
| 3 | +between three previously isolated, individually-tested modules (U-011/ | |
| 4 | +U-012/U-013), closed during U-031's regression audit (2026-08-01): each of | |
| 5 | +the three had its own unit tests (and even real-API validation) but none of | |
| 6 | +them had ever actually been called by production code, and the one | |
| 7 | +genuinely-validated closed loop (`e2e_pipeline.py`, U-014) used a much | |
| 8 | +simpler, separate mechanism (flat `org_node_trigger.trigger_org_node()` | |
| 9 | +calls) that bypassed all three. | |
| 10 | + | |
| 11 | +Design: each node's actual work happens via its OWN one-node dynamic | |
| 12 | +workflow script (`write_workflow_script` + `execute_work_graph`), not one | |
| 13 | +big multi-node script for the whole graph. This is a deliberate trade-off, | |
| 14 | +not the "amortize orchestration cost across many nodes in one `claude -p` | |
| 15 | +call" pattern 03-C §3.5 originally motivated `execute_work_graph` with -- | |
| 16 | +REQ-06's breakpoint-recovery requirement (resume after a process restart | |
| 17 | +without re-running already-`done` nodes) is only achievable with per-node, | |
| 18 | +Python-level persisted state (`work_node_run.py`'s whole design), which is | |
| 19 | +fundamentally incompatible with wrapping the WHOLE graph in one opaque | |
| 20 | +`claude` subprocess call Python has no visibility into mid-flight. | |
| 21 | + | |
| 22 | +Validated for real (2026-08-01): a one-node workflow script with a custom | |
| 23 | +`agentType` + `schema` genuinely loads the target org_node's migrated | |
| 24 | +persona (confirmed via a real call asking the agent to self-identify) and | |
| 25 | +returns real `structured_output` -- see 05 §5.4.1.5. | |
| 26 | +""" | |
| 27 | + | |
| 28 | +import json | |
| 29 | +import time | |
| 30 | +import uuid | |
| 31 | + | |
| 32 | +from src import work_graph_execute, work_node_run | |
| 33 | + | |
| 34 | + | |
| 35 | +def topological_order(graph_manifest): | |
| 36 | + """Kahn's algorithm. Dependencies are read from BOTH `node['depends_on']` | |
| 37 | + and `graph_manifest['edges']` and merged -- `plan_work_graph()`'s own | |
| 38 | + output isn't schema-validated (it can't be, `--json-schema` doesn't | |
| 39 | + work with `--agent`, see work_graph_plan.py), so a real LLM response | |
| 40 | + might populate one and not the other; trusting only one would silently | |
| 41 | + mis-order nodes rather than raise. Raises ValueError on a cycle or a | |
| 42 | + dependency referencing an unknown node_id. | |
| 43 | + """ | |
| 44 | + nodes = {n["node_id"]: n for n in graph_manifest["nodes"]} | |
| 45 | + deps = {node_id: set(n.get("depends_on") or []) for node_id, n in nodes.items()} | |
| 46 | + for edge in graph_manifest.get("edges", []): | |
| 47 | + to_id, from_id = edge.get("to"), edge.get("from") | |
| 48 | + if to_id in deps and from_id in nodes: | |
| 49 | + deps[to_id].add(from_id) | |
| 50 | + | |
| 51 | + in_degree = {node_id: len(d) for node_id, d in deps.items()} | |
| 52 | + dependents = {node_id: [] for node_id in nodes} | |
| 53 | + for node_id, d in deps.items(): | |
| 54 | + for dep in d: | |
| 55 | + if dep in dependents: | |
| 56 | + dependents[dep].append(node_id) | |
| 57 | + | |
| 58 | + ready = sorted(node_id for node_id, degree in in_degree.items() if degree == 0) | |
| 59 | + ordered = [] | |
| 60 | + while ready: | |
| 61 | + node_id = ready.pop(0) | |
| 62 | + ordered.append(nodes[node_id]) | |
| 63 | + for dependent in sorted(dependents[node_id]): | |
| 64 | + in_degree[dependent] -= 1 | |
| 65 | + if in_degree[dependent] == 0: | |
| 66 | + ready.append(dependent) | |
| 67 | + ready.sort() | |
| 68 | + if len(ordered) != len(nodes): | |
| 69 | + raise ValueError( | |
| 70 | + "graph_manifest has a dependency cycle or a depends_on/edge references an unknown node_id" | |
| 71 | + ) | |
| 72 | + return ordered | |
| 73 | + | |
| 74 | + | |
| 75 | +def ensure_placeholder_skill(conn, skill_id, owner_org_node): | |
| 76 | + """No Skill content has actually been migrated from the Hermes profiles | |
| 77 | + yet (skill_update.py's own documented gap) -- work_node_execution. | |
| 78 | + skill_ref is a required FK, so a real row has to exist regardless. | |
| 79 | + Idempotent.""" | |
| 80 | + exists = conn.execute("SELECT 1 FROM skill WHERE skill_id = ?", (skill_id,)).fetchone() | |
| 81 | + if exists is None: | |
| 82 | + conn.execute( | |
| 83 | + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) " | |
| 84 | + "VALUES (?, ?, ?, 'unknown', 'new', 'active')", | |
| 85 | + (skill_id, owner_org_node, "skills/" + skill_id + "/"), | |
| 86 | + ) | |
| 87 | + conn.commit() | |
| 88 | + | |
| 89 | + | |
| 90 | +def _ensure_node_row(conn, run_id, node_id, subagent_type, skill_ref, input_schema_ref, output_schema_ref, input_payload): | |
| 91 | + existing = conn.execute( | |
| 92 | + "SELECT node_exec_id FROM work_node_execution WHERE run_id = ? AND node_id = ?", (run_id, node_id) | |
| 93 | + ).fetchone() | |
| 94 | + if existing: | |
| 95 | + return existing[0] | |
| 96 | + node_exec_id = str(uuid.uuid4()) | |
| 97 | + conn.execute( | |
| 98 | + "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, " | |
| 99 | + "input_schema_ref, output_schema_ref, input_payload, status) " | |
| 100 | + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')", | |
| 101 | + (node_exec_id, run_id, node_id, subagent_type, skill_ref, input_schema_ref, output_schema_ref, json.dumps(input_payload)), | |
| 102 | + ) | |
| 103 | + conn.commit() | |
| 104 | + return node_exec_id | |
| 105 | + | |
| 106 | + | |
| 107 | +def _render_node_script(script_name, org_node, prompt, output_schema, model): | |
| 108 | + model_opt = ", model: " + json.dumps(model) if model else "" | |
| 109 | + return ( | |
| 110 | + "export const meta = {{ name: {name}, description: 'graphlemon Work Node ({org_node})' }}\n\n" | |
| 111 | + "const result = await agent({prompt}, {{ agentType: {org_node_json}, schema: {schema}{model_opt} }})\n\n" | |
| 112 | + "return result\n" | |
| 113 | + ).format( | |
| 114 | + name=json.dumps(script_name), org_node=org_node, org_node_json=json.dumps(org_node), | |
| 115 | + prompt=json.dumps(prompt), schema=json.dumps(output_schema), model_opt=model_opt, | |
| 116 | + ) | |
| 117 | + | |
| 118 | + | |
| 119 | +def _make_run_fn(target_folder_path, node_exec_id, org_node, prompt, output_schema, timeout): | |
| 120 | + script_name = "graphlemon-node-" + node_exec_id.replace("-", "") | |
| 121 | + | |
| 122 | + def run_fn(cwd, model=None): | |
| 123 | + script_content = _render_node_script(script_name, org_node, prompt, output_schema, model) | |
| 124 | + work_graph_execute.write_workflow_script(cwd, script_name, script_content) | |
| 125 | + try: | |
| 126 | + return work_graph_execute.execute_work_graph(cwd, script_name, {}, output_schema, timeout=timeout) | |
| 127 | + except work_graph_execute.UsageLimitReachedError as e: | |
| 128 | + work_node_run.raise_usage_limit_signal(e.quota_limit_type) | |
| 129 | + # ClassifierBlockedError / generic WorkGraphExecuteError propagate | |
| 130 | + # as-is -- run_work_node's own `except Exception` clause marks the | |
| 131 | + # node 'blocked' and re-raises, no special handling needed here. | |
| 132 | + | |
| 133 | + return run_fn | |
| 134 | + | |
| 135 | + | |
| 136 | +def run_graph( | |
| 137 | + conn, run_id, org_node, skill_id, target_folder_path, graph_manifest, | |
| 138 | + build_prompt, input_schema, output_schema, timeout=600, needs_worktree=False, | |
| 139 | + sleep=time.sleep, now=time.time, | |
| 140 | +): | |
| 141 | + """Runs every node in `graph_manifest` (topologically ordered) through | |
| 142 | + `run_work_node()`, each backed by a real one-node dynamic-workflow | |
| 143 | + `agent()` call using `org_node`'s migrated persona. | |
| 144 | + | |
| 145 | + `build_prompt(node, outputs)` is called right before running that | |
| 146 | + node -- `outputs` contains every already-completed node's | |
| 147 | + output_payload so far (topological order guarantees a node's | |
| 148 | + dependencies are always already present), so prompts needing upstream | |
| 149 | + content (e.g. "here is the approved BRD...") can be built just-in-time | |
| 150 | + instead of requiring the whole graph's prompts precomputed before | |
| 151 | + anything runs. This also makes resume-after-restart work correctly: | |
| 152 | + `run_work_node` short-circuits already-`done` nodes and returns their | |
| 153 | + persisted output, which lands in `outputs` the same as a freshly-run | |
| 154 | + one would. | |
| 155 | + | |
| 156 | + `needs_worktree=True` (03-C §3.6: "并行写代码的节点必须先申请 | |
| 157 | + git_worktree_ref") isolates each node's actual file changes in its own | |
| 158 | + git worktree, merged back only once the node succeeds -- callers | |
| 159 | + writing code (Coder) should pass this; callers only producing document | |
| 160 | + content (Planner) don't need it. | |
| 161 | + | |
| 162 | + Returns {node_id: output_payload}. | |
| 163 | + """ | |
| 164 | + ensure_placeholder_skill(conn, skill_id, org_node) | |
| 165 | + ordered = topological_order(graph_manifest) | |
| 166 | + outputs = {} | |
| 167 | + for node in ordered: | |
| 168 | + node_id = node["node_id"] | |
| 169 | + node_exec_id = _ensure_node_row( | |
| 170 | + conn, run_id, node_id, org_node, skill_id, | |
| 171 | + "work_graph_node_input.schema.json", "work_graph_node_output.schema.json", | |
| 172 | + {"node_id": node_id}, | |
| 173 | + ) | |
| 174 | + prompt = build_prompt(node, outputs) | |
| 175 | + run_fn = _make_run_fn(target_folder_path, node_exec_id, org_node, prompt, output_schema, timeout) | |
| 176 | + output = work_node_run.run_work_node( | |
| 177 | + conn, node_exec_id, {"node_id": node_id}, input_schema, output_schema, run_fn, | |
| 178 | + needs_worktree=needs_worktree, repo_path=target_folder_path, | |
| 179 | + sleep=sleep, now=now, | |
| 180 | + ) | |
| 181 | + outputs[node_id] = output | |
| 182 | + return outputs | ... | ... |
src/work_graph_plan.py
| ... | ... | @@ -102,6 +102,35 @@ def _extract_graph_manifest(text): |
| 102 | 102 | return None |
| 103 | 103 | |
| 104 | 104 | |
| 105 | +def _normalize_graph_manifest(manifest): | |
| 106 | + """Real LLM output has been observed (2026-08-01, U-031 integration | |
| 107 | + validation) to use `id` instead of the documented `node_id` key, and to | |
| 108 | + express dependencies purely via `edges` (from/to) without ever | |
| 109 | + populating each node's own `depends_on` array -- even though this | |
| 110 | + schema requires both. Normalizing here, right after parsing and before | |
| 111 | + persisting, means every downstream consumer (work_graph_orchestrate.py's | |
| 112 | + topological sort, v_task_detail's to-do tree, v_graph_visualizer's DAG | |
| 113 | + layering) gets a manifest that actually matches the documented shape, | |
| 114 | + instead of each one needing its own defensive "id or node_id, | |
| 115 | + depends_on or edges" fallback logic repeated independently. Mutates and | |
| 116 | + returns `manifest`. | |
| 117 | + """ | |
| 118 | + nodes = manifest.get("nodes", []) | |
| 119 | + for node in nodes: | |
| 120 | + if "node_id" not in node and "id" in node: | |
| 121 | + node["node_id"] = node["id"] | |
| 122 | + node.setdefault("depends_on", []) | |
| 123 | + | |
| 124 | + node_ids = {n.get("node_id") for n in nodes} | |
| 125 | + for edge in manifest.get("edges", []): | |
| 126 | + from_id, to_id = edge.get("from"), edge.get("to") | |
| 127 | + if from_id in node_ids and to_id in node_ids: | |
| 128 | + for node in nodes: | |
| 129 | + if node.get("node_id") == to_id and from_id not in node["depends_on"]: | |
| 130 | + node["depends_on"].append(from_id) | |
| 131 | + return manifest | |
| 132 | + | |
| 133 | + | |
| 105 | 134 | def plan_work_graph(conn, org_node, task_id, goal_text, target_folder_path, timeout=300): |
| 106 | 135 | """Returns {"run_id", "graph_manifest"}. Creates one `work_graph_run` row |
| 107 | 136 | (status='planning') -- moving it to 'running'/executing it is out of scope.""" |
| ... | ... | @@ -134,6 +163,7 @@ def plan_work_graph(conn, org_node, task_id, goal_text, target_folder_path, time |
| 134 | 163 | graph_manifest = _extract_graph_manifest(payload.get("result", "")) |
| 135 | 164 | if not graph_manifest: |
| 136 | 165 | raise PlanningFailedError("could not extract a graph_manifest for task {}: {}".format(task_id, payload)) |
| 166 | + graph_manifest = _normalize_graph_manifest(graph_manifest) | |
| 137 | 167 | |
| 138 | 168 | run_id = str(uuid.uuid4()) |
| 139 | 169 | conn.execute( | ... | ... |
src/work_node_run.py
| ... | ... | @@ -291,6 +291,22 @@ def run_work_node( |
| 291 | 291 | backoff_index = min(int(elapsed // (30 * 60)), len(_BACKOFF_SCHEDULE_MINUTES) - 1) |
| 292 | 292 | sleep(_BACKOFF_SCHEDULE_MINUTES[backoff_index] * 60) |
| 293 | 293 | continue |
| 294 | + except Exception: | |
| 295 | + # Anything else run_fn raises (e.g. CLASSIFIER_BLOCKED from | |
| 296 | + # work_graph_execute.execute_work_graph, 03-C §3.5 point 6) | |
| 297 | + # is an unretryable node-level failure -- mark 'blocked' | |
| 298 | + # before re-raising so the row doesn't sit stuck at | |
| 299 | + # 'running' forever with no record of why. This is | |
| 300 | + # deliberately broad (not just ClassifierBlockedError): | |
| 301 | + # REQ-06's "系统应明确报错并转 blocked_pending_human,而不是 | |
| 302 | + # 静默丢失进度" applies to any node-level catastrophic | |
| 303 | + # failure work_node_run.py doesn't have specific retry | |
| 304 | + # logic for, not just the one named error type. | |
| 305 | + conn.execute( | |
| 306 | + "UPDATE work_node_execution SET status = 'blocked' WHERE node_exec_id = ?", (node_exec_id,) | |
| 307 | + ) | |
| 308 | + conn.commit() | |
| 309 | + raise | |
| 294 | 310 | |
| 295 | 311 | if _validate_schema_lite(output_payload, output_schema): |
| 296 | 312 | conn.execute( |
| ... | ... | @@ -347,6 +363,15 @@ def raise_if_usage_limited(text): |
| 347 | 363 | raise _UsageLimitSignal(quota_type) |
| 348 | 364 | |
| 349 | 365 | |
| 366 | +def raise_usage_limit_signal(quota_limit_type): | |
| 367 | + """Same signal as raise_if_usage_limited, for callers that already know | |
| 368 | + the quota type structurally (e.g. work_graph_orchestrate.py wrapping | |
| 369 | + work_graph_execute.UsageLimitReachedError, which already parsed this | |
| 370 | + from the same detection regex at its own layer) rather than needing to | |
| 371 | + regex-match free text a second time.""" | |
| 372 | + raise _UsageLimitSignal(quota_limit_type) | |
| 373 | + | |
| 374 | + | |
| 350 | 375 | def resume_work_graph_run(conn, run_id): |
| 351 | 376 | """REQ-06 (04-P §4.2 p_work_graph_execution, breakpoint recovery): |
| 352 | 377 | after a process restart, determine which nodes in `run_id` still need | ... | ... |