Files
2026-06-22 08:55:57 +08:00

308 lines
11 KiB
Go

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