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