diff --git a/docs/dynamic-work-graph-design-skill-requirements.md b/docs/dynamic-work-graph-design-skill-requirements.md new file mode 100644 index 0000000..a64ce76 --- /dev/null +++ b/docs/dynamic-work-graph-design-skill-requirements.md @@ -0,0 +1,68 @@ +# 跨 Org Agent 的动态 Work Graph 设计能力——需求文档 + +**日期**: 2026-08-05 +**性质**: 需求文档(BRD/PRD 层面),不含详细技术设计。回应 `docs/todo/graph-engineering-open-items.md` 条目 E 的确认结果——"Planner agent 里面要有多个 skill,不仅仅是 brd/prd/sdd/benchmark,还有给其他 Org Graph 节点的 agent 做各种动态 Work Graph 设计,提示词强化肯定要做"。 +**依赖**:本文档定义的"节点分组可并行"标记方式需要跟 `docs/work-graph-parallel-execution-requirements.md`(条目 G)的详细设计对齐,建议 G 的 `graph_manifest` 数据结构定下来之后再最终敲定本文档的 FR-2。 +**状态**: 草案,待评审。 + +## 1. 背景——一个比原开放问题更大的真实缺口 + +综述版/14 步版对照笔记原本提出的问题是"要不要把识别并行节点的判断标准写进 Planning Skill 的 prompt"。深入代码后发现实际缺口比这更大: + +**当前生产代码里,只有 Planner 一个 Org Agent 真正用上了"动态生成 `graph_manifest` 再执行"这套机制**: + +- `src/work_graph_plan.py::plan_work_graph()` 的 `_PROMPT_TEMPLATE`(`work_graph_plan.py:64-74`)是**硬编码**的:不管调用方传入的 `org_node` 是什么,提示词永远要求"Output a graph_manifest with one node per artifact_type (brd, prd, sdd, benchmark)"——这个函数虽然签名上接受任意 `org_node`,但实质上只能用来生成 Planner 的文档写作链。 +- 唯一真实调用它的地方是 `src/e2e_pipeline.py::plan_documents()`,而且写死传入 `"planner"`(`e2e_pipeline.py:220`)。 +- **Coder 和 Validator 目前完全不走这条 plan→execute 的路径**。`work_graph_orchestrate.py` 模块 docstring 明确记录:真正跑通的闭环(`e2e_pipeline.py`,U-014)里,Coder/Validator 用的是"一个更简单、独立的机制(flat `org_node_trigger.trigger_org_node()` calls)"——一次性的单个 Agent 调用,不经过任何"先规划出一张图、再按图执行"的过程。 + +也就是说,架构文档 §5"每个 Org Agent 拥有一个 Planning Skill,在接到任务时先生成本轮的 Work Graph"这句话,目前只对 Planner 成立。Coder 的 to-do 拆解+并行分派(架构文档 §6.2)、Validator 的功能/性能/边界测试并行执行(架构文档 §6.3)都还停留在设计意图,没有对应的动态规划实现——这正是用户说"还有给其他 Org Graph 节点的 agent 做各种动态 Work Graph 的设计"这句话所指的真实缺口,不是一个小的提示词优化。 + +**一个已经存在、值得保留的细节**:`_PROMPT_TEMPLATE` 里已经有 `edge_type: 'zero_token' | 'needs_judgment'` 的区分(`work_graph_plan.py:69-70`),这正好对应 14 步版"边是数据契约,简单聚合应该是零 token 的代码边,只有真正需要判断力的合并才值得用 Agent"这条原则——只是目前这个字段只用在 Planner 的纯串行链上,从未真正驱动过任何"这条边要不要派生一个 Agent"的执行决策。新方案应该保留并真正用起来这个字段,而不是重新发明一套。 + +## 2. 目标 + +1. 把"如何设计一张动态 Work Graph"(识别节点边界、识别哪些节点可以 fan-out、区分 `zero_token`/`needs_judgment` 边、给 fan-out 出的并行节点分组打标记)这套方法论,从 Planner 专属的硬编码提示词里抽出来,做成一个**跨 Org Agent 通用**的能力。 +2. 让 Coder、Validator 也能各自调用这套能力,生成自己的 `graph_manifest`,走上跟 Planner 一样的 plan→execute 路径,而不是继续停留在"一次性单个 Agent 调用"的简化实现。 +3. 在生成 `graph_manifest` 阶段就识别并标注"哪些节点互不依赖、可以并行执行"——即使执行层的并行能力(条目 G)还没上线,至少先把这个信息在数据模型里留出来,不需要等 G 上线后再回头改 Planning 阶段的产出格式。 + +## 3. 非目标 + +1. **不要求第一版就覆盖 Coder/Validator 的全部真实工作场景**。架构文档 §6.2/§6.3 描述的 Coder to-do 拆解、Validator 多类型测试并行是长期目标,第一版只需要证明"Coder/Validator 也能用同一套通用方法论生成一个可执行的 `graph_manifest`"这件事本身走得通,不需要一次性把每种真实业务场景都覆盖。 +2. **不改变 `plan_work_graph`/`run_graph` 的既有函数签名和执行机制**。这是提示词/方法论层面的扩展("用什么内容去问 Claude"),不是执行引擎的改动——`_PROMPT_TEMPLATE` 硬编码这件事本身要改,但改的方式是让它按 `org_node`/任务类型选择不同的提示词模板,不是重写 `plan_work_graph` 的调用方式。 +3. **不在本文档里定案"节点分组可并行"的具体字段格式**。这个格式需要跟条目 G 的详细设计对齐(G 需要知道 `graph_manifest` 里怎么表达"这组节点可以并发触发",才能实现相应的调度逻辑),本文档只提出需求,不抢先定字段名。 + +## 4. 功能需求 + +| # | 需求描述 | 优先级 | +|---|---|---| +| FR-1 | 把"如何设计一张 Work Graph"的通用方法论(节点边界怎么划、`zero_token`/`needs_judgment` 边怎么区分、fan-out 候选点怎么识别)抽成一个独立的 Skill(暂定名 `design-dynamic-work-graph`,具体命名留待设计阶段确定),跟现有 `skills/write-brd` 一类专属技能平级存放。 | 必须 | +| FR-2 | 这个 Skill 的产出需要能标注"哪些节点属于同一个可并行分组"——具体字段格式待 G 的设计确定后再补全,本需求只要求"设计阶段必须把这个信息在 `graph_manifest` 层面表达出来"这件事本身。 | 必须 | +| FR-3 | `work_graph_plan.py::plan_work_graph()` 的提示词生成逻辑需要从"永远硬编码 brd/prd/sdd/benchmark"改造成"按调用方传入的任务描述/`org_node` 动态生成",具体改造方式(拆成多个模板、还是让 Skill 内容本身驱动 Claude 自主判断要生成哪些节点)留待设计阶段决定。 | 必须 | +| FR-4 | Coder、Validator 各自的 `.claude/agents/{coder,validator}.md`(及其 canonical 来源 `src/agent_profile.py::FRONTMATTER`)需要引用这个新 Skill。 | 必须 | +| FR-5 | 需要至少一条真实的 Coder 或 Validator 场景,验证"调用新 Skill 生成 `graph_manifest` → 走 `plan_work_graph`/`run_graph` 路径执行 → 产出正确结果"这条链路真的跑得通,不是只停留在 Planner 一个场景上验证。 | 应该 | + +## 5. 验收标准 + +1. 给 Coder 一个可以自然拆成 2-3 个互不依赖子任务的真实场景(比如"给三个独立的模块各自补一个单元测试"),验证 Coder 能通过新 Skill 生成一个 `graph_manifest`(而不是走现在的单次 `trigger_org_node` 调用),且该 `graph_manifest` 正确标注了这几个节点互不依赖。 +2. 验证 `plan_work_graph()` 面对 Coder/Validator 的调用时,不再原样返回"one node per artifact_type (brd, prd, sdd, benchmark)"这个 Planner 专属结构。 +3. 验证 Planner 现有的 BRD→PRD→SDD→Benchmark 场景在改造后行为不受影响(回归测试,`src/test_e2e_pipeline.py` 等既有用例应继续通过)。 +4. 部署后能在 Coder/Validator 的 `.claude/agents/*.md` 里看到新 Skill 出现在 `skills:` frontmatter 列表。 + +## 6. 约束与假设 + +- 复用现有的 `plan_work_graph()`/`run_graph()`/`work_graph_execute.execute_work_graph()` 执行机制,不新建一套并行的规划-执行引擎。 +- `_PROMPT_TEMPLATE` 里已有的 `zero_token`/`needs_judgment` 边类型区分要保留,新 Skill 的方法论要明确指导 Claude 在生成边时如何选择这个字段,而不是留空/随意填。 +- Coder/Validator 引入 plan→execute 路径后,原来的 `org_node_trigger.trigger_org_node()` 单次调用机制不需要立刻废弃——对于确实只需要单个节点就能完成的简单任务,继续走这条更轻量的路径是合理的(呼应架构文档和 14 步版共同的原则:"任务很小或孤立时不需要上图")。新 Skill 解决的是"任务复杂到值得规划成图时,Coder/Validator 有没有能力做这件事",不是要求所有任务都必须走图。 + +## 7. 风险 + +- **`_PROMPT_TEMPLATE` 硬编码改造有回归风险**:这是唯一一条已经在生产里真实跑通、支撑着 Planner BRD→PRD→SDD→Benchmark 全链路的代码路径,改造时必须有充分的回归测试覆盖,不能因为扩展到 Coder/Validator 而破坏 Planner 现有场景。 +- **FR-2 的具体字段格式依赖 G 的设计**,如果 G 的设计阶段推迟或方向调整,本条目的 FR-2/FR-3 也需要跟着调整,两者需要保持沟通同步,不要各自独立往前推进导致字段对不上。 +- **Coder/Validator 引入图规划后,"什么时候该规划成图、什么时候该走简单单次调用"这个判断本身也需要方法论指导**——如果不加约束,可能出现"任务很小也被规划成图"的过度设计,第 6 节约束里提到的原则需要在 Skill 提示词里显式写出来,不能只是隐式假设 Claude 会自己把握分寸。 + +## 8. 开放问题(留给详细设计阶段回答) + +- [ ] 新 Skill 的具体命名和目录结构。 +- [ ] `_PROMPT_TEMPLATE` 具体怎么改造——是给每个 `org_node` 各自一份模板,还是设计成让 Claude 根据任务描述自主决定要生成哪些节点(更灵活但更难约束输出格式)? +- [ ] FR-2 的并行分组字段具体格式(待 G 确定)。 +- [ ] Coder/Validator 第一个接入的真实业务场景选哪个,作为验收标准 1 的具体测试用例? diff --git a/docs/graphlemon_architecture.md b/docs/graphlemon_architecture.md index 52976ea..8224953 100644 --- a/docs/graphlemon_architecture.md +++ b/docs/graphlemon_architecture.md @@ -21,8 +21,8 @@ | 术语 | 定义 | |---|---| -| **Org Graph(Organizer Graph)** | 固定的、由人预先设计的顶层多 Agent 主流程图。节点是"角色"(Planner / Coder / Validator),边是角色间的移交(handoff)与回退(loop-back)关系。一个 Org Graph 定义一类"工厂"(如 Dev Organizer)。 | -| **Work Graph** | Org Graph 中某个节点 Agent 接到任务后,动态规划并生成的执行图。节点是"subagent + skill 的一次执行",边支持串行、并行 fan-out/fan-in、循环(loop)。每次任务执行都重新规划、重新生成,不复用上一次的图实例(但可以复用/学习历史图的经验)。 | +| **Org Graph(Organizer Graph)** | 固定的、由人预先设计的顶层多 Agent 主流程图。节点是"角色"(Planner / Coder / Validator),边是角色间的移交(handoff)与回退(loop-back)关系。一个 Org Graph 定义一类"工厂"(如 Dev Organizer)。等价于业界"Graph Engineering"语境下的**角色图**(role graph)——谁有权做什么、权限边界如何划分,必须慢变、可审计,不允许模型运行时自主调整(见下方"术语对照")。 | +| **Work Graph** | Org Graph 中某个节点 Agent 接到任务后,动态规划并生成的执行图。节点是"subagent + skill 的一次执行",边支持串行、并行 fan-out/fan-in、循环(loop)。每次任务执行都重新规划、重新生成,不复用上一次的图实例(但可以复用/学习历史图的经验)。等价于业界"Graph Engineering"语境下的**工作图**(work graph)——任务怎么拆、怎么合可以现场灵活调整,允许快变(见下方"术语对照")。 | | **Org Agent** | Org Graph 上的固定节点角色,例如 Planner Agent、Coder Agent、Validator Agent。每个 Org Agent 拥有一个"Planning Skill",用于在接到任务时生成自己的 Work Graph。 | | **Work Node** | Work Graph 中的最小执行单元 = 一个 Subagent 实例 + 它调用的一个(或一组)Skill + 明确的输入/输出契约。 | | **Skill** | 可复用的能力单元(提示词模板 + 工具集 + 产出规范),是 Work Node 执行任务时调用的"方法"。Skill 本身也是可被迭代的对象。 | @@ -33,6 +33,8 @@ | **Bug / Issue 溯源** | Validator 发现问题后,向上追溯问题源头属于 Planning 阶段的哪个环节,还是 Coder 阶段的哪个环节,并进一步判断是"这次执行的偶发失误"还是"Skill 本身存在缺陷"。 | | **Org-level Loop** | Org Graph 层面的回退边:Validator → Planner,把验证阶段发现的问题作为新任务反馈给 Planner,驱动下一轮 Org Graph 循环。 | +**术语对照(2026-08-05 补充)**:《Loop Engineering 就凉了?一文看懂 Graph Engineering 的"新范式"》(`docs/reference/graph-engineering-vs-loop-engineering-overview.md`)提出一条治理红线——"工作图"(任务怎么拆怎么合,可以快变)vs "角色图"(谁有权改数据库、谁能绕过审批,必须慢变、可审计)。这条红线跟本文档 Org Graph(人工固定设计,架构变更需人工评审,见下方"关键设计原则 1")/ Work Graph(每次任务动态生成,见"关键设计原则 2")的边界是同一件事的两种命名:**Org Graph ≡ 角色图,Work Graph ≡ 工作图**——不是新概念,只是行业已经给这条边界起了名字。对照笔记见 `docs/reference/graph-engineering-vs-loop-engineering-overview.diff-with-graphlemon.md` §5。 + ## 3. 整体架构:两层图 ``` diff --git a/docs/todo/graph-engineering-open-items.md b/docs/todo/graph-engineering-open-items.md index b5d57fa..0ea9d3f 100644 --- a/docs/todo/graph-engineering-open-items.md +++ b/docs/todo/graph-engineering-open-items.md @@ -30,7 +30,7 @@ **确认结果(2026-08-05)**:`工作图` 是 `Work Graph` 的字面翻译,graphlemon 本来就这么叫;`角色图` 不是 `Org Graph` 的字面翻译,但语义完全对得上——Org Graph 本来就是"角色(Planner/Coder/Validator)之间固定的组织关系图",`角色图` 是更准确的中文释义,不是新造的概念。两组术语直接画等号:**Work Graph ≡ 工作图,Org Graph ≡ 角色图**。 -**待办**:在 `docs/graphlemon_architecture.md` §2 术语表或 §3 整体架构处补一句显式对应说明("Org Graph 即角色图、Work Graph 即工作图"),引用综述版文章的治理红线原文作为设计依据。纯文档改动,不涉及代码,可以随手做掉。 +**待办**:✅ 已完成(2026-08-05)。`docs/graphlemon_architecture.md` §2 术语表的 Org Graph/Work Graph 定义行已补充等价说明,并在术语表后新增"术语对照"段落引用综述版文章原文。 --- @@ -44,7 +44,7 @@ **确认结果(2026-08-05)**:不改 Work Graph 执行模型本身(不引入图内回路)。做成一个独立的 Skill(例如 `validate-until-dry` 之类),把"连续 K 轮无新发现才停止、去重比对全部见过的而非仅已确认的"这套收敛逻辑封装成 Validator 可调用的方法论/工具,并且要求 `.claude/agents/validator.md` 在跑到"未知规模缺陷扫描"这类场景时记得调用它——落地方式是 Skill + Agent 定义引用,不是改 Graph Engine 底层。 -**待办**:写需求文档,明确这个 Skill 的触发场景(哪些任务算"未知规模扫描")、K 值默认多少、和现有 `root_cause_trace`/Org-level Loop 3 轮上限的关系(互不冲突:3 轮上限管的是 Org Graph 层 Validator→Planner 回退次数,这个 Skill 管的是 Validator 自己 Work Graph 内部一次验证过程要扫多少轮)。 +**待办**:✅ 需求文档已产出 → `docs/validator-loop-until-dry-skill-requirements.md`。关键设计澄清:loop-until-dry 不需要图内回路——收敛循环发生在**单个 Work Node 一次 `claude -p` 会话内部**(多轮工具调用),`graph_manifest` 本身仍是无环的,不需要碰 `topological_order`。 --- @@ -70,10 +70,7 @@ **确认结果(2026-08-05)**:立项。 -**待办**:写需求文档。需要先定义清楚几个问题再展开: -- "分叉重放"具体指什么粒度——回到某个 `work_node_execution` 之前,用新的输入/prompt 重新执行该节点及其下游,产出一条平行的执行分支? -- 分叉出来的新分支和原分支在数据模型上怎么共存(`work_graph_run`/`work_node_execution` 现有 schema 需不需要扩展"parent_run_id"之类的字段)? -- 和 G(并行执行)、控制台的关系——分叉调试大概率需要在控制台里可视化操作,属于第 13 节人机交互控制台的能力扩展,需要跟现有 v_task_detail 等页面的设计对齐。 +**待办**:✅ 需求文档已产出 → `docs/work-graph-time-travel-debugging-requirements.md`。已明确跟 Deploy Supervisor 自动回滚(`docs/supervisor-auto-rollback-design.md`)的命名消歧。最大的数据模型影响点:`work_graph_run` 表现有的 `ux_work_graph_run_active` 唯一约束(同一 `task_id`+`org_node` 同时只能有一个非终态 run)会挡住分叉分支,需要在设计阶段决定怎么扩展。 --- @@ -87,10 +84,7 @@ **确认结果(2026-08-05)**:提示词强化肯定要做——把"对每个箭头问是否真的有数据流动"的提问方式、以及"并行可分解 / 单一上下文装不下 / 收益覆盖成本"这三条尺子写进 Planning 相关 prompt。**但范围比原开放问题更大**:不能只强化 Planner 自己写 BRD/PRD/SDD/Benchmark 那条链的 prompt——架构文档 §5 本来就说"每个 Org Agent 拥有一个 Planning Skill"(Coder 和 Validator 各自也要规划自己的 Work Graph),"如何识别 fan-out/fan-in、设计一张动态 Work Graph"这套方法论应该是**跨 Org Agent 通用**的能力,而不是分别在 Planner/Coder/Validator 三份 Planning Skill 里各写一遍。用户原话:"planner agent 里面要有多个 skill,不仅仅是 brd prd sdd benchmark 还有给其他 org graph 的 node 节点的 agent 做各种动态 work graph 的设计"。 -**待办**:写需求文档,需要明确: -- 这套"动态 Work Graph 设计方法论"要不要抽成一个独立的、三个 Org Agent 共用的 Skill(类似现在 `skills/` 目录下已有的 `write-brd`/`write-prd` 等,新增一个例如 `design-dynamic-work-graph`),还是分别内嵌进各自的 Planning 环节? -- Planner 除了 BRD/PRD/SDD/Benchmark 这条主链的 skill 外,还需要哪些新 skill——用户提到"给其他 org graph 的 node 节点的 agent 做各种动态 work graph 的设计",这暗示 Planner 可能要承担"帮 Coder/Validator 设计他们各自 Work Graph 结构"的角色,这跟架构文档 §5"每个 Org Agent 自己的 Planning Skill 各自生成自己的 Work Graph"的原有设计是否冲突,需要先对齐。 -- 和 F(三视角评审团做成 fan-out 图)、G(并行执行)的衔接——这个 Skill 生成的 graph_manifest 里,fan-out 的节点组要用什么标记方式,让 G 的执行引擎能识别出"这些节点可以并发"。 +**待办**:✅ 需求文档已产出 → `docs/dynamic-work-graph-design-skill-requirements.md`。深挖代码后发现真实缺口比原开放问题更大:`work_graph_plan.py::plan_work_graph()` 的提示词目前**硬编码**成永远只生成 brd/prd/sdd/benchmark 四个节点,且只有 Planner(`e2e_pipeline.py::plan_documents`)真正调用它——**Coder 和 Validator 现在根本没有走"先规划图再执行"这条路径**,走的是更简化的单次 `org_node_trigger.trigger_org_node()` 调用。所以 E 不只是"强化提示词",而是要先把动态 Work Graph 规划能力从 Planner 专属扩展成三个 Org Agent 通用。 **依赖关系**:这条和「G. Work Graph 节点并行执行」是互补的——G 解决"执行层能不能并发跑",E 解决"规划层会不会生成值得并发的图"。两条需要一起排期,建议先看 G 的需求文档,E 的需求文档要在 G 确认的数据结构(`graph_manifest` 里怎么表达"这组节点可并行")基础上写,避免两边字段对不上。 @@ -106,9 +100,7 @@ **确认结果(2026-08-05)**:要做,而且明确了实现方式——**不是**继续用现在 `root_cause_trace.judge_root_cause` 那种 Python 层直接顺序调用三个 `perspective_fn` 的写法,而是把三视角评审团**做成一张真正的 Work Graph fan-out/fan-in 菱形拓扑**:fan-out 出 N 个独立的验证节点(每个节点是一次独立的 `claude -p` 调用,带自己的视角提示词),fan-in 节点做投票/汇总。用户原话:"三视角评审团可以做,做成 work graph 的 fan out 图来实现吧"。 -**待办**:写需求文档。这条**强依赖 G(并行执行)**——评审团的 fan-out 节点如果还是串行跑,就丢失了"多视角同时判断"的时间收益(现在 `judge_root_cause` 本来就是纯函数式顺序调用,改成 Work Graph fan-out 如果不配合并行执行,反而更慢)。需要确认: -- 现有 `root_cause_trace.judge_root_cause` 要不要保留作为"轻量级/无需 worktree/纯 Python 场景"的备选实现,还是完全被 Work Graph fan-out 版本取代? -- 哪些验证点要升级成这种 fan-out 评审团(SDD 审核?代码变更审核?)——"高风险"判定标准怎么定。 +**待办**:✅ 需求文档已产出 → `docs/verifier-fan-out-jury-requirements.md`。明确标注**强依赖 G**——没有并行执行能力,把 `judge_root_cause` 改成 Work Graph fan-out 反而更慢(多了 Work Node 调度开销,没有并行收益),建议排在 G 之后。 --- @@ -128,16 +120,18 @@ ## 确认结果汇总(2026-08-05) -| 项 | 结果 | 后续动作 | +| 项 | 结果 | 需求文档 | |---|---|---| -| A | ✅ 确认,两组术语等价 | 待办:架构文档补一句对应说明(可直接做,无需求文档) | -| B | ✅ 确认,做成 Validator 的 Skill | 待办:写需求文档 | +| A | ✅ 已完成 | 无需求文档,直接改了 `docs/graphlemon_architecture.md` §2 术语表 | +| B | ✅ 已确认,需求文档已产出 | `docs/validator-loop-until-dry-skill-requirements.md` | | C | ⛔ 暂不做 | 无 | -| D | ✅ 确认立项 | 待办:写需求文档 | -| E | ✅ 确认,范围扩大为跨 Org Agent 的通用 Work Graph 设计能力 | 待办:写需求文档(依赖 G 的数据结构先定) | -| F | ✅ 确认,用 Work Graph fan-out/fan-in 实现 | 待办:写需求文档(强依赖 G) | -| G | ✅ 确认,核心优先级 | 需求文档已就绪,进入详细设计阶段,是 E/F 的前置依赖 | +| D | ✅ 已确认,需求文档已产出 | `docs/work-graph-time-travel-debugging-requirements.md` | +| E | ✅ 已确认,需求文档已产出(范围比原开放问题更大) | `docs/dynamic-work-graph-design-skill-requirements.md` | +| F | ✅ 已确认,需求文档已产出 | `docs/verifier-fan-out-jury-requirements.md` | +| G | ✅ 已确认,核心优先级,需求文档已产出 | `docs/work-graph-parallel-execution-requirements.md` | -**建议的推进顺序**:G 先进入详细设计(E、F 都依赖它的并行执行能力和数据结构),A 可以随手做掉(纯文档改动,不占用设计精力)。B、D 相对独立,可以在 G 设计的同时并行写需求文档。E、F 的需求文档建议等 G 的详细设计里"`graph_manifest` 怎么表达可并行节点组"这个数据结构定下来之后再写,避免字段对不上要返工。 +**当前状态**:A 已落地;B/C/D/E/F/G 六项全部完成需求文档(BRD/PRD 层面),均未进入详细设计(SDD 层面)。 -下一步要我先做哪一件——是把 G 推进到详细设计,还是先把 B/D 的需求文档写掉,或者先去把 A 的架构文档措辞补上? +**推进顺序建议**:G 是 E、F 的前置依赖(E 的并行分组字段格式、F 的 fan-out 时间收益都需要 G 先定型),建议优先把 G 推进到详细设计。B、D 相对独立,可以随时单独推进详细设计。E、F 的详细设计需要等 G 的数据结构定下来。 + +下一步要我先把哪一份需求文档推进到详细设计(SDD 层面)?还是先把六份需求文档过一遍评审,确认没有需要调整的地方,再决定设计阶段的排期? diff --git a/docs/validator-loop-until-dry-skill-requirements.md b/docs/validator-loop-until-dry-skill-requirements.md new file mode 100644 index 0000000..fa295d1 --- /dev/null +++ b/docs/validator-loop-until-dry-skill-requirements.md @@ -0,0 +1,59 @@ +# Validator "未知规模扫描收敛" Skill——需求文档 + +**日期**: 2026-08-05 +**性质**: 需求文档(BRD/PRD 层面),不含详细技术设计。回应 `docs/todo/graph-engineering-open-items.md` 条目 B 的确认结果——"loop-until-dry 落地在 Validator,做成一个 Skill,`validator.md` 运行时记得调用"。 +**状态**: 草案,待评审。 + +## 1. 背景 + +14 步版对照笔记(`docs/reference/Graph Engineering with Claude_ 14-Step roadmap from 0 to graph architect (Full Course).diff-with-graphlemon.md` §1 第 11 行、§2)指出:graphlemon 现有的收敛机制只有 Org-level Loop 的固定 3 轮上限(`docs/sdd/04-P-流程与状态流转.md` 已标注"未来优化候选"),而 14 步版描述的 loop-until-dry(连续 K 轮无新发现才停止,去重要对比"全部见过的"而非"已确认的")没有对应实现。 + +同一份笔记也指出一个结构性事实:`work_graph_orchestrate.py::topological_order` 对有环的 `graph_manifest` 直接报错("graph_manifest has a dependency cycle")——**Work Graph 被设计成严格 DAG,图内不支持回路**。这意味着 loop-until-dry **不能**实现成"`graph_manifest` 里画一条指回自己的边",那需要改动整个执行模型(`run_graph()`/`topological_order()`),代价远大于收益。 + +**关键澄清(本需求的立论基础)**:loop-until-dry 不需要是 Work Graph 层面的图内回路。它可以是**一个 Work Node 内部、单次 `agent()` 调用会话自己的行为**——一次 `claude -p` 调用本身就是一个多轮工具调用的会话(读文件、搜索、判断、再读文件……),只要 Skill 的提示词要求 Agent"持续搜索直到连续两轮没有新发现才停止、去重比对全部见过而非仅已确认的结果",这个收敛循环完全发生在**一次 Work Node 执行内部**,`graph_manifest` 本身仍然是一条无环的边(该节点的输入是任务描述,输出是收敛后的结构化 bug list),不需要改动 `topological_order`/`run_graph` 的任何一行代码。这跟"B. loop-until-dry 要不要在 Validator 落地"的确认结果——"做成一个 Skill"——是同一个思路:Skill 是提示词层面的方法论,不是图结构。 + +## 2. 目标 + +1. 给 Validator 一个新的 Skill,让它在面对"未知规模缺陷扫描"类任务时,能够自主判断收敛(连续 K 轮无新发现才停止),而不是像现在这样只能做"扫一遍就结束"的单轮验证。 +2. 这个 Skill 的去重逻辑必须对比"全部已见过的候选项",而不是只对比"已确认通过的结果"——避免被验证器打回的候选项在下一轮又被当成新发现重新提出,导致循环永远不收敛(14 步版原文点名的"几乎所有人第一次实现都会踩的坑")。 +3. `.claude/agents/validator.md`(及其 canonical 来源 `src/agent_profile.py::FRONTMATTER["validator"]`)需要引用这个新 Skill,并且 Validator 的人设文档(`agents/validator/SOUL.md` 迁移版)需要有明确指引:什么场景下应该主动调用这个 Skill,而不是走默认的单轮验证路径。 + +## 3. 非目标 + +1. **不改 Work Graph 执行模型**。不给 `graph_manifest`/`topological_order`/`run_graph` 引入图内回路支持,见第 1 节的关键澄清。 +2. **不改 Org-level Loop 的 3 轮上限**。Org-level Loop 管的是"Validator 判定不通过、打回 Planner/Coder、再来一轮"这个更粗粒度的循环,跟这个 Skill 管的"Validator 一次验证任务内部要扫多少轮才算扫完"是两层不同的收敛机制,互不冲突、互不替代。 +3. **不要求这个 Skill 覆盖 Validator 的所有验证场景**。大多数验证任务(比如验收一个边界清晰的 Development Unit)本来就是有限规模的,不需要 loop-until-dry;这个 Skill 只服务于"规模未知"这一类场景(比如"扫描整个代码库找所有未处理的 Promise""审计所有路由文件的鉴权缺失"这种没有预先划定范围的任务)。 + +## 4. 功能需求 + +| # | 需求描述 | 优先级 | +|---|---|---| +| FR-1 | 新增一个 Skill(暂定名 `unknown-scope-defect-sweep`,具体命名留待设计阶段确定),描述 loop-until-dry 的方法论:持续搜索/扫描 → 每轮结果与"全部已见过的候选项"去重 → 连续 K 轮(默认值留待设计阶段确定,参考 14 步版建议的 K=2)无新发现 → 停止并汇总。 | 必须 | +| FR-2 | Skill 需要明确定义"候选项"的去重键(key)该怎么取——不同任务场景(bug 扫描 vs 路由审计 vs 依赖审计)候选项的自然标识不同,Skill 需要给出通用指导原则,而不是绑定某一种具体场景。 | 必须 | +| FR-3 | Skill 执行过程中每一轮的候选发现,在最终汇总前应当经过验证(复用 Validator 已有的 PASS/FAIL/INCONCLUSIVE 判定框架或 `root_cause_trace` 机制,具体复用方式留待设计阶段确定)——不能是"只要出现过就算数",避免把误报也计入最终结果。 | 应该 | +| FR-4 | `src/agent_profile.py::FRONTMATTER["validator"]["skills"]` 需要加入这个新 Skill 的目录名,并且 `agents/validator/SOUL.md`(canonical 人设源文件)需要新增一段指引:什么信号(比如任务描述里出现"扫描/审计/全部/所有"这类无边界表述)应该触发这个 Skill,而不是走默认单轮验证路径。 | 必须 | +| FR-5 | Skill 需要有一个硬性上限(最大轮次或最大 token/时间预算),防止"连续 K 轮无新发现才停"这个收敛条件本身因为某种原因(比如候选项生成逻辑有 bug,每轮都能生成一个"看起来新"但实际重复的假发现)永远不触发,导致单次 Work Node 执行无限跑下去。 | 必须 | + +## 5. 验收标准 + +1. 给 Validator 一个人为构造的"扫描规模未知"任务(例如一个测试仓库里散落 5 处同一类缺陷),验证 Validator 使用新 Skill 后能够找全全部 5 处,并在连续两轮无新发现后主动停止,不需要人工告诉它"已经找完了"。 +2. 验证去重逻辑:人为构造一个"候选项会被验证器打回、但下一轮扫描逻辑本身还会再次生成同一个候选项"的场景,确认该候选项不会被无限重复计入"新发现",循环能够正常收敛(不是因为侥幸没再被搜到,而是因为去重键命中了"已见过"记录)。 +3. 验证 FR-5 的硬上限:人为构造一个"永远能生成看似新其实重复"的极端场景,确认 Skill 会在触达硬上限后强制停止并如实报告"可能未收敛",而不是无限执行下去耗尽预算。 +4. `.claude/agents/validator.md` 部署后(`deploy_agent_definitions`),能看到新 Skill 出现在 `skills:` frontmatter 列表里。 + +## 6. 约束与假设 + +- 复用 Validator 现有的技能目录约定(`skills//SKILL.md`,参考 `skills/qa-task-validation/SKILL.md` 的文件结构),但**不复用**该文件里遗留的飞书 Base 相关内容(`tblnlIVgHNaQXLAL` 等)——那是尚未清理的 Hermes profile 遗留细节,新 Skill 要按 graphlemon 当前的实际架构(无飞书 Base 依赖)编写,不能照抄旧格式。 +- 假设这个 Skill 在**单次 Work Node 执行内部**完成收敛循环(一次 `claude -p` 调用内的多轮工具使用),不依赖 Work Graph 层面的任何调度改动,因此和「G. Work Graph 节点并行执行」没有直接依赖关系,可以独立排期。 + +## 7. 风险 + +- **收敛判定本身依赖模型自我报告"这轮有没有新发现"**,如果模型判断不准确(漏报"这是新发现"或误报"这不是新发现"),收敛条件可能提前/延迟触发。FR-3 的"每轮发现经过验证"部分程度上缓解这个风险,但无法完全消除——这是提示词工程层面的固有局限,不是可以被架构设计完全解决的问题。 +- **K 值和硬上限的默认值需要实测校准**,不同任务规模下合适的 K 值可能不同,第一版先给一个保守默认值,观察真实运行数据后再调整。 + +## 8. 开放问题(留给详细设计阶段回答) + +- [ ] Skill 的具体命名和目录结构。 +- [ ] FR-3"复用已有验证框架"的具体接线方式——是在 Skill 提示词里直接要求"每个候选项过一遍 PASS/FAIL/INCONCLUSIVE 判断",还是调用 `root_cause_trace` 现成的多视角判断机制? +- [ ] K 值、硬上限的默认数字。 +- [ ] `agents/validator/SOUL.md` 里新增指引段落的具体触发条件描述(关键词匹配?任务类型标注?)。 diff --git a/docs/verifier-fan-out-jury-requirements.md b/docs/verifier-fan-out-jury-requirements.md new file mode 100644 index 0000000..39eba7a --- /dev/null +++ b/docs/verifier-fan-out-jury-requirements.md @@ -0,0 +1,69 @@ +# 三视角评审团——Work Graph Fan-out 实现——需求文档 + +**日期**: 2026-08-05 +**性质**: 需求文档(BRD/PRD 层面),不含详细技术设计。回应 `docs/todo/graph-engineering-open-items.md` 条目 F 的确认结果——"三视角评审团可以做,做成 Work Graph 的 fan-out 图来实现"。 +**依赖**:本文档描述的 fan-out 验证节点若要真正获得"多视角同时判断"的时间收益,强依赖 `docs/work-graph-parallel-execution-requirements.md`(条目 G)的并行执行能力——没有 G,把 `judge_root_cause` 现有的顺序调用改成 Work Graph fan-out 反而更慢(多了 Work Node 调度开销,却没有并行收益)。建议 G 先进入实现或至少详细设计定型后,再排期本文档。 +**状态**: 草案,待评审。 + +## 1. 背景 + +`src/root_cause_trace.py::judge_root_cause()` 已经实现了 14 步版"多视角差异化校验"模式的核心逻辑: + +```python +def judge_root_cause(perspective_fns): + """要求 >= 2/3 视角一致才定案 ... 否则 raise InconclusiveEscalateError""" + judgments = [fn() for fn in perspective_fns] + ... +``` + +三个视角(正确性/需求完整性/Skill 系统性缺陷)目前只用在 Validator 的 `test_failed` 根因溯源场景,且是**纯 Python 函数式实现**——`perspective_fns` 是一组闭包,`judge_root_cause` 一个一个顺序调用它们(`judgments = [fn() for fn in perspective_fns]` 是一个普通的列表推导,不是并发调用),每个 `fn()` 内部大概率是一次独立的 `claude -p` 调用(`real_validator_perspective` 函数名暗示了这一点)。 + +这次确认的方向:把这套机制从"Python 函数顺序调用三个闭包"升级为"Work Graph 里一张真正的菱形拓扑"——fan-out 出 N 个独立的验证 Work Node(每个节点携带自己的视角提示词,是一次独立的 Work Node 执行,而不是一个 Python 闭包),fan-in 节点做投票/汇总。这跟综述版对照笔记(`docs/reference/graph-engineering-vs-loop-engineering-overview.diff-with-graphlemon.md` §2)指出的"这正好对应文章第 08 章'评审团'式多视角交叉审"是同一个模式,只是要把它从函数调用升格为图结构。 + +## 2. 目标 + +1. 把三视角(或未来可能扩展到更多视角)评审团实现成一张真正的 Work Graph fan-out/fan-in 菱形:一个 split 节点(或直接由调用方给出待评审对象)→ N 个并行的独立验证 Work Node(各自视角、各自独立的 `claude -p` 调用、各自独立的持久化状态) → 一个 fan-in 节点汇总投票结果。 +2. 这套 fan-out 评审团机制要能被**不同的验证场景复用**,不只是 Validator 的根因溯源——`run_graph()` 现有的 `verify_fn`(单一验证器 + 失败重试一次)应该能够选择性地升级为这套多视角 fan-out 验证,用于高风险节点(比如 SDD 审核、代码变更审核)。 +3. 每个视角的验证节点获得**独立、干净的上下文**(呼应 5 步版对照笔记已确认的"Verifier 需要干净上下文"这条原则)——不是同一个对话里问三次不同角度的问题,而是三次完全独立的 Work Node 执行。 + +## 3. 非目标 + +1. **不要求所有验证点都升级成 fan-out 评审团**。这是一种"重打法",只用于高风险节点,大多数验证场景继续用现有的单一 `verify_fn`(一个验证器 + 失败重试一次)就够了——具体"高风险"的判定标准留待设计阶段确定,但原则上默认走轻量路径,评审团是可选升级,不是新的默认行为。 +2. **不要求第一版就废弃 `root_cause_trace.judge_root_cause` 的现有 Python 函数式实现**。是否完全替换、还是保留作为"不需要 worktree/轻量场景"的备选实现,留待设计阶段确定(见第 8 节开放问题)。 +3. **不在本文档定案 fan-out 验证节点的并发触发机制细节**。具体怎么并行触发这几个验证节点,是条目 G 的职责范围,本文档只提出"这几个节点需要能并行执行"这个需求,不重复 G 已经在做的设计工作。 + +## 4. 功能需求 + +| # | 需求描述 | 优先级 | +|---|---|---| +| FR-1 | 设计一种可复用的 `graph_manifest` 模式(菱形拓扑:1 个输入节点或直接的调用方输入 → N 个独立视角验证节点 → 1 个汇总节点),可以被任何需要"多视角评审团"式验证的调用方复用,不是针对根因溯源场景写死的一次性结构。 | 必须 | +| FR-2 | N 个视角验证节点的具体视角内容(提示词)需要能按调用场景参数化——根因溯源场景用"正确性/需求完整性/Skill 缺陷"三视角,未来其他场景(比如代码审查)可能需要"正确性/安全/性能"这类不同的视角组合,机制本身要跟具体视角内容解耦。 | 必须 | +| FR-3 | 汇总节点需要保留现有 `judge_root_cause` 已经验证过的判定规则——`>= 2/3` 视角一致才定案,不足则升级(`InconclusiveEscalateError` 的等价行为),这条规则本身经过实战验证,不需要重新设计,只需要把它从"Python 函数内的逻辑"迁移成"fan-in 节点的判定逻辑"。 | 必须 | +| FR-4 | `run_graph()` 的 `verify_fn` 接口需要能够对接这种 fan-out 评审团实现——具体是扩展 `verify_fn` 的调用约定,还是提供一个新的 `verify_fn` 工厂函数(生成一个内部会触发 fan-out 评审团的 `verify_fn`),留待设计阶段确定,但调用方(比如 `e2e_pipeline.py::plan_documents` 的 `_prd_consistency_verify_fn`)不应该需要感知内部是单一验证器还是评审团。 | 应该 | +| FR-5 | Validator 现有的 `test_failed` 根因溯源场景要能迁移到这套新机制上,作为第一个真实落地场景,验证机制本身可用,而不是只停留在通用接口设计层面。 | 必须 | + +## 5. 验收标准 + +1. 给定一个需要根因溯源的模拟场景,用新的 fan-out 评审团机制跑一遍,验证三个视角节点各自作为独立的 Work Node 执行(能在 `execution_event`/日志里看到三次独立的节点执行记录,而不是一次函数调用内部的三次闭包调用)。 +2. 验证判定规则等价性:构造"2/3 一致""不足 2/3"两种场景,确认新机制的判定结果跟现有 `judge_root_cause` 单测覆盖的场景给出相同的结论。 +3. 验证视角节点确实使用独立、干净的上下文——每个视角节点的 `input_payload` 只包含它需要判断的内容,不包含其他视角节点的推理过程。 +4. 在 G(并行执行)就绪后补充验证:三个视角节点的执行时间有重叠(不是排队执行)。 + +## 6. 约束与假设 + +- 复用现有的 Work Node 执行机制(`work_node_run.run_work_node`/`work_graph_execute.execute_work_graph`),每个视角节点是一次独立的单节点 workflow 脚本调用,跟现有其他 Work Node 没有本质区别。 +- 复用现有的 `graph_manifest`/`run_graph()` 执行入口,不新建专门的"评审团执行器"——评审团只是 `graph_manifest` 的一种特定拓扑形状(菱形),不需要新的执行引擎能力(依赖 G 提供的并行触发能力,但执行入口本身还是 `run_graph()`)。 +- `root_cause_trace.judge_root_cause` 现有的单测(如果存在)应该被保留或迁移,作为判定规则本身正确性的回归保障,不因为改成图结构就失去测试覆盖。 + +## 7. 风险 + +- **强依赖 G,排期上不能先于 G**:如果 G 推迟,这条也要跟着推迟,或者先落地"fan-out 但暂时串行执行"的过渡版本(功能正确但没有时间收益),需要跟用户确认是否接受这种过渡状态,还是宁可等 G 一起上。 +- **每个视角节点变成独立 Work Node 后,token 成本会上升**:现有 Python 函数式实现里,"三次独立调用"这件事本身没有变化,但包装成 Work Node 后每个节点会多一层 schema 校验往返、多一次单节点 workflow 脚本渲染的开销——这个开销预计很小,但需要在设计阶段实测确认,不是纯粹的免费升级。 +- **"高风险"判定标准如果定得太宽,会导致大量场景都触发评审团**,造成不必要的成本增长——需要在设计阶段给出明确、克制的判定标准,而不是默认"能用评审团就用评审团"。 + +## 8. 开放问题(留给详细设计阶段回答) + +- [ ] `root_cause_trace.judge_root_cause` 的 Python 函数式实现要不要保留作为轻量级备选(比如单元测试场景、不需要真实 `claude` 子进程调用的场景),还是完全被 Work Graph 版本取代? +- [ ] FR-4 的具体接口设计——`verify_fn` 扩展方式。 +- [ ] 除了 Validator 根因溯源,哪些验证点要升级成评审团(SDD 审核?代码变更审核?)——"高风险"判定标准怎么定,是节点类型固定标注还是运行时动态判断? +- [ ] 视角数量 N 是否需要可配置(不同场景可能需要 2 个视角或 5 个视角,不一定总是 3 个)? diff --git a/docs/work-graph-time-travel-debugging-requirements.md b/docs/work-graph-time-travel-debugging-requirements.md new file mode 100644 index 0000000..ffabb5a --- /dev/null +++ b/docs/work-graph-time-travel-debugging-requirements.md @@ -0,0 +1,73 @@ +# Work Graph 执行历史 Time-Travel 调试——需求文档 + +**日期**: 2026-08-05 +**性质**: 需求文档(BRD/PRD 层面),不含详细技术设计。回应 `docs/todo/graph-engineering-open-items.md` 条目 D 的确认结果——"time travel 可以立项"。 +**状态**: 草案,待评审。 + +**命名消歧(仿照 CLAUDE.md「两个 Supervisor」的先例)**:本文档说的"Time-Travel 调试"跟 `docs/supervisor-auto-rollback-design.md` 描述的 Deploy Supervisor 自动回滚是两件完全不同的事——不要混淆: + +| | Time-Travel 调试(本文档) | Deploy Supervisor 自动回滚 | +|---|---|---| +| 回滚的对象 | 某一次任务的 Work Graph 执行过程(某个 `work_node_execution` 的中间状态) | graphlemon 自身的代码版本(git commit) | +| 触发者 | 人工调试意图("我想看看如果这一步换个输入会怎样") | 自动健康检查失败信号 | +| 影响范围 | 单个 task 的执行历史,不影响其他任务 | 整个 graphlemon 进程的运行版本 | +| 相关文档 | 本文档 | `docs/supervisor-auto-rollback-design.md` | + +## 1. 背景 + +综述版对照笔记(`docs/reference/graph-engineering-vs-loop-engineering-overview.diff-with-graphlemon.md` §6)指出:LangGraph 的 checkpointer 机制带来四个能力——人在回路、记忆、时间旅行调试、容错。逐项对照 graphlemon 现状: + +| LangGraph 能力 | graphlemon 现状 | +|---|---| +| 容错(部分节点失败保留已成功节点输出) | ✅ 已具备(`work_node_execution` 按节点独立持久化状态) | +| 人在回路(暂停/恢复) | ✅ 已具备(`src/task_execution.py` 的 Start/Pause/Stop) | +| 记忆(多轮交互间保留上下文) | ✅ 已具备(`.claude/agent-memory/`) | +| 时间旅行调试(回到任意历史检查点重放/分叉) | ❌ 未实现,现状是"用 git 历史 + 手动重跑" | + +`work_node_execution` 表(`src/schema.py:138`)目前按节点持久化 `input_payload`/`output_payload`/`status`/`retry_count` 等字段,`work_graph_run` 表(`src/schema.py:118`)持久化 `graph_manifest` 和整体 `status`——这些数据本身已经具备"回放某一次执行发生了什么"的信息基础,缺的是"从某个历史节点分叉出一条新的执行分支"这个操作本身。 + +## 2. 目标 + +1. 允许人工从某次 Work Graph 执行的某个历史节点(`work_node_execution`)出发,用新的输入(不同的 prompt、不同的上游产物)重新执行该节点及其下游依赖节点,产出一条与原执行并存、可独立查看的新分支,而不覆盖或污染原始执行记录。 +2. 分叉出来的新分支要能在控制台里被查看、区分(哪条是原始记录、哪条是分叉出来的调试分支)。 +3. 这个能力主要服务于调试/复盘场景(例如:"如果 SDD 阶段换一版方案,后续的 Coder 实现会有什么不同"),不是生产环境的正常任务执行路径的一部分。 + +## 3. 非目标 + +1. **不是自动化机制**。分叉重放由人工在控制台里主动触发,不是系统自主决定"要不要回溯重跑"的行为——这跟 Org-level Loop 那种系统自主触发的回退是两回事。 +2. **不追求支持任意粒度的时间点**(比如"回到某个节点执行到一半的中间状态")。粒度限定在 `work_node_execution` 这个已有的持久化单元——从"某个节点完成之后(它的 `output_payload` 已经落库)"这个时间点分叉,不试图捕获节点内部一次 `claude -p` 调用执行过程中的中间状态。 +3. **不在第一版里做完整的可视化 diff/比较工具**(比如"原分支和新分支的产物并排对比高亮差异")。第一版先解决"分叉出一条新分支并能分别查看"这个核心能力,比较/diff 视图留给后续迭代。 + +## 4. 功能需求 + +| # | 需求描述 | 优先级 | +|---|---|---| +| FR-1 | 给定一个已完成(`status='done'`)的 `work_node_execution`,人工可以指定新的输入(覆盖原 `input_payload` 的部分或全部字段),触发从这个节点开始的重新执行。 | 必须 | +| FR-2 | 分叉执行产生的是一次**新的** `work_graph_run`(不是修改原 `work_graph_run` 的记录),需要能追溯"这次运行是从哪个历史节点、哪个原始 run 分叉出来的"。 | 必须 | +| FR-3 | 分叉出的新 run 只重新执行"从分叉点开始,到该节点的下游依赖链",分叉点**之前**的节点(分叉点本身的上游)直接复用原始 run 已经产出的 `output_payload`,不重新执行——这跟现有断点续跑"复用已完成节点输出"的逻辑是同一个原则的延伸。 | 必须 | +| FR-4 | 控制台(架构文档 §13 的人机交互控制台)需要能列出"某个 task 下所有的分叉分支",并支持切换查看每条分支各自的节点执行详情(复用现有 v_task_detail 一类页面的节点级展示能力)。 | 应该 | +| FR-5 | 分叉出的调试分支不应该影响原始任务的正常执行状态——即使分叉分支的执行结果比原分支"更好",也不会自动替换/合并回原始任务的 `task.status`,是否采纳分叉分支的结果需要人工另外决定(比如人工把分叉分支的产物手动复制回主线,或者认可分叉分支本身就是新的正确路径)。 | 必须 | + +## 5. 验收标准 + +1. 给定一个已经跑完的简单 Work Graph(比如 Planner 的 BRD→PRD→SDD→Benchmark 四节点链),从 PRD 节点分叉、换一个不同的 PRD 内容,触发重新执行,验证 SDD、Benchmark 两个下游节点用新的 PRD 内容重新跑了一遍,而 BRD 节点没有被重新执行(直接复用原值)。 +2. 验证原始 run 的记录在分叉操作后完全不受影响——分叉前后分别查询原始 `work_graph_run`/`work_node_execution`,字段值一致。 +3. 验证同一个历史节点可以被分叉多次,产生多条互不干扰的分支,且每条分支都能独立追溯到"从哪个节点、哪个原始 run 分叉而来"。 +4. 控制台里能看到某个 task 下全部分支的列表,并能分别打开查看。 + +## 6. 约束与假设 + +- `work_graph_run` 表当前有一条唯一约束:`ux_work_graph_run_active`——同一个 `(task_id, org_node)` 组合同一时间只能有一个非终态的 run(`src/schema.py:134`)。分叉产生的新 run 如果沿用同一个 `task_id`,会撞上这条约束(分叉分支和原分支可能同时处于非终态)。需要在设计阶段决定:分叉出的 run 是否需要一个独立的标识维度(比如新增一个 `parent_run_id`/`fork_of_node_exec_id` 字段,并调整唯一约束的范围,使其只约束"同一条分支线"内部,而不是整个 `task_id`),这是本需求对现有数据模型影响最大的一处,必须在设计阶段仔细过一遍现有依赖这条约束的代码路径(比如断点续跑逻辑是否假设了"同一 task_id+org_node 只有一个活跃 run")。 +- 分叉执行复用现有的每节点独立子进程执行机制(`work_node_run.run_work_node`/`work_graph_execute.execute_work_graph`),不需要新的执行引擎——分叉本质上是"用一份新的 `graph_manifest`(去掉已复用的上游节点)+ 新的初始 `outputs` 字典(预填充复用节点的输出)调用一次 `run_graph()`",复用度很高。 + +## 7. 风险 + +- **数据模型改动的影响面需要在设计阶段仔细评估**,`ux_work_graph_run_active` 这条唯一约束背后可能还有其他代码路径隐式依赖"一个 task_id+org_node 只有一个活跃 run"这个假设(比如查询任务当前状态的逻辑、WebSocket 推送逻辑),改动前需要逐一排查,不能只看 schema 本身。 +- **控制台可视化工作量可能比后端数据模型改动更大**——FR-4 涉及新的 UI 交互(选择分叉点、对比分支列表),需要跟现有 `docs/wireframes/` 里的 `v_task_detail` 类页面设计对齐,这部分工作量需要在设计阶段单独评估,不要低估。 + +## 8. 开放问题(留给详细设计阶段回答) + +- [ ] `parent_run_id`/`fork_of_node_exec_id` 这类新字段的具体 schema 设计,以及 `ux_work_graph_run_active` 约束要怎么调整。 +- [ ] 分叉操作的触发入口——只在控制台里做,还是也要开一个后端 API 供其他方式调用? +- [ ] FR-5"是否采纳分叉分支结果"的人工操作具体是什么交互(一个"设为主线"按钮?还是纯只读展示,人工另外手动处理)? +- [ ] 是否需要给分叉分支设置生命周期/清理策略(长期堆积的调试分支会不会需要定期清理,类似原始执行日志的保留天数机制)? diff --git a/frontend/app.js b/frontend/app.js index 5a7da1d..da24c1e 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1738,6 +1738,9 @@ function renderSkillAuditLog(skillId) {

