You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
package orchestrator
|
||||
|
||||
import "time"
|
||||
|
||||
// Config 是编排器模块的运行参数(从 config.OrchestratorConfig 派生)。
|
||||
type Config struct {
|
||||
// MaxAttemptsPerPhase 是单个阶段 step 在 dead-letter 前的最大尝试次数(run.config
|
||||
// 覆盖优先)。
|
||||
MaxAttemptsPerPhase int
|
||||
// TurnTimeout 约束单个回合(SendPromptAndWait)的最长时长,须 < jobReaper
|
||||
// LeaseTimeout,避免回合中途被 reaper 重抢租约导致重复驱动。
|
||||
TurnTimeout time.Duration
|
||||
// MaxDepth / FanOutLimit 是 request_subtask 的递归深度与并发子任务上限(run.config
|
||||
// 覆盖优先)。
|
||||
MaxDepth int
|
||||
FanOutLimit int
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
)
|
||||
|
||||
// fakeApplier: 内存版 DecompositionApplier,记录建出的 issue 与边。
|
||||
type fakeApplier struct {
|
||||
nextNum int
|
||||
created []string // titles in order
|
||||
edges [][2]int // (blockedNumber, blockerNumber)
|
||||
byNum map[int]*project.Issue // number → issue
|
||||
}
|
||||
|
||||
func newFakeApplier() *fakeApplier {
|
||||
return &fakeApplier{byNum: map[int]*project.Issue{}}
|
||||
}
|
||||
|
||||
func (f *fakeApplier) mk(in project.CreateIssueInput, parent *int) *project.Issue {
|
||||
f.nextNum++
|
||||
prio := 0
|
||||
if in.Priority != nil {
|
||||
prio = *in.Priority
|
||||
}
|
||||
var pid *uuid.UUID
|
||||
if parent != nil {
|
||||
id := uuid.New()
|
||||
pid = &id
|
||||
}
|
||||
iss := &project.Issue{ID: uuid.New(), Number: f.nextNum, Title: in.Title, Priority: prio, ParentID: pid}
|
||||
f.byNum[iss.Number] = iss
|
||||
f.created = append(f.created, in.Title)
|
||||
return iss
|
||||
}
|
||||
|
||||
func (f *fakeApplier) Create(_ context.Context, _ project.Caller, _ string, in project.CreateIssueInput) (*project.Issue, error) {
|
||||
return f.mk(in, nil), nil
|
||||
}
|
||||
func (f *fakeApplier) CreateSubtask(_ context.Context, _ project.Caller, _ string, parentNumber int, in project.CreateIssueInput) (*project.Issue, error) {
|
||||
return f.mk(in, &parentNumber), nil
|
||||
}
|
||||
func (f *fakeApplier) AddDependency(_ context.Context, _ project.Caller, _ string, in project.AddDependencyInput) (*project.Dependency, error) {
|
||||
f.edges = append(f.edges, [2]int{in.BlockedNumber, in.BlockerNumber})
|
||||
return &project.Dependency{ID: uuid.New()}, nil
|
||||
}
|
||||
|
||||
func TestApplyDecomposition_RejectsCycleBeforeAnyWrite(t *testing.T) {
|
||||
f := newFakeApplier()
|
||||
d := Decomposition{
|
||||
Subtasks: []SubtaskSpec{{Ref: "a", Title: "A"}, {Ref: "b", Title: "B"}},
|
||||
Edges: []EdgeSpec{
|
||||
{BlockedRef: "a", BlockerRef: "b"},
|
||||
{BlockedRef: "b", BlockerRef: "a"}, // 环
|
||||
},
|
||||
}
|
||||
_, err := ApplyDecomposition(context.Background(), f, project.Caller{}, "demo", d)
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeInvalidInput, ae.Code)
|
||||
require.Empty(t, f.created, "校验失败前不应有任何写入")
|
||||
require.Empty(t, f.edges)
|
||||
}
|
||||
|
||||
func TestApplyDecomposition_ValidDAGCreatesIssuesThenEdges(t *testing.T) {
|
||||
f := newFakeApplier()
|
||||
parent := 100
|
||||
f.byNum[parent] = &project.Issue{Number: parent}
|
||||
d := Decomposition{
|
||||
ParentNumber: &parent,
|
||||
Subtasks: []SubtaskSpec{
|
||||
{Ref: "a", Title: "A", Priority: 2},
|
||||
{Ref: "b", Title: "B"},
|
||||
{Ref: "c", Title: "C"},
|
||||
},
|
||||
Edges: []EdgeSpec{
|
||||
{BlockedRef: "a", BlockerRef: "b"},
|
||||
{BlockedRef: "a", BlockerRef: "c"},
|
||||
},
|
||||
}
|
||||
res, err := ApplyDecomposition(context.Background(), f, project.Caller{}, "demo", d)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, f.created, 3)
|
||||
require.Len(t, f.edges, 2)
|
||||
// refs 映射到真实 number
|
||||
require.Contains(t, res.RefToNumber, "a")
|
||||
require.Contains(t, res.RefToNumber, "b")
|
||||
require.Contains(t, res.RefToNumber, "c")
|
||||
// 边以真实 number 落库
|
||||
na, nb := res.RefToNumber["a"], res.RefToNumber["b"]
|
||||
require.Contains(t, f.edges, [2]int{na, nb})
|
||||
}
|
||||
|
||||
func TestApplyDecomposition_RejectsEmpty(t *testing.T) {
|
||||
f := newFakeApplier()
|
||||
_, err := ApplyDecomposition(context.Background(), f, project.Caller{}, "demo", Decomposition{})
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeInvalidInput, ae.Code)
|
||||
}
|
||||
|
||||
func TestApplyDecomposition_RejectsUnknownEdgeRef(t *testing.T) {
|
||||
f := newFakeApplier()
|
||||
d := Decomposition{
|
||||
Subtasks: []SubtaskSpec{{Ref: "a", Title: "A"}},
|
||||
Edges: []EdgeSpec{{BlockedRef: "a", BlockerRef: "zzz"}},
|
||||
}
|
||||
_, err := ApplyDecomposition(context.Background(), f, project.Caller{}, "demo", d)
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeInvalidInput, ae.Code)
|
||||
require.Empty(t, f.created)
|
||||
}
|
||||
|
||||
func TestApplyDecomposition_RejectsDuplicateRef(t *testing.T) {
|
||||
f := newFakeApplier()
|
||||
d := Decomposition{
|
||||
Subtasks: []SubtaskSpec{{Ref: "a", Title: "A"}, {Ref: "a", Title: "A2"}},
|
||||
}
|
||||
_, err := ApplyDecomposition(context.Background(), f, project.Caller{}, "demo", d)
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeInvalidInput, ae.Code)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Package orchestrator 实现"编排器/规划器":一个 per-requirement 的 run 聚合根,
|
||||
// 驱动 6 阶段状态机(planning → prototyping → auditing → implementing → reviewing
|
||||
// → done)。每个 step 选取一个 agent-kind + prompt,以特权但真实的 owner 身份经
|
||||
// acp.SessionService 创建一个 ACP session,发一条 prompt 并阻塞等待回合完成
|
||||
// (session/prompt stopReason),评估阶段网关后推进或回环。run 状态落库到
|
||||
// orchestrator_runs / orchestrator_steps,每个 step 由 jobs (orchestrator.step) 驱动,
|
||||
// 因而能跨重启恢复并复用既有 backoff/dead-letter 机制。
|
||||
//
|
||||
// agent-to-agent handoff 发生在编排器层:特权身份创建下一个 session,不放松
|
||||
// checkNotSystemToken。request_subtask MCP 工具让运行中的 agent 在 depth/fan-out
|
||||
// 限制内入队一个子 step。崩溃的 session 在同一 worktree 上重试/恢复。
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
)
|
||||
|
||||
// RunStatus 是 orchestrator_runs.status 枚举。
|
||||
type RunStatus string
|
||||
|
||||
const (
|
||||
RunPending RunStatus = "pending"
|
||||
RunRunning RunStatus = "running"
|
||||
RunPaused RunStatus = "paused"
|
||||
RunSucceeded RunStatus = "succeeded"
|
||||
RunFailed RunStatus = "failed"
|
||||
RunCanceled RunStatus = "canceled"
|
||||
)
|
||||
|
||||
// IsTerminal 报告 run 是否已到终态(不再驱动)。
|
||||
func (s RunStatus) IsTerminal() bool {
|
||||
return s == RunSucceeded || s == RunFailed || s == RunCanceled
|
||||
}
|
||||
|
||||
// IsActive 报告 run 是否处于可驱动状态(pending/running)。paused 不驱动但非终态。
|
||||
func (s RunStatus) IsActive() bool { return s == RunPending || s == RunRunning }
|
||||
|
||||
// StepStatus 是 orchestrator_steps.status 枚举。
|
||||
type StepStatus string
|
||||
|
||||
const (
|
||||
StepPending StepStatus = "pending"
|
||||
StepSpawning StepStatus = "spawning"
|
||||
StepRunning StepStatus = "running"
|
||||
StepAwaitingGate StepStatus = "awaiting_gate"
|
||||
StepSucceeded StepStatus = "succeeded"
|
||||
StepFailed StepStatus = "failed"
|
||||
StepDead StepStatus = "dead"
|
||||
)
|
||||
|
||||
// Run 是一次编排运行聚合根。
|
||||
type Run struct {
|
||||
ID uuid.UUID
|
||||
ProjectID uuid.UUID
|
||||
RequirementID uuid.UUID
|
||||
WorkspaceID uuid.UUID
|
||||
OwnerID uuid.UUID
|
||||
Status RunStatus
|
||||
CurrentPhase project.Phase
|
||||
Config RunConfig
|
||||
LastError *string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
EndedAt *time.Time
|
||||
}
|
||||
|
||||
// Step 是 run 内一次阶段尝试。parent_step_id 非 nil 表示由 request_subtask fan-out。
|
||||
type Step struct {
|
||||
ID uuid.UUID
|
||||
RunID uuid.UUID
|
||||
Phase project.Phase
|
||||
Attempt int
|
||||
ParentStepID *uuid.UUID
|
||||
Depth int
|
||||
Status StepStatus
|
||||
AgentKindID uuid.UUID
|
||||
ACPSessionID *uuid.UUID
|
||||
Prompt string
|
||||
StopReason *string
|
||||
GateResult *GateResult
|
||||
LastError *string
|
||||
JobID *uuid.UUID
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
EndedAt *time.Time
|
||||
}
|
||||
|
||||
// RunConfig 是 run 的 per-phase 配置(落 JSONB)。零值合法:PhaseToAgentKind 退回
|
||||
// defaults,MaxAttemptsPerPhase/MaxDepth/FanOutLimit<=0 时由 service 用配置默认填充。
|
||||
type RunConfig struct {
|
||||
PhaseAgentKinds map[project.Phase]uuid.UUID `json:"phase_agent_kinds,omitempty"`
|
||||
PromptOverrides map[project.Phase]string `json:"prompt_overrides,omitempty"`
|
||||
MaxAttemptsPerPhase int `json:"max_attempts_per_phase,omitempty"`
|
||||
MaxDepth int `json:"max_depth,omitempty"`
|
||||
FanOutLimit int `json:"fan_out_limit,omitempty"`
|
||||
}
|
||||
|
||||
// GateResult 是一次阶段网关评估结果。Pass=true 推进;Retry=true 且未通过 → 可重试
|
||||
// (回环本阶段);Retry=false 且未通过 → 永久失败(dead-letter)。
|
||||
type GateResult struct {
|
||||
Pass bool `json:"pass"`
|
||||
Retry bool `json:"retry"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Detail map[string]any `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// PhaseGate 是可插拔的阶段网关接口。v1 实现见 gate.go。
|
||||
type PhaseGate interface {
|
||||
Evaluate(ctx context.Context, run *Run, step *Step, stopReason string) (GateResult, error)
|
||||
}
|
||||
|
||||
// Caller 表示发起编排操作的用户上下文(与其他模块的 Caller 同语义)。
|
||||
type Caller struct {
|
||||
UserID uuid.UUID
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
// StartRunInput 是启动一次编排运行的入参。
|
||||
type StartRunInput struct {
|
||||
ProjectSlug string
|
||||
RequirementNumber int
|
||||
StartPhase project.Phase
|
||||
Config RunConfig
|
||||
}
|
||||
|
||||
// SubtaskInput 是 request_subtask 的入参(由 MCP bridge 透传)。
|
||||
type SubtaskInput struct {
|
||||
Phase project.Phase
|
||||
Prompt string
|
||||
AgentKindID *uuid.UUID
|
||||
}
|
||||
|
||||
// RunFilter 控制 ListRuns 的过滤维度。零值不过滤。
|
||||
type RunFilter struct {
|
||||
ProjectID *uuid.UUID
|
||||
RequirementID *uuid.UUID
|
||||
Status RunStatus
|
||||
Limit int
|
||||
}
|
||||
|
||||
// Service 暴露编排器聚合根的应用服务方法。
|
||||
type Service interface {
|
||||
StartRun(ctx context.Context, c Caller, in StartRunInput) (*Run, error)
|
||||
GetRun(ctx context.Context, c Caller, id uuid.UUID) (*Run, error)
|
||||
ListRuns(ctx context.Context, c Caller, f RunFilter) ([]*Run, error)
|
||||
Pause(ctx context.Context, c Caller, id uuid.UUID) error
|
||||
Resume(ctx context.Context, c Caller, id uuid.UUID) error
|
||||
Cancel(ctx context.Context, c Caller, id uuid.UUID) error
|
||||
// RequestSubtask 由 MCP bridge 调用:运行中的 agent 请求编排器为其当前 step 派生子 step。
|
||||
RequestSubtask(ctx context.Context, parentACPSessionID uuid.UUID, in SubtaskInput) (*Step, error)
|
||||
// EnqueueStepRedrive 由 acp supervisor onExit 注入的回调间接调用:把崩溃 session 的
|
||||
// step 重新入队(带 backoff)。
|
||||
EnqueueStepRedrive(ctx context.Context, stepID uuid.UUID, reason string)
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/acp"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/jobs"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
)
|
||||
|
||||
// ===== fakeRepo: 内存版 orchestrator.Repository =====
|
||||
|
||||
type fakeRepo struct {
|
||||
mu sync.Mutex
|
||||
runs map[uuid.UUID]*Run
|
||||
steps map[uuid.UUID]*Step
|
||||
}
|
||||
|
||||
func newFakeRepo() *fakeRepo {
|
||||
return &fakeRepo{runs: map[uuid.UUID]*Run{}, steps: map[uuid.UUID]*Step{}}
|
||||
}
|
||||
|
||||
func cloneRun(r *Run) *Run { c := *r; return &c }
|
||||
func cloneStep(s *Step) *Step { c := *s; return &c }
|
||||
|
||||
func (r *fakeRepo) InsertRun(_ context.Context, run *Run) (*Run, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if run.ID == uuid.Nil {
|
||||
run.ID = uuid.New()
|
||||
}
|
||||
run.CreatedAt = time.Now()
|
||||
run.UpdatedAt = run.CreatedAt
|
||||
r.runs[run.ID] = cloneRun(run)
|
||||
return cloneRun(run), nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetRun(_ context.Context, id uuid.UUID) (*Run, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
run, ok := r.runs[id]
|
||||
if !ok {
|
||||
return nil, errs.New(errs.CodeNotFound, "run not found")
|
||||
}
|
||||
return cloneRun(run), nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetActiveRunByRequirement(_ context.Context, reqID uuid.UUID) (*Run, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, run := range r.runs {
|
||||
if run.RequirementID == reqID && run.Status.IsActive() || (run.RequirementID == reqID && run.Status == RunPaused) {
|
||||
return cloneRun(run), nil
|
||||
}
|
||||
}
|
||||
return nil, errs.New(errs.CodeNotFound, "no active run")
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListRuns(_ context.Context, f RunFilter) ([]*Run, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := []*Run{}
|
||||
for _, run := range r.runs {
|
||||
if f.RequirementID != nil && run.RequirementID != *f.RequirementID {
|
||||
continue
|
||||
}
|
||||
if f.Status != "" && run.Status != f.Status {
|
||||
continue
|
||||
}
|
||||
out = append(out, cloneRun(run))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) UpdateRunStatus(_ context.Context, id uuid.UUID, status RunStatus, lastErr *string) (*Run, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
run, ok := r.runs[id]
|
||||
if !ok {
|
||||
return nil, errs.New(errs.CodeNotFound, "run not found")
|
||||
}
|
||||
run.Status = status
|
||||
run.LastError = lastErr
|
||||
run.UpdatedAt = time.Now()
|
||||
return cloneRun(run), nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) UpdateRunPhase(_ context.Context, id uuid.UUID, phase project.Phase) (*Run, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
run, ok := r.runs[id]
|
||||
if !ok {
|
||||
return nil, errs.New(errs.CodeNotFound, "run not found")
|
||||
}
|
||||
run.CurrentPhase = phase
|
||||
run.UpdatedAt = time.Now()
|
||||
return cloneRun(run), nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) InsertStep(_ context.Context, s *Step) (*Step, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if s.ID == uuid.Nil {
|
||||
s.ID = uuid.New()
|
||||
}
|
||||
s.CreatedAt = time.Now()
|
||||
s.UpdatedAt = s.CreatedAt
|
||||
r.steps[s.ID] = cloneStep(s)
|
||||
return cloneStep(s), nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetStep(_ context.Context, id uuid.UUID) (*Step, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
s, ok := r.steps[id]
|
||||
if !ok {
|
||||
return nil, errs.New(errs.CodeNotFound, "step not found")
|
||||
}
|
||||
return cloneStep(s), nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetStepBySession(_ context.Context, sessionID uuid.UUID) (*Step, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, s := range r.steps {
|
||||
if s.ACPSessionID != nil && *s.ACPSessionID == sessionID {
|
||||
return cloneStep(s), nil
|
||||
}
|
||||
}
|
||||
return nil, errs.New(errs.CodeNotFound, "step not found")
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListStepsByRun(_ context.Context, runID uuid.UUID) ([]*Step, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := []*Step{}
|
||||
for _, s := range r.steps {
|
||||
if s.RunID == runID {
|
||||
out = append(out, cloneStep(s))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) UpdateStepStatus(_ context.Context, s *Step) (*Step, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
cur, ok := r.steps[s.ID]
|
||||
if !ok {
|
||||
return nil, errs.New(errs.CodeNotFound, "step not found")
|
||||
}
|
||||
cur.Status = s.Status
|
||||
cur.ACPSessionID = s.ACPSessionID
|
||||
cur.StopReason = s.StopReason
|
||||
cur.GateResult = s.GateResult
|
||||
cur.LastError = s.LastError
|
||||
cur.JobID = s.JobID
|
||||
cur.UpdatedAt = time.Now()
|
||||
return cloneStep(cur), nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) IncrementStepAttempt(_ context.Context, id uuid.UUID) (*Step, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
s, ok := r.steps[id]
|
||||
if !ok {
|
||||
return nil, errs.New(errs.CodeNotFound, "step not found")
|
||||
}
|
||||
s.Attempt++
|
||||
s.UpdatedAt = time.Now()
|
||||
return cloneStep(s), nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) CountActiveChildSteps(_ context.Context, parentStepID uuid.UUID) (int64, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var n int64
|
||||
for _, s := range r.steps {
|
||||
if s.ParentStepID != nil && *s.ParentStepID == parentStepID {
|
||||
switch s.Status {
|
||||
case StepPending, StepSpawning, StepRunning, StepAwaitingGate:
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ResetStuckStepsOnRestart(_ context.Context) ([]*Step, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := []*Step{}
|
||||
for _, s := range r.steps {
|
||||
run, ok := r.runs[s.RunID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
active := run.Status.IsActive() || run.Status == RunPaused
|
||||
switch s.Status {
|
||||
case StepSpawning, StepRunning, StepAwaitingGate:
|
||||
if active {
|
||||
s.Status = StepPending
|
||||
out = append(out, cloneStep(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fakeRepo 不支持真正的事务:InTx 直接调用 fn(同一实例),WithTx 返回自身。
|
||||
func (r *fakeRepo) InTx(ctx context.Context, fn func(ctx context.Context, tx pgx.Tx) error) error {
|
||||
return fn(ctx, nil)
|
||||
}
|
||||
func (r *fakeRepo) WithTx(_ pgx.Tx) Repository { return r }
|
||||
|
||||
// ===== fakePM: 内存版 PMAccess =====
|
||||
|
||||
type fakePM struct {
|
||||
mu sync.Mutex
|
||||
proj *project.Project
|
||||
req *project.Requirement
|
||||
artifacts []*project.Artifact
|
||||
phaseWrites []project.Phase
|
||||
phaseErr error
|
||||
}
|
||||
|
||||
func (p *fakePM) GetProjectByID(_ context.Context, _ uuid.UUID) (*project.Project, error) {
|
||||
return p.proj, nil
|
||||
}
|
||||
func (p *fakePM) GetProjectBySlug(_ context.Context, _ string) (*project.Project, error) {
|
||||
return p.proj, nil
|
||||
}
|
||||
func (p *fakePM) GetRequirementByID(_ context.Context, _ uuid.UUID) (*project.Requirement, error) {
|
||||
return p.req, nil
|
||||
}
|
||||
func (p *fakePM) GetRequirementByNumber(_ context.Context, _ uuid.UUID, _ int) (*project.Requirement, error) {
|
||||
return p.req, nil
|
||||
}
|
||||
func (p *fakePM) ListLatestArtifacts(_ context.Context, _ uuid.UUID) ([]*project.Artifact, error) {
|
||||
return p.artifacts, nil
|
||||
}
|
||||
func (p *fakePM) ChangeRequirementPhase(_ context.Context, _ uuid.UUID, _ bool, _ string, _ int, to project.Phase) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.phaseErr != nil {
|
||||
return p.phaseErr
|
||||
}
|
||||
p.phaseWrites = append(p.phaseWrites, to)
|
||||
if p.req != nil {
|
||||
p.req.Phase = to
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ===== fakeTurnRunner: acp.TurnRunner =====
|
||||
|
||||
type fakeTurnRunner struct {
|
||||
stopReason string
|
||||
err error
|
||||
resumeErr error
|
||||
created *acp.Session
|
||||
resumed *acp.Session
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeTurnRunner) SendPromptAndWait(_ context.Context, sessionID uuid.UUID, _ string) (string, error) {
|
||||
f.calls++
|
||||
return f.stopReason, f.err
|
||||
}
|
||||
func (f *fakeTurnRunner) ResumeSession(_ context.Context, _ acp.Caller, sessionID uuid.UUID) (*acp.Session, error) {
|
||||
if f.resumeErr != nil {
|
||||
return nil, f.resumeErr
|
||||
}
|
||||
return &acp.Session{ID: sessionID, Status: acp.SessionStarting}, nil
|
||||
}
|
||||
|
||||
// ===== fakeACPSessions: orchestrator.ACPSessions =====
|
||||
|
||||
type fakeACPSessions struct {
|
||||
mu sync.Mutex
|
||||
created []acp.CreateSessionInput
|
||||
nextID uuid.UUID
|
||||
createErr error
|
||||
}
|
||||
|
||||
func (f *fakeACPSessions) Create(_ context.Context, _ acp.Caller, in acp.CreateSessionInput) (*acp.Session, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.createErr != nil {
|
||||
return nil, f.createErr
|
||||
}
|
||||
f.created = append(f.created, in)
|
||||
id := f.nextID
|
||||
if id == uuid.Nil {
|
||||
id = uuid.New()
|
||||
}
|
||||
return &acp.Session{ID: id, Status: acp.SessionStarting, OrchestratorStepID: in.OrchestratorStepID}, nil
|
||||
}
|
||||
|
||||
// ===== fakeLookup: orchestrator.ACPSessionLookup =====
|
||||
|
||||
type fakeLookup struct {
|
||||
crashed map[uuid.UUID]*acp.Session // stepID → crashed session
|
||||
live map[uuid.UUID]*acp.Session // stepID → live session
|
||||
}
|
||||
|
||||
func newFakeLookup() *fakeLookup {
|
||||
return &fakeLookup{crashed: map[uuid.UUID]*acp.Session{}, live: map[uuid.UUID]*acp.Session{}}
|
||||
}
|
||||
func (f *fakeLookup) GetSessionByStepID(_ context.Context, stepID uuid.UUID) (*acp.Session, error) {
|
||||
if s, ok := f.live[stepID]; ok {
|
||||
return s, nil
|
||||
}
|
||||
return nil, errs.New(errs.CodeNotFound, "no session")
|
||||
}
|
||||
func (f *fakeLookup) GetCrashedSessionForResume(_ context.Context, stepID uuid.UUID) (*acp.Session, error) {
|
||||
if s, ok := f.crashed[stepID]; ok {
|
||||
return s, nil
|
||||
}
|
||||
return nil, errs.New(errs.CodeNotFound, "no crashed session")
|
||||
}
|
||||
|
||||
// ===== fakeJobs: orchestrator.JobEnqueuer =====
|
||||
|
||||
type fakeJobs struct {
|
||||
mu sync.Mutex
|
||||
enqueued []stepPayload
|
||||
}
|
||||
|
||||
func (j *fakeJobs) Enqueue(_ context.Context, _ jobs.JobType, payload json.RawMessage, _ time.Time, _ int32) (*jobs.Job, error) {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
var p stepPayload
|
||||
_ = json.Unmarshal(payload, &p)
|
||||
j.enqueued = append(j.enqueued, p)
|
||||
return &jobs.Job{ID: uuid.New()}, nil
|
||||
}
|
||||
func (j *fakeJobs) count() int {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
return len(j.enqueued)
|
||||
}
|
||||
|
||||
// ===== fakeGate: orchestrator.PhaseGate =====
|
||||
|
||||
type fakeGate struct {
|
||||
result GateResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (g fakeGate) Evaluate(_ context.Context, _ *Run, _ *Step, _ string) (GateResult, error) {
|
||||
return g.result, g.err
|
||||
}
|
||||
|
||||
// ===== fakeAudit: orchestrator.AuditRecorder =====
|
||||
|
||||
type fakeAudit struct {
|
||||
mu sync.Mutex
|
||||
actions []string
|
||||
}
|
||||
|
||||
func (a *fakeAudit) Record(_ context.Context, _ uuid.UUID, action, _ string, _ map[string]any) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.actions = append(a.actions, action)
|
||||
}
|
||||
func (a *fakeAudit) has(action string) bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
for _, x := range a.actions {
|
||||
if x == action {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ===== fakeSessTerm: orchestrator.sessionTerminator =====
|
||||
|
||||
type fakeSessTerm struct {
|
||||
mu sync.Mutex
|
||||
terminated []uuid.UUID
|
||||
}
|
||||
|
||||
func (s *fakeSessTerm) TerminateSession(_ context.Context, _ uuid.UUID, _ bool, sessionID uuid.UUID) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.terminated = append(s.terminated, sessionID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ===== fakeACL: orchestrator.ProjectACL =====
|
||||
|
||||
type fakeACL struct {
|
||||
allow bool
|
||||
read bool
|
||||
}
|
||||
|
||||
func (a fakeACL) CanRead(_ context.Context, _ uuid.UUID, _ bool, _ uuid.UUID) (bool, error) {
|
||||
return a.allow || a.read, nil
|
||||
}
|
||||
|
||||
func (a fakeACL) CanWrite(_ context.Context, _ uuid.UUID, _ bool, _ uuid.UUID) (bool, error) {
|
||||
return a.allow, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// gate.go 实现阶段网关:把"agent 停了"(stopReason)与"工作确实就绪"(产物/issue
|
||||
// 谓词)结合,避免仅凭 end_turn 就静默把需求标记完成。v1 是可插拔的 per-phase 谓词。
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
)
|
||||
|
||||
// GateArtifactAccess 是网关查询某需求阶段产物的窄接口(app 层用 project.ArtifactService 适配)。
|
||||
type GateArtifactAccess interface {
|
||||
// ListArtifacts 返回某 requirement 在某 phase 的产物(phase 传零值 "" 表示全部阶段)。
|
||||
ListArtifacts(ctx context.Context, projectID, requirementID uuid.UUID, phase project.Phase) ([]*project.Artifact, error)
|
||||
}
|
||||
|
||||
// GateIssueAccess 是网关查询某需求未关闭 issue 数的窄接口(app 层用 project.IssueService/Repository 适配)。
|
||||
type GateIssueAccess interface {
|
||||
CountOpenIssues(ctx context.Context, requirementID uuid.UUID) (int, error)
|
||||
}
|
||||
|
||||
// v1Gate 是 v1 阶段网关:
|
||||
//
|
||||
// - stopReason 必须是 end_turn,否则不通过。refusal/max_tokens 等明确是 agent
|
||||
// 主动放弃/受限,不通过且不重试(Retry=false → dead-letter)。其它非空 stopReason
|
||||
// 不通过但可重试。
|
||||
// - 产物型阶段(planning/prototyping/auditing/implementing/reviewing)额外要求该阶段
|
||||
// 已有至少一个产物;缺失则不通过但可重试(让 agent 再跑一次产出)。
|
||||
// - reviewing/done 额外检查未关闭 issue 数为 0(有开放 issue 视为未就绪,可重试)。
|
||||
// - done 阶段:end_turn 即视为通过(汇总视图,无单独产物)。
|
||||
type v1Gate struct {
|
||||
artifacts GateArtifactAccess // 可为 nil(跳过产物谓词)
|
||||
issues GateIssueAccess // 可为 nil(跳过 issue 谓词)
|
||||
}
|
||||
|
||||
// NewV1Gate 构造 v1 阶段网关。artifacts/issues 可为 nil(对应谓词被跳过)。
|
||||
func NewV1Gate(artifacts GateArtifactAccess, issues GateIssueAccess) PhaseGate {
|
||||
return &v1Gate{artifacts: artifacts, issues: issues}
|
||||
}
|
||||
|
||||
func (g *v1Gate) Evaluate(ctx context.Context, run *Run, step *Step, stopReason string) (GateResult, error) {
|
||||
// 1. stopReason 闸门。
|
||||
switch stopReason {
|
||||
case string(acpStopEndTurn):
|
||||
// 继续 phase 谓词。
|
||||
case string(acpStopRefusal), string(acpStopMaxTokens):
|
||||
return GateResult{Pass: false, Retry: false,
|
||||
Reason: "agent stopped with non-recoverable reason: " + stopReason}, nil
|
||||
default:
|
||||
// 空串 / cancelled / vendor-specific:不通过,可重试。
|
||||
reason := stopReason
|
||||
if reason == "" {
|
||||
reason = "no stop reason"
|
||||
}
|
||||
return GateResult{Pass: false, Retry: true,
|
||||
Reason: "turn did not end cleanly: " + reason}, nil
|
||||
}
|
||||
|
||||
phase := step.Phase
|
||||
|
||||
// 2. done:end_turn 即通过。
|
||||
if phase == project.PhaseDone {
|
||||
return GateResult{Pass: true, Reason: "done"}, nil
|
||||
}
|
||||
|
||||
// 3. 产物型阶段:要求该阶段已有产物。
|
||||
if phase.IsArtifactPhase() && g.artifacts != nil {
|
||||
arts, err := g.artifacts.ListArtifacts(ctx, run.ProjectID, run.RequirementID, phase)
|
||||
if err != nil {
|
||||
return GateResult{}, err
|
||||
}
|
||||
if len(arts) == 0 {
|
||||
return GateResult{Pass: false, Retry: true,
|
||||
Reason: "no artifact produced for phase " + string(phase),
|
||||
Detail: map[string]any{"phase": string(phase)}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 4. reviewing:额外要求无开放 issue(避免遗留缺陷推进)。
|
||||
if phase == project.PhaseReviewing && g.issues != nil {
|
||||
open, err := g.issues.CountOpenIssues(ctx, run.RequirementID)
|
||||
if err != nil {
|
||||
return GateResult{}, err
|
||||
}
|
||||
if open > 0 {
|
||||
return GateResult{Pass: false, Retry: true,
|
||||
Reason: "open issues remain",
|
||||
Detail: map[string]any{"open_issues": open}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return GateResult{Pass: true, Reason: "phase gate passed",
|
||||
Detail: map[string]any{"phase": string(phase)}}, nil
|
||||
}
|
||||
|
||||
// acp stopReason 原始字符串常量(与 internal/acp.StopReason 取值一致,此处复制以免引入
|
||||
// acp 依赖到纯网关逻辑)。
|
||||
const (
|
||||
acpStopEndTurn = "end_turn"
|
||||
acpStopRefusal = "refusal"
|
||||
acpStopMaxTokens = "max_tokens"
|
||||
)
|
||||
@@ -0,0 +1,111 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
)
|
||||
|
||||
type fakeGateArtifacts struct {
|
||||
byPhase map[project.Phase]int // phase → 产物数
|
||||
}
|
||||
|
||||
func (f fakeGateArtifacts) ListArtifacts(_ context.Context, _, _ uuid.UUID, phase project.Phase) ([]*project.Artifact, error) {
|
||||
n := f.byPhase[phase]
|
||||
out := make([]*project.Artifact, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, &project.Artifact{Phase: phase, Version: i + 1})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type fakeGateIssues struct{ open int }
|
||||
|
||||
func (f fakeGateIssues) CountOpenIssues(_ context.Context, _ uuid.UUID) (int, error) {
|
||||
return f.open, nil
|
||||
}
|
||||
|
||||
func gateRun() *Run {
|
||||
return &Run{ID: uuid.New(), ProjectID: uuid.New(), RequirementID: uuid.New()}
|
||||
}
|
||||
|
||||
func TestGate_PassOnEndTurnWithArtifact(t *testing.T) {
|
||||
g := NewV1Gate(fakeGateArtifacts{byPhase: map[project.Phase]int{project.PhasePlanning: 1}}, nil)
|
||||
step := &Step{Phase: project.PhasePlanning}
|
||||
res, err := g.Evaluate(context.Background(), gateRun(), step, "end_turn")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Pass {
|
||||
t.Errorf("expected pass; got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGate_RetryOnEndTurnMissingArtifact(t *testing.T) {
|
||||
g := NewV1Gate(fakeGateArtifacts{byPhase: map[project.Phase]int{}}, nil)
|
||||
step := &Step{Phase: project.PhasePlanning}
|
||||
res, err := g.Evaluate(context.Background(), gateRun(), step, "end_turn")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Pass || !res.Retry {
|
||||
t.Errorf("expected no-pass retryable; got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGate_FailNoRetryOnRefusalAndMaxTokens(t *testing.T) {
|
||||
g := NewV1Gate(fakeGateArtifacts{byPhase: map[project.Phase]int{project.PhasePlanning: 1}}, nil)
|
||||
step := &Step{Phase: project.PhasePlanning}
|
||||
for _, sr := range []string{"refusal", "max_tokens"} {
|
||||
res, err := g.Evaluate(context.Background(), gateRun(), step, sr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Pass || res.Retry {
|
||||
t.Errorf("stopReason %q: expected fail no-retry; got %+v", sr, res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGate_RetryOnEmptyOrCancelled(t *testing.T) {
|
||||
g := NewV1Gate(fakeGateArtifacts{byPhase: map[project.Phase]int{project.PhasePlanning: 1}}, nil)
|
||||
step := &Step{Phase: project.PhasePlanning}
|
||||
for _, sr := range []string{"", "cancelled"} {
|
||||
res, err := g.Evaluate(context.Background(), gateRun(), step, sr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Pass || !res.Retry {
|
||||
t.Errorf("stopReason %q: expected retryable; got %+v", sr, res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGate_ReviewingRequiresNoOpenIssues(t *testing.T) {
|
||||
arts := fakeGateArtifacts{byPhase: map[project.Phase]int{project.PhaseReviewing: 1}}
|
||||
step := &Step{Phase: project.PhaseReviewing}
|
||||
|
||||
gOpen := NewV1Gate(arts, fakeGateIssues{open: 2})
|
||||
res, _ := gOpen.Evaluate(context.Background(), gateRun(), step, "end_turn")
|
||||
if res.Pass || !res.Retry {
|
||||
t.Errorf("open issues should block reviewing (retryable); got %+v", res)
|
||||
}
|
||||
|
||||
gClean := NewV1Gate(arts, fakeGateIssues{open: 0})
|
||||
res, _ = gClean.Evaluate(context.Background(), gateRun(), step, "end_turn")
|
||||
if !res.Pass {
|
||||
t.Errorf("no open issues should pass reviewing; got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGate_DonePassesOnEndTurn(t *testing.T) {
|
||||
g := NewV1Gate(nil, nil)
|
||||
step := &Step{Phase: project.PhaseDone}
|
||||
res, _ := g.Evaluate(context.Background(), gateRun(), step, "end_turn")
|
||||
if !res.Pass {
|
||||
t.Errorf("done should pass on end_turn; got %+v", res)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// handler.go 暴露编排器模块的 HTTP 端点:/api/v1/orchestrator/runs(POST 启动、
|
||||
// GET 列表)、/runs/{id}(GET)、/runs/{id}/pause|resume|cancel(POST)。
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
|
||||
)
|
||||
|
||||
// AdminLookup 解析某用户是否为 admin(与 acp/chat/workspace 一致)。
|
||||
type AdminLookup interface {
|
||||
IsAdmin(ctx context.Context, userID uuid.UUID) (bool, error)
|
||||
}
|
||||
|
||||
// Handler 暴露编排器 HTTP 端点。
|
||||
type Handler struct {
|
||||
svc Service
|
||||
resolver middleware.SessionResolver
|
||||
adminLookup AdminLookup
|
||||
}
|
||||
|
||||
// NewHandler 构造 Handler。
|
||||
func NewHandler(svc Service, resolver middleware.SessionResolver, al AdminLookup) *Handler {
|
||||
return &Handler{svc: svc, resolver: resolver, adminLookup: al}
|
||||
}
|
||||
|
||||
// Mount 注册 /api/v1/orchestrator/* 路由。
|
||||
func (h *Handler) Mount(r chi.Router) {
|
||||
r.Route("/api/v1/orchestrator/runs", func(r chi.Router) {
|
||||
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
|
||||
r.Post("/", h.startRun)
|
||||
r.Get("/", h.listRuns)
|
||||
r.Get("/{id}", h.getRun)
|
||||
r.Post("/{id}/pause", h.pauseRun)
|
||||
r.Post("/{id}/resume", h.resumeRun)
|
||||
r.Post("/{id}/cancel", h.cancelRun)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) caller(r *http.Request) (Caller, error) {
|
||||
uid, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
return Caller{}, errs.New(errs.CodeUnauthorized, "not authenticated")
|
||||
}
|
||||
admin, err := h.adminLookup.IsAdmin(r.Context(), uid)
|
||||
if err != nil {
|
||||
return Caller{}, err
|
||||
}
|
||||
return Caller{UserID: uid, IsAdmin: admin}, nil
|
||||
}
|
||||
|
||||
// ===== DTO =====
|
||||
|
||||
type startRunReq struct {
|
||||
ProjectSlug string `json:"project_slug"`
|
||||
RequirementNumber int `json:"requirement_number"`
|
||||
StartPhase string `json:"start_phase,omitempty"`
|
||||
PhaseAgentKinds map[string]string `json:"phase_agent_kinds,omitempty"`
|
||||
PromptOverrides map[string]string `json:"prompt_overrides,omitempty"`
|
||||
MaxAttemptsPerPhase int `json:"max_attempts_per_phase,omitempty"`
|
||||
MaxDepth int `json:"max_depth,omitempty"`
|
||||
FanOutLimit int `json:"fan_out_limit,omitempty"`
|
||||
}
|
||||
|
||||
type runDTO struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
RequirementID string `json:"requirement_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
Status string `json:"status"`
|
||||
CurrentPhase string `json:"current_phase"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
EndedAt *time.Time `json:"ended_at,omitempty"`
|
||||
}
|
||||
|
||||
func runToDTO(r *Run) runDTO {
|
||||
d := runDTO{
|
||||
ID: r.ID.String(),
|
||||
ProjectID: r.ProjectID.String(),
|
||||
RequirementID: r.RequirementID.String(),
|
||||
WorkspaceID: r.WorkspaceID.String(),
|
||||
OwnerID: r.OwnerID.String(),
|
||||
Status: string(r.Status),
|
||||
CurrentPhase: string(r.CurrentPhase),
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
EndedAt: r.EndedAt,
|
||||
}
|
||||
if r.LastError != nil {
|
||||
d.LastError = *r.LastError
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// ===== handlers =====
|
||||
|
||||
func (h *Handler) startRun(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := h.caller(r)
|
||||
if err != nil {
|
||||
h.writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
var body startRunReq
|
||||
if derr := json.NewDecoder(r.Body).Decode(&body); derr != nil {
|
||||
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad json"))
|
||||
return
|
||||
}
|
||||
if body.ProjectSlug == "" || body.RequirementNumber <= 0 {
|
||||
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "project_slug and requirement_number are required"))
|
||||
return
|
||||
}
|
||||
|
||||
cfg := RunConfig{
|
||||
MaxAttemptsPerPhase: body.MaxAttemptsPerPhase,
|
||||
MaxDepth: body.MaxDepth,
|
||||
FanOutLimit: body.FanOutLimit,
|
||||
}
|
||||
if len(body.PhaseAgentKinds) > 0 {
|
||||
cfg.PhaseAgentKinds = map[project.Phase]uuid.UUID{}
|
||||
for ph, idStr := range body.PhaseAgentKinds {
|
||||
id, perr := uuid.Parse(idStr)
|
||||
if perr != nil {
|
||||
h.writeErr(w, r, errs.Wrap(perr, errs.CodeInvalidInput, "phase_agent_kinds id parse"))
|
||||
return
|
||||
}
|
||||
cfg.PhaseAgentKinds[project.Phase(ph)] = id
|
||||
}
|
||||
}
|
||||
if len(body.PromptOverrides) > 0 {
|
||||
cfg.PromptOverrides = map[project.Phase]string{}
|
||||
for ph, p := range body.PromptOverrides {
|
||||
cfg.PromptOverrides[project.Phase(ph)] = p
|
||||
}
|
||||
}
|
||||
|
||||
run, err := h.svc.StartRun(r.Context(), c, StartRunInput{
|
||||
ProjectSlug: body.ProjectSlug,
|
||||
RequirementNumber: body.RequirementNumber,
|
||||
StartPhase: project.Phase(body.StartPhase),
|
||||
Config: cfg,
|
||||
})
|
||||
if err != nil {
|
||||
h.writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusCreated, runToDTO(run))
|
||||
}
|
||||
|
||||
func (h *Handler) getRun(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := h.caller(r)
|
||||
if err != nil {
|
||||
h.writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
id, perr := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if perr != nil {
|
||||
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
|
||||
return
|
||||
}
|
||||
run, err := h.svc.GetRun(r.Context(), c, id)
|
||||
if err != nil {
|
||||
h.writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, runToDTO(run))
|
||||
}
|
||||
|
||||
func (h *Handler) listRuns(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := h.caller(r)
|
||||
if err != nil {
|
||||
h.writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
f := RunFilter{Status: RunStatus(r.URL.Query().Get("status"))}
|
||||
if rid := r.URL.Query().Get("requirement_id"); rid != "" {
|
||||
if id, perr := uuid.Parse(rid); perr == nil {
|
||||
f.RequirementID = &id
|
||||
}
|
||||
}
|
||||
if pid := r.URL.Query().Get("project_id"); pid != "" {
|
||||
if id, perr := uuid.Parse(pid); perr == nil {
|
||||
f.ProjectID = &id
|
||||
}
|
||||
}
|
||||
runs, err := h.svc.ListRuns(r.Context(), c, f)
|
||||
if err != nil {
|
||||
h.writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
out := make([]runDTO, 0, len(runs))
|
||||
for _, run := range runs {
|
||||
out = append(out, runToDTO(run))
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *Handler) pauseRun(w http.ResponseWriter, r *http.Request) { h.runAction(w, r, h.svc.Pause) }
|
||||
func (h *Handler) resumeRun(w http.ResponseWriter, r *http.Request) { h.runAction(w, r, h.svc.Resume) }
|
||||
func (h *Handler) cancelRun(w http.ResponseWriter, r *http.Request) { h.runAction(w, r, h.svc.Cancel) }
|
||||
|
||||
func (h *Handler) runAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, Caller, uuid.UUID) error) {
|
||||
c, err := h.caller(r)
|
||||
if err != nil {
|
||||
h.writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
id, perr := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if perr != nil {
|
||||
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
|
||||
return
|
||||
}
|
||||
if aerr := fn(r.Context(), c, id); aerr != nil {
|
||||
h.writeErr(w, r, aerr)
|
||||
return
|
||||
}
|
||||
run, gerr := h.svc.GetRun(r.Context(), c, id)
|
||||
if gerr != nil {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, runToDTO(run))
|
||||
}
|
||||
|
||||
func (h *Handler) writeErr(w http.ResponseWriter, r *http.Request, err error) {
|
||||
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
)
|
||||
|
||||
// StepEnqueuer 是建好 step 后入队其 orchestrator.step job 的窄接口(service 实现,
|
||||
// 包装 jobs.Repository.Enqueue)。导出以便 app.go 在启动恢复时直接调用 EnqueueStep。
|
||||
type StepEnqueuer interface {
|
||||
EnqueueStep(ctx context.Context, runID, stepID uuid.UUID, scheduledAt time.Time) error
|
||||
}
|
||||
|
||||
// AuditRecorder 是编排器写审计的窄接口(app 层用 audit.Recorder 适配)。导出以便
|
||||
// 外部包(app)的适配器可实现(unexported 方法的接口无法被跨包实现)。
|
||||
type AuditRecorder interface {
|
||||
Record(ctx context.Context, userID uuid.UUID, action, targetID string, meta map[string]any)
|
||||
}
|
||||
|
||||
// buildPhaseStep 为 run 在指定 phase 建一个新 step(pending 状态),解析 agent-kind
|
||||
// 与 prompt 后落库。parent 非 nil 时记录 parent_step_id(request_subtask fan-out)。
|
||||
// 解析失败(如缺 agent-kind)返回 error,调用方据此处理(标 run 失败 / 拒绝 subtask)。
|
||||
func buildPhaseStep(ctx context.Context, repo Repository, pm PMAccess, run *Run,
|
||||
phase project.Phase, parentStepID *uuid.UUID, depth int,
|
||||
defaults map[project.Phase]uuid.UUID) (*Step, error) {
|
||||
|
||||
akID, err := PhaseAgentKind(run.Config, phase, defaults)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := pm.GetRequirementByID(ctx, run.RequirementID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
artifacts, _ := pm.ListLatestArtifacts(ctx, run.RequirementID)
|
||||
override := run.Config.PromptOverrides[phase]
|
||||
prompt := BuildPrompt(phase, req, artifacts, override)
|
||||
|
||||
step := &Step{
|
||||
ID: uuid.New(),
|
||||
RunID: run.ID,
|
||||
Phase: phase,
|
||||
Attempt: 1,
|
||||
ParentStepID: parentStepID,
|
||||
Depth: depth,
|
||||
Status: StepPending,
|
||||
AgentKindID: akID,
|
||||
Prompt: prompt,
|
||||
}
|
||||
return repo.InsertStep(ctx, step)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// mcp_bridge.go 实现 mcp.OrchestratorBridge:把 request_subtask MCP 工具的调用转译到
|
||||
// 本包 Service.RequestSubtask。放在 orchestrator 包内(orchestrator 已 import mcp 间接
|
||||
// 无需,故这里只 import mcp 接口),避免 mcp → orchestrator 的循环依赖。
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/mcp"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
)
|
||||
|
||||
// MCPBridge 适配 Service 到 mcp.OrchestratorBridge。
|
||||
type MCPBridge struct {
|
||||
svc Service
|
||||
}
|
||||
|
||||
// NewMCPBridge 构造桥接适配器,由 app.go 装配 mcp.ServerDeps 时调用。
|
||||
func NewMCPBridge(svc Service) *MCPBridge { return &MCPBridge{svc: svc} }
|
||||
|
||||
var _ mcp.OrchestratorBridge = (*MCPBridge)(nil)
|
||||
|
||||
// RequestSubtask 透传到 Service.RequestSubtask;返回新建子 step 的 id 字符串。
|
||||
func (b *MCPBridge) RequestSubtask(ctx context.Context, parentACPSessionID uuid.UUID,
|
||||
phase string, prompt string, agentKindID *uuid.UUID) (string, error) {
|
||||
step, err := b.svc.RequestSubtask(ctx, parentACPSessionID, SubtaskInput{
|
||||
Phase: project.Phase(phase),
|
||||
Prompt: prompt,
|
||||
AgentKindID: agentKindID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return step.ID.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// planner.go 是纯函数(无 I/O)的阶段规划逻辑:阶段→agent-kind 解析、阶段→prompt
|
||||
// 组装、网关结果→下一阶段。可独立单测(模仿 acp.ResolveBranch 的取舍)。
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
)
|
||||
|
||||
// phaseOrder 是 6 阶段的线性推进顺序,对应 project.AllPhases。
|
||||
var phaseOrder = project.AllPhases
|
||||
|
||||
// NextPhase 根据当前阶段与网关结果决定下一阶段。
|
||||
//
|
||||
// - 网关未通过(!g.Pass)→ 回环本阶段(next==current,terminal=false),让 step
|
||||
// 在同阶段重试。
|
||||
// - 网关通过且 current==done → 终态(terminal=true),run 成功。
|
||||
// - 网关通过且 current!=done → 线性推进到下一阶段。
|
||||
//
|
||||
// current 非法(不在枚举内)时视为终态,避免无限循环。
|
||||
func NextPhase(current project.Phase, g GateResult) (next project.Phase, terminal bool) {
|
||||
if !g.Pass {
|
||||
return current, false
|
||||
}
|
||||
if current == project.PhaseDone {
|
||||
return project.PhaseDone, true
|
||||
}
|
||||
for i, p := range phaseOrder {
|
||||
if p == current {
|
||||
if i+1 >= len(phaseOrder) {
|
||||
// current 已是最后一个阶段(done)——由上面的分支处理;兜底终止。
|
||||
return project.PhaseDone, true
|
||||
}
|
||||
// 推进到下一阶段。即便下一阶段是 done,也非终态:done 仍要跑一个 step,
|
||||
// 只有 done 阶段网关通过后(current==done 分支)run 才成功。
|
||||
return phaseOrder[i+1], false
|
||||
}
|
||||
}
|
||||
// 未知阶段:保守终止。
|
||||
return current, true
|
||||
}
|
||||
|
||||
// PhaseAgentKind 解析某阶段应使用的 agent-kind:优先 run.config 覆盖,否则退回
|
||||
// defaults(app 层从配置注入的"阶段→默认 agent-kind"映射)。两者皆缺则报错。
|
||||
func PhaseAgentKind(cfg RunConfig, phase project.Phase, defaults map[project.Phase]uuid.UUID) (uuid.UUID, error) {
|
||||
if cfg.PhaseAgentKinds != nil {
|
||||
if id, ok := cfg.PhaseAgentKinds[phase]; ok && id != uuid.Nil {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
if defaults != nil {
|
||||
if id, ok := defaults[phase]; ok && id != uuid.Nil {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
return uuid.Nil, errs.New(errs.CodeInvalidInput,
|
||||
fmt.Sprintf("no agent kind configured for phase %q", phase))
|
||||
}
|
||||
|
||||
// BuildPrompt 组装某阶段发给 agent 的 prompt。config 提供覆盖模板时优先用覆盖
|
||||
// (仍前置需求/产物上下文);否则用内置的 per-phase 默认指令。纯字符串拼接,无 I/O。
|
||||
func BuildPrompt(phase project.Phase, req *project.Requirement, artifacts []*project.Artifact, override string) string {
|
||||
var b strings.Builder
|
||||
|
||||
if req != nil {
|
||||
fmt.Fprintf(&b, "Requirement #%d: %s\n", req.Number, req.Title)
|
||||
if strings.TrimSpace(req.Description) != "" {
|
||||
fmt.Fprintf(&b, "\nDescription:\n%s\n", strings.TrimSpace(req.Description))
|
||||
}
|
||||
}
|
||||
|
||||
if len(artifacts) > 0 {
|
||||
b.WriteString("\nPrior artifacts:\n")
|
||||
for _, a := range artifacts {
|
||||
if a == nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "- [%s v%d] %s\n", a.Phase, a.Version, summarizeArtifact(a))
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\nCurrent phase: ")
|
||||
b.WriteString(string(phase))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if strings.TrimSpace(override) != "" {
|
||||
b.WriteString(strings.TrimSpace(override))
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
b.WriteString(defaultPhaseInstruction(phase))
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// summarizeArtifact 取产物内容首行(截断)作为简介,避免 prompt 过长。
|
||||
func summarizeArtifact(a *project.Artifact) string {
|
||||
content := strings.TrimSpace(a.Content)
|
||||
if content == "" {
|
||||
return "(empty)"
|
||||
}
|
||||
if idx := strings.IndexByte(content, '\n'); idx >= 0 {
|
||||
content = content[:idx]
|
||||
}
|
||||
if len(content) > 120 {
|
||||
content = content[:120] + "..."
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
// defaultPhaseInstruction 是每个阶段的内置默认指令。
|
||||
func defaultPhaseInstruction(phase project.Phase) string {
|
||||
switch phase {
|
||||
case project.PhasePlanning:
|
||||
return "Produce a clear plan for this requirement and record it as a planning artifact."
|
||||
case project.PhasePrototyping:
|
||||
return "Build a prototype validating the approach and record a prototyping artifact."
|
||||
case project.PhaseAuditing:
|
||||
return "Audit the plan/prototype for risks and gaps; record an auditing artifact."
|
||||
case project.PhaseImplementing:
|
||||
return "Implement the requirement on this worktree; commit your changes."
|
||||
case project.PhaseReviewing:
|
||||
return "Review the implementation, fix issues, and record a reviewing artifact."
|
||||
case project.PhaseDone:
|
||||
return "Summarize the completed work; no further action is required."
|
||||
default:
|
||||
return "Proceed with the current phase."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
)
|
||||
|
||||
func TestNextPhase_AdvancesLinearly(t *testing.T) {
|
||||
pass := GateResult{Pass: true}
|
||||
cases := []struct {
|
||||
from project.Phase
|
||||
wantNext project.Phase
|
||||
wantTerm bool
|
||||
}{
|
||||
{project.PhasePlanning, project.PhasePrototyping, false},
|
||||
{project.PhasePrototyping, project.PhaseAuditing, false},
|
||||
{project.PhaseAuditing, project.PhaseImplementing, false},
|
||||
{project.PhaseImplementing, project.PhaseReviewing, false},
|
||||
{project.PhaseReviewing, project.PhaseDone, false},
|
||||
{project.PhaseDone, project.PhaseDone, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
next, term := NextPhase(c.from, pass)
|
||||
if next != c.wantNext || term != c.wantTerm {
|
||||
t.Errorf("NextPhase(%s,pass) = (%s,%v); want (%s,%v)",
|
||||
c.from, next, term, c.wantNext, c.wantTerm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextPhase_GateFailLoopsBack(t *testing.T) {
|
||||
fail := GateResult{Pass: false, Retry: true}
|
||||
for _, ph := range project.AllPhases {
|
||||
next, term := NextPhase(ph, fail)
|
||||
if next != ph || term {
|
||||
t.Errorf("NextPhase(%s, fail) = (%s,%v); want (%s,false)", ph, next, term, ph)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextPhase_UnknownPhaseTerminates(t *testing.T) {
|
||||
next, term := NextPhase(project.Phase("bogus"), GateResult{Pass: true})
|
||||
if !term {
|
||||
t.Errorf("unknown phase should terminate; got next=%s term=%v", next, term)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhaseAgentKind_OverridePreferredThenDefault(t *testing.T) {
|
||||
override := uuid.New()
|
||||
def := uuid.New()
|
||||
cfg := RunConfig{PhaseAgentKinds: map[project.Phase]uuid.UUID{project.PhasePlanning: override}}
|
||||
defaults := map[project.Phase]uuid.UUID{project.PhasePlanning: def, project.PhaseAuditing: def}
|
||||
|
||||
got, err := PhaseAgentKind(cfg, project.PhasePlanning, defaults)
|
||||
if err != nil || got != override {
|
||||
t.Fatalf("override path: got=%s err=%v; want %s", got, err, override)
|
||||
}
|
||||
|
||||
got, err = PhaseAgentKind(cfg, project.PhaseAuditing, defaults)
|
||||
if err != nil || got != def {
|
||||
t.Fatalf("default fallback: got=%s err=%v; want %s", got, err, def)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhaseAgentKind_MissingErrors(t *testing.T) {
|
||||
_, err := PhaseAgentKind(RunConfig{}, project.PhasePlanning, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no agent kind configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrompt_IncludesReqAndArtifacts(t *testing.T) {
|
||||
req := &project.Requirement{Number: 7, Title: "Login flow", Description: "Add OAuth login"}
|
||||
arts := []*project.Artifact{
|
||||
{Phase: project.PhasePlanning, Version: 2, Content: "Plan: use OAuth2 PKCE\nmore detail"},
|
||||
}
|
||||
out := BuildPrompt(project.PhaseAuditing, req, arts, "")
|
||||
for _, want := range []string{"#7", "Login flow", "Add OAuth login", "Plan: use OAuth2 PKCE", "auditing"} {
|
||||
if !contains(out, want) {
|
||||
t.Errorf("prompt missing %q\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrompt_OverrideUsed(t *testing.T) {
|
||||
req := &project.Requirement{Number: 1, Title: "T"}
|
||||
out := BuildPrompt(project.PhasePlanning, req, nil, "DO THE SPECIAL THING")
|
||||
if !contains(out, "DO THE SPECIAL THING") {
|
||||
t.Errorf("override not used:\n%s", out)
|
||||
}
|
||||
if contains(out, "Produce a clear plan") {
|
||||
t.Errorf("default instruction should be suppressed when override present:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (indexOf(s, sub) >= 0)
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// pm_access.go 定义编排器对 project (PM) 模块的窄读写接口。app 层用 project 的
|
||||
// Repository / RequirementService 适配,避免在 orchestrator 内直接耦合 project 的
|
||||
// Caller/Service 全貌。
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
)
|
||||
|
||||
// PMAccess 是编排器驱动阶段所需的 project 读写能力。
|
||||
type PMAccess interface {
|
||||
// GetProjectByID / GetProjectBySlug 解析项目(slug 用于 ChangePhase / Create 入参)。
|
||||
GetProjectByID(ctx context.Context, id uuid.UUID) (*project.Project, error)
|
||||
GetProjectBySlug(ctx context.Context, slug string) (*project.Project, error)
|
||||
// GetRequirementByID / GetRequirementByNumber 解析需求(号用于 ChangePhase / session 绑定)。
|
||||
GetRequirementByID(ctx context.Context, id uuid.UUID) (*project.Requirement, error)
|
||||
GetRequirementByNumber(ctx context.Context, projectID uuid.UUID, number int) (*project.Requirement, error)
|
||||
// ListLatestArtifacts 返回某需求各阶段最新产物(供 BuildPrompt 注入上下文)。
|
||||
ListLatestArtifacts(ctx context.Context, requirementID uuid.UUID) ([]*project.Artifact, error)
|
||||
// ChangeRequirementPhase 以 owner 身份推进需求阶段(编排器特权身份调用)。
|
||||
ChangeRequirementPhase(ctx context.Context, ownerID uuid.UUID, isAdmin bool, projectSlug string, number int, to project.Phase) error
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
-- name: InsertRun :one
|
||||
INSERT INTO orchestrator_runs (
|
||||
id, project_id, requirement_id, workspace_id, owner_id,
|
||||
status, current_phase, config, last_error
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, project_id, requirement_id, workspace_id, owner_id,
|
||||
status, current_phase, config, last_error,
|
||||
created_at, updated_at, ended_at;
|
||||
|
||||
-- name: GetRun :one
|
||||
SELECT id, project_id, requirement_id, workspace_id, owner_id,
|
||||
status, current_phase, config, last_error,
|
||||
created_at, updated_at, ended_at
|
||||
FROM orchestrator_runs
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetActiveRunByRequirement :one
|
||||
SELECT id, project_id, requirement_id, workspace_id, owner_id,
|
||||
status, current_phase, config, last_error,
|
||||
created_at, updated_at, ended_at
|
||||
FROM orchestrator_runs
|
||||
WHERE requirement_id = $1
|
||||
AND status IN ('pending','running','paused')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: ListRuns :many
|
||||
SELECT id, project_id, requirement_id, workspace_id, owner_id,
|
||||
status, current_phase, config, last_error,
|
||||
created_at, updated_at, ended_at
|
||||
FROM orchestrator_runs
|
||||
WHERE ($1::uuid IS NULL OR project_id = $1)
|
||||
AND ($2::uuid IS NULL OR requirement_id = $2)
|
||||
AND ($3::text = '' OR status = $3)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $4;
|
||||
|
||||
-- name: UpdateRunStatus :one
|
||||
UPDATE orchestrator_runs
|
||||
SET status = $2,
|
||||
last_error = $3,
|
||||
ended_at = CASE WHEN $2 IN ('succeeded','failed','canceled') THEN now() ELSE ended_at END,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, project_id, requirement_id, workspace_id, owner_id,
|
||||
status, current_phase, config, last_error,
|
||||
created_at, updated_at, ended_at;
|
||||
|
||||
-- name: UpdateRunPhase :one
|
||||
UPDATE orchestrator_runs
|
||||
SET current_phase = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, project_id, requirement_id, workspace_id, owner_id,
|
||||
status, current_phase, config, last_error,
|
||||
created_at, updated_at, ended_at;
|
||||
@@ -0,0 +1,77 @@
|
||||
-- name: InsertStep :one
|
||||
INSERT INTO orchestrator_steps (
|
||||
id, run_id, phase, attempt, parent_step_id, depth,
|
||||
status, agent_kind_id, acp_session_id, prompt, stop_reason,
|
||||
gate_result, last_error, job_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING id, run_id, phase, attempt, parent_step_id, depth,
|
||||
status, agent_kind_id, acp_session_id, prompt, stop_reason,
|
||||
gate_result, last_error, job_id, created_at, updated_at, ended_at;
|
||||
|
||||
-- name: GetStep :one
|
||||
SELECT id, run_id, phase, attempt, parent_step_id, depth,
|
||||
status, agent_kind_id, acp_session_id, prompt, stop_reason,
|
||||
gate_result, last_error, job_id, created_at, updated_at, ended_at
|
||||
FROM orchestrator_steps
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetStepBySession :one
|
||||
SELECT id, run_id, phase, attempt, parent_step_id, depth,
|
||||
status, agent_kind_id, acp_session_id, prompt, stop_reason,
|
||||
gate_result, last_error, job_id, created_at, updated_at, ended_at
|
||||
FROM orchestrator_steps
|
||||
WHERE acp_session_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: ListStepsByRun :many
|
||||
SELECT id, run_id, phase, attempt, parent_step_id, depth,
|
||||
status, agent_kind_id, acp_session_id, prompt, stop_reason,
|
||||
gate_result, last_error, job_id, created_at, updated_at, ended_at
|
||||
FROM orchestrator_steps
|
||||
WHERE run_id = $1
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
-- name: UpdateStepStatus :one
|
||||
UPDATE orchestrator_steps
|
||||
SET status = $2,
|
||||
acp_session_id = $3,
|
||||
stop_reason = $4,
|
||||
gate_result = $5,
|
||||
last_error = $6,
|
||||
job_id = $7,
|
||||
ended_at = CASE WHEN $2 IN ('succeeded','failed','dead') THEN now() ELSE ended_at END,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, run_id, phase, attempt, parent_step_id, depth,
|
||||
status, agent_kind_id, acp_session_id, prompt, stop_reason,
|
||||
gate_result, last_error, job_id, created_at, updated_at, ended_at;
|
||||
|
||||
-- name: IncrementStepAttempt :one
|
||||
UPDATE orchestrator_steps
|
||||
SET attempt = attempt + 1,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, run_id, phase, attempt, parent_step_id, depth,
|
||||
status, agent_kind_id, acp_session_id, prompt, stop_reason,
|
||||
gate_result, last_error, job_id, created_at, updated_at, ended_at;
|
||||
|
||||
-- name: CountActiveChildSteps :one
|
||||
SELECT COUNT(*) FROM orchestrator_steps
|
||||
WHERE parent_step_id = $1
|
||||
AND status IN ('pending','spawning','running','awaiting_gate');
|
||||
|
||||
-- name: ResetStuckStepsOnRestart :many
|
||||
-- 把活跃 run 下卡在 spawning/running/awaiting_gate 的 step 重置为 pending,便于
|
||||
-- 重新入队恢复(镜像 acp ResetStuckSessionsOnRestart)。succeeded/canceled/failed
|
||||
-- run 下的 step 不动。
|
||||
UPDATE orchestrator_steps s
|
||||
SET status = 'pending',
|
||||
updated_at = now()
|
||||
FROM orchestrator_runs r
|
||||
WHERE s.run_id = r.id
|
||||
AND r.status IN ('pending','running','paused')
|
||||
AND s.status IN ('spawning','running','awaiting_gate')
|
||||
RETURNING s.id, s.run_id, s.phase, s.attempt, s.parent_step_id, s.depth,
|
||||
s.status, s.agent_kind_id, s.acp_session_id, s.prompt, s.stop_reason,
|
||||
s.gate_result, s.last_error, s.job_id, s.created_at, s.updated_at, s.ended_at;
|
||||
@@ -0,0 +1,381 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgerrcode"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
orchsqlc "github.com/yan1h/agent-coding-workflow/internal/orchestrator/sqlc"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
)
|
||||
|
||||
// Repository 是 orchestrator 模块对 PG 的全部依赖。Service 单测通过手写 fakeRepo 满足。
|
||||
type Repository interface {
|
||||
// Run
|
||||
InsertRun(ctx context.Context, r *Run) (*Run, error)
|
||||
GetRun(ctx context.Context, id uuid.UUID) (*Run, error)
|
||||
GetActiveRunByRequirement(ctx context.Context, requirementID uuid.UUID) (*Run, error)
|
||||
ListRuns(ctx context.Context, f RunFilter) ([]*Run, error)
|
||||
UpdateRunStatus(ctx context.Context, id uuid.UUID, status RunStatus, lastErr *string) (*Run, error)
|
||||
UpdateRunPhase(ctx context.Context, id uuid.UUID, phase project.Phase) (*Run, error)
|
||||
|
||||
// Step
|
||||
InsertStep(ctx context.Context, s *Step) (*Step, error)
|
||||
GetStep(ctx context.Context, id uuid.UUID) (*Step, error)
|
||||
GetStepBySession(ctx context.Context, sessionID uuid.UUID) (*Step, error)
|
||||
ListStepsByRun(ctx context.Context, runID uuid.UUID) ([]*Step, error)
|
||||
UpdateStepStatus(ctx context.Context, s *Step) (*Step, error)
|
||||
IncrementStepAttempt(ctx context.Context, id uuid.UUID) (*Step, error)
|
||||
CountActiveChildSteps(ctx context.Context, parentStepID uuid.UUID) (int64, error)
|
||||
ResetStuckStepsOnRestart(ctx context.Context) ([]*Step, error)
|
||||
|
||||
InTx(ctx context.Context, fn func(ctx context.Context, tx pgx.Tx) error) error
|
||||
WithTx(tx pgx.Tx) Repository
|
||||
}
|
||||
|
||||
// IsUniqueViolation 报告 err 是否为 PG 唯一约束冲突(用于一 requirement 一活跃 run 的 409 翻译)。
|
||||
func IsUniqueViolation(err error) bool {
|
||||
var pg *pgconn.PgError
|
||||
return errors.As(err, &pg) && pg.Code == pgerrcode.UniqueViolation
|
||||
}
|
||||
|
||||
type pgRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
q *orchsqlc.Queries
|
||||
}
|
||||
|
||||
// NewPostgresRepository 用 pgxpool 构造 Repository。
|
||||
func NewPostgresRepository(pool *pgxpool.Pool) Repository {
|
||||
return &pgRepo{pool: pool, q: orchsqlc.New(pool)}
|
||||
}
|
||||
|
||||
// InTx 在单事务内执行 fn。nil error commit,否则 rollback。StartRun 用它原子地
|
||||
// 插入 run + 第一个 step。
|
||||
func (r *pgRepo) InTx(ctx context.Context, fn func(ctx context.Context, tx pgx.Tx) error) error {
|
||||
if r.pool == nil {
|
||||
return errs.New(errs.CodeInternal, "orchestrator.Repository.InTx: nil pool (tx-bound instance)")
|
||||
}
|
||||
return pgx.BeginFunc(ctx, r.pool, func(tx pgx.Tx) error {
|
||||
return fn(ctx, tx)
|
||||
})
|
||||
}
|
||||
|
||||
// WithTx 返回绑定到指定事务的 Repository 副本。
|
||||
func (r *pgRepo) WithTx(tx pgx.Tx) Repository {
|
||||
return &pgRepo{pool: nil, q: r.q.WithTx(tx)}
|
||||
}
|
||||
|
||||
// ===== 类型转换 helper =====
|
||||
|
||||
func toPgUUID(u uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: u, Valid: true} }
|
||||
func toPgUUIDPtr(u *uuid.UUID) pgtype.UUID {
|
||||
if u == nil {
|
||||
return pgtype.UUID{}
|
||||
}
|
||||
return pgtype.UUID{Bytes: *u, Valid: true}
|
||||
}
|
||||
func fromPgUUID(p pgtype.UUID) uuid.UUID {
|
||||
if !p.Valid {
|
||||
return uuid.Nil
|
||||
}
|
||||
return uuid.UUID(p.Bytes)
|
||||
}
|
||||
func fromPgUUIDPtr(p pgtype.UUID) *uuid.UUID {
|
||||
if !p.Valid {
|
||||
return nil
|
||||
}
|
||||
u := uuid.UUID(p.Bytes)
|
||||
return &u
|
||||
}
|
||||
|
||||
func timePtr(p pgtype.Timestamptz) *time.Time {
|
||||
if !p.Valid {
|
||||
return nil
|
||||
}
|
||||
t := p.Time
|
||||
return &t
|
||||
}
|
||||
|
||||
func marshalConfig(c RunConfig) ([]byte, error) {
|
||||
b, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "marshal run config")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func unmarshalConfig(b []byte) RunConfig {
|
||||
var c RunConfig
|
||||
if len(b) > 0 {
|
||||
_ = json.Unmarshal(b, &c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func marshalGate(g *GateResult) ([]byte, error) {
|
||||
if g == nil {
|
||||
return nil, nil
|
||||
}
|
||||
b, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "marshal gate result")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func unmarshalGate(b []byte) *GateResult {
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
var g GateResult
|
||||
if err := json.Unmarshal(b, &g); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &g
|
||||
}
|
||||
|
||||
// ===== Run =====
|
||||
|
||||
func (r *pgRepo) InsertRun(ctx context.Context, run *Run) (*Run, error) {
|
||||
cfg, err := marshalConfig(run.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row, err := r.q.InsertRun(ctx, orchsqlc.InsertRunParams{
|
||||
ID: toPgUUID(run.ID),
|
||||
ProjectID: toPgUUID(run.ProjectID),
|
||||
RequirementID: toPgUUID(run.RequirementID),
|
||||
WorkspaceID: toPgUUID(run.WorkspaceID),
|
||||
OwnerID: toPgUUID(run.OwnerID),
|
||||
Status: string(run.Status),
|
||||
CurrentPhase: string(run.CurrentPhase),
|
||||
Config: cfg,
|
||||
LastError: run.LastError,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "insert run")
|
||||
}
|
||||
return rowToRun(row), nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) GetRun(ctx context.Context, id uuid.UUID) (*Run, error) {
|
||||
row, err := r.q.GetRun(ctx, toPgUUID(id))
|
||||
if err != nil {
|
||||
return nil, pgxNoRowsToNotFound(err, "orchestrator run not found")
|
||||
}
|
||||
return rowToRun(row), nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) GetActiveRunByRequirement(ctx context.Context, requirementID uuid.UUID) (*Run, error) {
|
||||
row, err := r.q.GetActiveRunByRequirement(ctx, toPgUUID(requirementID))
|
||||
if err != nil {
|
||||
return nil, pgxNoRowsToNotFound(err, "no active orchestrator run for requirement")
|
||||
}
|
||||
return rowToRun(row), nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) ListRuns(ctx context.Context, f RunFilter) ([]*Run, error) {
|
||||
limit := int32(f.Limit)
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := r.q.ListRuns(ctx, orchsqlc.ListRunsParams{
|
||||
Column1: toPgUUIDPtr(f.ProjectID),
|
||||
Column2: toPgUUIDPtr(f.RequirementID),
|
||||
Column3: string(f.Status),
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "list runs")
|
||||
}
|
||||
out := make([]*Run, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, rowToRun(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) UpdateRunStatus(ctx context.Context, id uuid.UUID, status RunStatus, lastErr *string) (*Run, error) {
|
||||
row, err := r.q.UpdateRunStatus(ctx, orchsqlc.UpdateRunStatusParams{
|
||||
ID: toPgUUID(id),
|
||||
Status: string(status),
|
||||
LastError: lastErr,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, pgxNoRowsToNotFound(err, "orchestrator run not found")
|
||||
}
|
||||
return rowToRun(row), nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) UpdateRunPhase(ctx context.Context, id uuid.UUID, phase project.Phase) (*Run, error) {
|
||||
row, err := r.q.UpdateRunPhase(ctx, orchsqlc.UpdateRunPhaseParams{
|
||||
ID: toPgUUID(id),
|
||||
CurrentPhase: string(phase),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, pgxNoRowsToNotFound(err, "orchestrator run not found")
|
||||
}
|
||||
return rowToRun(row), nil
|
||||
}
|
||||
|
||||
// ===== Step =====
|
||||
|
||||
func (r *pgRepo) InsertStep(ctx context.Context, s *Step) (*Step, error) {
|
||||
gate, err := marshalGate(s.GateResult)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row, err := r.q.InsertStep(ctx, orchsqlc.InsertStepParams{
|
||||
ID: toPgUUID(s.ID),
|
||||
RunID: toPgUUID(s.RunID),
|
||||
Phase: string(s.Phase),
|
||||
Attempt: int32(s.Attempt),
|
||||
ParentStepID: toPgUUIDPtr(s.ParentStepID),
|
||||
Depth: int32(s.Depth),
|
||||
Status: string(s.Status),
|
||||
AgentKindID: toPgUUID(s.AgentKindID),
|
||||
AcpSessionID: toPgUUIDPtr(s.ACPSessionID),
|
||||
Prompt: s.Prompt,
|
||||
StopReason: s.StopReason,
|
||||
GateResult: gate,
|
||||
LastError: s.LastError,
|
||||
JobID: toPgUUIDPtr(s.JobID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "insert step")
|
||||
}
|
||||
return rowToStep(row), nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) GetStep(ctx context.Context, id uuid.UUID) (*Step, error) {
|
||||
row, err := r.q.GetStep(ctx, toPgUUID(id))
|
||||
if err != nil {
|
||||
return nil, pgxNoRowsToNotFound(err, "orchestrator step not found")
|
||||
}
|
||||
return rowToStep(row), nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) GetStepBySession(ctx context.Context, sessionID uuid.UUID) (*Step, error) {
|
||||
row, err := r.q.GetStepBySession(ctx, toPgUUID(sessionID))
|
||||
if err != nil {
|
||||
return nil, pgxNoRowsToNotFound(err, "orchestrator step not found for session")
|
||||
}
|
||||
return rowToStep(row), nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) ListStepsByRun(ctx context.Context, runID uuid.UUID) ([]*Step, error) {
|
||||
rows, err := r.q.ListStepsByRun(ctx, toPgUUID(runID))
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "list steps by run")
|
||||
}
|
||||
out := make([]*Step, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, rowToStep(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) UpdateStepStatus(ctx context.Context, s *Step) (*Step, error) {
|
||||
gate, err := marshalGate(s.GateResult)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row, err := r.q.UpdateStepStatus(ctx, orchsqlc.UpdateStepStatusParams{
|
||||
ID: toPgUUID(s.ID),
|
||||
Status: string(s.Status),
|
||||
AcpSessionID: toPgUUIDPtr(s.ACPSessionID),
|
||||
StopReason: s.StopReason,
|
||||
GateResult: gate,
|
||||
LastError: s.LastError,
|
||||
JobID: toPgUUIDPtr(s.JobID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, pgxNoRowsToNotFound(err, "orchestrator step not found")
|
||||
}
|
||||
return rowToStep(row), nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) IncrementStepAttempt(ctx context.Context, id uuid.UUID) (*Step, error) {
|
||||
row, err := r.q.IncrementStepAttempt(ctx, toPgUUID(id))
|
||||
if err != nil {
|
||||
return nil, pgxNoRowsToNotFound(err, "orchestrator step not found")
|
||||
}
|
||||
return rowToStep(row), nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) CountActiveChildSteps(ctx context.Context, parentStepID uuid.UUID) (int64, error) {
|
||||
n, err := r.q.CountActiveChildSteps(ctx, toPgUUIDPtr(&parentStepID))
|
||||
if err != nil {
|
||||
return 0, errs.Wrap(err, errs.CodeInternal, "count active child steps")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) ResetStuckStepsOnRestart(ctx context.Context) ([]*Step, error) {
|
||||
rows, err := r.q.ResetStuckStepsOnRestart(ctx)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "reset stuck steps on restart")
|
||||
}
|
||||
out := make([]*Step, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, rowToStep(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ===== row → domain =====
|
||||
|
||||
func rowToRun(row orchsqlc.OrchestratorRun) *Run {
|
||||
return &Run{
|
||||
ID: fromPgUUID(row.ID),
|
||||
ProjectID: fromPgUUID(row.ProjectID),
|
||||
RequirementID: fromPgUUID(row.RequirementID),
|
||||
WorkspaceID: fromPgUUID(row.WorkspaceID),
|
||||
OwnerID: fromPgUUID(row.OwnerID),
|
||||
Status: RunStatus(row.Status),
|
||||
CurrentPhase: project.Phase(row.CurrentPhase),
|
||||
Config: unmarshalConfig(row.Config),
|
||||
LastError: row.LastError,
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
UpdatedAt: row.UpdatedAt.Time,
|
||||
EndedAt: timePtr(row.EndedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func rowToStep(row orchsqlc.OrchestratorStep) *Step {
|
||||
return &Step{
|
||||
ID: fromPgUUID(row.ID),
|
||||
RunID: fromPgUUID(row.RunID),
|
||||
Phase: project.Phase(row.Phase),
|
||||
Attempt: int(row.Attempt),
|
||||
ParentStepID: fromPgUUIDPtr(row.ParentStepID),
|
||||
Depth: int(row.Depth),
|
||||
Status: StepStatus(row.Status),
|
||||
AgentKindID: fromPgUUID(row.AgentKindID),
|
||||
ACPSessionID: fromPgUUIDPtr(row.AcpSessionID),
|
||||
Prompt: row.Prompt,
|
||||
StopReason: row.StopReason,
|
||||
GateResult: unmarshalGate(row.GateResult),
|
||||
LastError: row.LastError,
|
||||
JobID: fromPgUUIDPtr(row.JobID),
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
UpdatedAt: row.UpdatedAt.Time,
|
||||
EndedAt: timePtr(row.EndedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func pgxNoRowsToNotFound(err error, msg string) error {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return errs.New(errs.CodeNotFound, msg)
|
||||
}
|
||||
return errs.Wrap(err, errs.CodeInternal, msg)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// runner.go 是回合驱动器:给定一个 Step,要么经 acp.SessionService.Create 以特权
|
||||
// owner 身份创建新 session,要么在崩溃 session 的同一 worktree 上 ResumeSession,然后
|
||||
// 经 acp.TurnRunner.SendPromptAndWait 阻塞跑完一个回合。封装重试/恢复策略。
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/acp"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
)
|
||||
|
||||
// ACPSessions 是 runner 创建 session 所需的窄接口(acp.SessionService 满足)。
|
||||
type ACPSessions interface {
|
||||
Create(ctx context.Context, c acp.Caller, in acp.CreateSessionInput) (*acp.Session, error)
|
||||
}
|
||||
|
||||
// ACPSessionLookup 是 runner 查找 step 已有 session 所需的窄接口(acp.Repository 满足)。
|
||||
type ACPSessionLookup interface {
|
||||
GetSessionByStepID(ctx context.Context, stepID uuid.UUID) (*acp.Session, error)
|
||||
GetCrashedSessionForResume(ctx context.Context, stepID uuid.UUID) (*acp.Session, error)
|
||||
}
|
||||
|
||||
// turnResult 是一次回合驱动的结果。
|
||||
type turnResult struct {
|
||||
SessionID uuid.UUID
|
||||
StopReason string
|
||||
}
|
||||
|
||||
// runner 封装 spawn-or-resume + SendPromptAndWait。
|
||||
type runner struct {
|
||||
sessions ACPSessions
|
||||
turns acp.TurnRunner
|
||||
lookup ACPSessionLookup
|
||||
turnTimeout time.Duration
|
||||
}
|
||||
|
||||
// NewRunner 构造回合驱动器(由 app.go 装配 StepHandler 时调用)。turnTimeout<=0 时
|
||||
// 退回 10 分钟默认值。
|
||||
func NewRunner(sessions ACPSessions, turns acp.TurnRunner, lookup ACPSessionLookup, turnTimeout time.Duration) *runner {
|
||||
if turnTimeout <= 0 {
|
||||
turnTimeout = 10 * time.Minute
|
||||
}
|
||||
return &runner{sessions: sessions, turns: turns, lookup: lookup, turnTimeout: turnTimeout}
|
||||
}
|
||||
|
||||
// driveTurn 为 step 驱动一个回合:
|
||||
//
|
||||
// 1. 若该 step 已有崩溃/退出 session → ResumeSession(同 worktree)。
|
||||
// 2. 否则若该 step 已有活跃 session → 复用(幂等:避免重试重复 spawn)。
|
||||
// 3. 否则 → Create 一个新 session(特权 owner 身份;OrchestratorStepID 反向关联)。
|
||||
//
|
||||
// requirementNumber 用于把新 session 绑定到 req/<slug>-N worktree,使同一 run 的各
|
||||
// 阶段 step 共享同一 worktree(resume 也据此回到原 worktree)。
|
||||
//
|
||||
// 然后 SendPromptAndWait(受 turnTimeout 约束)。返回 sessionID + 原始 stopReason。
|
||||
func (r *runner) driveTurn(ctx context.Context, run *Run, step *Step, requirementNumber int) (turnResult, error) {
|
||||
owner := acp.Caller{UserID: run.OwnerID, IsAdmin: false}
|
||||
|
||||
var sessionID uuid.UUID
|
||||
|
||||
// 1. 崩溃 session → resume(同一 worktree)。
|
||||
if crashed, err := r.lookup.GetCrashedSessionForResume(ctx, step.ID); err == nil && crashed != nil {
|
||||
resumed, rerr := r.turns.ResumeSession(ctx, owner, crashed.ID)
|
||||
if rerr != nil {
|
||||
return turnResult{}, rerr
|
||||
}
|
||||
sessionID = resumed.ID
|
||||
} else if live, lerr := r.lookup.GetSessionByStepID(ctx, step.ID); lerr == nil && live != nil && live.Status.IsActive() {
|
||||
// 2. 已有活跃 session → 复用。
|
||||
sessionID = live.ID
|
||||
} else {
|
||||
// 3. 创建新 session(特权 owner;反向关联到 step)。
|
||||
stepID := step.ID
|
||||
in := acp.CreateSessionInput{
|
||||
WorkspaceID: run.WorkspaceID,
|
||||
AgentKindID: step.AgentKindID,
|
||||
InitialPrompt: nil, // 由 SendPromptAndWait 单独发送,确保阻塞等待回合
|
||||
OrchestratorStepID: &stepID,
|
||||
}
|
||||
if requirementNumber > 0 {
|
||||
// 绑定到 req 分支 worktree,使同一 run 的各阶段共享同一 worktree。
|
||||
rn := requirementNumber
|
||||
in.RequirementNumber = &rn
|
||||
}
|
||||
created, cerr := r.sessions.Create(ctx, owner, in)
|
||||
if cerr != nil {
|
||||
return turnResult{}, cerr
|
||||
}
|
||||
sessionID = created.ID
|
||||
}
|
||||
|
||||
if sessionID == uuid.Nil {
|
||||
return turnResult{}, errs.New(errs.CodeInternal, "orchestrator: no session id after spawn/resume")
|
||||
}
|
||||
|
||||
turnCtx, cancel := context.WithTimeout(ctx, r.turnTimeout)
|
||||
defer cancel()
|
||||
stop, err := r.turns.SendPromptAndWait(turnCtx, sessionID, step.Prompt)
|
||||
if err != nil {
|
||||
return turnResult{SessionID: sessionID}, err
|
||||
}
|
||||
return turnResult{SessionID: sessionID, StopReason: stop}, nil
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// scheduler.go 实现任务分解的并行调度器(autonomy roadmap §10)。Tick 拓扑式地
|
||||
// 选取"就绪"(未被阻塞、open、叶子、无活跃 session)的子任务,并把相互独立的若干个
|
||||
// 扇出到独立 worktree 上的并行 ACP session。调度器是服务端组件,以特权 owner 身份
|
||||
// 调用 acp.SessionService.Create,因而不受 checkNotSystemToken 限制。
|
||||
//
|
||||
// 调度器无状态、幂等:ListReadyLeafSubtasks 已排除有活跃 session 的 issue,故重复 tick
|
||||
// 不会重复派单。并发硬上限由 acp 层 MaxActiveGlobal/MaxActivePerUser 兜底(命中时
|
||||
// CodeAcpSessionQuotaExceeded 当作软失败,下一 tick 重试);软上限由 MaxConcurrentPerProject
|
||||
// 在调度器内预检。把 Scheduler.Tick 注册为 jobs RunnerFn(单实例、单 tick),即可跨重启存活。
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
)
|
||||
|
||||
// ReadyTaskSource 是调度器读取就绪子任务的窄接口(app 层用 project.Repository 适配)。
|
||||
type ReadyTaskSource interface {
|
||||
// ListSchedulableProjects 返回当前至少有一个就绪子任务的 project id(预筛)。
|
||||
ListSchedulableProjects(ctx context.Context) ([]uuid.UUID, error)
|
||||
// ListReadyLeafSubtasks 返回 project 下就绪叶子子任务(priority DESC, number ASC),最多 limit 条。
|
||||
ListReadyLeafSubtasks(ctx context.Context, projectID uuid.UUID, limit int) ([]*project.Issue, error)
|
||||
// CountActiveSessionsForProject 返回 project 内活跃 acp_session 数(软并发预检用)。
|
||||
CountActiveSessionsForProject(ctx context.Context, projectID uuid.UUID) (int, error)
|
||||
}
|
||||
|
||||
// SessionStarter 为某 issue 启动一个 ACP session(包装 acp.SessionService.Create)。
|
||||
// 实现以特权身份(项目 owner / 系统账户)调用,使 worktree Acquire 鉴权通过。
|
||||
type SessionStarter interface {
|
||||
// StartForIssue 为 issue 创建一个绑定 issue/<slug>-N worktree 的 session,返回 session id。
|
||||
StartForIssue(ctx context.Context, iss *project.Issue) (uuid.UUID, error)
|
||||
}
|
||||
|
||||
// SchedulerNotifier 是调度器投递 ready/dispatch/quota 通知的窄接口(app 用 notify.Dispatcher 适配)。
|
||||
type SchedulerNotifier interface {
|
||||
NotifyDispatched(ctx context.Context, iss *project.Issue, sessionID uuid.UUID)
|
||||
NotifyQuotaBlocked(ctx context.Context, iss *project.Issue)
|
||||
}
|
||||
|
||||
// SchedulerDeps 是调度器依赖集合。
|
||||
type SchedulerDeps struct {
|
||||
Projects ReadyTaskSource
|
||||
Sessions SessionStarter
|
||||
Notify SchedulerNotifier
|
||||
Log *slog.Logger
|
||||
// MaxFanoutPerTick 是每个 project 每 tick 最多派发的 session 数(<=0 时默认 8)。
|
||||
MaxFanoutPerTick int
|
||||
// MaxConcurrentPerProject 是 project 内活跃 session 软上限(<=0 时不预检,仅靠 acp 层硬上限)。
|
||||
MaxConcurrentPerProject int
|
||||
}
|
||||
|
||||
// Scheduler 是任务分解并行调度器。单一 Tick 方法便于解耦其宿主(jobs RunnerFn 或
|
||||
// 未来的 pipeline host)。
|
||||
type Scheduler interface {
|
||||
// Tick 执行一轮调度,返回本轮派发的 session 数。单个 issue 派发失败不致整轮失败。
|
||||
Tick(ctx context.Context) (dispatched int, err error)
|
||||
// Run 是 Tick 的 jobs.RunnerFunc 适配(func(ctx)):记录日志、吞掉错误。
|
||||
Run(ctx context.Context)
|
||||
}
|
||||
|
||||
type scheduler struct {
|
||||
projects ReadyTaskSource
|
||||
sessions SessionStarter
|
||||
notify SchedulerNotifier
|
||||
log *slog.Logger
|
||||
maxFanoutPerTick int
|
||||
maxConcurrentPerProject int
|
||||
}
|
||||
|
||||
// NewScheduler 构造调度器。
|
||||
func NewScheduler(d SchedulerDeps) Scheduler {
|
||||
log := d.Log
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
fanout := d.MaxFanoutPerTick
|
||||
if fanout <= 0 {
|
||||
fanout = 8
|
||||
}
|
||||
return &scheduler{
|
||||
projects: d.Projects,
|
||||
sessions: d.Sessions,
|
||||
notify: d.Notify,
|
||||
log: log,
|
||||
maxFanoutPerTick: fanout,
|
||||
maxConcurrentPerProject: d.MaxConcurrentPerProject,
|
||||
}
|
||||
}
|
||||
|
||||
var _ Scheduler = (*scheduler)(nil)
|
||||
|
||||
// Run 把 Tick 适配为 jobs.RunnerFunc 签名(func(ctx)):记录日志、吞掉错误。
|
||||
func (s *scheduler) Run(ctx context.Context) {
|
||||
n, err := s.Tick(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("orchestrator.scheduler.tick_failed", "err", err.Error())
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
s.log.Info("orchestrator.scheduler.tick", "dispatched", n)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *scheduler) Tick(ctx context.Context) (int, error) {
|
||||
projects, err := s.projects.ListSchedulableProjects(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total := 0
|
||||
for _, projID := range projects {
|
||||
n, perr := s.tickProject(ctx, projID)
|
||||
total += n
|
||||
if perr != nil {
|
||||
// 单个 project 失败不致整轮失败:记录并继续下一个 project。
|
||||
s.log.Warn("orchestrator.scheduler.project_failed", "project_id", projID.String(), "err", perr.Error())
|
||||
continue
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// tickProject 为单个 project 派发就绪子任务。受 MaxFanoutPerTick 与(可选)
|
||||
// MaxConcurrentPerProject 软上限约束。命中 acp 配额(CodeAcpSessionQuotaExceeded)时
|
||||
// 停止本 project(软失败,下一 tick 重试),不返回 error。
|
||||
func (s *scheduler) tickProject(ctx context.Context, projectID uuid.UUID) (int, error) {
|
||||
limit := s.maxFanoutPerTick
|
||||
// 软并发预检:若已达 project 内活跃上限,本 tick 不派发。
|
||||
if s.maxConcurrentPerProject > 0 {
|
||||
active, err := s.projects.CountActiveSessionsForProject(ctx, projectID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
budget := s.maxConcurrentPerProject - active
|
||||
if budget <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if budget < limit {
|
||||
limit = budget
|
||||
}
|
||||
}
|
||||
|
||||
ready, err := s.projects.ListReadyLeafSubtasks(ctx, projectID, limit)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
dispatched := 0
|
||||
for _, iss := range ready {
|
||||
sessionID, serr := s.sessions.StartForIssue(ctx, iss)
|
||||
if serr != nil {
|
||||
if isQuotaExceeded(serr) {
|
||||
// 软配额:停止本 project 的派发,下一 tick 重试。通知关注者。
|
||||
if s.notify != nil {
|
||||
s.notify.NotifyQuotaBlocked(ctx, iss)
|
||||
}
|
||||
return dispatched, nil
|
||||
}
|
||||
// 其它失败:记录并跳过该 issue,继续其余就绪 issue。
|
||||
s.log.Warn("orchestrator.scheduler.start_failed",
|
||||
"issue_id", iss.ID.String(), "number", iss.Number, "err", serr.Error())
|
||||
continue
|
||||
}
|
||||
dispatched++
|
||||
if s.notify != nil {
|
||||
s.notify.NotifyDispatched(ctx, iss, sessionID)
|
||||
}
|
||||
}
|
||||
return dispatched, nil
|
||||
}
|
||||
|
||||
// isQuotaExceeded 报告 err 是否为 acp 会话配额超限(软失败语义)。
|
||||
func isQuotaExceeded(err error) bool {
|
||||
var ae *errs.AppError
|
||||
if errors.As(err, &ae) {
|
||||
return ae.Code == errs.CodeAcpSessionQuotaExceeded
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
)
|
||||
|
||||
// ===== fakeReadySource: orchestrator.ReadyTaskSource =====
|
||||
|
||||
type fakeReadySource struct {
|
||||
mu sync.Mutex
|
||||
projects []uuid.UUID
|
||||
ready map[uuid.UUID][]*project.Issue // projectID → ready issues
|
||||
active map[uuid.UUID]int // projectID → active session count
|
||||
listCalls int
|
||||
}
|
||||
|
||||
func (f *fakeReadySource) ListSchedulableProjects(_ context.Context) ([]uuid.UUID, error) {
|
||||
return f.projects, nil
|
||||
}
|
||||
|
||||
func (f *fakeReadySource) ListReadyLeafSubtasks(_ context.Context, projectID uuid.UUID, limit int) ([]*project.Issue, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.listCalls++
|
||||
all := f.ready[projectID]
|
||||
// 模拟 DB:排除已被启动(active)的 issue 由调用方维护;此处仅按 limit 截断。
|
||||
if limit > 0 && len(all) > limit {
|
||||
return all[:limit], nil
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (f *fakeReadySource) CountActiveSessionsForProject(_ context.Context, projectID uuid.UUID) (int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.active[projectID], nil
|
||||
}
|
||||
|
||||
// ===== fakeSessionStarter: orchestrator.SessionStarter =====
|
||||
|
||||
type fakeSessionStarter struct {
|
||||
mu sync.Mutex
|
||||
started []uuid.UUID // issue ids
|
||||
quotaFrom int // 第 quotaFrom 次起返回配额错误(0 表示不触发)
|
||||
calls int
|
||||
failAll bool
|
||||
}
|
||||
|
||||
func (f *fakeSessionStarter) StartForIssue(_ context.Context, iss *project.Issue) (uuid.UUID, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls++
|
||||
if f.failAll {
|
||||
return uuid.Nil, errs.New(errs.CodeInternal, "boom")
|
||||
}
|
||||
if f.quotaFrom > 0 && f.calls >= f.quotaFrom {
|
||||
return uuid.Nil, errs.New(errs.CodeAcpSessionQuotaExceeded, "quota")
|
||||
}
|
||||
f.started = append(f.started, iss.ID)
|
||||
return uuid.New(), nil
|
||||
}
|
||||
|
||||
// ===== fakeSchedNotify: orchestrator.SchedulerNotifier =====
|
||||
|
||||
type fakeSchedNotify struct {
|
||||
dispatched int
|
||||
quota int
|
||||
}
|
||||
|
||||
func (f *fakeSchedNotify) NotifyDispatched(_ context.Context, _ *project.Issue, _ uuid.UUID) {
|
||||
f.dispatched++
|
||||
}
|
||||
func (f *fakeSchedNotify) NotifyQuotaBlocked(_ context.Context, _ *project.Issue) { f.quota++ }
|
||||
|
||||
func mkReadyIssues(projectID uuid.UUID, n int) []*project.Issue {
|
||||
out := make([]*project.Issue, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, &project.Issue{ID: uuid.New(), ProjectID: projectID, Number: i + 1})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestScheduler_DispatchesUpToFanout(t *testing.T) {
|
||||
projID := uuid.New()
|
||||
src := &fakeReadySource{
|
||||
projects: []uuid.UUID{projID},
|
||||
ready: map[uuid.UUID][]*project.Issue{projID: mkReadyIssues(projID, 5)},
|
||||
active: map[uuid.UUID]int{},
|
||||
}
|
||||
starter := &fakeSessionStarter{}
|
||||
notify := &fakeSchedNotify{}
|
||||
sched := NewScheduler(SchedulerDeps{
|
||||
Projects: src, Sessions: starter, Notify: notify,
|
||||
MaxFanoutPerTick: 3,
|
||||
})
|
||||
|
||||
n, err := sched.Tick(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 3, n, "应只派发 min(ready=5, fanout=3)")
|
||||
require.Len(t, starter.started, 3)
|
||||
require.Equal(t, 3, notify.dispatched)
|
||||
}
|
||||
|
||||
func TestScheduler_QuotaIsSoftFailure(t *testing.T) {
|
||||
projID := uuid.New()
|
||||
src := &fakeReadySource{
|
||||
projects: []uuid.UUID{projID},
|
||||
ready: map[uuid.UUID][]*project.Issue{projID: mkReadyIssues(projID, 5)},
|
||||
active: map[uuid.UUID]int{},
|
||||
}
|
||||
// 第 3 次起返回配额错误:前 2 个成功,第 3 个软失败 → 停止本 project。
|
||||
starter := &fakeSessionStarter{quotaFrom: 3}
|
||||
notify := &fakeSchedNotify{}
|
||||
sched := NewScheduler(SchedulerDeps{
|
||||
Projects: src, Sessions: starter, Notify: notify,
|
||||
MaxFanoutPerTick: 5,
|
||||
})
|
||||
|
||||
n, err := sched.Tick(context.Background())
|
||||
require.NoError(t, err, "配额超限是软失败,不应返回 error")
|
||||
require.Equal(t, 2, n)
|
||||
require.Len(t, starter.started, 2)
|
||||
require.Equal(t, 1, notify.quota)
|
||||
}
|
||||
|
||||
func TestScheduler_OtherErrorSkipsIssueNotProject(t *testing.T) {
|
||||
projID := uuid.New()
|
||||
src := &fakeReadySource{
|
||||
projects: []uuid.UUID{projID},
|
||||
ready: map[uuid.UUID][]*project.Issue{projID: mkReadyIssues(projID, 3)},
|
||||
active: map[uuid.UUID]int{},
|
||||
}
|
||||
starter := &fakeSessionStarter{failAll: true}
|
||||
sched := NewScheduler(SchedulerDeps{
|
||||
Projects: src, Sessions: starter, MaxFanoutPerTick: 5,
|
||||
})
|
||||
n, err := sched.Tick(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, n, "全部 start 失败但非配额错误:跳过 issue,整轮不报错")
|
||||
}
|
||||
|
||||
func TestScheduler_MaxConcurrentPerProjectPreCheck(t *testing.T) {
|
||||
projID := uuid.New()
|
||||
src := &fakeReadySource{
|
||||
projects: []uuid.UUID{projID},
|
||||
ready: map[uuid.UUID][]*project.Issue{projID: mkReadyIssues(projID, 10)},
|
||||
active: map[uuid.UUID]int{projID: 3}, // 已有 3 个活跃
|
||||
}
|
||||
starter := &fakeSessionStarter{}
|
||||
sched := NewScheduler(SchedulerDeps{
|
||||
Projects: src, Sessions: starter,
|
||||
MaxFanoutPerTick: 10,
|
||||
MaxConcurrentPerProject: 4, // budget = 4-3 = 1
|
||||
})
|
||||
n, err := sched.Tick(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, n, "软并发预检:budget=1,本 tick 只派 1 个")
|
||||
}
|
||||
|
||||
func TestScheduler_AtConcurrencyCapDispatchesNothing(t *testing.T) {
|
||||
projID := uuid.New()
|
||||
src := &fakeReadySource{
|
||||
projects: []uuid.UUID{projID},
|
||||
ready: map[uuid.UUID][]*project.Issue{projID: mkReadyIssues(projID, 5)},
|
||||
active: map[uuid.UUID]int{projID: 4},
|
||||
}
|
||||
starter := &fakeSessionStarter{}
|
||||
sched := NewScheduler(SchedulerDeps{
|
||||
Projects: src, Sessions: starter,
|
||||
MaxFanoutPerTick: 10, MaxConcurrentPerProject: 4,
|
||||
})
|
||||
n, err := sched.Tick(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, n)
|
||||
require.Empty(t, starter.started)
|
||||
}
|
||||
|
||||
func TestScheduler_IdempotentAcrossTicks(t *testing.T) {
|
||||
projID := uuid.New()
|
||||
issues := mkReadyIssues(projID, 2)
|
||||
src := &fakeReadySource{
|
||||
projects: []uuid.UUID{projID},
|
||||
ready: map[uuid.UUID][]*project.Issue{projID: issues},
|
||||
active: map[uuid.UUID]int{},
|
||||
}
|
||||
starter := &fakeSessionStarter{}
|
||||
sched := NewScheduler(SchedulerDeps{Projects: src, Sessions: starter, MaxFanoutPerTick: 5})
|
||||
|
||||
n1, err := sched.Tick(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, n1)
|
||||
|
||||
// 第二 tick:模拟 DB 已排除被启动的 issue(清空 ready),不应重复派发。
|
||||
src.mu.Lock()
|
||||
src.ready[projID] = nil
|
||||
src.mu.Unlock()
|
||||
n2, err := sched.Tick(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, n2)
|
||||
require.Len(t, starter.started, 2, "跨 tick 不重复派发")
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
// service.go 实现 orchestrator.Service:StartRun / GetRun / ListRuns / Pause /
|
||||
// Resume / Cancel / RequestSubtask / EnqueueStepRedrive。step 的实际驱动在
|
||||
// step_handler.go(jobs worker 内执行)。
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/jobs"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
)
|
||||
|
||||
// JobEnqueuer 是 service 入队 orchestrator.step job 所需的窄接口(jobs.Repository 满足)。
|
||||
type JobEnqueuer interface {
|
||||
Enqueue(ctx context.Context, typ jobs.JobType, payload json.RawMessage, scheduledAt time.Time, maxAttempts int32) (*jobs.Job, error)
|
||||
}
|
||||
|
||||
// ProjectACL 是 StartRun 校验 owner 对项目写权限的窄接口(app 层用 project 的
|
||||
// owner+admin ACL 适配)。
|
||||
type ProjectACL interface {
|
||||
CanRead(ctx context.Context, userID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, error)
|
||||
CanWrite(ctx context.Context, userID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, error)
|
||||
}
|
||||
|
||||
// ServiceDeps 是 service 的依赖集合。
|
||||
type ServiceDeps struct {
|
||||
Repo Repository
|
||||
PM PMAccess
|
||||
ACL ProjectACL
|
||||
Jobs JobEnqueuer
|
||||
Sessions sessionTerminator
|
||||
Defaults map[project.Phase]uuid.UUID
|
||||
Config Config
|
||||
Audit AuditRecorder
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
// sessionTerminator 终止某 acp session(Pause/Cancel 用)。acp.SessionService 经 adapter 适配。
|
||||
type sessionTerminator interface {
|
||||
TerminateSession(ctx context.Context, ownerID uuid.UUID, isAdmin bool, sessionID uuid.UUID) error
|
||||
}
|
||||
|
||||
type service struct {
|
||||
repo Repository
|
||||
pm PMAccess
|
||||
acl ProjectACL
|
||||
jobs JobEnqueuer
|
||||
sessions sessionTerminator
|
||||
defaults map[project.Phase]uuid.UUID
|
||||
cfg Config
|
||||
audit AuditRecorder
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewService 构造编排器 Service。
|
||||
func NewService(d ServiceDeps) Service {
|
||||
log := d.Log
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &service{
|
||||
repo: d.Repo, pm: d.PM, acl: d.ACL, jobs: d.Jobs, sessions: d.Sessions,
|
||||
defaults: d.Defaults, cfg: d.Config, audit: d.Audit, log: log,
|
||||
}
|
||||
}
|
||||
|
||||
var _ Service = (*service)(nil)
|
||||
|
||||
// EnqueueStep 实现 stepEnqueuer:入队一个 orchestrator.step job。
|
||||
func (s *service) EnqueueStep(ctx context.Context, runID, stepID uuid.UUID, scheduledAt time.Time) error {
|
||||
payload, err := json.Marshal(stepPayload{RunID: runID, StepID: stepID})
|
||||
if err != nil {
|
||||
return errs.Wrap(err, errs.CodeInternal, "marshal step payload")
|
||||
}
|
||||
// maxAttempts: jobs 层的尝试计数仅用于 reaper/worker 调度;编排器自身以 step.attempt
|
||||
// + MaxAttemptsPerPhase 控制重试上限。给一个较大的 jobs maxAttempts,让 worker 在
|
||||
// 编排器返回非 Permanent error 时继续重试,直到编排器返回 Permanent。
|
||||
maxAttempts := int32(s.effectiveMaxAttempts() + 2)
|
||||
if _, err := s.jobs.Enqueue(ctx, JobTypeOrchestratorStep, payload, scheduledAt, maxAttempts); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) effectiveMaxAttempts() int {
|
||||
if s.cfg.MaxAttemptsPerPhase > 0 {
|
||||
return s.cfg.MaxAttemptsPerPhase
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
// StartRun 启动一次编排运行:解析项目/需求 + ACL 校验 + 在一个事务内插入 run + 第一个
|
||||
// step,事务提交后入队第一个 orchestrator.step job。每需求只允许一个活跃 run(DB 唯一
|
||||
// 部分索引兜底,命中翻译为 409)。
|
||||
func (s *service) StartRun(ctx context.Context, c Caller, in StartRunInput) (*Run, error) {
|
||||
proj, err := s.pm.GetProjectBySlug(ctx, in.ProjectSlug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 写权限校验:owner 须对项目可写(不依赖 admin 放行)。
|
||||
if s.acl != nil {
|
||||
ok, aerr := s.acl.CanWrite(ctx, c.UserID, c.IsAdmin, proj.ID)
|
||||
if aerr != nil {
|
||||
return nil, aerr
|
||||
}
|
||||
if !ok {
|
||||
return nil, errs.New(errs.CodeForbidden, "no write access to project")
|
||||
}
|
||||
}
|
||||
|
||||
req, err := s.pm.GetRequirementByNumber(ctx, proj.ID, in.RequirementNumber)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.WorkspaceID == nil {
|
||||
return nil, errs.New(errs.CodeInvalidInput,
|
||||
"requirement has no workspace; link a workspace before starting a run")
|
||||
}
|
||||
if req.Status == project.StatusClosed {
|
||||
return nil, errs.New(errs.CodeRequirementClosed, "requirement is closed")
|
||||
}
|
||||
|
||||
startPhase := in.StartPhase
|
||||
if startPhase == "" {
|
||||
startPhase = req.Phase
|
||||
}
|
||||
if !startPhase.IsValid() {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "invalid start phase")
|
||||
}
|
||||
|
||||
// 规范化 config 默认值。
|
||||
cfg := in.Config
|
||||
if cfg.MaxAttemptsPerPhase <= 0 {
|
||||
cfg.MaxAttemptsPerPhase = s.effectiveMaxAttempts()
|
||||
}
|
||||
if cfg.MaxDepth <= 0 {
|
||||
cfg.MaxDepth = s.cfg.MaxDepth
|
||||
}
|
||||
if cfg.FanOutLimit <= 0 {
|
||||
cfg.FanOutLimit = s.cfg.FanOutLimit
|
||||
}
|
||||
|
||||
run := &Run{
|
||||
ID: uuid.New(),
|
||||
ProjectID: proj.ID,
|
||||
RequirementID: req.ID,
|
||||
WorkspaceID: *req.WorkspaceID,
|
||||
OwnerID: proj.OwnerID,
|
||||
Status: RunPending,
|
||||
CurrentPhase: startPhase,
|
||||
Config: cfg,
|
||||
}
|
||||
|
||||
var (
|
||||
outRun *Run
|
||||
outStep *Step
|
||||
)
|
||||
txErr := s.repo.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error {
|
||||
txRepo := s.repo.WithTx(tx)
|
||||
inserted, ierr := txRepo.InsertRun(txCtx, run)
|
||||
if ierr != nil {
|
||||
return ierr
|
||||
}
|
||||
outRun = inserted
|
||||
step, serr := buildPhaseStep(txCtx, txRepo, s.pm, inserted, startPhase, nil, 0, s.defaults)
|
||||
if serr != nil {
|
||||
return serr
|
||||
}
|
||||
outStep = step
|
||||
return nil
|
||||
})
|
||||
if txErr != nil {
|
||||
if IsUniqueViolation(txErr) {
|
||||
return nil, errs.New(errs.CodeConflict, "an active orchestrator run already exists for this requirement")
|
||||
}
|
||||
return nil, txErr
|
||||
}
|
||||
|
||||
// 事务已提交:入队第一个 step job。
|
||||
if eerr := s.EnqueueStep(ctx, outRun.ID, outStep.ID, time.Now().UTC()); eerr != nil {
|
||||
// 入队失败:标 run 失败(step 已落库但永不会被驱动)。
|
||||
msg := eerr.Error()
|
||||
_, _ = s.repo.UpdateRunStatus(ctx, outRun.ID, RunFailed, &msg)
|
||||
return nil, eerr
|
||||
}
|
||||
|
||||
s.recordAudit(ctx, c.UserID, "orchestrator.run.start", outRun.ID.String(), map[string]any{
|
||||
"project_slug": in.ProjectSlug,
|
||||
"requirement": in.RequirementNumber,
|
||||
"start_phase": string(startPhase),
|
||||
"first_step_id": outStep.ID.String(),
|
||||
"execution_owner_id": outRun.OwnerID.String(),
|
||||
})
|
||||
return outRun, nil
|
||||
}
|
||||
|
||||
func (s *service) GetRun(ctx context.Context, c Caller, id uuid.UUID) (*Run, error) {
|
||||
run, err := s.repo.GetRun(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !s.canReadRun(ctx, c, run) {
|
||||
return nil, errs.New(errs.CodeNotFound, "orchestrator run not found")
|
||||
}
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func (s *service) ListRuns(ctx context.Context, c Caller, f RunFilter) ([]*Run, error) {
|
||||
runs, err := s.repo.ListRuns(ctx, f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.IsAdmin {
|
||||
return runs, nil
|
||||
}
|
||||
out := make([]*Run, 0, len(runs))
|
||||
for _, r := range runs {
|
||||
if s.canReadRun(ctx, c, r) {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Pause 把活跃 run 置 paused 并终止其当前活跃 session(停止驱动;已入队 step job 在
|
||||
// Handle 时见 run 非活跃即 no-op)。
|
||||
func (s *service) Pause(ctx context.Context, c Caller, id uuid.UUID) error {
|
||||
run, err := s.GetRun(ctx, c, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !run.Status.IsActive() {
|
||||
return errs.New(errs.CodeConflict, "run is not active")
|
||||
}
|
||||
if _, uerr := s.repo.UpdateRunStatus(ctx, run.ID, RunPaused, nil); uerr != nil {
|
||||
return uerr
|
||||
}
|
||||
s.terminateActiveSessions(ctx, run)
|
||||
s.recordAudit(ctx, c.UserID, "orchestrator.run.pause", run.ID.String(), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resume 把 paused run 置回 running 并重新入队当前阶段的一个 step(让驱动继续)。
|
||||
func (s *service) Resume(ctx context.Context, c Caller, id uuid.UUID) error {
|
||||
run, err := s.GetRun(ctx, c, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if run.Status != RunPaused {
|
||||
return errs.New(errs.CodeConflict, "run is not paused")
|
||||
}
|
||||
if _, uerr := s.repo.UpdateRunStatus(ctx, run.ID, RunRunning, nil); uerr != nil {
|
||||
return uerr
|
||||
}
|
||||
// 找当前阶段最近一个非终态 step 重新入队;若没有则新建一个。
|
||||
step, serr := s.latestResumableStep(ctx, run)
|
||||
if serr != nil {
|
||||
return serr
|
||||
}
|
||||
if step == nil {
|
||||
built, berr := buildPhaseStep(ctx, s.repo, s.pm, run, run.CurrentPhase, nil, 0, s.defaults)
|
||||
if berr != nil {
|
||||
return berr
|
||||
}
|
||||
step = built
|
||||
}
|
||||
if eerr := s.EnqueueStep(ctx, run.ID, step.ID, time.Now().UTC()); eerr != nil {
|
||||
return eerr
|
||||
}
|
||||
s.recordAudit(ctx, c.UserID, "orchestrator.run.resume", run.ID.String(), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cancel 把 run 置 canceled 并终止其活跃 session。
|
||||
func (s *service) Cancel(ctx context.Context, c Caller, id uuid.UUID) error {
|
||||
run, err := s.GetRun(ctx, c, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if run.Status.IsTerminal() {
|
||||
return errs.New(errs.CodeConflict, "run already terminal")
|
||||
}
|
||||
if _, uerr := s.repo.UpdateRunStatus(ctx, run.ID, RunCanceled, nil); uerr != nil {
|
||||
return uerr
|
||||
}
|
||||
s.terminateActiveSessions(ctx, run)
|
||||
s.recordAudit(ctx, c.UserID, "orchestrator.run.cancel", run.ID.String(), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequestSubtask 让运行中的 agent 为其当前 step 派生子 step。在一个事务内校验
|
||||
// depth<MaxDepth 与 active children<FanOutLimit(避免并发竞态突破上限),插入子 step,
|
||||
// 事务提交后入队其 job。
|
||||
func (s *service) RequestSubtask(ctx context.Context, parentACPSessionID uuid.UUID, in SubtaskInput) (*Step, error) {
|
||||
parent, err := s.repo.GetStepBySession(ctx, parentACPSessionID)
|
||||
if err != nil {
|
||||
return nil, errs.New(errs.CodeForbidden, "calling session is not an orchestrator step")
|
||||
}
|
||||
run, err := s.repo.GetRun(ctx, parent.RunID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !run.Status.IsActive() {
|
||||
return nil, errs.New(errs.CodeConflict, "run is not active")
|
||||
}
|
||||
|
||||
phase := in.Phase
|
||||
if phase == "" {
|
||||
phase = parent.Phase
|
||||
}
|
||||
if !phase.IsValid() {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "invalid subtask phase")
|
||||
}
|
||||
|
||||
maxDepth := run.Config.MaxDepth
|
||||
if maxDepth <= 0 {
|
||||
maxDepth = s.cfg.MaxDepth
|
||||
}
|
||||
fanOut := run.Config.FanOutLimit
|
||||
if fanOut <= 0 {
|
||||
fanOut = s.cfg.FanOutLimit
|
||||
}
|
||||
childDepth := parent.Depth + 1
|
||||
if maxDepth > 0 && childDepth > maxDepth {
|
||||
return nil, errs.New(errs.CodeForbidden, "subtask depth limit reached")
|
||||
}
|
||||
|
||||
akID := uuid.Nil
|
||||
if in.AgentKindID != nil {
|
||||
akID = *in.AgentKindID
|
||||
}
|
||||
|
||||
var outStep *Step
|
||||
txErr := s.repo.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error {
|
||||
txRepo := s.repo.WithTx(tx)
|
||||
// 在同一事务内计活跃子 step,避免并发突破 fan-out。
|
||||
n, cerr := txRepo.CountActiveChildSteps(txCtx, parent.ID)
|
||||
if cerr != nil {
|
||||
return cerr
|
||||
}
|
||||
if fanOut > 0 && int(n) >= fanOut {
|
||||
return errs.New(errs.CodeForbidden, "subtask fan-out limit reached")
|
||||
}
|
||||
|
||||
// 解析 agent-kind:显式给定优先,否则走 planner(config/defaults)。
|
||||
resolvedAK := akID
|
||||
if resolvedAK == uuid.Nil {
|
||||
id, perr := PhaseAgentKind(run.Config, phase, s.defaults)
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
resolvedAK = id
|
||||
}
|
||||
|
||||
req, rerr := s.pm.GetRequirementByID(txCtx, run.RequirementID)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
artifacts, _ := s.pm.ListLatestArtifacts(txCtx, run.RequirementID)
|
||||
prompt := in.Prompt
|
||||
if prompt == "" {
|
||||
prompt = BuildPrompt(phase, req, artifacts, run.Config.PromptOverrides[phase])
|
||||
}
|
||||
parentID := parent.ID
|
||||
step := &Step{
|
||||
ID: uuid.New(),
|
||||
RunID: run.ID,
|
||||
Phase: phase,
|
||||
Attempt: 1,
|
||||
ParentStepID: &parentID,
|
||||
Depth: childDepth,
|
||||
Status: StepPending,
|
||||
AgentKindID: resolvedAK,
|
||||
Prompt: prompt,
|
||||
}
|
||||
inserted, ierr := txRepo.InsertStep(txCtx, step)
|
||||
if ierr != nil {
|
||||
return ierr
|
||||
}
|
||||
outStep = inserted
|
||||
return nil
|
||||
})
|
||||
if txErr != nil {
|
||||
return nil, txErr
|
||||
}
|
||||
|
||||
if eerr := s.EnqueueStep(ctx, run.ID, outStep.ID, time.Now().UTC()); eerr != nil {
|
||||
return nil, eerr
|
||||
}
|
||||
s.recordAudit(ctx, run.OwnerID, "orchestrator.subtask.request", outStep.ID.String(), map[string]any{
|
||||
"parent_step_id": parent.ID.String(),
|
||||
"phase": string(phase),
|
||||
"depth": childDepth,
|
||||
})
|
||||
return outStep, nil
|
||||
}
|
||||
|
||||
// EnqueueStepRedrive 由 acp supervisor onExit 的注入回调间接调用:把崩溃 session 的
|
||||
// step 重新入队(短延迟,给 worktree 释放留窗口)。
|
||||
func (s *service) EnqueueStepRedrive(ctx context.Context, stepID uuid.UUID, reason string) {
|
||||
step, err := s.repo.GetStep(ctx, stepID)
|
||||
if err != nil {
|
||||
s.log.Warn("orchestrator.redrive.step_missing", "step_id", stepID, "err", err.Error())
|
||||
return
|
||||
}
|
||||
switch step.Status {
|
||||
case StepSucceeded, StepFailed, StepDead:
|
||||
return // 已终态:无需再驱动
|
||||
case StepSpawning, StepRunning, StepAwaitingGate:
|
||||
// 仍有活跃 job 在驱动本 step:其 driveTurn 会因 session 崩溃触发 relayCtx 取消而
|
||||
// 返回错误 → 走 worker 重试(driveTurn 是 spawn-or-resume,会在同一 worktree 续跑);
|
||||
// worker 进程整体崩溃的情形由 job_reaper 重新租约兜底。此处若再入队会让同一 step
|
||||
// 产生两条 job、双重驱动(两个 agent 会话)。故跳过,恢复只走活跃 job 这一条路径。
|
||||
s.log.Info("orchestrator.redrive.skip_active",
|
||||
"step_id", stepID, "status", step.Status, "reason", reason)
|
||||
return
|
||||
}
|
||||
run, err := s.repo.GetRun(ctx, step.RunID)
|
||||
if err != nil || !run.Status.IsActive() {
|
||||
return // run 不活跃:不再驱动
|
||||
}
|
||||
// 短延迟入队,让 onExit 的 worktree 释放先落定。
|
||||
if eerr := s.EnqueueStep(ctx, run.ID, step.ID, time.Now().UTC().Add(2*time.Second)); eerr != nil {
|
||||
s.log.Warn("orchestrator.redrive.enqueue_failed", "step_id", stepID, "err", eerr.Error())
|
||||
return
|
||||
}
|
||||
s.recordAudit(ctx, run.OwnerID, "orchestrator.step.redrive", step.ID.String(), map[string]any{
|
||||
"reason": reason,
|
||||
})
|
||||
}
|
||||
|
||||
// ===== helpers =====
|
||||
|
||||
// latestResumableStep 返回当前阶段最近一个非终态 step(用于 Resume 重新入队)。
|
||||
func (s *service) latestResumableStep(ctx context.Context, run *Run) (*Step, error) {
|
||||
steps, err := s.repo.ListStepsByRun(ctx, run.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var found *Step
|
||||
for _, st := range steps {
|
||||
if st.Phase != run.CurrentPhase {
|
||||
continue
|
||||
}
|
||||
switch st.Status {
|
||||
case StepSucceeded, StepFailed, StepDead:
|
||||
continue
|
||||
}
|
||||
found = st // ListStepsByRun 按 created_at 升序:取最后一个匹配
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// terminateActiveSessions 终止 run 下当前活跃 step 的 session(best-effort)。
|
||||
func (s *service) terminateActiveSessions(ctx context.Context, run *Run) {
|
||||
if s.sessions == nil {
|
||||
return
|
||||
}
|
||||
steps, err := s.repo.ListStepsByRun(ctx, run.ID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, st := range steps {
|
||||
if st.ACPSessionID == nil {
|
||||
continue
|
||||
}
|
||||
switch st.Status {
|
||||
case StepSpawning, StepRunning, StepAwaitingGate:
|
||||
if terr := s.sessions.TerminateSession(ctx, run.OwnerID, false, *st.ACPSessionID); terr != nil {
|
||||
s.log.Warn("orchestrator.terminate_session_failed",
|
||||
"session_id", *st.ACPSessionID, "err", terr.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) recordAudit(ctx context.Context, userID uuid.UUID, action, targetID string, meta map[string]any) {
|
||||
if s.audit == nil {
|
||||
return
|
||||
}
|
||||
s.audit.Record(context.WithoutCancel(ctx), userID, action, targetID, meta)
|
||||
}
|
||||
|
||||
func (s *service) canReadRun(ctx context.Context, c Caller, run *Run) bool {
|
||||
if c.IsAdmin || run.OwnerID == c.UserID {
|
||||
return true
|
||||
}
|
||||
if s.acl == nil {
|
||||
return false
|
||||
}
|
||||
ok, err := s.acl.CanRead(ctx, c.UserID, c.IsAdmin, run.ProjectID)
|
||||
return err == nil && ok
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
)
|
||||
|
||||
// codeOf 从 error 链中提取 errs.Code(测试断言用)。
|
||||
func codeOf(err error) errs.Code {
|
||||
var ae *errs.AppError
|
||||
if errors.As(err, &ae) {
|
||||
return ae.Code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func newServiceHarness(maxDepth, fanOut int) (*service, *fakeRepo, *fakePM, *fakeJobs, uuid.UUID) {
|
||||
repo := newFakeRepo()
|
||||
wsID := uuid.New()
|
||||
akID := uuid.New()
|
||||
ownerID := uuid.New()
|
||||
pm := &fakePM{
|
||||
proj: &project.Project{ID: uuid.New(), Slug: "demo", OwnerID: ownerID},
|
||||
req: &project.Requirement{ID: uuid.New(), Number: 5, Title: "T", WorkspaceID: &wsID, Phase: project.PhasePlanning, Status: project.StatusOpen, OwnerID: ownerID},
|
||||
}
|
||||
jq := &fakeJobs{}
|
||||
svc := NewService(ServiceDeps{
|
||||
Repo: repo, PM: pm, ACL: fakeACL{allow: true}, Jobs: jq,
|
||||
Sessions: &fakeSessTerm{},
|
||||
Defaults: allPhaseKinds(akID),
|
||||
Config: Config{MaxAttemptsPerPhase: 3, MaxDepth: maxDepth, FanOutLimit: fanOut},
|
||||
Audit: &fakeAudit{},
|
||||
}).(*service)
|
||||
return svc, repo, pm, jq, akID
|
||||
}
|
||||
|
||||
func TestStartRun_CreatesRunStepAndEnqueues(t *testing.T) {
|
||||
svc, repo, _, jq, _ := newServiceHarness(3, 3)
|
||||
run, err := svc.StartRun(context.Background(), Caller{UserID: uuid.New()}, StartRunInput{
|
||||
ProjectSlug: "demo", RequirementNumber: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StartRun: %v", err)
|
||||
}
|
||||
if run.Status != RunPending {
|
||||
t.Errorf("run status = %s; want pending", run.Status)
|
||||
}
|
||||
steps, _ := repo.ListStepsByRun(context.Background(), run.ID)
|
||||
if len(steps) != 1 {
|
||||
t.Fatalf("expected 1 step; got %d", len(steps))
|
||||
}
|
||||
if steps[0].Phase != project.PhasePlanning {
|
||||
t.Errorf("first step phase = %s; want planning", steps[0].Phase)
|
||||
}
|
||||
if jq.count() != 1 {
|
||||
t.Errorf("expected 1 job enqueued; got %d", jq.count())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartRun_UsesProjectOwnerAsExecutionOwner(t *testing.T) {
|
||||
svc, _, pm, _, _ := newServiceHarness(3, 3)
|
||||
starter := uuid.New()
|
||||
|
||||
run, err := svc.StartRun(context.Background(), Caller{UserID: starter, IsAdmin: true}, StartRunInput{
|
||||
ProjectSlug: "demo", RequirementNumber: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StartRun: %v", err)
|
||||
}
|
||||
if run.OwnerID != pm.proj.OwnerID {
|
||||
t.Errorf("run owner = %s; want project owner %s", run.OwnerID, pm.proj.OwnerID)
|
||||
}
|
||||
if run.OwnerID == starter {
|
||||
t.Error("run owner must be the autonomous execution identity, not the starter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartRun_ForbiddenWithoutWriteAccess(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
wsID := uuid.New()
|
||||
pm := &fakePM{
|
||||
proj: &project.Project{ID: uuid.New(), Slug: "demo"},
|
||||
req: &project.Requirement{ID: uuid.New(), Number: 5, WorkspaceID: &wsID, Status: project.StatusOpen},
|
||||
}
|
||||
svc := NewService(ServiceDeps{
|
||||
Repo: repo, PM: pm, ACL: fakeACL{allow: false}, Jobs: &fakeJobs{},
|
||||
Defaults: allPhaseKinds(uuid.New()), Config: Config{MaxAttemptsPerPhase: 3}, Audit: &fakeAudit{},
|
||||
})
|
||||
_, err := svc.StartRun(context.Background(), Caller{UserID: uuid.New()}, StartRunInput{ProjectSlug: "demo", RequirementNumber: 5})
|
||||
if err == nil || codeOf(err) != errs.CodeForbidden {
|
||||
t.Fatalf("expected forbidden; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRun_AllowsProjectReader(t *testing.T) {
|
||||
svc, repo, pm, _, _ := newServiceHarness(3, 3)
|
||||
reader := uuid.New()
|
||||
run := &Run{
|
||||
ID: uuid.New(), ProjectID: pm.proj.ID, RequirementID: pm.req.ID, WorkspaceID: *pm.req.WorkspaceID,
|
||||
OwnerID: pm.req.OwnerID, Status: RunRunning, CurrentPhase: project.PhasePlanning,
|
||||
}
|
||||
repo.runs[run.ID] = run
|
||||
svc.acl = fakeACL{read: true}
|
||||
|
||||
got, err := svc.GetRun(context.Background(), Caller{UserID: reader}, run.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRun: %v", err)
|
||||
}
|
||||
if got.ID != run.ID {
|
||||
t.Errorf("run id = %s; want %s", got.ID, run.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartRun_RequiresWorkspace(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
pm := &fakePM{
|
||||
proj: &project.Project{ID: uuid.New(), Slug: "demo"},
|
||||
req: &project.Requirement{ID: uuid.New(), Number: 5, WorkspaceID: nil, Status: project.StatusOpen},
|
||||
}
|
||||
svc := NewService(ServiceDeps{
|
||||
Repo: repo, PM: pm, ACL: fakeACL{allow: true}, Jobs: &fakeJobs{},
|
||||
Defaults: allPhaseKinds(uuid.New()), Config: Config{MaxAttemptsPerPhase: 3}, Audit: &fakeAudit{},
|
||||
})
|
||||
_, err := svc.StartRun(context.Background(), Caller{UserID: uuid.New()}, StartRunInput{ProjectSlug: "demo", RequirementNumber: 5})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for requirement without workspace")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== RequestSubtask =====
|
||||
|
||||
func seedParentStep(svc *service, repo *fakeRepo, akID uuid.UUID, depth int) (*Run, *Step, uuid.UUID) {
|
||||
wsID := uuid.New()
|
||||
run := &Run{
|
||||
ID: uuid.New(), ProjectID: uuid.New(), RequirementID: uuid.New(), WorkspaceID: wsID,
|
||||
OwnerID: uuid.New(), Status: RunRunning, CurrentPhase: project.PhaseImplementing,
|
||||
Config: RunConfig{PhaseAgentKinds: allPhaseKinds(akID)},
|
||||
}
|
||||
repo.runs[run.ID] = run
|
||||
sessID := uuid.New()
|
||||
parent := &Step{
|
||||
ID: uuid.New(), RunID: run.ID, Phase: project.PhaseImplementing, Attempt: 1, Depth: depth,
|
||||
Status: StepRunning, AgentKindID: akID, ACPSessionID: &sessID, Prompt: "p",
|
||||
}
|
||||
repo.steps[parent.ID] = parent
|
||||
return run, parent, sessID
|
||||
}
|
||||
|
||||
func TestRequestSubtask_HappyInsertsChildAndEnqueues(t *testing.T) {
|
||||
svc, repo, _, jq, akID := newServiceHarness(3, 3)
|
||||
_, _, parentSession := seedParentStep(svc, repo, akID, 0)
|
||||
|
||||
child, err := svc.RequestSubtask(context.Background(), parentSession, SubtaskInput{
|
||||
Phase: project.PhaseImplementing, Prompt: "do subtask",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RequestSubtask: %v", err)
|
||||
}
|
||||
if child.Depth != 1 || child.ParentStepID == nil {
|
||||
t.Errorf("child depth/parent wrong: depth=%d parent=%v", child.Depth, child.ParentStepID)
|
||||
}
|
||||
if jq.count() != 1 {
|
||||
t.Errorf("expected child step enqueued; got %d", jq.count())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestSubtask_DepthLimit(t *testing.T) {
|
||||
svc, repo, _, _, akID := newServiceHarness(2, 5)
|
||||
// parent depth 2 == MaxDepth; child would be depth 3 → rejected.
|
||||
_, _, parentSession := seedParentStep(svc, repo, akID, 2)
|
||||
|
||||
_, err := svc.RequestSubtask(context.Background(), parentSession, SubtaskInput{Prompt: "x"})
|
||||
if err == nil || codeOf(err) != errs.CodeForbidden {
|
||||
t.Fatalf("expected forbidden depth; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestSubtask_FanOutLimit(t *testing.T) {
|
||||
svc, repo, _, _, akID := newServiceHarness(5, 1)
|
||||
run, parent, parentSession := seedParentStep(svc, repo, akID, 0)
|
||||
// 已有一个活跃子 step,FanOutLimit=1 → 再请求被拒。
|
||||
pid := parent.ID
|
||||
repo.steps[uuid.New()] = &Step{
|
||||
ID: uuid.New(), RunID: run.ID, Phase: project.PhaseImplementing, ParentStepID: &pid,
|
||||
Depth: 1, Status: StepRunning, AgentKindID: akID,
|
||||
}
|
||||
_, err := svc.RequestSubtask(context.Background(), parentSession, SubtaskInput{Prompt: "x"})
|
||||
if err == nil || codeOf(err) != errs.CodeForbidden {
|
||||
t.Fatalf("expected forbidden fan-out; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestSubtask_RejectsUnknownSession(t *testing.T) {
|
||||
svc, _, _, _, _ := newServiceHarness(3, 3)
|
||||
_, err := svc.RequestSubtask(context.Background(), uuid.New(), SubtaskInput{Prompt: "x"})
|
||||
if err == nil || codeOf(err) != errs.CodeForbidden {
|
||||
t.Fatalf("expected forbidden for unknown session; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Pause/Cancel/Resume =====
|
||||
|
||||
func TestPauseTerminatesActiveSessions(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
term := &fakeSessTerm{}
|
||||
akID := uuid.New()
|
||||
wsID := uuid.New()
|
||||
owner := uuid.New()
|
||||
run := &Run{ID: uuid.New(), ProjectID: uuid.New(), RequirementID: uuid.New(), WorkspaceID: wsID, OwnerID: owner, Status: RunRunning, CurrentPhase: project.PhasePlanning}
|
||||
repo.runs[run.ID] = run
|
||||
sessID := uuid.New()
|
||||
repo.steps[uuid.New()] = &Step{ID: uuid.New(), RunID: run.ID, Phase: project.PhasePlanning, Status: StepRunning, AgentKindID: akID, ACPSessionID: &sessID}
|
||||
|
||||
svc := NewService(ServiceDeps{Repo: repo, PM: &fakePM{}, ACL: fakeACL{allow: true}, Jobs: &fakeJobs{}, Sessions: term, Defaults: allPhaseKinds(akID), Config: Config{}, Audit: &fakeAudit{}})
|
||||
if err := svc.Pause(context.Background(), Caller{UserID: owner}, run.ID); err != nil {
|
||||
t.Fatalf("Pause: %v", err)
|
||||
}
|
||||
gotRun, _ := repo.GetRun(context.Background(), run.ID)
|
||||
if gotRun.Status != RunPaused {
|
||||
t.Errorf("run status = %s; want paused", gotRun.Status)
|
||||
}
|
||||
if len(term.terminated) != 1 || term.terminated[0] != sessID {
|
||||
t.Errorf("expected session %s terminated; got %v", sessID, term.terminated)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== ResetStuckStepsOnRestart =====
|
||||
|
||||
func TestResetStuckStepsOnRestart(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
akID := uuid.New()
|
||||
activeRun := &Run{ID: uuid.New(), Status: RunRunning}
|
||||
doneRun := &Run{ID: uuid.New(), Status: RunSucceeded}
|
||||
repo.runs[activeRun.ID] = activeRun
|
||||
repo.runs[doneRun.ID] = doneRun
|
||||
|
||||
stuckActive := &Step{ID: uuid.New(), RunID: activeRun.ID, Status: StepRunning, AgentKindID: akID}
|
||||
awaiting := &Step{ID: uuid.New(), RunID: activeRun.ID, Status: StepAwaitingGate, AgentKindID: akID}
|
||||
doneStep := &Step{ID: uuid.New(), RunID: doneRun.ID, Status: StepRunning, AgentKindID: akID}
|
||||
repo.steps[stuckActive.ID] = stuckActive
|
||||
repo.steps[awaiting.ID] = awaiting
|
||||
repo.steps[doneStep.ID] = doneStep
|
||||
|
||||
reset, err := repo.ResetStuckStepsOnRestart(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(reset) != 2 {
|
||||
t.Fatalf("expected 2 reset steps; got %d", len(reset))
|
||||
}
|
||||
if s, _ := repo.GetStep(context.Background(), doneStep.ID); s.Status != StepRunning {
|
||||
t.Errorf("step under succeeded run must be untouched; got %s", s.Status)
|
||||
}
|
||||
if s, _ := repo.GetStep(context.Background(), stuckActive.ID); s.Status != StepPending {
|
||||
t.Errorf("stuck step under active run should be pending; got %s", s.Status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package orchestratorsqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
|
||||
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
||||
QueryRow(context.Context, string, ...interface{}) pgx.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package orchestratorsqlc
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/pgvector/pgvector-go"
|
||||
)
|
||||
|
||||
type AcpAgentKind struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ClientType string `json:"client_type"`
|
||||
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
|
||||
ModelID pgtype.UUID `json:"model_id"`
|
||||
MaxCostUsd pgtype.Numeric `json:"max_cost_usd"`
|
||||
MaxTokens *int64 `json:"max_tokens"`
|
||||
MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type AcpAgentKindConfigFile struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
RelPath string `json:"rel_path"`
|
||||
EncryptedContent []byte `json:"encrypted_content"`
|
||||
UpdatedBy pgtype.UUID `json:"updated_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type AcpEvent struct {
|
||||
ID int64 `json:"id"`
|
||||
SessionID pgtype.UUID `json:"session_id"`
|
||||
Direction string `json:"direction"`
|
||||
RpcKind string `json:"rpc_kind"`
|
||||
Method *string `json:"method"`
|
||||
Payload []byte `json:"payload"`
|
||||
PayloadSize int32 `json:"payload_size"`
|
||||
Truncated bool `json:"truncated"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type AcpPermissionRequest struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
SessionID pgtype.UUID `json:"session_id"`
|
||||
AgentRequestID string `json:"agent_request_id"`
|
||||
ToolName string `json:"tool_name"`
|
||||
ToolCall []byte `json:"tool_call"`
|
||||
Options []byte `json:"options"`
|
||||
Status string `json:"status"`
|
||||
ChosenOptionID *string `json:"chosen_option_id"`
|
||||
DecidedBy pgtype.UUID `json:"decided_by"`
|
||||
DecidedAt pgtype.Timestamptz `json:"decided_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type AcpSession struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
AgentSessionID *string `json:"agent_session_id"`
|
||||
Branch string `json:"branch"`
|
||||
CwdPath string `json:"cwd_path"`
|
||||
IsMainWorktree bool `json:"is_main_worktree"`
|
||||
Status string `json:"status"`
|
||||
Pid *int32 `json:"pid"`
|
||||
ExitCode *int32 `json:"exit_code"`
|
||||
LastError *string `json:"last_error"`
|
||||
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||
EndedAt pgtype.Timestamptz `json:"ended_at"`
|
||||
LastStopReason *string `json:"last_stop_reason"`
|
||||
OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"`
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
ThinkingTokens int64 `json:"thinking_tokens"`
|
||||
TotalCostUsd pgtype.Numeric `json:"total_cost_usd"`
|
||||
LastActivityAt pgtype.Timestamptz `json:"last_activity_at"`
|
||||
BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"`
|
||||
BudgetMaxTokens *int64 `json:"budget_max_tokens"`
|
||||
BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"`
|
||||
TerminatedReason *string `json:"terminated_reason"`
|
||||
SandboxMode *string `json:"sandbox_mode"`
|
||||
CostUsd pgtype.Numeric `json:"cost_usd"`
|
||||
TokensTotal int64 `json:"tokens_total"`
|
||||
}
|
||||
|
||||
type AcpSessionUsage struct {
|
||||
ID int64 `json:"id"`
|
||||
SessionID pgtype.UUID `json:"session_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
ModelID pgtype.UUID `json:"model_id"`
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
ThinkingTokens int64 `json:"thinking_tokens"`
|
||||
CostUsd pgtype.Numeric `json:"cost_usd"`
|
||||
SourceEventID *int64 `json:"source_event_id"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type AcpTurn struct {
|
||||
ID int64 `json:"id"`
|
||||
SessionID pgtype.UUID `json:"session_id"`
|
||||
TurnIndex int32 `json:"turn_index"`
|
||||
PromptRequestID *string `json:"prompt_request_id"`
|
||||
Status string `json:"status"`
|
||||
StopReason *string `json:"stop_reason"`
|
||||
UpdateCount int32 `json:"update_count"`
|
||||
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||
CompletedAt pgtype.Timestamptz `json:"completed_at"`
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
Action string `json:"action"`
|
||||
TargetType *string `json:"target_type"`
|
||||
TargetID *string `json:"target_id"`
|
||||
Ip *netip.Addr `json:"ip"`
|
||||
Metadata []byte `json:"metadata"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type ChangeRequest struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
Number int32 `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
SourceBranch string `json:"source_branch"`
|
||||
TargetBranch string `json:"target_branch"`
|
||||
State string `json:"state"`
|
||||
ReviewVerdict string `json:"review_verdict"`
|
||||
CiState string `json:"ci_state"`
|
||||
Provider string `json:"provider"`
|
||||
ExternalID *int64 `json:"external_id"`
|
||||
ExternalUrl string `json:"external_url"`
|
||||
MergeCommitSha string `json:"merge_commit_sha"`
|
||||
ReviewedBy pgtype.UUID `json:"reviewed_by"`
|
||||
ReviewedAt pgtype.Timestamptz `json:"reviewed_at"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
MergedAt pgtype.Timestamptz `json:"merged_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CodeChunk struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
RunID pgtype.UUID `json:"run_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
CommitSha string `json:"commit_sha"`
|
||||
FilePath string `json:"file_path"`
|
||||
Lang *string `json:"lang"`
|
||||
StartLine int32 `json:"start_line"`
|
||||
EndLine int32 `json:"end_line"`
|
||||
Content string `json:"content"`
|
||||
ContentSha []byte `json:"content_sha"`
|
||||
TokenCount *int32 `json:"token_count"`
|
||||
Embedding *pgvector.Vector `json:"embedding"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type CodeIndexRun struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
WorktreeID pgtype.UUID `json:"worktree_id"`
|
||||
Branch string `json:"branch"`
|
||||
CommitSha string `json:"commit_sha"`
|
||||
Status string `json:"status"`
|
||||
EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"`
|
||||
EmbeddingModel string `json:"embedding_model"`
|
||||
Dims int32 `json:"dims"`
|
||||
FilesIndexed int32 `json:"files_indexed"`
|
||||
ChunksIndexed int32 `json:"chunks_indexed"`
|
||||
Error *string `json:"error"`
|
||||
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||
FinishedAt pgtype.Timestamptz `json:"finished_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type CommitSummary struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
WorktreeID pgtype.UUID `json:"worktree_id"`
|
||||
Branch string `json:"branch"`
|
||||
CommitSha string `json:"commit_sha"`
|
||||
Kind string `json:"kind"`
|
||||
Title *string `json:"title"`
|
||||
BodyMd string `json:"body_md"`
|
||||
Model *string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
Error *string `json:"error"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Conversation struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
ModelID pgtype.UUID `json:"model_id"`
|
||||
SystemPrompt string `json:"system_prompt"`
|
||||
Title *string `json:"title"`
|
||||
TitleGeneratedAt pgtype.Timestamptz `json:"title_generated_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
}
|
||||
|
||||
type CryptoKey struct {
|
||||
Version int32 `json:"version"`
|
||||
Provider string `json:"provider"`
|
||||
KeyRef *string `json:"key_ref"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type GitCredential struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Kind string `json:"kind"`
|
||||
Username *string `json:"username"`
|
||||
EncryptedSecret []byte `json:"encrypted_secret"`
|
||||
Fingerprint *string `json:"fingerprint"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type Issue struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
Number int32 `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
AssigneeID pgtype.UUID `json:"assignee_id"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
ClosedAt pgtype.Timestamptz `json:"closed_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
ParentID pgtype.UUID `json:"parent_id"`
|
||||
Priority int32 `json:"priority"`
|
||||
}
|
||||
|
||||
type IssueDependency struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
BlockedID pgtype.UUID `json:"blocked_id"`
|
||||
BlockerID pgtype.UUID `json:"blocker_id"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Payload []byte `json:"payload"`
|
||||
Status string `json:"status"`
|
||||
ScheduledAt pgtype.Timestamptz `json:"scheduled_at"`
|
||||
Attempts int32 `json:"attempts"`
|
||||
MaxAttempts int32 `json:"max_attempts"`
|
||||
LeasedAt pgtype.Timestamptz `json:"leased_at"`
|
||||
LeasedBy *string `json:"leased_by"`
|
||||
LastError *string `json:"last_error"`
|
||||
CompletedAt pgtype.Timestamptz `json:"completed_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type LlmEndpoint struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
DisplayName string `json:"display_name"`
|
||||
BaseUrl string `json:"base_url"`
|
||||
ApiKeyEncrypted []byte `json:"api_key_encrypted"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type LlmModel struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
EndpointID pgtype.UUID `json:"endpoint_id"`
|
||||
ModelID string `json:"model_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Capabilities []byte `json:"capabilities"`
|
||||
ContextWindow int32 `json:"context_window"`
|
||||
MaxOutputTokens int32 `json:"max_output_tokens"`
|
||||
PromptPricePerMillionUsd pgtype.Numeric `json:"prompt_price_per_million_usd"`
|
||||
CompletionPricePerMillionUsd pgtype.Numeric `json:"completion_price_per_million_usd"`
|
||||
ThinkingPricePerMillionUsd pgtype.Numeric `json:"thinking_price_per_million_usd"`
|
||||
IsTitleGenerator bool `json:"is_title_generator"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type LlmUsage struct {
|
||||
ID int64 `json:"id"`
|
||||
ConversationID pgtype.UUID `json:"conversation_id"`
|
||||
MessageID int64 `json:"message_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
EndpointID pgtype.UUID `json:"endpoint_id"`
|
||||
ModelID pgtype.UUID `json:"model_id"`
|
||||
PromptTokens int32 `json:"prompt_tokens"`
|
||||
CompletionTokens int32 `json:"completion_tokens"`
|
||||
ThinkingTokens int32 `json:"thinking_tokens"`
|
||||
CostUsd pgtype.Numeric `json:"cost_usd"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type McpToken struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
TokenHash []byte `json:"token_hash"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Issuer string `json:"issuer"`
|
||||
Scope []byte `json:"scope"`
|
||||
AcpSessionID pgtype.UUID `json:"acp_session_id"`
|
||||
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||
LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
|
||||
RevokedAt pgtype.Timestamptz `json:"revoked_at"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
ID int64 `json:"id"`
|
||||
ConversationID pgtype.UUID `json:"conversation_id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Thinking *string `json:"thinking"`
|
||||
ToolCalls []byte `json:"tool_calls"`
|
||||
Status string `json:"status"`
|
||||
ErrorMessage *string `json:"error_message"`
|
||||
PromptTokens *int32 `json:"prompt_tokens"`
|
||||
CompletionTokens *int32 `json:"completion_tokens"`
|
||||
ThinkingTokens *int32 `json:"thinking_tokens"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type MessageAttachment struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
MessageID *int64 `json:"message_id"`
|
||||
Filename string `json:"filename"`
|
||||
MimeType string `json:"mime_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
Sha256 string `json:"sha256"`
|
||||
StoragePath string `json:"storage_path"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
Topic string `json:"topic"`
|
||||
Severity string `json:"severity"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Link *string `json:"link"`
|
||||
Metadata []byte `json:"metadata"`
|
||||
ReadAt pgtype.Timestamptz `json:"read_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type NotificationPref struct {
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
EmailEnabled bool `json:"email_enabled"`
|
||||
ImEnabled bool `json:"im_enabled"`
|
||||
ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"`
|
||||
MinSeverity string `json:"min_severity"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type OrchestratorRun struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
OwnerID pgtype.UUID `json:"owner_id"`
|
||||
Status string `json:"status"`
|
||||
CurrentPhase string `json:"current_phase"`
|
||||
Config []byte `json:"config"`
|
||||
LastError *string `json:"last_error"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
EndedAt pgtype.Timestamptz `json:"ended_at"`
|
||||
}
|
||||
|
||||
type OrchestratorStep struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
RunID pgtype.UUID `json:"run_id"`
|
||||
Phase string `json:"phase"`
|
||||
Attempt int32 `json:"attempt"`
|
||||
ParentStepID pgtype.UUID `json:"parent_step_id"`
|
||||
Depth int32 `json:"depth"`
|
||||
Status string `json:"status"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
AcpSessionID pgtype.UUID `json:"acp_session_id"`
|
||||
Prompt string `json:"prompt"`
|
||||
StopReason *string `json:"stop_reason"`
|
||||
GateResult []byte `json:"gate_result"`
|
||||
LastError *string `json:"last_error"`
|
||||
JobID pgtype.UUID `json:"job_id"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
EndedAt pgtype.Timestamptz `json:"ended_at"`
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Visibility string `json:"visibility"`
|
||||
OwnerID pgtype.UUID `json:"owner_id"`
|
||||
ArchivedAt pgtype.Timestamptz `json:"archived_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ProjectMember struct {
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
AddedBy pgtype.UUID `json:"added_by"`
|
||||
}
|
||||
|
||||
type ProjectMemory struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Tags []string `json:"tags"`
|
||||
Source string `json:"source"`
|
||||
Embedding *pgvector.Vector `json:"embedding"`
|
||||
EmbeddingModel *string `json:"embedding_model"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PromptTemplate struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Scope string `json:"scope"`
|
||||
OwnerID pgtype.UUID `json:"owner_id"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Requirement struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
Number int32 `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Phase string `json:"phase"`
|
||||
Status string `json:"status"`
|
||||
OwnerID pgtype.UUID `json:"owner_id"`
|
||||
ClosedAt pgtype.Timestamptz `json:"closed_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
}
|
||||
|
||||
type RequirementArtifact struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
Phase string `json:"phase"`
|
||||
Version int32 `json:"version"`
|
||||
Content string `json:"content"`
|
||||
Note string `json:"note"`
|
||||
SourceMessageID *int64 `json:"source_message_id"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
Verdict string `json:"verdict"`
|
||||
}
|
||||
|
||||
type RequirementPhasePointer struct {
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
Phase string `json:"phase"`
|
||||
ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"`
|
||||
ApprovedBy pgtype.UUID `json:"approved_by"`
|
||||
ApprovedAt pgtype.Timestamptz `json:"approved_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
DisplayName string `json:"display_name"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type UserSession struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
TokenHash []byte `json:"token_hash"`
|
||||
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||
LastSeenAt pgtype.Timestamptz `json:"last_seen_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Workspace struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
GitRemoteUrl string `json:"git_remote_url"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
MainPath string `json:"main_path"`
|
||||
SyncStatus string `json:"sync_status"`
|
||||
LastSyncedAt pgtype.Timestamptz `json:"last_synced_at"`
|
||||
LastSyncError *string `json:"last_sync_error"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"`
|
||||
}
|
||||
|
||||
type WorkspaceRunProfile struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastStartedAt pgtype.Timestamptz `json:"last_started_at"`
|
||||
LastExitCode *int32 `json:"last_exit_code"`
|
||||
LastError string `json:"last_error"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type WorkspaceWorktree struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Branch string `json:"branch"`
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"`
|
||||
ActiveHolder *string `json:"active_holder"`
|
||||
AcquiredAt pgtype.Timestamptz `json:"acquired_at"`
|
||||
LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
PruneWarningAt pgtype.Timestamptz `json:"prune_warning_at"`
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: runs.sql
|
||||
|
||||
package orchestratorsqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const getActiveRunByRequirement = `-- name: GetActiveRunByRequirement :one
|
||||
SELECT id, project_id, requirement_id, workspace_id, owner_id,
|
||||
status, current_phase, config, last_error,
|
||||
created_at, updated_at, ended_at
|
||||
FROM orchestrator_runs
|
||||
WHERE requirement_id = $1
|
||||
AND status IN ('pending','running','paused')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetActiveRunByRequirement(ctx context.Context, requirementID pgtype.UUID) (OrchestratorRun, error) {
|
||||
row := q.db.QueryRow(ctx, getActiveRunByRequirement, requirementID)
|
||||
var i OrchestratorRun
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.RequirementID,
|
||||
&i.WorkspaceID,
|
||||
&i.OwnerID,
|
||||
&i.Status,
|
||||
&i.CurrentPhase,
|
||||
&i.Config,
|
||||
&i.LastError,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EndedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getRun = `-- name: GetRun :one
|
||||
SELECT id, project_id, requirement_id, workspace_id, owner_id,
|
||||
status, current_phase, config, last_error,
|
||||
created_at, updated_at, ended_at
|
||||
FROM orchestrator_runs
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetRun(ctx context.Context, id pgtype.UUID) (OrchestratorRun, error) {
|
||||
row := q.db.QueryRow(ctx, getRun, id)
|
||||
var i OrchestratorRun
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.RequirementID,
|
||||
&i.WorkspaceID,
|
||||
&i.OwnerID,
|
||||
&i.Status,
|
||||
&i.CurrentPhase,
|
||||
&i.Config,
|
||||
&i.LastError,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EndedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertRun = `-- name: InsertRun :one
|
||||
INSERT INTO orchestrator_runs (
|
||||
id, project_id, requirement_id, workspace_id, owner_id,
|
||||
status, current_phase, config, last_error
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, project_id, requirement_id, workspace_id, owner_id,
|
||||
status, current_phase, config, last_error,
|
||||
created_at, updated_at, ended_at
|
||||
`
|
||||
|
||||
type InsertRunParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
OwnerID pgtype.UUID `json:"owner_id"`
|
||||
Status string `json:"status"`
|
||||
CurrentPhase string `json:"current_phase"`
|
||||
Config []byte `json:"config"`
|
||||
LastError *string `json:"last_error"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertRun(ctx context.Context, arg InsertRunParams) (OrchestratorRun, error) {
|
||||
row := q.db.QueryRow(ctx, insertRun,
|
||||
arg.ID,
|
||||
arg.ProjectID,
|
||||
arg.RequirementID,
|
||||
arg.WorkspaceID,
|
||||
arg.OwnerID,
|
||||
arg.Status,
|
||||
arg.CurrentPhase,
|
||||
arg.Config,
|
||||
arg.LastError,
|
||||
)
|
||||
var i OrchestratorRun
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.RequirementID,
|
||||
&i.WorkspaceID,
|
||||
&i.OwnerID,
|
||||
&i.Status,
|
||||
&i.CurrentPhase,
|
||||
&i.Config,
|
||||
&i.LastError,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EndedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listRuns = `-- name: ListRuns :many
|
||||
SELECT id, project_id, requirement_id, workspace_id, owner_id,
|
||||
status, current_phase, config, last_error,
|
||||
created_at, updated_at, ended_at
|
||||
FROM orchestrator_runs
|
||||
WHERE ($1::uuid IS NULL OR project_id = $1)
|
||||
AND ($2::uuid IS NULL OR requirement_id = $2)
|
||||
AND ($3::text = '' OR status = $3)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $4
|
||||
`
|
||||
|
||||
type ListRunsParams struct {
|
||||
Column1 pgtype.UUID `json:"column_1"`
|
||||
Column2 pgtype.UUID `json:"column_2"`
|
||||
Column3 string `json:"column_3"`
|
||||
Limit int32 `json:"limit"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListRuns(ctx context.Context, arg ListRunsParams) ([]OrchestratorRun, error) {
|
||||
rows, err := q.db.Query(ctx, listRuns,
|
||||
arg.Column1,
|
||||
arg.Column2,
|
||||
arg.Column3,
|
||||
arg.Limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []OrchestratorRun
|
||||
for rows.Next() {
|
||||
var i OrchestratorRun
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.RequirementID,
|
||||
&i.WorkspaceID,
|
||||
&i.OwnerID,
|
||||
&i.Status,
|
||||
&i.CurrentPhase,
|
||||
&i.Config,
|
||||
&i.LastError,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EndedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateRunPhase = `-- name: UpdateRunPhase :one
|
||||
UPDATE orchestrator_runs
|
||||
SET current_phase = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, project_id, requirement_id, workspace_id, owner_id,
|
||||
status, current_phase, config, last_error,
|
||||
created_at, updated_at, ended_at
|
||||
`
|
||||
|
||||
type UpdateRunPhaseParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
CurrentPhase string `json:"current_phase"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateRunPhase(ctx context.Context, arg UpdateRunPhaseParams) (OrchestratorRun, error) {
|
||||
row := q.db.QueryRow(ctx, updateRunPhase, arg.ID, arg.CurrentPhase)
|
||||
var i OrchestratorRun
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.RequirementID,
|
||||
&i.WorkspaceID,
|
||||
&i.OwnerID,
|
||||
&i.Status,
|
||||
&i.CurrentPhase,
|
||||
&i.Config,
|
||||
&i.LastError,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EndedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateRunStatus = `-- name: UpdateRunStatus :one
|
||||
UPDATE orchestrator_runs
|
||||
SET status = $2,
|
||||
last_error = $3,
|
||||
ended_at = CASE WHEN $2 IN ('succeeded','failed','canceled') THEN now() ELSE ended_at END,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, project_id, requirement_id, workspace_id, owner_id,
|
||||
status, current_phase, config, last_error,
|
||||
created_at, updated_at, ended_at
|
||||
`
|
||||
|
||||
type UpdateRunStatusParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Status string `json:"status"`
|
||||
LastError *string `json:"last_error"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateRunStatus(ctx context.Context, arg UpdateRunStatusParams) (OrchestratorRun, error) {
|
||||
row := q.db.QueryRow(ctx, updateRunStatus, arg.ID, arg.Status, arg.LastError)
|
||||
var i OrchestratorRun
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.RequirementID,
|
||||
&i.WorkspaceID,
|
||||
&i.OwnerID,
|
||||
&i.Status,
|
||||
&i.CurrentPhase,
|
||||
&i.Config,
|
||||
&i.LastError,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EndedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: steps.sql
|
||||
|
||||
package orchestratorsqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const countActiveChildSteps = `-- name: CountActiveChildSteps :one
|
||||
SELECT COUNT(*) FROM orchestrator_steps
|
||||
WHERE parent_step_id = $1
|
||||
AND status IN ('pending','spawning','running','awaiting_gate')
|
||||
`
|
||||
|
||||
func (q *Queries) CountActiveChildSteps(ctx context.Context, parentStepID pgtype.UUID) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, countActiveChildSteps, parentStepID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const getStep = `-- name: GetStep :one
|
||||
SELECT id, run_id, phase, attempt, parent_step_id, depth,
|
||||
status, agent_kind_id, acp_session_id, prompt, stop_reason,
|
||||
gate_result, last_error, job_id, created_at, updated_at, ended_at
|
||||
FROM orchestrator_steps
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetStep(ctx context.Context, id pgtype.UUID) (OrchestratorStep, error) {
|
||||
row := q.db.QueryRow(ctx, getStep, id)
|
||||
var i OrchestratorStep
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.RunID,
|
||||
&i.Phase,
|
||||
&i.Attempt,
|
||||
&i.ParentStepID,
|
||||
&i.Depth,
|
||||
&i.Status,
|
||||
&i.AgentKindID,
|
||||
&i.AcpSessionID,
|
||||
&i.Prompt,
|
||||
&i.StopReason,
|
||||
&i.GateResult,
|
||||
&i.LastError,
|
||||
&i.JobID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EndedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getStepBySession = `-- name: GetStepBySession :one
|
||||
SELECT id, run_id, phase, attempt, parent_step_id, depth,
|
||||
status, agent_kind_id, acp_session_id, prompt, stop_reason,
|
||||
gate_result, last_error, job_id, created_at, updated_at, ended_at
|
||||
FROM orchestrator_steps
|
||||
WHERE acp_session_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetStepBySession(ctx context.Context, acpSessionID pgtype.UUID) (OrchestratorStep, error) {
|
||||
row := q.db.QueryRow(ctx, getStepBySession, acpSessionID)
|
||||
var i OrchestratorStep
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.RunID,
|
||||
&i.Phase,
|
||||
&i.Attempt,
|
||||
&i.ParentStepID,
|
||||
&i.Depth,
|
||||
&i.Status,
|
||||
&i.AgentKindID,
|
||||
&i.AcpSessionID,
|
||||
&i.Prompt,
|
||||
&i.StopReason,
|
||||
&i.GateResult,
|
||||
&i.LastError,
|
||||
&i.JobID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EndedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const incrementStepAttempt = `-- name: IncrementStepAttempt :one
|
||||
UPDATE orchestrator_steps
|
||||
SET attempt = attempt + 1,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, run_id, phase, attempt, parent_step_id, depth,
|
||||
status, agent_kind_id, acp_session_id, prompt, stop_reason,
|
||||
gate_result, last_error, job_id, created_at, updated_at, ended_at
|
||||
`
|
||||
|
||||
func (q *Queries) IncrementStepAttempt(ctx context.Context, id pgtype.UUID) (OrchestratorStep, error) {
|
||||
row := q.db.QueryRow(ctx, incrementStepAttempt, id)
|
||||
var i OrchestratorStep
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.RunID,
|
||||
&i.Phase,
|
||||
&i.Attempt,
|
||||
&i.ParentStepID,
|
||||
&i.Depth,
|
||||
&i.Status,
|
||||
&i.AgentKindID,
|
||||
&i.AcpSessionID,
|
||||
&i.Prompt,
|
||||
&i.StopReason,
|
||||
&i.GateResult,
|
||||
&i.LastError,
|
||||
&i.JobID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EndedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertStep = `-- name: InsertStep :one
|
||||
INSERT INTO orchestrator_steps (
|
||||
id, run_id, phase, attempt, parent_step_id, depth,
|
||||
status, agent_kind_id, acp_session_id, prompt, stop_reason,
|
||||
gate_result, last_error, job_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING id, run_id, phase, attempt, parent_step_id, depth,
|
||||
status, agent_kind_id, acp_session_id, prompt, stop_reason,
|
||||
gate_result, last_error, job_id, created_at, updated_at, ended_at
|
||||
`
|
||||
|
||||
type InsertStepParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
RunID pgtype.UUID `json:"run_id"`
|
||||
Phase string `json:"phase"`
|
||||
Attempt int32 `json:"attempt"`
|
||||
ParentStepID pgtype.UUID `json:"parent_step_id"`
|
||||
Depth int32 `json:"depth"`
|
||||
Status string `json:"status"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
AcpSessionID pgtype.UUID `json:"acp_session_id"`
|
||||
Prompt string `json:"prompt"`
|
||||
StopReason *string `json:"stop_reason"`
|
||||
GateResult []byte `json:"gate_result"`
|
||||
LastError *string `json:"last_error"`
|
||||
JobID pgtype.UUID `json:"job_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertStep(ctx context.Context, arg InsertStepParams) (OrchestratorStep, error) {
|
||||
row := q.db.QueryRow(ctx, insertStep,
|
||||
arg.ID,
|
||||
arg.RunID,
|
||||
arg.Phase,
|
||||
arg.Attempt,
|
||||
arg.ParentStepID,
|
||||
arg.Depth,
|
||||
arg.Status,
|
||||
arg.AgentKindID,
|
||||
arg.AcpSessionID,
|
||||
arg.Prompt,
|
||||
arg.StopReason,
|
||||
arg.GateResult,
|
||||
arg.LastError,
|
||||
arg.JobID,
|
||||
)
|
||||
var i OrchestratorStep
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.RunID,
|
||||
&i.Phase,
|
||||
&i.Attempt,
|
||||
&i.ParentStepID,
|
||||
&i.Depth,
|
||||
&i.Status,
|
||||
&i.AgentKindID,
|
||||
&i.AcpSessionID,
|
||||
&i.Prompt,
|
||||
&i.StopReason,
|
||||
&i.GateResult,
|
||||
&i.LastError,
|
||||
&i.JobID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EndedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listStepsByRun = `-- name: ListStepsByRun :many
|
||||
SELECT id, run_id, phase, attempt, parent_step_id, depth,
|
||||
status, agent_kind_id, acp_session_id, prompt, stop_reason,
|
||||
gate_result, last_error, job_id, created_at, updated_at, ended_at
|
||||
FROM orchestrator_steps
|
||||
WHERE run_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListStepsByRun(ctx context.Context, runID pgtype.UUID) ([]OrchestratorStep, error) {
|
||||
rows, err := q.db.Query(ctx, listStepsByRun, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []OrchestratorStep
|
||||
for rows.Next() {
|
||||
var i OrchestratorStep
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.RunID,
|
||||
&i.Phase,
|
||||
&i.Attempt,
|
||||
&i.ParentStepID,
|
||||
&i.Depth,
|
||||
&i.Status,
|
||||
&i.AgentKindID,
|
||||
&i.AcpSessionID,
|
||||
&i.Prompt,
|
||||
&i.StopReason,
|
||||
&i.GateResult,
|
||||
&i.LastError,
|
||||
&i.JobID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EndedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const resetStuckStepsOnRestart = `-- name: ResetStuckStepsOnRestart :many
|
||||
UPDATE orchestrator_steps s
|
||||
SET status = 'pending',
|
||||
updated_at = now()
|
||||
FROM orchestrator_runs r
|
||||
WHERE s.run_id = r.id
|
||||
AND r.status IN ('pending','running','paused')
|
||||
AND s.status IN ('spawning','running','awaiting_gate')
|
||||
RETURNING s.id, s.run_id, s.phase, s.attempt, s.parent_step_id, s.depth,
|
||||
s.status, s.agent_kind_id, s.acp_session_id, s.prompt, s.stop_reason,
|
||||
s.gate_result, s.last_error, s.job_id, s.created_at, s.updated_at, s.ended_at
|
||||
`
|
||||
|
||||
// 把活跃 run 下卡在 spawning/running/awaiting_gate 的 step 重置为 pending,便于
|
||||
// 重新入队恢复(镜像 acp ResetStuckSessionsOnRestart)。succeeded/canceled/failed
|
||||
// run 下的 step 不动。
|
||||
func (q *Queries) ResetStuckStepsOnRestart(ctx context.Context) ([]OrchestratorStep, error) {
|
||||
rows, err := q.db.Query(ctx, resetStuckStepsOnRestart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []OrchestratorStep
|
||||
for rows.Next() {
|
||||
var i OrchestratorStep
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.RunID,
|
||||
&i.Phase,
|
||||
&i.Attempt,
|
||||
&i.ParentStepID,
|
||||
&i.Depth,
|
||||
&i.Status,
|
||||
&i.AgentKindID,
|
||||
&i.AcpSessionID,
|
||||
&i.Prompt,
|
||||
&i.StopReason,
|
||||
&i.GateResult,
|
||||
&i.LastError,
|
||||
&i.JobID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EndedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateStepStatus = `-- name: UpdateStepStatus :one
|
||||
UPDATE orchestrator_steps
|
||||
SET status = $2,
|
||||
acp_session_id = $3,
|
||||
stop_reason = $4,
|
||||
gate_result = $5,
|
||||
last_error = $6,
|
||||
job_id = $7,
|
||||
ended_at = CASE WHEN $2 IN ('succeeded','failed','dead') THEN now() ELSE ended_at END,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, run_id, phase, attempt, parent_step_id, depth,
|
||||
status, agent_kind_id, acp_session_id, prompt, stop_reason,
|
||||
gate_result, last_error, job_id, created_at, updated_at, ended_at
|
||||
`
|
||||
|
||||
type UpdateStepStatusParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Status string `json:"status"`
|
||||
AcpSessionID pgtype.UUID `json:"acp_session_id"`
|
||||
StopReason *string `json:"stop_reason"`
|
||||
GateResult []byte `json:"gate_result"`
|
||||
LastError *string `json:"last_error"`
|
||||
JobID pgtype.UUID `json:"job_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateStepStatus(ctx context.Context, arg UpdateStepStatusParams) (OrchestratorStep, error) {
|
||||
row := q.db.QueryRow(ctx, updateStepStatus,
|
||||
arg.ID,
|
||||
arg.Status,
|
||||
arg.AcpSessionID,
|
||||
arg.StopReason,
|
||||
arg.GateResult,
|
||||
arg.LastError,
|
||||
arg.JobID,
|
||||
)
|
||||
var i OrchestratorStep
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.RunID,
|
||||
&i.Phase,
|
||||
&i.Attempt,
|
||||
&i.ParentStepID,
|
||||
&i.Depth,
|
||||
&i.Status,
|
||||
&i.AgentKindID,
|
||||
&i.AcpSessionID,
|
||||
&i.Prompt,
|
||||
&i.StopReason,
|
||||
&i.GateResult,
|
||||
&i.LastError,
|
||||
&i.JobID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EndedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
// step_handler.go 把 orchestrator.step 接入 jobs:每个 step 由一个 orchestrator.step
|
||||
// job 驱动,因而能跨重启恢复并复用既有 backoff/dead-letter 机制。
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/jobs"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
)
|
||||
|
||||
// JobTypeOrchestratorStep 是驱动单个编排 step 的 job 类型。
|
||||
const JobTypeOrchestratorStep jobs.JobType = "orchestrator.step"
|
||||
|
||||
// stepPayload 是 orchestrator.step job 的 payload。
|
||||
type stepPayload struct {
|
||||
RunID uuid.UUID `json:"run_id"`
|
||||
StepID uuid.UUID `json:"step_id"`
|
||||
}
|
||||
|
||||
// StepHandlerDeps 是 StepHandler 的依赖集合。
|
||||
type StepHandlerDeps struct {
|
||||
Repo Repository
|
||||
Runner *runner
|
||||
Gate PhaseGate
|
||||
PM PMAccess
|
||||
Enqueue StepEnqueuer
|
||||
DefaultAgentKind map[project.Phase]uuid.UUID
|
||||
Config Config
|
||||
Audit AuditRecorder
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
// StepHandler 实现 jobs.Handler,驱动一个阶段回合并推进/回环状态机。
|
||||
type StepHandler struct {
|
||||
repo Repository
|
||||
runner *runner
|
||||
gate PhaseGate
|
||||
pm PMAccess
|
||||
enqueue StepEnqueuer
|
||||
defaults map[project.Phase]uuid.UUID
|
||||
cfg Config
|
||||
audit AuditRecorder
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewStepHandler 构造 StepHandler。
|
||||
func NewStepHandler(d StepHandlerDeps) *StepHandler {
|
||||
log := d.Log
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &StepHandler{
|
||||
repo: d.Repo, runner: d.Runner, gate: d.Gate, pm: d.PM,
|
||||
enqueue: d.Enqueue, defaults: d.DefaultAgentKind, cfg: d.Config,
|
||||
audit: d.Audit, log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// Type 返回 job 类型。
|
||||
func (h *StepHandler) Type() jobs.JobType { return JobTypeOrchestratorStep }
|
||||
|
||||
// Handle 驱动一个 step:load step+run;run 非活跃 → no-op;spawn-or-resume 跑回合;
|
||||
// 评估网关;通过 → ChangePhase + 入队下一 step(或 done → run 成功);否则递增 attempt
|
||||
// 并返回 error(attempt 耗尽 → jobs.Permanent → dead-letter)。
|
||||
//
|
||||
// 幂等:先按 step.status 短路,避免重复 leased 时重复 spawn(worker 已有 Complete fence)。
|
||||
func (h *StepHandler) Handle(ctx context.Context, job jobs.Job) error {
|
||||
var p stepPayload
|
||||
if err := json.Unmarshal(job.Payload, &p); err != nil {
|
||||
return jobs.Permanent(errs.Wrap(err, errs.CodeInvalidInput, "orchestrator.step: bad payload"))
|
||||
}
|
||||
|
||||
step, err := h.repo.GetStep(ctx, p.StepID)
|
||||
if err != nil {
|
||||
// step 不存在:永久失败(无法恢复)。
|
||||
return jobs.Permanent(err)
|
||||
}
|
||||
// 已到终态:no-op(重复投递 / 重启重入)。
|
||||
switch step.Status {
|
||||
case StepSucceeded, StepFailed, StepDead:
|
||||
return nil
|
||||
}
|
||||
|
||||
run, err := h.repo.GetRun(ctx, step.RunID)
|
||||
if err != nil {
|
||||
return jobs.Permanent(err)
|
||||
}
|
||||
// run 已暂停/取消/终态:不驱动,完成 job(释放 worker)。
|
||||
if !run.Status.IsActive() {
|
||||
h.log.Info("orchestrator.step.skip_inactive_run",
|
||||
"run_id", run.ID, "step_id", step.ID, "run_status", run.Status)
|
||||
return nil
|
||||
}
|
||||
|
||||
// run.status pending → running(首个 step 起跑)。
|
||||
if run.Status == RunPending {
|
||||
if _, uerr := h.repo.UpdateRunStatus(ctx, run.ID, RunRunning, nil); uerr != nil {
|
||||
h.log.Warn("orchestrator.run.mark_running_failed", "run_id", run.ID, "err", uerr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// 解析需求(用于 prompt 上下文 + 阶段绑定 + ChangePhase)。
|
||||
req, err := h.pm.GetRequirementByID(ctx, run.RequirementID)
|
||||
if err != nil {
|
||||
return jobs.Permanent(err)
|
||||
}
|
||||
proj, err := h.pm.GetProjectByID(ctx, run.ProjectID)
|
||||
if err != nil {
|
||||
return jobs.Permanent(err)
|
||||
}
|
||||
|
||||
// step.prompt 为空时(首建只填了占位)补建 prompt。通常 service 建 step 时已填。
|
||||
if step.Prompt == "" {
|
||||
artifacts, _ := h.pm.ListLatestArtifacts(ctx, run.RequirementID)
|
||||
override := run.Config.PromptOverrides[step.Phase]
|
||||
step.Prompt = BuildPrompt(step.Phase, req, artifacts, override)
|
||||
}
|
||||
|
||||
if step.Status == StepAwaitingGate && step.GateResult != nil && step.GateResult.Pass {
|
||||
return h.advancePassedStep(ctx, run, step, req, proj, *step.GateResult, "")
|
||||
}
|
||||
|
||||
// 标记 spawning(关联本 job)。
|
||||
jobID := job.ID
|
||||
step.Status = StepSpawning
|
||||
step.JobID = &jobID
|
||||
if updated, uerr := h.repo.UpdateStepStatus(ctx, step); uerr == nil {
|
||||
step = updated
|
||||
}
|
||||
|
||||
// 驱动回合(spawn-or-resume + SendPromptAndWait)。
|
||||
res, terr := h.runner.driveTurn(ctx, run, step, req.Number)
|
||||
if terr != nil {
|
||||
return h.failStep(ctx, run, step, terr)
|
||||
}
|
||||
|
||||
// 记录 session + stopReason,进入 awaiting_gate。
|
||||
step.ACPSessionID = &res.SessionID
|
||||
stop := res.StopReason
|
||||
step.StopReason = &stop
|
||||
step.Status = StepAwaitingGate
|
||||
if updated, uerr := h.repo.UpdateStepStatus(ctx, step); uerr == nil {
|
||||
step = updated
|
||||
}
|
||||
|
||||
// 评估网关。
|
||||
gate, gerr := h.gate.Evaluate(ctx, run, step, res.StopReason)
|
||||
if gerr != nil {
|
||||
return h.failStep(ctx, run, step, gerr)
|
||||
}
|
||||
step.GateResult = &gate
|
||||
if !gate.Pass {
|
||||
return h.failStepGate(ctx, run, step, gate)
|
||||
}
|
||||
|
||||
// 网关通过后先停在 awaiting_gate;只有 project.ChangePhase 与 run phase 都写成功后,
|
||||
// 才能把 step 标为 succeeded 并入队下一阶段,避免绕过 project 的 entry gate。
|
||||
step.Status = StepAwaitingGate
|
||||
step.GateResult = &gate
|
||||
step.LastError = nil
|
||||
if updated, uerr := h.repo.UpdateStepStatus(ctx, step); uerr == nil {
|
||||
step = updated
|
||||
}
|
||||
return h.advancePassedStep(ctx, run, step, req, proj, gate, res.StopReason)
|
||||
}
|
||||
|
||||
func (h *StepHandler) advancePassedStep(ctx context.Context, run *Run, step *Step, req *project.Requirement, proj *project.Project, gate GateResult, stopReason string) error {
|
||||
// 推进阶段。
|
||||
next, terminal := NextPhase(step.Phase, gate)
|
||||
if terminal {
|
||||
step.Status = StepSucceeded
|
||||
step.LastError = nil
|
||||
if updated, uerr := h.repo.UpdateStepStatus(ctx, step); uerr == nil {
|
||||
step = updated
|
||||
}
|
||||
h.recordAudit(ctx, run, "orchestrator.step.advance", step.ID.String(), map[string]any{
|
||||
"phase": string(step.Phase),
|
||||
"stop_reason": stopReason,
|
||||
})
|
||||
// 整条 run 完成。
|
||||
if _, uerr := h.repo.UpdateRunStatus(ctx, run.ID, RunSucceeded, nil); uerr != nil {
|
||||
h.log.Warn("orchestrator.run.mark_succeeded_failed", "run_id", run.ID, "err", uerr.Error())
|
||||
}
|
||||
h.recordAudit(ctx, run, "orchestrator.run.succeeded", run.ID.String(), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 写需求阶段(run.owner 身份)。失败必须阻塞编排推进;project.ChangePhase 是
|
||||
// 更靠近领域状态的准入网关,不能被 orchestrator.run.current_phase 越过。
|
||||
if cerr := h.pm.ChangeRequirementPhase(ctx, run.OwnerID, false, proj.Slug, req.Number, next); cerr != nil {
|
||||
h.log.Warn("orchestrator.change_phase_failed",
|
||||
"run_id", run.ID, "to", next, "err", cerr.Error())
|
||||
return h.failPhaseAdvance(ctx, run, step, next, cerr)
|
||||
}
|
||||
if _, uerr := h.repo.UpdateRunPhase(ctx, run.ID, next); uerr != nil {
|
||||
h.log.Warn("orchestrator.run.update_phase_failed", "run_id", run.ID, "err", uerr.Error())
|
||||
return h.failPhaseAdvance(ctx, run, step, next, uerr)
|
||||
}
|
||||
|
||||
step.Status = StepSucceeded
|
||||
step.LastError = nil
|
||||
if updated, uerr := h.repo.UpdateStepStatus(ctx, step); uerr == nil {
|
||||
step = updated
|
||||
}
|
||||
h.recordAudit(ctx, run, "orchestrator.step.advance", step.ID.String(), map[string]any{
|
||||
"phase": string(step.Phase),
|
||||
"stop_reason": stopReason,
|
||||
})
|
||||
|
||||
// 建并入队下一阶段 step。
|
||||
if _, eerr := h.enqueueNextStep(ctx, run, next); eerr != nil {
|
||||
return eerr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enqueueNextStep 为 run 在 phase 上建一个新 step 并入队其 job。复用 service 的逻辑。
|
||||
func (h *StepHandler) enqueueNextStep(ctx context.Context, run *Run, phase project.Phase) (*Step, error) {
|
||||
step, err := buildPhaseStep(ctx, h.repo, h.pm, run, phase, nil, 0, h.defaults)
|
||||
if err != nil {
|
||||
// 无法为下一阶段建 step(如缺 agent kind):标 run 失败。
|
||||
msg := err.Error()
|
||||
if _, uerr := h.repo.UpdateRunStatus(ctx, run.ID, RunFailed, &msg); uerr != nil {
|
||||
h.log.Warn("orchestrator.run.mark_failed", "run_id", run.ID, "err", uerr.Error())
|
||||
}
|
||||
return nil, jobs.Permanent(err)
|
||||
}
|
||||
if eerr := h.enqueue.EnqueueStep(ctx, run.ID, step.ID, time.Now().UTC()); eerr != nil {
|
||||
return nil, eerr
|
||||
}
|
||||
return step, nil
|
||||
}
|
||||
|
||||
// failStep 处理回合驱动/网关评估的错误:递增 attempt,返回可重试或 Permanent error。
|
||||
func (h *StepHandler) failStep(ctx context.Context, run *Run, step *Step, cause error) error {
|
||||
msg := cause.Error()
|
||||
step.Status = StepRunning // 保持非终态,等待重试或 dead-letter 落定
|
||||
step.LastError = &msg
|
||||
_, _ = h.repo.UpdateStepStatus(ctx, step)
|
||||
updated, ierr := h.repo.IncrementStepAttempt(ctx, step.ID)
|
||||
if ierr == nil {
|
||||
step = updated
|
||||
}
|
||||
return h.retryOrDead(ctx, run, step, cause, false)
|
||||
}
|
||||
|
||||
// failStepGate 处理网关未通过:Retry=false → 立即 Permanent;否则递增 attempt 后判断。
|
||||
func (h *StepHandler) failStepGate(ctx context.Context, run *Run, step *Step, gate GateResult) error {
|
||||
reason := gate.Reason
|
||||
step.LastError = &reason
|
||||
step.Status = StepRunning
|
||||
_, _ = h.repo.UpdateStepStatus(ctx, step)
|
||||
cause := errs.New(errs.CodePhaseGateFailed, gate.Reason)
|
||||
if !gate.Retry {
|
||||
return h.markDead(ctx, run, step, cause)
|
||||
}
|
||||
updated, ierr := h.repo.IncrementStepAttempt(ctx, step.ID)
|
||||
if ierr == nil {
|
||||
step = updated
|
||||
}
|
||||
return h.retryOrDead(ctx, run, step, cause, true)
|
||||
}
|
||||
|
||||
func (h *StepHandler) failPhaseAdvance(ctx context.Context, run *Run, step *Step, next project.Phase, cause error) error {
|
||||
msg := cause.Error()
|
||||
step.Status = StepAwaitingGate
|
||||
step.LastError = &msg
|
||||
_, _ = h.repo.UpdateStepStatus(ctx, step)
|
||||
|
||||
if ae, ok := errs.As(cause); ok && ae.Code == errs.CodePhaseGateFailed {
|
||||
if _, uerr := h.repo.UpdateRunStatus(ctx, run.ID, RunPaused, &msg); uerr != nil {
|
||||
h.log.Warn("orchestrator.run.mark_paused_failed", "run_id", run.ID, "err", uerr.Error())
|
||||
}
|
||||
h.recordAudit(ctx, run, "orchestrator.step.phase_blocked", step.ID.String(), map[string]any{
|
||||
"phase": string(step.Phase),
|
||||
"next": string(next),
|
||||
"err": msg,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
updated, ierr := h.repo.IncrementStepAttempt(ctx, step.ID)
|
||||
if ierr == nil {
|
||||
step = updated
|
||||
}
|
||||
return h.retryOrDead(ctx, run, step, cause, false)
|
||||
}
|
||||
|
||||
// retryOrDead 根据 attempt 与 MaxAttemptsPerPhase 决定可重试还是 dead-letter。
|
||||
func (h *StepHandler) retryOrDead(ctx context.Context, run *Run, step *Step, cause error, gateFail bool) error {
|
||||
maxAttempts := run.Config.MaxAttemptsPerPhase
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = h.cfg.MaxAttemptsPerPhase
|
||||
}
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = 3
|
||||
}
|
||||
if step.Attempt >= maxAttempts {
|
||||
return h.markDead(ctx, run, step, cause)
|
||||
}
|
||||
h.recordAudit(ctx, run, "orchestrator.step.retry", step.ID.String(), map[string]any{
|
||||
"phase": string(step.Phase),
|
||||
"attempt": step.Attempt,
|
||||
"err": cause.Error(),
|
||||
"gate": gateFail,
|
||||
})
|
||||
// 返回非 Permanent error:jobs worker 走 backoff 重试,重新驱动本 step。
|
||||
return fmt.Errorf("orchestrator.step retry (attempt %d/%d): %w", step.Attempt, maxAttempts, cause)
|
||||
}
|
||||
|
||||
// markDead 把 step 标 dead、run 标 failed,返回 jobs.Permanent(dead-letter)。
|
||||
func (h *StepHandler) markDead(ctx context.Context, run *Run, step *Step, cause error) error {
|
||||
msg := cause.Error()
|
||||
step.Status = StepDead
|
||||
step.LastError = &msg
|
||||
_, _ = h.repo.UpdateStepStatus(ctx, step)
|
||||
if _, uerr := h.repo.UpdateRunStatus(ctx, run.ID, RunFailed, &msg); uerr != nil {
|
||||
h.log.Warn("orchestrator.run.mark_failed", "run_id", run.ID, "err", uerr.Error())
|
||||
}
|
||||
h.recordAudit(ctx, run, "orchestrator.step.dead_letter", step.ID.String(), map[string]any{
|
||||
"phase": string(step.Phase),
|
||||
"err": msg,
|
||||
})
|
||||
return jobs.Permanent(cause)
|
||||
}
|
||||
|
||||
func (h *StepHandler) recordAudit(ctx context.Context, run *Run, action, targetID string, meta map[string]any) {
|
||||
if h.audit == nil {
|
||||
return
|
||||
}
|
||||
uid := run.OwnerID
|
||||
h.audit.Record(ctx, uid, action, targetID, meta)
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/acp"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/jobs"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
)
|
||||
|
||||
type stepHandlerHarness struct {
|
||||
repo *fakeRepo
|
||||
pm *fakePM
|
||||
turns *fakeTurnRunner
|
||||
sess *fakeACPSessions
|
||||
lookup *fakeLookup
|
||||
jobs *fakeJobs
|
||||
audit *fakeAudit
|
||||
handler *StepHandler
|
||||
run *Run
|
||||
}
|
||||
|
||||
func newStepHandlerHarness(t *testing.T, gate PhaseGate, startPhase project.Phase, maxAttempts int) *stepHandlerHarness {
|
||||
t.Helper()
|
||||
repo := newFakeRepo()
|
||||
wsID := uuid.New()
|
||||
akID := uuid.New()
|
||||
pm := &fakePM{
|
||||
proj: &project.Project{ID: uuid.New(), Slug: "demo"},
|
||||
req: &project.Requirement{ID: uuid.New(), Number: 3, Title: "T", WorkspaceID: &wsID, Phase: startPhase, Status: project.StatusOpen},
|
||||
}
|
||||
run := &Run{
|
||||
ID: uuid.New(), ProjectID: pm.proj.ID, RequirementID: pm.req.ID,
|
||||
WorkspaceID: wsID, OwnerID: uuid.New(), Status: RunRunning,
|
||||
CurrentPhase: startPhase,
|
||||
Config: RunConfig{MaxAttemptsPerPhase: maxAttempts, PhaseAgentKinds: allPhaseKinds(akID)},
|
||||
}
|
||||
repo.runs[run.ID] = run
|
||||
|
||||
turns := &fakeTurnRunner{stopReason: "end_turn"}
|
||||
sess := &fakeACPSessions{}
|
||||
lookup := newFakeLookup()
|
||||
jq := &fakeJobs{}
|
||||
audit := &fakeAudit{}
|
||||
|
||||
svc := NewService(ServiceDeps{
|
||||
Repo: repo, PM: pm, ACL: fakeACL{allow: true}, Jobs: jq,
|
||||
Defaults: allPhaseKinds(akID), Config: Config{MaxAttemptsPerPhase: maxAttempts}, Audit: audit,
|
||||
})
|
||||
|
||||
h := NewStepHandler(StepHandlerDeps{
|
||||
Repo: repo,
|
||||
Runner: NewRunner(sess, turns, lookup, time.Minute),
|
||||
Gate: gate,
|
||||
PM: pm,
|
||||
Enqueue: svc.(StepEnqueuer),
|
||||
DefaultAgentKind: allPhaseKinds(akID),
|
||||
Config: Config{MaxAttemptsPerPhase: maxAttempts},
|
||||
Audit: audit,
|
||||
})
|
||||
|
||||
return &stepHandlerHarness{repo: repo, pm: pm, turns: turns, sess: sess, lookup: lookup, jobs: jq, audit: audit, handler: h, run: run}
|
||||
}
|
||||
|
||||
func allPhaseKinds(akID uuid.UUID) map[project.Phase]uuid.UUID {
|
||||
m := map[project.Phase]uuid.UUID{}
|
||||
for _, p := range project.AllPhases {
|
||||
m[p] = akID
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (h *stepHandlerHarness) seedStep(phase project.Phase) *Step {
|
||||
step := &Step{
|
||||
ID: uuid.New(), RunID: h.run.ID, Phase: phase, Attempt: 1,
|
||||
Status: StepPending, AgentKindID: h.run.Config.PhaseAgentKinds[phase], Prompt: "do it",
|
||||
}
|
||||
h.repo.steps[step.ID] = step
|
||||
return step
|
||||
}
|
||||
|
||||
func jobFor(step *Step) jobs.Job {
|
||||
payload, _ := json.Marshal(stepPayload{RunID: step.RunID, StepID: step.ID})
|
||||
return jobs.Job{ID: uuid.New(), Type: JobTypeOrchestratorStep, Payload: payload}
|
||||
}
|
||||
|
||||
func TestStepHandler_HappyAdvancesAndEnqueuesNext(t *testing.T) {
|
||||
h := newStepHandlerHarness(t, fakeGate{result: GateResult{Pass: true}}, project.PhasePlanning, 3)
|
||||
step := h.seedStep(project.PhasePlanning)
|
||||
|
||||
if err := h.handler.Handle(context.Background(), jobFor(step)); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
got, _ := h.repo.GetStep(context.Background(), step.ID)
|
||||
if got.Status != StepSucceeded {
|
||||
t.Errorf("step status = %s; want succeeded", got.Status)
|
||||
}
|
||||
if h.jobs.count() != 1 {
|
||||
t.Errorf("expected 1 next-step enqueued; got %d", h.jobs.count())
|
||||
}
|
||||
if len(h.pm.phaseWrites) != 1 || h.pm.phaseWrites[0] != project.PhasePrototyping {
|
||||
t.Errorf("expected phase advance to prototyping; got %v", h.pm.phaseWrites)
|
||||
}
|
||||
if !h.audit.has("orchestrator.step.advance") {
|
||||
t.Error("expected step.advance audit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepHandler_PhaseGateFailurePausesWithoutAdvancing(t *testing.T) {
|
||||
h := newStepHandlerHarness(t, fakeGate{result: GateResult{Pass: true}}, project.PhaseAuditing, 3)
|
||||
h.pm.phaseErr = errs.New(errs.CodePhaseGateFailed, "audit artifact not approved")
|
||||
step := h.seedStep(project.PhaseAuditing)
|
||||
|
||||
if err := h.handler.Handle(context.Background(), jobFor(step)); err != nil {
|
||||
t.Fatalf("Handle should complete job and pause run on external phase gate; got %v", err)
|
||||
}
|
||||
got, _ := h.repo.GetStep(context.Background(), step.ID)
|
||||
if got.Status != StepAwaitingGate {
|
||||
t.Errorf("step status = %s; want awaiting_gate", got.Status)
|
||||
}
|
||||
if got.Attempt != 1 {
|
||||
t.Errorf("phase gate pause must not consume agent attempt; got %d", got.Attempt)
|
||||
}
|
||||
gotRun, _ := h.repo.GetRun(context.Background(), h.run.ID)
|
||||
if gotRun.Status != RunPaused {
|
||||
t.Errorf("run status = %s; want paused", gotRun.Status)
|
||||
}
|
||||
if gotRun.CurrentPhase != project.PhaseAuditing {
|
||||
t.Errorf("run phase = %s; want auditing", gotRun.CurrentPhase)
|
||||
}
|
||||
if h.pm.req.Phase != project.PhaseAuditing {
|
||||
t.Errorf("requirement phase = %s; want auditing", h.pm.req.Phase)
|
||||
}
|
||||
if h.jobs.count() != 0 {
|
||||
t.Errorf("next step must not be enqueued when project gate blocks; got %d", h.jobs.count())
|
||||
}
|
||||
if !h.audit.has("orchestrator.step.phase_blocked") {
|
||||
t.Error("expected phase_blocked audit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepHandler_PhaseWriteFailureRetriesWithoutAdvancing(t *testing.T) {
|
||||
h := newStepHandlerHarness(t, fakeGate{result: GateResult{Pass: true}}, project.PhasePlanning, 3)
|
||||
h.pm.phaseErr = errors.New("db temporarily unavailable")
|
||||
step := h.seedStep(project.PhasePlanning)
|
||||
|
||||
err := h.handler.Handle(context.Background(), jobFor(step))
|
||||
if err == nil {
|
||||
t.Fatal("expected retryable error")
|
||||
}
|
||||
if jobs.IsPermanent(err) {
|
||||
t.Fatalf("expected retryable error; got permanent: %v", err)
|
||||
}
|
||||
got, _ := h.repo.GetStep(context.Background(), step.ID)
|
||||
if got.Status != StepAwaitingGate {
|
||||
t.Errorf("step status = %s; want awaiting_gate", got.Status)
|
||||
}
|
||||
if got.Attempt != 2 {
|
||||
t.Errorf("attempt = %d; want 2", got.Attempt)
|
||||
}
|
||||
gotRun, _ := h.repo.GetRun(context.Background(), h.run.ID)
|
||||
if gotRun.CurrentPhase != project.PhasePlanning {
|
||||
t.Errorf("run phase = %s; want planning", gotRun.CurrentPhase)
|
||||
}
|
||||
if h.jobs.count() != 0 {
|
||||
t.Errorf("next step must not be enqueued after failed phase write; got %d", h.jobs.count())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepHandler_AwaitingGateResumeDoesNotRerunAgent(t *testing.T) {
|
||||
h := newStepHandlerHarness(t, fakeGate{result: GateResult{Pass: true}}, project.PhasePlanning, 3)
|
||||
step := h.seedStep(project.PhasePlanning)
|
||||
gate := GateResult{Pass: true, Reason: "ready"}
|
||||
step.Status = StepAwaitingGate
|
||||
step.GateResult = &gate
|
||||
h.repo.steps[step.ID] = step
|
||||
|
||||
if err := h.handler.Handle(context.Background(), jobFor(step)); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
if h.turns.calls != 0 {
|
||||
t.Errorf("awaiting gate resume should not rerun agent; calls=%d", h.turns.calls)
|
||||
}
|
||||
if len(h.sess.created) != 0 {
|
||||
t.Errorf("awaiting gate resume should not create session; got %d", len(h.sess.created))
|
||||
}
|
||||
if h.jobs.count() != 1 {
|
||||
t.Errorf("expected next step enqueued after phase write succeeds; got %d", h.jobs.count())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepHandler_GateRetryReturnsRetryableError(t *testing.T) {
|
||||
h := newStepHandlerHarness(t, fakeGate{result: GateResult{Pass: false, Retry: true, Reason: "no artifact"}}, project.PhasePlanning, 3)
|
||||
step := h.seedStep(project.PhasePlanning)
|
||||
|
||||
err := h.handler.Handle(context.Background(), jobFor(step))
|
||||
if err == nil {
|
||||
t.Fatal("expected retryable error")
|
||||
}
|
||||
if jobs.IsPermanent(err) {
|
||||
t.Errorf("expected non-permanent error; got permanent: %v", err)
|
||||
}
|
||||
got, _ := h.repo.GetStep(context.Background(), step.ID)
|
||||
if got.Attempt != 2 {
|
||||
t.Errorf("attempt = %d; want 2", got.Attempt)
|
||||
}
|
||||
if h.jobs.count() != 0 {
|
||||
t.Errorf("no next step should be enqueued on retry; got %d", h.jobs.count())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepHandler_AttemptsExhaustedDeadLetters(t *testing.T) {
|
||||
h := newStepHandlerHarness(t, fakeGate{result: GateResult{Pass: false, Retry: true}}, project.PhasePlanning, 1)
|
||||
step := h.seedStep(project.PhasePlanning) // attempt already 1 == max
|
||||
|
||||
err := h.handler.Handle(context.Background(), jobFor(step))
|
||||
if !jobs.IsPermanent(err) {
|
||||
t.Fatalf("expected permanent error; got %v", err)
|
||||
}
|
||||
got, _ := h.repo.GetStep(context.Background(), step.ID)
|
||||
if got.Status != StepDead {
|
||||
t.Errorf("step status = %s; want dead", got.Status)
|
||||
}
|
||||
gotRun, _ := h.repo.GetRun(context.Background(), h.run.ID)
|
||||
if gotRun.Status != RunFailed {
|
||||
t.Errorf("run status = %s; want failed", gotRun.Status)
|
||||
}
|
||||
if !h.audit.has("orchestrator.step.dead_letter") {
|
||||
t.Error("expected dead_letter audit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepHandler_GateNoRetryImmediateDeadLetter(t *testing.T) {
|
||||
h := newStepHandlerHarness(t, fakeGate{result: GateResult{Pass: false, Retry: false, Reason: "refusal"}}, project.PhasePlanning, 5)
|
||||
step := h.seedStep(project.PhasePlanning)
|
||||
|
||||
err := h.handler.Handle(context.Background(), jobFor(step))
|
||||
if !jobs.IsPermanent(err) {
|
||||
t.Fatalf("expected permanent error on no-retry gate; got %v", err)
|
||||
}
|
||||
got, _ := h.repo.GetStep(context.Background(), step.ID)
|
||||
if got.Status != StepDead {
|
||||
t.Errorf("step status = %s; want dead", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepHandler_PausedRunNoOp(t *testing.T) {
|
||||
h := newStepHandlerHarness(t, fakeGate{result: GateResult{Pass: true}}, project.PhasePlanning, 3)
|
||||
h.run.Status = RunPaused
|
||||
h.repo.runs[h.run.ID].Status = RunPaused
|
||||
step := h.seedStep(project.PhasePlanning)
|
||||
|
||||
if err := h.handler.Handle(context.Background(), jobFor(step)); err != nil {
|
||||
t.Fatalf("Handle should no-op complete; got %v", err)
|
||||
}
|
||||
if h.turns.calls != 0 {
|
||||
t.Errorf("paused run should not drive a turn; calls=%d", h.turns.calls)
|
||||
}
|
||||
if len(h.sess.created) != 0 {
|
||||
t.Errorf("paused run should not spawn a session; got %d", len(h.sess.created))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepHandler_DoneMarksRunSucceeded(t *testing.T) {
|
||||
h := newStepHandlerHarness(t, fakeGate{result: GateResult{Pass: true}}, project.PhaseDone, 3)
|
||||
step := h.seedStep(project.PhaseDone)
|
||||
|
||||
if err := h.handler.Handle(context.Background(), jobFor(step)); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
gotRun, _ := h.repo.GetRun(context.Background(), h.run.ID)
|
||||
if gotRun.Status != RunSucceeded {
|
||||
t.Errorf("run status = %s; want succeeded", gotRun.Status)
|
||||
}
|
||||
if h.jobs.count() != 0 {
|
||||
t.Errorf("done should enqueue nothing; got %d", h.jobs.count())
|
||||
}
|
||||
if !h.audit.has("orchestrator.run.succeeded") {
|
||||
t.Error("expected run.succeeded audit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepHandler_ResumesCrashedSession(t *testing.T) {
|
||||
h := newStepHandlerHarness(t, fakeGate{result: GateResult{Pass: true}}, project.PhasePlanning, 3)
|
||||
step := h.seedStep(project.PhasePlanning)
|
||||
// 该 step 已有崩溃 session:driveTurn 应走 resume,而非 Create。
|
||||
crashedID := uuid.New()
|
||||
h.lookup.crashed[step.ID] = &acp.Session{ID: crashedID, Status: acp.SessionCrashed}
|
||||
|
||||
if err := h.handler.Handle(context.Background(), jobFor(step)); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
if len(h.sess.created) != 0 {
|
||||
t.Errorf("should resume not create; created=%d", len(h.sess.created))
|
||||
}
|
||||
got, _ := h.repo.GetStep(context.Background(), step.ID)
|
||||
if got.ACPSessionID == nil || *got.ACPSessionID != crashedID {
|
||||
t.Errorf("step should reference resumed session %s; got %v", crashedID, got.ACPSessionID)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user