You've already forked agentic-coding-workflow
264 lines
9.4 KiB
Go
264 lines
9.4 KiB
Go
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)
|
|
}
|
|
}
|