${escapeHtml(skillId)} 变更审计

+

Skill 内容

+
加载中…
+

迭代记录

加载中…
`; @@ -1746,6 +1749,24 @@ function renderSkillAuditLog(skillId) { function mountSkillAuditLog(skillId) { const listEl = document.getElementById("skill_audit_list"); const errorEl = document.getElementById("skill_audit_error"); + const contentEl = document.getElementById("skill_content"); + + // Independent of the iteration-history panel below (a skill can have real + // content with zero skill_iteration rows -- that's the normal post- + // migration state) -- fetched and rendered separately, side by side, not + // as an either/or with refresh()'s own list. + fetch("/api/skills/" + encodeURIComponent(skillId) + "/content").then(async (resp) => { + if (resp.status !== 200) { + const body = await resp.json(); + contentEl.innerHTML = '
' + (body.error === "CONTENT_MISSING" ? "该 Skill 的真实文件尚未迁移" : "该 Skill 不存在") + "
"; + return; + } + const skill = await resp.json(); + const supportHtml = skill.support_files.length + ? '
支持文件:' + skill.support_files.map(escapeHtml).join("、") + "
" + : ""; + contentEl.innerHTML = supportHtml + '
' + escapeHtml(skill.content) + "
"; + }); async function refresh() { const resp = await fetch("/api/skills/" + encodeURIComponent(skillId) + "/audit"); diff --git a/src/backend_server.py b/src/backend_server.py index 2b442d4..470de0d 100644 --- a/src/backend_server.py +++ b/src/backend_server.py @@ -195,6 +195,10 @@ class _Handler(BaseHTTPRequestHandler): skill_id = parsed.path[len(_SKILLS_PREFIX):-len("/audit")] self._handle_skill_audit_log(skill_id) return + if parsed.path.startswith(_SKILLS_PREFIX) and parsed.path.endswith("/content"): + skill_id = parsed.path[len(_SKILLS_PREFIX):-len("/content")] + self._handle_skill_content(skill_id) + return if parsed.path == "/api/deployments": self._handle_deployment_list() return @@ -704,6 +708,22 @@ class _Handler(BaseHTTPRequestHandler): else: self._send_json(200, result) + def _handle_skill_content(self, skill_id): + """c_skill_content (bug-fix Unit, no MVCP operation_id -- same + "unowned gap" precedent as c_artifact_get before U-025). Real + SKILL.md content off disk, alongside (not instead of) the existing + /audit iteration history -- v_skill_audit_log's page renders both + independently.""" + try: + with self.server.conn_lock: + result = skill_query.get_skill_content(self.server.conn, skill_id, repo_path=self.server.skill_repo_path) + except skill_query.SkillNotFoundError: + self._send_json(404, {"error": "NOT_FOUND"}) + except skill_query.SkillContentMissingError: + self._send_json(404, {"error": "CONTENT_MISSING"}) + else: + self._send_json(200, result) + def _handle_skill_diff(self, query_string): params = urllib.parse.parse_qs(query_string) before = params.get("before", [None])[0] diff --git a/src/skill_query.py b/src/skill_query.py index 395e987..326727b 100644 --- a/src/skill_query.py +++ b/src/skill_query.py @@ -10,6 +10,8 @@ rather than inventing a second way to locate it. import os import subprocess +from src import skill_update + _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -52,6 +54,67 @@ def get_skill_audit_log(conn, skill_id): ] +class SkillContentMissingError(Exception): + """CONTENT_MISSING -- get_skill_content on a skill row whose real + SKILL.md file isn't present on disk. Distinct from SkillNotFoundError + (no such skill row at all): a skill row can legitimately exist with its + real file not-yet-migrated, and callers (v_skill_audit_log's new "Skill + 内容" panel) need to tell the two apart to show the right message.""" + + +def get_skill_content(conn, skill_id, repo_path=None): + """v_skill_audit_log's new "Skill 内容" panel (bug fix: this page used to + show only skill_iteration history -- correct on its own terms, but + silent about the real SKILL.md content a skill migration already wrote + to disk under skills//). Independent of get_skill_audit_log: + a skill can have real content with zero iterations, which is exactly + the post-migration, pre-any-self-iteration state today. + + skill_id is user-controlled (comes straight off the URL), but it is + never spliced into a filesystem path itself -- the lookup below is a + parameterized query, and the actual on-disk path comes from the + trusted `skill.file_path` DB column. Still applies two-layer + path-traversal protection on that DB value: reuse + skill_update._skill_file_path() for the same SKILL.md-suffix + resolution c_skill_update/c_skill_rollback already rely on, then + realpath+containment-check the result against the repo's skills/ root + before ever opening it -- defense in depth against a future schema/data + bug turning this read path into an arbitrary-file-read, not a fix for + an exploit that's live today. + """ + row = conn.execute("SELECT file_path FROM skill WHERE skill_id = ?", (skill_id,)).fetchone() + if row is None: + raise SkillNotFoundError(skill_id) + + repo_root = repo_path or _REPO_ROOT + skills_root = os.path.realpath(os.path.join(repo_root, "skills")) + rel_path = skill_update._skill_file_path(row[0]) + abs_path = os.path.realpath(os.path.join(repo_root, rel_path)) + if os.path.commonpath([abs_path, skills_root]) != skills_root: + # Layer 1: DB-stored file_path resolves outside skills/ entirely. + raise SkillContentMissingError(skill_id) + + try: + with open(abs_path, "r", encoding="utf-8", errors="replace") as f: + content = f.read() + except OSError: + # Layer 2: within skills/ but the file itself isn't there (not-yet- + # migrated skill, or a stale DB row). + raise SkillContentMissingError(skill_id) + + skill_dir = os.path.dirname(abs_path) + support_files = [] + for dirpath, _dirnames, filenames in os.walk(skill_dir): + for name in filenames: + full_path = os.path.realpath(os.path.join(dirpath, name)) + if full_path == abs_path: + continue + support_files.append(os.path.relpath(full_path, skill_dir)) + support_files.sort() + + return {"skill_id": skill_id, "content": content, "support_files": support_files} + + def get_diff(before, after, repo_path=None): """c_skill_diff: `git diff ` -- skill_update.py's _write_and_commit makes every iteration an atomic single-file commit, diff --git a/src/test_backend_server.py b/src/test_backend_server.py index c156be1..5dc6071 100644 --- a/src/test_backend_server.py +++ b/src/test_backend_server.py @@ -1521,6 +1521,23 @@ class SkillRouteTest(unittest.TestCase): status, body = self._post("/api/skills/skill-1/rollback", {}) self.assertEqual(status, 400) + def test_content_returns_real_file_content(self): + skill_update._write_and_commit(self.tmp.name, "skills/skill-1/SKILL.md", "real content\n", "seed skill") + status, body = self._get("/api/skills/skill-1/content") + self.assertEqual(status, 200) + self.assertEqual(body["content"], "real content\n") + self.assertEqual(body["support_files"], []) + + def test_content_nonexistent_skill_returns_404(self): + status, body = self._get("/api/skills/no-such-skill/content") + self.assertEqual(status, 404) + self.assertEqual(body["error"], "NOT_FOUND") + + def test_content_missing_file_returns_404_content_missing(self): + status, body = self._get("/api/skills/skill-1/content") + self.assertEqual(status, 404) + self.assertEqual(body["error"], "CONTENT_MISSING") + class SettingsRouteTest(unittest.TestCase): """U-030: c_concurrency_config_update / c_budget_config_update / diff --git a/src/test_skill_query.py b/src/test_skill_query.py index b97a63f..3f4b8bd 100644 --- a/src/test_skill_query.py +++ b/src/test_skill_query.py @@ -5,6 +5,7 @@ graphlemon's own -- same reasoning as test_skill_update.py). Run: python3 -m unittest src.test_skill_query -v """ +import os import subprocess import tempfile import unittest @@ -87,6 +88,85 @@ class GetSkillAuditLogTest(SkillQueryTestBase): self.assertEqual(result[0]["change_summary"], "fix A") +class GetSkillContentTest(SkillQueryTestBase): + """Bug fix: the skill detail page showed only skill_iteration history + and nothing for the real migrated SKILL.md content sitting on disk. + Uses the same tmp git repo as the rest of this file's tests -- these + file reads don't need git at all, but keeping repo_path consistent with + the other tests here avoids inventing a second fixture shape.""" + + def _write_skill_file(self, skill_id, content, extra_files=None): + skill_dir = os.path.join(self.tmp.name, "skills", skill_id) + os.makedirs(skill_dir, exist_ok=True) + with open(os.path.join(skill_dir, "SKILL.md"), "w", encoding="utf-8") as f: + f.write(content) + for rel in extra_files or []: + full = os.path.join(skill_dir, rel) + os.makedirs(os.path.dirname(full), exist_ok=True) + with open(full, "w", encoding="utf-8") as f: + f.write("support file content") + + def test_nonexistent_skill_raises_not_found(self): + with self.assertRaises(skill_query.SkillNotFoundError): + skill_query.get_skill_content(self.conn, "no-such-skill", repo_path=self.tmp.name) + + def test_returns_real_file_content(self): + self._insert_skill("s1", "coder") + self._write_skill_file("s1", "real skill content\n") + result = skill_query.get_skill_content(self.conn, "s1", repo_path=self.tmp.name) + self.assertEqual(result["skill_id"], "s1") + self.assertEqual(result["content"], "real skill content\n") + self.assertEqual(result["support_files"], []) + + def test_lists_support_files_alongside_content(self): + self._insert_skill("s1", "coder") + self._write_skill_file( + "s1", "content\n", + extra_files=["references/notes.md", "templates/t.md", "scripts/run.sh"], + ) + result = skill_query.get_skill_content(self.conn, "s1", repo_path=self.tmp.name) + self.assertEqual( + set(result["support_files"]), + {"references/notes.md", "templates/t.md", "scripts/run.sh"}, + ) + + def test_row_exists_but_file_missing_raises_content_missing(self): + self._insert_skill("s1", "coder") + with self.assertRaises(skill_query.SkillContentMissingError): + skill_query.get_skill_content(self.conn, "s1", repo_path=self.tmp.name) + + def test_file_path_already_ending_in_md_is_read_directly(self): + self._insert_skill("s1", "coder") + self.conn.execute("UPDATE skill SET file_path = ? WHERE skill_id = ?", ("skills/s1/SKILL.md", "s1")) + self.conn.commit() + self._write_skill_file("s1", "direct path content\n") + result = skill_query.get_skill_content(self.conn, "s1", repo_path=self.tmp.name) + self.assertEqual(result["content"], "direct path content\n") + + def test_path_traversal_in_stored_file_path_is_rejected(self): + """Defense-in-depth: even if skill.file_path were somehow poisoned + with a traversal sequence (not reachable via the HTTP skill_id + param today, since that only drives a parameterized WHERE clause), + the resolved path must stay confined to skills/.""" + self._insert_skill("s1", "coder") + self.conn.execute( + "UPDATE skill SET file_path = ? WHERE skill_id = ?", ("../../../../etc/passwd", "s1"), + ) + self.conn.commit() + with self.assertRaises(skill_query.SkillContentMissingError): + skill_query.get_skill_content(self.conn, "s1", repo_path=self.tmp.name) + + def test_path_traversal_via_dotdot_segments_stays_confined(self): + self._insert_skill("s1", "coder") + self.conn.execute( + "UPDATE skill SET file_path = ? WHERE skill_id = ?", + ("skills/../../outside/SKILL.md", "s1"), + ) + self.conn.commit() + with self.assertRaises(skill_query.SkillContentMissingError): + skill_query.get_skill_content(self.conn, "s1", repo_path=self.tmp.name) + + class GetDiffTest(SkillQueryTestBase): def test_diff_between_two_commits_shows_content_change(self): before_sha = skill_update._write_and_commit(self.tmp.name, "skills/s1/SKILL.md", "v1 content\n", "seed")