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) } }