This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+17
View File
@@ -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
}
+163
View File
@@ -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
}
+130
View File
@@ -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)
}
+159
View File
@@ -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)
}
+412
View File
@@ -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
}
+104
View File
@@ -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"
)
+111
View File
@@ -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)
}
}
+239
View File
@@ -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)
}
+56
View File
@@ -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)
}
+37
View File
@@ -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
}
+135
View File
@@ -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."
}
}
+110
View File
@@ -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
}
+26
View File
@@ -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
}
+56
View File
@@ -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;
+77
View File
@@ -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;
+381
View File
@@ -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)
}
+107
View File
@@ -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
}
+184
View File
@@ -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
}
+209
View File
@@ -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 不重复派发")
}
+500
View File
@@ -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
}
+263
View File
@@ -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)
}
}
+32
View File
@@ -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,
}
}
+593
View File
@@ -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"`
}
+253
View File
@@ -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
}
+356
View File
@@ -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
}
+341
View File
@@ -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)
}
+307
View File
@@ -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)
}
}