You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user