// decomposition.go 实现 ApplyDecomposition:让规划阶段的 agent 在一次 MCP 调用中 // 原子地提交整张任务分解图(子任务 + blocks/blocked-by 边 + 优先级),而不必发 N 次 // create_subtask + M 次 add_dependency。先在内存中对 in-batch 图做 DAG 校验(拓扑排序), // 全部通过后再落库:先建全部子任务,再建全部依赖边。校验失败则在任何写入前返回, // 不留半成品。 package orchestrator import ( "context" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" "github.com/yan1h/agent-coding-workflow/internal/project" ) // SubtaskSpec 描述分解图里的一个子任务。Ref 是 batch 内的本地引用键(如 "a"/"1"), // 供 EdgeSpec 跨条目引用;落库后映射为真实 issue number。 type SubtaskSpec struct { Ref string Title string Description string Priority int } // EdgeSpec 描述一条依赖边:BlockedRef 被 BlockerRef 阻塞。两端均为 batch 内 ref。 type EdgeSpec struct { BlockedRef string BlockerRef string } // Decomposition 是一次完整任务分解提交。 type Decomposition struct { // ParentNumber 非 nil 时所有子任务挂在该 issue 下(通常是 requirement 的锚定 issue)。 ParentNumber *int Subtasks []SubtaskSpec Edges []EdgeSpec } // DecompositionResult 报告落库结果:ref → 真实 issue number。 type DecompositionResult struct { // RefToNumber 把每个 SubtaskSpec.Ref 映射到创建出的 issue number。 RefToNumber map[string]int } // DecompositionApplier 是 ApplyDecomposition 依赖的窄 issue 服务接口(project.IssueService 满足)。 type DecompositionApplier interface { CreateSubtask(ctx context.Context, c project.Caller, projectSlug string, parentNumber int, in project.CreateIssueInput) (*project.Issue, error) Create(ctx context.Context, c project.Caller, projectSlug string, in project.CreateIssueInput) (*project.Issue, error) AddDependency(ctx context.Context, c project.Caller, projectSlug string, in project.AddDependencyInput) (*project.Dependency, error) } // ApplyDecomposition 原子(best-effort)地落库一张任务分解图。先校验 in-batch 图是 DAG, // 再建子任务、建边。返回 ref→number 映射。校验在任何写入前完成,故无效图不留半成品。 func ApplyDecomposition(ctx context.Context, svc DecompositionApplier, c project.Caller, projectSlug string, d Decomposition) (DecompositionResult, error) { res := DecompositionResult{RefToNumber: map[string]int{}} if len(d.Subtasks) == 0 { return res, errs.New(errs.CodeInvalidInput, "decomposition 至少需要一个子任务") } // 1) ref 去重 + 建集合。 refSet := make(map[string]struct{}, len(d.Subtasks)) for _, st := range d.Subtasks { if st.Ref == "" { return res, errs.New(errs.CodeInvalidInput, "子任务 ref 不能为空") } if st.Title == "" { return res, errs.New(errs.CodeInvalidInput, "子任务 title 不能为空") } if _, dup := refSet[st.Ref]; dup { return res, errs.New(errs.CodeInvalidInput, "子任务 ref 重复: "+st.Ref) } if st.Priority < 0 || st.Priority > 3 { return res, errs.New(errs.CodeInvalidInput, "子任务 priority 必须在 0..3") } refSet[st.Ref] = struct{}{} } // 2) 校验边端点存在、非自环。 for _, e := range d.Edges { if _, ok := refSet[e.BlockedRef]; !ok { return res, errs.New(errs.CodeInvalidInput, "边引用了未知 blocked ref: "+e.BlockedRef) } if _, ok := refSet[e.BlockerRef]; !ok { return res, errs.New(errs.CodeInvalidInput, "边引用了未知 blocker ref: "+e.BlockerRef) } if e.BlockedRef == e.BlockerRef { return res, errs.New(errs.CodeInvalidInput, "边不能自指: "+e.BlockedRef) } } // 3) DAG 校验:沿 blocked-by 边(blocked → blocker)做拓扑排序,存在环则拒绝。 if err := validateDAG(d.Subtasks, d.Edges); err != nil { return res, err } // 4) 落库:先建全部子任务(记录 ref→number),再建全部依赖边。 for _, st := range d.Subtasks { prio := st.Priority in := project.CreateIssueInput{ Title: st.Title, Description: st.Description, Priority: &prio, } var iss *project.Issue var err error if d.ParentNumber != nil { iss, err = svc.CreateSubtask(ctx, c, projectSlug, *d.ParentNumber, in) } else { iss, err = svc.Create(ctx, c, projectSlug, in) } if err != nil { return res, err } res.RefToNumber[st.Ref] = iss.Number } for _, e := range d.Edges { if _, err := svc.AddDependency(ctx, c, projectSlug, project.AddDependencyInput{ BlockedNumber: res.RefToNumber[e.BlockedRef], BlockerNumber: res.RefToNumber[e.BlockerRef], }); err != nil { return res, err } } return res, nil } // validateDAG 对 (子任务, 边) 做 Kahn 拓扑排序,存在环返回 CodeInvalidInput。 // 边语义 blocked blocked-by blocker,建图方向 blocker → blocked(依赖先于被依赖)。 func validateDAG(subtasks []SubtaskSpec, edges []EdgeSpec) error { indeg := make(map[string]int, len(subtasks)) for _, st := range subtasks { indeg[st.Ref] = 0 } adj := make(map[string][]string, len(subtasks)) // blocker → blocked:blocked 的入度 +1。 for _, e := range edges { adj[e.BlockerRef] = append(adj[e.BlockerRef], e.BlockedRef) indeg[e.BlockedRef]++ } queue := make([]string, 0, len(subtasks)) for ref, d := range indeg { if d == 0 { queue = append(queue, ref) } } visited := 0 for len(queue) > 0 { cur := queue[0] queue = queue[1:] visited++ for _, next := range adj[cur] { indeg[next]-- if indeg[next] == 0 { queue = append(queue, next) } } } if visited != len(subtasks) { return errs.New(errs.CodeInvalidInput, "decomposition 边集合存在环") } return nil }