From f85b0d2ffe99328336d6ebf99226dad8e17dc3be Mon Sep 17 00:00:00 2001 From: neversettle <41864816+neversettle17-101@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:40:00 +0530 Subject: [PATCH] Refactoring Reviews feature code and idempotency check (#377) * fix: dedupe AO review change nudges * fix: satisfy review delivery lint * fix: renumber review delivery migration * fix: gate review trigger idempotency on verdict * fix: preserve running review trigger idempotency * fix: fail stale running review runs * Update review.go * fix: avoid review context import ambiguity * docs: clarify defensive review idempotency branch * fix: keep review submit persistence single-sourced * fix: remove unused review nudge send result * fix: preserve existing pr review nudge copy * fix: simplify merge conflict nudge return --- backend/internal/daemon/lifecycle_wiring.go | 6 +- backend/internal/domain/review.go | 12 +- backend/internal/httpd/apispec/openapi.yaml | 5 + backend/internal/lifecycle/manager_test.go | 97 ++++++- backend/internal/lifecycle/reactions.go | 63 +++++ backend/internal/review/review.go | 174 ++++-------- backend/internal/review/review_test.go | 247 +++++++----------- backend/internal/service/review/review.go | 126 ++++++++- .../internal/service/review/review_test.go | 131 ++++++++++ backend/internal/storage/sqlite/gen/models.go | 1 + .../internal/storage/sqlite/gen/review.sql.go | 62 ++++- .../0018_review_run_delivered_at.sql | 14 + .../storage/sqlite/queries/review.sql | 15 +- .../storage/sqlite/store/review_store.go | 49 ++++ frontend/src/api/schema.ts | 2 + 15 files changed, 712 insertions(+), 292 deletions(-) create mode 100644 backend/internal/service/review/review_test.go create mode 100644 backend/internal/storage/sqlite/migrations/0018_review_run_delivered_at.sql diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index 8bc179634..3bf5ff7d6 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -121,12 +121,8 @@ func startSession(cfg config.Config, runtime *zellij.Runtime, store *sqlite.Stor PRs: store, Projects: store, Launcher: reviewcore.NewLauncher(reviewers, runtime), - // On a changes_requested verdict the engine nudges the worker's live pane - // directly, using the same per-daemon messenger lifecycle uses for SCM - // nudges (issue #337). - Messenger: messenger, }) - reviewSvc := reviewsvc.New(reviewEngine) + reviewSvc := reviewsvc.New(reviewEngine, store, reviewsvc.WithLifecycleReducer(lcm)) return sessionSvc, reviewSvc, nil } diff --git a/backend/internal/domain/review.go b/backend/internal/domain/review.go index 6ed840d3d..6750a16d2 100644 --- a/backend/internal/domain/review.go +++ b/backend/internal/domain/review.go @@ -46,8 +46,9 @@ type ReviewRun struct { // submit time. It is empty when the reviewer could not post to the provider. // When the pass requests changes, AO includes it in the message to the // worker so the worker knows exactly which review to address and reply to. - GithubReviewID string `json:"githubReviewId"` - CreatedAt time.Time `json:"createdAt"` + GithubReviewID string `json:"githubReviewId"` + CreatedAt time.Time `json:"createdAt"` + DeliveredAt *time.Time `json:"deliveredAt,omitempty"` } // ReviewRunStatus is the lifecycle state of a single review pass. @@ -55,9 +56,10 @@ type ReviewRunStatus string // Review run statuses. const ( - ReviewRunRunning ReviewRunStatus = "running" - ReviewRunComplete ReviewRunStatus = "complete" - ReviewRunFailed ReviewRunStatus = "failed" + ReviewRunRunning ReviewRunStatus = "running" + ReviewRunComplete ReviewRunStatus = "complete" + ReviewRunDelivered ReviewRunStatus = "delivered" + ReviewRunFailed ReviewRunStatus = "failed" ) // ReviewVerdict is the outcome a reviewer reports. The empty verdict marks a diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index bc388206b..95ee8c811 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -1897,6 +1897,11 @@ components: createdAt: format: date-time type: string + deliveredAt: + format: date-time + type: + - "null" + - string githubReviewId: type: string harness: diff --git a/backend/internal/lifecycle/manager_test.go b/backend/internal/lifecycle/manager_test.go index e76377a21..4ccbe21de 100644 --- a/backend/internal/lifecycle/manager_test.go +++ b/backend/internal/lifecycle/manager_test.go @@ -222,7 +222,7 @@ func TestPRObservation_CIFailingNudgesAgentWithLogs(t *testing.T) { func TestPRObservation_ReviewCommentsNudgeAgent(t *testing.T) { m, st, msg := newManager() st.sessions["mer-1"] = working("mer-1") - o := ports.PRObservation{Fetched: true, URL: "pr1", Review: domain.ReviewChangesRequest, Comments: []ports.PRCommentObservation{{ID: "1", Body: "fix this"}}} + o := ports.PRObservation{Fetched: true, URL: "pr1", Review: domain.ReviewChangesRequest, Comments: []ports.PRCommentObservation{{ID: "1", Author: "alice", Body: "fix this"}}} if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil { t.Fatal(err) } @@ -520,6 +520,101 @@ func TestPRObservation_DedupPersistsAcrossPRs(t *testing.T) { } } +func TestApplyReviewResultSendsAndDedupsThroughPRSignature(t *testing.T) { + st := newFakeStore() + st.sessions["mer-1"] = working("mer-1") + msg := &fakeMessenger{} + m := New(st, msg) + result := ReviewResult{ + RunID: "run-1", + WorkerID: "mer-1", + PRURL: "https://github.com/o/r/pull/1", + TargetSHA: "sha1", + Verdict: domain.VerdictChangesRequested, + Body: "fix the bug", + GithubReviewID: "98\x1b[2J765", + } + + outcome, err := m.ApplyReviewResult(ctx, "mer-1", result) + if err != nil { + t.Fatalf("ApplyReviewResult: %v", err) + } + if outcome != ReviewDeliverySent || len(msg.msgs) != 1 { + t.Fatalf("outcome/messages = %q/%v, want sent once", outcome, msg.msgs) + } + got := msg.msgs[0] + if !strings.Contains(got, "[AO reviewer]") || !strings.Contains(got, "fix the bug") || !strings.Contains(got, "98[2J765") { + t.Fatalf("AO review nudge missing label/body/review id: %q", got) + } + if strings.Contains(got, "\x1b") { + t.Fatalf("AO review nudge should sanitize control bytes: %q", got) + } + if st.signatures[result.PRURL] == "" { + t.Fatal("AO review nudge did not persist sendOnce signature") + } + + outcome, err = m.ApplyReviewResult(ctx, "mer-1", result) + if err != nil { + t.Fatalf("repeat ApplyReviewResult: %v", err) + } + if outcome != ReviewDeliverySent || len(msg.msgs) != 1 { + t.Fatalf("repeat should report delivered outcome and suppress duplicate send, outcome=%q msgs=%v", outcome, msg.msgs) + } + + result.RunID = "run-2" + result.TargetSHA = "sha2" + outcome, err = m.ApplyReviewResult(ctx, "mer-1", result) + if err != nil { + t.Fatalf("new pass ApplyReviewResult: %v", err) + } + if outcome != ReviewDeliverySent || len(msg.msgs) != 2 { + t.Fatalf("new review pass should send again, outcome=%q msgs=%v", outcome, msg.msgs) + } +} + +func TestApplyReviewResultNoopsWhenIrrelevant(t *testing.T) { + deliveredAt := time.Unix(100, 0).UTC() + tests := []struct { + name string + result ReviewResult + rec domain.SessionRecord + }{ + { + name: "approved", + result: ReviewResult{RunID: "run-1", PRURL: "pr1", Verdict: domain.VerdictApproved}, + rec: working("mer-1"), + }, + { + name: "already delivered", + result: ReviewResult{RunID: "run-1", PRURL: "pr1", Verdict: domain.VerdictChangesRequested, DeliveredAt: &deliveredAt}, + rec: working("mer-1"), + }, + { + name: "terminated worker", + result: ReviewResult{RunID: "run-1", PRURL: "pr1", Verdict: domain.VerdictChangesRequested}, + rec: func() domain.SessionRecord { r := working("mer-1"); r.IsTerminated = true; return r }(), + }, + { + name: "worker waiting input", + result: ReviewResult{RunID: "run-1", PRURL: "pr1", Verdict: domain.VerdictChangesRequested}, + rec: domain.SessionRecord{ID: "mer-1", Activity: domain.Activity{State: domain.ActivityWaitingInput}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m, st, msg := newManager() + st.sessions["mer-1"] = tt.rec + outcome, err := m.ApplyReviewResult(ctx, "mer-1", tt.result) + if err != nil { + t.Fatalf("ApplyReviewResult: %v", err) + } + if outcome != ReviewDeliveryNoop || len(msg.msgs) != 0 || st.signatureWrites != 0 { + t.Fatalf("irrelevant result should no-op, outcome=%q msgs=%v signatureWrites=%d", outcome, msg.msgs, st.signatureWrites) + } + }) + } +} + func TestApplyTrackerFacts_TerminalStateMarksTerminated(t *testing.T) { for _, state := range []domain.NormalizedIssueState{domain.IssueDone, domain.IssueCancelled} { t.Run(string(state), func(t *testing.T) { diff --git a/backend/internal/lifecycle/reactions.go b/backend/internal/lifecycle/reactions.go index 851f11c00..526119568 100644 --- a/backend/internal/lifecycle/reactions.go +++ b/backend/internal/lifecycle/reactions.go @@ -3,9 +3,11 @@ package lifecycle import ( "context" "encoding/json" + "fmt" "log/slog" "strings" "sync" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" @@ -13,6 +15,33 @@ import ( const reviewMaxNudge = 3 +// ReviewDeliveryOutcome reports what ApplyReviewResult did with a completed +// AO-internal review pass. +type ReviewDeliveryOutcome string + +const ( + // ReviewDeliveryNoop means lifecycle did not send or confirm a review nudge + // because the result was not relevant for delivery. + ReviewDeliveryNoop ReviewDeliveryOutcome = "no_op" + // ReviewDeliverySent means the worker nudge was sent or was already covered + // by sendOnce dedup state and may be stamped delivered. + ReviewDeliverySent ReviewDeliveryOutcome = "sent" +) + +// ReviewResult is the already-persisted result of an AO-internal review pass. +// Lifecycle treats it as input to the reaction reducer; it does not write the +// review_run row. +type ReviewResult struct { + RunID string + WorkerID domain.SessionID + PRURL string + TargetSHA string + Verdict domain.ReviewVerdict + Body string + GithubReviewID string + DeliveredAt *time.Time +} + type reactionState struct { mu sync.Mutex seen map[string]string @@ -108,6 +137,40 @@ func (m *Manager) ApplyPRObservation(ctx context.Context, id domain.SessionID, o return nil } +// ApplyReviewResult reacts to a completed AO-internal review pass after the +// review service has persisted the run result. It mirrors ApplyPRObservation: +// no change_log reads, no review_run writes, only lifecycle side effects. +func (m *Manager) ApplyReviewResult(ctx context.Context, workerID domain.SessionID, r ReviewResult) (ReviewDeliveryOutcome, error) { + if r.Verdict != domain.VerdictChangesRequested || r.DeliveredAt != nil { + return ReviewDeliveryNoop, nil + } + rec, ok, err := m.store.GetSession(ctx, workerID) + if err != nil || !ok { + return ReviewDeliveryNoop, err + } + if rec.IsTerminated || rec.Activity.State == domain.ActivityWaitingInput { + return ReviewDeliveryNoop, nil + } + if m.messenger == nil { + return ReviewDeliveryNoop, nil + } + msg := "[AO reviewer] AO's internal code reviewer requested changes on your PR. Review the feedback below and address it." + if r.GithubReviewID != "" { + safeReviewID := domain.SanitizeControlChars(r.GithubReviewID) + msg += fmt.Sprintf(" This feedback is GitHub review %s. Once you have addressed it, reply on that review referencing id %s with how you addressed it, then resolve the review comment threads you addressed.", safeReviewID, safeReviewID) + } + if r.Body != "" { + msg += "\n\n" + domain.SanitizeControlChars(r.Body) + } + key := "review:" + r.PRURL + ":ao:" + r.RunID + sig := strings.Join([]string{r.TargetSHA, r.RunID, r.GithubReviewID, r.Body}, "\x00") + err = m.sendOnce(ctx, workerID, r.PRURL, key, sig, msg, reviewMaxNudge) + if err != nil { + return ReviewDeliveryNoop, err + } + return ReviewDeliverySent, nil +} + // sessionComplete reports whether the session has reached the multi-PR // completion bar: at least one PR merged and no PR still open. A session with no // PRs, or with any open PR, is not complete. diff --git a/backend/internal/review/review.go b/backend/internal/review/review.go index 4c8c812c1..1b9816402 100644 --- a/backend/internal/review/review.go +++ b/backend/internal/review/review.go @@ -9,7 +9,7 @@ package review import ( - "context" + stdctx "context" "errors" "fmt" "sync" @@ -18,7 +18,6 @@ import ( "github.com/google/uuid" "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) // ErrInvalid and ErrNotFound let the transport layer map failures to 422/404. @@ -30,38 +29,39 @@ var ( // Store is the persistence surface the engine needs. *sqlite.Store satisfies it // in production; tests use a fake. type Store interface { - UpsertReview(ctx context.Context, r domain.Review) error - GetReviewBySession(ctx context.Context, id domain.SessionID) (domain.Review, bool, error) - InsertReviewRun(ctx context.Context, r domain.ReviewRun) error - UpdateReviewRunResult(ctx context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body, githubReviewID string) (bool, error) - GetReviewRun(ctx context.Context, id string) (domain.ReviewRun, bool, error) - GetReviewRunBySessionAndSHA(ctx context.Context, id domain.SessionID, targetSHA string) (domain.ReviewRun, bool, error) - ListReviewRunsBySession(ctx context.Context, id domain.SessionID) ([]domain.ReviewRun, error) + UpsertReview(ctx stdctx.Context, r domain.Review) error + GetReviewBySession(ctx stdctx.Context, id domain.SessionID) (domain.Review, bool, error) + InsertReviewRun(ctx stdctx.Context, r domain.ReviewRun) error + UpdateReviewRunResult(ctx stdctx.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body, githubReviewID string) (bool, error) + SupersedeReviewRun(ctx stdctx.Context, id, body string) (bool, error) + SupersedeStaleRunningReviewRuns(ctx stdctx.Context, sessionID domain.SessionID, targetSHA, body string) (int64, error) + GetReviewRun(ctx stdctx.Context, id string) (domain.ReviewRun, bool, error) + GetReviewRunBySessionAndSHA(ctx stdctx.Context, id domain.SessionID, targetSHA string) (domain.ReviewRun, bool, error) + ListReviewRunsBySession(ctx stdctx.Context, id domain.SessionID) ([]domain.ReviewRun, error) } // Sessions resolves the worker session under review. type Sessions interface { - GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) + GetSession(ctx stdctx.Context, id domain.SessionID) (domain.SessionRecord, bool, error) } // PRs resolves the PR a worker owns. type PRs interface { - ListPRsBySession(ctx context.Context, id domain.SessionID) ([]domain.PullRequest, error) + ListPRsBySession(ctx stdctx.Context, id domain.SessionID) ([]domain.PullRequest, error) } // Projects resolves the per-project reviewer config. type Projects interface { - GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error) + GetProject(ctx stdctx.Context, id string) (domain.ProjectRecord, bool, error) } // Deps wires the engine. type Deps struct { - Store Store - Sessions Sessions - PRs PRs - Projects Projects - Launcher Launcher - Messenger ports.AgentMessenger + Store Store + Sessions Sessions + PRs PRs + Projects Projects + Launcher Launcher // Clock and NewID are injectable for deterministic tests. Clock func() time.Time @@ -70,14 +70,13 @@ type Deps struct { // Engine is the core code-review engine. type Engine struct { - store Store - sessions Sessions - prs PRs - projects Projects - launcher Launcher - messenger ports.AgentMessenger - clock func() time.Time - newID func() string + store Store + sessions Sessions + prs PRs + projects Projects + launcher Launcher + clock func() time.Time + newID func() string // triggerMu guards triggerLocks; triggerLocks holds one mutex per worker // session so concurrent Trigger calls for the same worker serialise (see @@ -102,7 +101,6 @@ func New(d Deps) *Engine { prs: d.PRs, projects: d.Projects, launcher: d.Launcher, - messenger: d.Messenger, clock: clock, newID: newID, triggerLocks: make(map[domain.SessionID]*sync.Mutex), @@ -151,7 +149,7 @@ type SessionReviews struct { // new commit; if not, a fresh reviewer is spawned; // - the run is recorded before launch so startup failures leave a visible // failed pass instead of an empty gap. -func (e *Engine) Trigger(ctx context.Context, workerID domain.SessionID) (TriggerResult, error) { +func (e *Engine) Trigger(ctx stdctx.Context, workerID domain.SessionID) (TriggerResult, error) { if workerID == "" { return TriggerResult{}, fmt.Errorf("%w: worker session id is required", ErrInvalid) } @@ -188,12 +186,29 @@ func (e *Engine) Trigger(ctx context.Context, workerID domain.SessionID) (Trigge return TriggerResult{}, err } - // Idempotency: return a non-failed pass as-is. Failed passes stay visible - // but can be retried after the user fixes the underlying issue. + // Idempotency: a pass for this commit is reusable while it is still running + // or once it carries a verdict. The fallback branch below is defensive for + // any non-running, non-failed row that somehow lacks a verdict; normal + // Submit paths complete a run only with a valid verdict (#342). if existing, ok, err := e.store.GetReviewRunBySessionAndSHA(ctx, workerID, targetSHA); err != nil { return TriggerResult{}, err - } else if ok && existing.Status != domain.ReviewRunFailed { + } else if ok && (existing.Status == domain.ReviewRunRunning || existing.Verdict != domain.VerdictNone) { return TriggerResult{Run: existing, ReviewerHandleID: review.ReviewerHandleID, Created: false}, nil + } else if ok && existing.Status != domain.ReviewRunFailed { + superseded, err := e.store.SupersedeReviewRun(ctx, existing.ID, "superseded by a new review trigger") + if err != nil { + return TriggerResult{}, err + } + if !superseded { + if latest, ok, err := e.store.GetReviewRun(ctx, existing.ID); err != nil { + return TriggerResult{}, err + } else if ok { + return TriggerResult{Run: latest, ReviewerHandleID: review.ReviewerHandleID, Created: false}, nil + } + } + } + if _, err := e.store.SupersedeStaleRunningReviewRuns(ctx, workerID, targetSHA, "superseded by a review trigger for a newer commit"); err != nil { + return TriggerResult{}, err } harness, err := e.reviewerHarness(ctx, worker) @@ -276,97 +291,8 @@ func (e *Engine) Trigger(ctx context.Context, workerID domain.SessionID) (Trigge return TriggerResult{Run: run, ReviewerHandleID: handleID, Created: true}, nil } -// Submit records the reviewer's result for a specific worker review pass: it -// marks the run complete and stores the verdict, body, and the GitHub review id -// the reviewer posted. AO does not post the review — the reviewer agent posts it -// to the PR itself. -// -// On a changes_requested verdict, Submit also messages the worker session with -// the review feedback directly, so the worker learns about it event-driven -// rather than via the SCM poll loop (which never observes CHANGES_REQUESTED for -// self-reviews or COMMENT-state reviews; issue #337). When a GitHub review id is -// known, it is included so the worker knows exactly which review to address and -// reply to. -func (e *Engine) Submit(ctx context.Context, workerID domain.SessionID, runID string, verdict domain.ReviewVerdict, body, githubReviewID string) (domain.ReviewRun, error) { - if workerID == "" { - return domain.ReviewRun{}, fmt.Errorf("%w: worker session id is required", ErrInvalid) - } - if runID == "" { - return domain.ReviewRun{}, fmt.Errorf("%w: review run id is required", ErrInvalid) - } - if !verdict.Valid() { - return domain.ReviewRun{}, fmt.Errorf("%w: verdict must be %q or %q", ErrInvalid, domain.VerdictApproved, domain.VerdictChangesRequested) - } - if verdict == domain.VerdictChangesRequested && body == "" { - return domain.ReviewRun{}, fmt.Errorf("%w: a changes_requested review requires a body", ErrInvalid) - } - - run, ok, err := e.store.GetReviewRun(ctx, runID) - if err != nil { - return domain.ReviewRun{}, err - } - if !ok { - return domain.ReviewRun{}, fmt.Errorf("%w: review run %q", ErrNotFound, runID) - } - if run.SessionID != workerID { - return domain.ReviewRun{}, fmt.Errorf("%w: review run %q does not belong to worker %q", ErrInvalid, runID, workerID) - } - if run.Status != domain.ReviewRunRunning { - return domain.ReviewRun{}, fmt.Errorf("%w: review run %q is not running", ErrInvalid, runID) - } - - // Notify the worker before marking the run complete. If the message fails, - // the run stays 'running' so a retried `ao review submit` runs again instead - // of tripping the status='running' guard above on an already-completed run. A - // message that lands but a DB write that then fails degrades to one extra - // nudge on retry — the same trade lifecycle's sendOnce makes. - if verdict == domain.VerdictChangesRequested { - if err := e.notifyWorkerChangesRequested(ctx, workerID, body, githubReviewID); err != nil { - return domain.ReviewRun{}, err - } - } - - updated, err := e.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunComplete, verdict, body, githubReviewID) - if err != nil { - return domain.ReviewRun{}, err - } - if !updated { - return domain.ReviewRun{}, fmt.Errorf("%w: review run %q is not running", ErrInvalid, runID) - } - run.Status = domain.ReviewRunComplete - run.Verdict = verdict - run.Body = body - run.GithubReviewID = githubReviewID - return run, nil -} - -// notifyWorkerChangesRequested injects the AO reviewer's feedback into the -// worker's live agent pane via the same messenger lifecycle uses for SCM nudges. -// -// When the GitHub review id is known, the worker is asked to reply on that -// review referencing its id with how it addressed the feedback and to resolve -// the review comment threads it addressed. The reviewer posts inline comments -// (per its prompt), so the per-finding threads are resolvable via `gh api` -// GraphQL resolveReviewThread; the top-level review object itself is not -// resolvable, hence the reply. The body is reviewer-authored text pasted into a -// PTY, so it is sanitized first (matching the lifecycle reaction path). -func (e *Engine) notifyWorkerChangesRequested(ctx context.Context, workerID domain.SessionID, body, githubReviewID string) error { - if e.messenger == nil { - return nil - } - msg := "An AO code reviewer requested changes on your PR. Review the feedback below and address it." - if githubReviewID != "" { - safeReviewID := domain.SanitizeControlChars(githubReviewID) - msg += fmt.Sprintf(" This feedback is GitHub review %s. Once you have addressed it, reply on that review referencing id %s with how you addressed it, then resolve the review comment threads you addressed.", safeReviewID, safeReviewID) - } - if body != "" { - msg += "\n\n" + domain.SanitizeControlChars(body) - } - return e.messenger.Send(ctx, workerID, msg) -} - // List returns a worker's review state: the live reviewer handle and its passes. -func (e *Engine) List(ctx context.Context, workerID domain.SessionID) (SessionReviews, error) { +func (e *Engine) List(ctx stdctx.Context, workerID domain.SessionID) (SessionReviews, error) { if workerID == "" { return SessionReviews{}, fmt.Errorf("%w: worker session id is required", ErrInvalid) } @@ -383,7 +309,7 @@ func (e *Engine) List(ctx context.Context, workerID domain.SessionID) (SessionRe return SessionReviews{ReviewerHandleID: handle, Runs: runs}, nil } -func (e *Engine) workerPR(ctx context.Context, workerID domain.SessionID) (domain.PullRequest, error) { +func (e *Engine) workerPR(ctx stdctx.Context, workerID domain.SessionID) (domain.PullRequest, error) { prs, err := e.prs.ListPRsBySession(ctx, workerID) if err != nil { return domain.PullRequest{}, err @@ -397,7 +323,7 @@ func (e *Engine) workerPR(ctx context.Context, workerID domain.SessionID) (domai // reviewerHarness resolves which harness reviews the worker's PR: a configured // reviewer wins, otherwise the worker's own harness is reused (falling back to // claude-code), per domain.ResolveReviewerHarness. -func (e *Engine) reviewerHarness(ctx context.Context, worker domain.SessionRecord) (domain.ReviewerHarness, error) { +func (e *Engine) reviewerHarness(ctx stdctx.Context, worker domain.SessionRecord) (domain.ReviewerHarness, error) { var cfg domain.ProjectConfig if e.projects != nil { if proj, ok, err := e.projects.GetProject(ctx, string(worker.ProjectID)); err != nil { @@ -409,7 +335,7 @@ func (e *Engine) reviewerHarness(ctx context.Context, worker domain.SessionRecor return cfg.ResolveReviewerHarness(worker.Harness), nil } -func (e *Engine) upsertReview(ctx context.Context, worker domain.SessionRecord, harness domain.ReviewerHarness, prURL, handleID string, now time.Time) (domain.Review, error) { +func (e *Engine) upsertReview(ctx stdctx.Context, worker domain.SessionRecord, harness domain.ReviewerHarness, prURL, handleID string, now time.Time) (domain.Review, error) { existing, ok, err := e.store.GetReviewBySession(ctx, worker.ID) if err != nil { return domain.Review{}, err diff --git a/backend/internal/review/review_test.go b/backend/internal/review/review_test.go index 54b0b8d12..003b19b5b 100644 --- a/backend/internal/review/review_test.go +++ b/backend/internal/review/review_test.go @@ -61,6 +61,30 @@ func (f *fakeStore) UpdateReviewRunResult(_ context.Context, id string, status d } return false, nil } +func (f *fakeStore) SupersedeReviewRun(_ context.Context, id, body string) (bool, error) { + for i := range f.runs { + if f.runs[i].ID == id { + if f.runs[i].Verdict != domain.VerdictNone || f.runs[i].Status == domain.ReviewRunFailed { + return false, nil + } + f.runs[i].Status = domain.ReviewRunFailed + f.runs[i].Body = body + return true, nil + } + } + return false, nil +} +func (f *fakeStore) SupersedeStaleRunningReviewRuns(_ context.Context, sessionID domain.SessionID, targetSHA, body string) (int64, error) { + var n int64 + for i := range f.runs { + if f.runs[i].SessionID == sessionID && f.runs[i].TargetSHA != targetSHA && f.runs[i].Status == domain.ReviewRunRunning && f.runs[i].Verdict == domain.VerdictNone { + f.runs[i].Status = domain.ReviewRunFailed + f.runs[i].Body = body + n++ + } + } + return n, nil +} func (f *fakeStore) GetReviewRun(_ context.Context, id string) (domain.ReviewRun, bool, error) { for _, r := range f.runs { if r.ID == id { @@ -129,20 +153,8 @@ func (f *fakeLauncher) Notify(_ context.Context, handleID string, spec LaunchSpe f.gotSpec = spec return f.notifyErr } -func (f *fakeLauncher) Alive(_ context.Context, _ string) (bool, error) { return f.alive, nil } - -type fakeMessenger struct { - sends int - gotID domain.SessionID - gotMsg string - sendErr error -} - -func (f *fakeMessenger) Send(_ context.Context, id domain.SessionID, msg string) error { - f.sends++ - f.gotID = id - f.gotMsg = msg - return f.sendErr +func (f *fakeLauncher) Alive(_ context.Context, _ string) (bool, error) { + return f.alive || f.spawned, nil } func liveWorker() domain.SessionRecord { @@ -222,7 +234,6 @@ func TestTriggerConcurrentSameWorkerSpawnsOnce(t *testing.T) { created++ } } - // Exactly one trigger does the work; the rest reuse its run. if created != 1 { t.Errorf("Created=true count = %d, want exactly 1", created) } @@ -259,9 +270,12 @@ func TestTriggerFallsBackToExistingRunOnUniqueConflict(t *testing.T) { func TestTriggerIsIdempotentForSameCommit(t *testing.T) { store := &fakeStore{ review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"}, - runs: []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", TargetSHA: "sha1", Status: domain.ReviewRunRunning}}, + runs: []domain.ReviewRun{{ + ID: "run-1", SessionID: "mer-1", TargetSHA: "sha1", + Status: domain.ReviewRunComplete, Verdict: domain.VerdictApproved, + }}, } - launcher := &fakeLauncher{} + launcher := &fakeLauncher{alive: true} eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prAt("sha1"), fakeProjects{}, launcher) res, err := eng.Trigger(context.Background(), "mer-1") @@ -279,6 +293,52 @@ func TestTriggerIsIdempotentForSameCommit(t *testing.T) { } } +func TestTriggerReusesRunningRowWithNoVerdict(t *testing.T) { + store := &fakeStore{ + review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"}, + runs: []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", TargetSHA: "sha1", Status: domain.ReviewRunRunning}}, + } + launcher := &fakeLauncher{alive: false, handle: "review-mer-2"} + eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prAt("sha1"), fakeProjects{}, launcher) + + res, err := eng.Trigger(context.Background(), "mer-1") + if err != nil { + t.Fatalf("Trigger: %v", err) + } + if res.Created || res.Run.ID != "run-1" { + t.Fatalf("expected reuse of the running review for the same commit: %+v", res) + } + if launcher.spawned || launcher.notified { + t.Fatalf("running same-commit review should not relaunch: %+v", launcher) + } + if got := store.runs[0]; got.Status != domain.ReviewRunRunning { + t.Fatalf("running row should remain running, got %+v", got) + } +} + +func TestTriggerSupersedesNonRunningRowWithNoVerdict(t *testing.T) { + store := &fakeStore{ + review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"}, + runs: []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", TargetSHA: "sha1", Status: domain.ReviewRunComplete}}, + } + launcher := &fakeLauncher{alive: true, handle: "review-mer-1"} + eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prAt("sha1"), fakeProjects{}, launcher) + + res, err := eng.Trigger(context.Background(), "mer-1") + if err != nil { + t.Fatalf("Trigger: %v", err) + } + if !res.Created { + t.Fatalf("expected a fresh pass when prior non-running row has no verdict: %+v", res) + } + if !launcher.notified || launcher.spawned { + t.Fatalf("expected notify on live reviewer pane, not spawn: %+v", launcher) + } + if stale := store.runs[0]; stale.ID != "run-1" || stale.Status != domain.ReviewRunFailed { + t.Fatalf("expected stale run-1 marked failed, got %+v", stale) + } +} + func TestTriggerNotifiesLiveReviewerOnNewCommit(t *testing.T) { store := &fakeStore{ review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"}, @@ -302,6 +362,29 @@ func TestTriggerNotifiesLiveReviewerOnNewCommit(t *testing.T) { } } +func TestTriggerSupersedesOlderRunningRunOnNewCommit(t *testing.T) { + store := &fakeStore{ + review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"}, + runs: []domain.ReviewRun{{ID: "run-old", SessionID: "mer-1", TargetSHA: "sha0", Status: domain.ReviewRunRunning}}, + } + launcher := &fakeLauncher{alive: true, handle: "review-mer-1"} + eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prAt("sha1"), fakeProjects{}, launcher) + + res, err := eng.Trigger(context.Background(), "mer-1") + if err != nil { + t.Fatalf("Trigger: %v", err) + } + if !res.Created || res.Run.TargetSHA != "sha1" { + t.Fatalf("expected new run for new commit, got %+v", res) + } + if old := store.runs[0]; old.ID != "run-old" || old.Status != domain.ReviewRunFailed { + t.Fatalf("expected older running run to be failed, got %+v", old) + } + if !launcher.notified || launcher.spawned { + t.Fatalf("expected live reviewer pane reused for new commit: %+v", launcher) + } +} + func TestTriggerSpawnsWhenReviewerDead(t *testing.T) { store := &fakeStore{ review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"}, @@ -388,136 +471,6 @@ func TestTriggerRejectsBadWorkerState(t *testing.T) { }) } -func TestSubmitRecordsVerdictAndBody(t *testing.T) { - store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", Status: domain.ReviewRunRunning}}} - eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prAt("sha1"), fakeProjects{}, &fakeLauncher{}) - - run, err := eng.Submit(context.Background(), "mer-1", "run-1", domain.VerdictChangesRequested, "please fix", "") - if err != nil { - t.Fatalf("Submit: %v", err) - } - if run.Status != domain.ReviewRunComplete || run.Verdict != domain.VerdictChangesRequested || run.Body != "please fix" { - t.Fatalf("run = %+v", run) - } -} - -func newEngineWithMessenger(store Store, messenger ports.AgentMessenger) *Engine { - ids := 0 - return New(Deps{ - Store: store, Sessions: fakeSessions{rec: liveWorker(), ok: true}, PRs: prAt("sha1"), - Projects: fakeProjects{}, Launcher: &fakeLauncher{}, Messenger: messenger, - Clock: func() time.Time { return time.Unix(0, 0).UTC() }, - NewID: func() string { ids++; return "id-" + string(rune('0'+ids)) }, - }) -} - -func TestSubmitChangesRequestedMessagesWorker(t *testing.T) { - store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", Status: domain.ReviewRunRunning}}} - msgr := &fakeMessenger{} - eng := newEngineWithMessenger(store, msgr) - - run, err := eng.Submit(context.Background(), "mer-1", "run-1", domain.VerdictChangesRequested, "fix the bug", "98\x1b[2J765") - if err != nil { - t.Fatalf("Submit: %v", err) - } - if run.GithubReviewID != "98\x1b[2J765" || store.runs[0].GithubReviewID != "98\x1b[2J765" { - t.Fatalf("review id not persisted: run=%+v stored=%+v", run, store.runs[0]) - } - if msgr.sends != 1 || msgr.gotID != "mer-1" { - t.Fatalf("expected one message to worker mer-1, got %+v", msgr) - } - if !strings.Contains(msgr.gotMsg, "fix the bug") || !strings.Contains(msgr.gotMsg, "98[2J765") { - t.Fatalf("message missing body or review id: %q", msgr.gotMsg) - } - if strings.Contains(msgr.gotMsg, "\x1b") { - t.Fatalf("message should sanitize review id before sending to worker: %q", msgr.gotMsg) - } - // The worker must be able to tell an AO internal review from external SCM - // reviewer feedback, and is asked to reply + resolve for an AO review. - if !strings.Contains(msgr.gotMsg, "AO code reviewer") { - t.Fatalf("message should identify the AO internal review: %q", msgr.gotMsg) - } - if !strings.Contains(msgr.gotMsg, "reply on that review") || !strings.Contains(msgr.gotMsg, "resolve") { - t.Fatalf("message should ask the worker to reply and resolve: %q", msgr.gotMsg) - } -} - -func TestSubmitApprovedDoesNotMessageWorker(t *testing.T) { - store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", Status: domain.ReviewRunRunning}}} - msgr := &fakeMessenger{} - eng := newEngineWithMessenger(store, msgr) - - if _, err := eng.Submit(context.Background(), "mer-1", "run-1", domain.VerdictApproved, "", "98765"); err != nil { - t.Fatalf("Submit: %v", err) - } - if msgr.sends != 0 { - t.Fatalf("approved review should not message the worker: %+v", msgr) - } -} - -func TestSubmitChangesRequestedOmitsReviewIDWhenAbsent(t *testing.T) { - store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", Status: domain.ReviewRunRunning}}} - msgr := &fakeMessenger{} - eng := newEngineWithMessenger(store, msgr) - - if _, err := eng.Submit(context.Background(), "mer-1", "run-1", domain.VerdictChangesRequested, "fix it", ""); err != nil { - t.Fatalf("Submit: %v", err) - } - if msgr.sends != 1 || !strings.Contains(msgr.gotMsg, "fix it") { - t.Fatalf("expected message with body: %+v", msgr) - } - // Still identified as an AO review, but with no id there is nothing to reply - // to or resolve, so that instruction is omitted. - if !strings.Contains(msgr.gotMsg, "AO code reviewer") { - t.Fatalf("message should identify the AO internal review: %q", msgr.gotMsg) - } - if strings.Contains(msgr.gotMsg, "reply on that review") { - t.Fatalf("message should not ask to reply/resolve when no review id was supplied: %q", msgr.gotMsg) - } -} - -func TestSubmitPropagatesMessengerError(t *testing.T) { - store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", Status: domain.ReviewRunRunning}}} - msgr := &fakeMessenger{sendErr: fmt.Errorf("dead pane")} - eng := newEngineWithMessenger(store, msgr) - - if _, err := eng.Submit(context.Background(), "mer-1", "run-1", domain.VerdictChangesRequested, "fix it", "1"); err == nil { - t.Fatal("expected Submit to surface the messenger error") - } - // The run must stay running so a retried submit can try again, rather than - // being marked complete and then failing the status='running' guard. - if store.runs[0].Status != domain.ReviewRunRunning { - t.Fatalf("run should stay running after a failed send, got %q", store.runs[0].Status) - } - - // A retry once the pane is back completes the run and messages the worker. - msgr.sendErr = nil - if _, err := eng.Submit(context.Background(), "mer-1", "run-1", domain.VerdictChangesRequested, "fix it", "1"); err != nil { - t.Fatalf("retry after recovered send: %v", err) - } - if store.runs[0].Status != domain.ReviewRunComplete || msgr.sends != 2 { - t.Fatalf("retry should complete the run and re-send: status=%q sends=%d", store.runs[0].Status, msgr.sends) - } -} - -func TestSubmitValidationAndOwnership(t *testing.T) { - store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", SessionID: "other", Status: domain.ReviewRunRunning}}} - eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prAt("sha1"), fakeProjects{}, &fakeLauncher{}) - - if _, err := eng.Submit(context.Background(), "mer-1", "", domain.VerdictApproved, "", ""); !errors.Is(err, ErrInvalid) { - t.Fatalf("missing run id err = %v", err) - } - if _, err := eng.Submit(context.Background(), "mer-1", "run-1", "garbage", "b", ""); !errors.Is(err, ErrInvalid) { - t.Fatalf("bad verdict err = %v", err) - } - if _, err := eng.Submit(context.Background(), "mer-1", "missing", domain.VerdictApproved, "", ""); !errors.Is(err, ErrNotFound) { - t.Fatalf("unknown run err = %v", err) - } - if _, err := eng.Submit(context.Background(), "mer-1", "run-1", domain.VerdictApproved, "", ""); !errors.Is(err, ErrInvalid) { - t.Fatalf("ownership err = %v", err) - } -} - func TestListReturnsHandleAndRuns(t *testing.T) { store := &fakeStore{ review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"}, diff --git a/backend/internal/service/review/review.go b/backend/internal/service/review/review.go index a7ed9c194..be23d3a83 100644 --- a/backend/internal/service/review/review.go +++ b/backend/internal/service/review/review.go @@ -6,8 +6,11 @@ package review import ( "context" + "fmt" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" "github.com/aoagents/agent-orchestrator/backend/internal/ports" reviewcore "github.com/aoagents/agent-orchestrator/backend/internal/review" ) @@ -29,14 +32,47 @@ type Manager interface { // Service is the API-facing review service. It delegates to the core engine. type Service struct { - engine *reviewcore.Engine + engine *reviewcore.Engine + store Store + lifecycle Reducer + clock func() time.Time } var _ Manager = (*Service)(nil) +// Store is the review_run persistence surface owned by the service submit path. +type Store interface { + GetReviewRun(ctx context.Context, id string) (domain.ReviewRun, bool, error) + UpdateReviewRunResult(ctx context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body, githubReviewID string) (bool, error) + MarkReviewRunDelivered(ctx context.Context, id string, deliveredAt time.Time) (bool, error) +} + +// Reducer is the lifecycle reaction boundary used after a review result has +// been persisted. +type Reducer interface { + ApplyReviewResult(ctx context.Context, workerID domain.SessionID, result lifecycle.ReviewResult) (lifecycle.ReviewDeliveryOutcome, error) +} + +// Option customizes the review service. +type Option func(*Service) + +// WithLifecycleReducer wires post-submit review delivery through lifecycle. +func WithLifecycleReducer(r Reducer) Option { + return func(s *Service) { s.lifecycle = r } +} + +// WithClock overrides the service clock for tests. +func WithClock(clock func() time.Time) Option { + return func(s *Service) { s.clock = clock } +} + // New wraps a core review engine as the API-facing service. -func New(engine *reviewcore.Engine) *Service { - return &Service{engine: engine} +func New(engine *reviewcore.Engine, store Store, opts ...Option) *Service { + s := &Service{engine: engine, store: store, clock: func() time.Time { return time.Now().UTC() }} + for _, opt := range opts { + opt(s) + } + return s } // Trigger starts (or reuses) a review pass for a worker's PR. @@ -46,7 +82,89 @@ func (s *Service) Trigger(ctx context.Context, workerID domain.SessionID) (revie // Submit records a reviewer's result for a specific worker review pass. func (s *Service) Submit(ctx context.Context, workerID domain.SessionID, runID string, verdict domain.ReviewVerdict, body, githubReviewID string) (domain.ReviewRun, error) { - return s.engine.Submit(ctx, workerID, runID, verdict, body, githubReviewID) + if workerID == "" { + return domain.ReviewRun{}, fmt.Errorf("%w: worker session id is required", ErrInvalid) + } + if runID == "" { + return domain.ReviewRun{}, fmt.Errorf("%w: review run id is required", ErrInvalid) + } + if !verdict.Valid() { + return domain.ReviewRun{}, fmt.Errorf("%w: verdict must be %q or %q", ErrInvalid, domain.VerdictApproved, domain.VerdictChangesRequested) + } + if verdict == domain.VerdictChangesRequested && body == "" { + return domain.ReviewRun{}, fmt.Errorf("%w: a changes_requested review requires a body", ErrInvalid) + } + if s.store == nil { + return domain.ReviewRun{}, fmt.Errorf("review service store is not configured") + } + run, ok, err := s.store.GetReviewRun(ctx, runID) + if err != nil { + return domain.ReviewRun{}, err + } + if !ok { + return domain.ReviewRun{}, fmt.Errorf("%w: review run %q", ErrNotFound, runID) + } + if run.SessionID != workerID { + return domain.ReviewRun{}, fmt.Errorf("%w: review run %q does not belong to worker %q", ErrInvalid, runID, workerID) + } + + switch run.Status { + case domain.ReviewRunRunning: + updated, err := s.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunComplete, verdict, body, githubReviewID) + if err != nil { + return domain.ReviewRun{}, err + } + if !updated { + return domain.ReviewRun{}, fmt.Errorf("%w: review run %q is not running", ErrInvalid, runID) + } + run.Status = domain.ReviewRunComplete + run.Verdict = verdict + run.Body = body + run.GithubReviewID = githubReviewID + case domain.ReviewRunComplete: + if run.Verdict != verdict { + return domain.ReviewRun{}, fmt.Errorf("%w: review run %q already recorded verdict %q", ErrInvalid, runID, run.Verdict) + } + if body != "" && body != run.Body { + return domain.ReviewRun{}, fmt.Errorf("%w: review run %q already recorded a different body", ErrInvalid, runID) + } + if githubReviewID != "" && githubReviewID != run.GithubReviewID { + return domain.ReviewRun{}, fmt.Errorf("%w: review run %q already recorded GitHub review id %q", ErrInvalid, runID, run.GithubReviewID) + } + case domain.ReviewRunDelivered: + return run, nil + default: + return domain.ReviewRun{}, fmt.Errorf("%w: review run %q is not running", ErrInvalid, runID) + } + + if s.lifecycle == nil { + return run, nil + } + outcome, err := s.lifecycle.ApplyReviewResult(ctx, workerID, lifecycle.ReviewResult{ + RunID: run.ID, + WorkerID: workerID, + PRURL: run.PRURL, + TargetSHA: run.TargetSHA, + Verdict: run.Verdict, + Body: run.Body, + GithubReviewID: run.GithubReviewID, + DeliveredAt: run.DeliveredAt, + }) + if err != nil { + return domain.ReviewRun{}, err + } + if outcome == lifecycle.ReviewDeliverySent { + deliveredAt := s.clock() + updated, err := s.store.MarkReviewRunDelivered(ctx, run.ID, deliveredAt) + if err != nil { + return domain.ReviewRun{}, err + } + if updated { + run.Status = domain.ReviewRunDelivered + run.DeliveredAt = &deliveredAt + } + } + return run, nil } // List returns a worker's review state. diff --git a/backend/internal/service/review/review_test.go b/backend/internal/service/review/review_test.go new file mode 100644 index 000000000..c9e4c4553 --- /dev/null +++ b/backend/internal/service/review/review_test.go @@ -0,0 +1,131 @@ +package review + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" +) + +type fakeStore struct { + run domain.ReviewRun + ok bool + + updateCalls int + markCalls int +} + +func (f *fakeStore) GetReviewRun(context.Context, string) (domain.ReviewRun, bool, error) { + return f.run, f.ok, nil +} + +func (f *fakeStore) UpdateReviewRunResult(_ context.Context, _ string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body, githubReviewID string) (bool, error) { + if f.run.Status != domain.ReviewRunRunning { + return false, nil + } + f.updateCalls++ + f.run.Status = status + f.run.Verdict = verdict + f.run.Body = body + f.run.GithubReviewID = githubReviewID + return true, nil +} + +func (f *fakeStore) MarkReviewRunDelivered(_ context.Context, _ string, deliveredAt time.Time) (bool, error) { + f.markCalls++ + if f.run.Status != domain.ReviewRunComplete || f.run.DeliveredAt != nil { + return false, nil + } + f.run.Status = domain.ReviewRunDelivered + f.run.DeliveredAt = &deliveredAt + return true, nil +} + +type fakeReducer struct { + outcome lifecycle.ReviewDeliveryOutcome + err error + calls int + got lifecycle.ReviewResult +} + +func (f *fakeReducer) ApplyReviewResult(_ context.Context, _ domain.SessionID, result lifecycle.ReviewResult) (lifecycle.ReviewDeliveryOutcome, error) { + f.calls++ + f.got = result + return f.outcome, f.err +} + +func TestSubmitPersistsThenAppliesThenStampsDelivered(t *testing.T) { + now := time.Unix(100, 0).UTC() + st := &fakeStore{ok: true, run: domain.ReviewRun{ID: "run-1", SessionID: "mer-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunRunning}} + reducer := &fakeReducer{outcome: lifecycle.ReviewDeliverySent} + svc := New(nil, st, WithLifecycleReducer(reducer), WithClock(func() time.Time { return now })) + + run, err := svc.Submit(context.Background(), "mer-1", "run-1", domain.VerdictChangesRequested, "fix it", "987") + if err != nil { + t.Fatalf("Submit: %v", err) + } + if st.updateCalls != 1 || reducer.calls != 1 || st.markCalls != 1 { + t.Fatalf("calls update/reducer/mark = %d/%d/%d", st.updateCalls, reducer.calls, st.markCalls) + } + if reducer.got.Verdict != domain.VerdictChangesRequested || reducer.got.Body != "fix it" || reducer.got.GithubReviewID != "987" { + t.Fatalf("reducer saw wrong result: %+v", reducer.got) + } + if run.Status != domain.ReviewRunDelivered || run.DeliveredAt == nil || !run.DeliveredAt.Equal(now) { + t.Fatalf("run not stamped delivered: %+v", run) + } +} + +func TestSubmitDeliveryFailureLeavesCompletedUndeliveredForRetry(t *testing.T) { + sendErr := errors.New("dead pane") + st := &fakeStore{ok: true, run: domain.ReviewRun{ID: "run-1", SessionID: "mer-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunRunning}} + reducer := &fakeReducer{err: sendErr} + svc := New(nil, st, WithLifecycleReducer(reducer)) + + if _, err := svc.Submit(context.Background(), "mer-1", "run-1", domain.VerdictChangesRequested, "fix it", "987"); !errors.Is(err, sendErr) { + t.Fatalf("err = %v, want sendErr", err) + } + if st.run.Status != domain.ReviewRunComplete || st.run.DeliveredAt != nil || st.markCalls != 0 { + t.Fatalf("failed delivery should leave completed/undelivered without stamp: %+v markCalls=%d", st.run, st.markCalls) + } + + reducer.err = nil + reducer.outcome = lifecycle.ReviewDeliverySent + if _, err := svc.Submit(context.Background(), "mer-1", "run-1", domain.VerdictChangesRequested, "fix it", "987"); err != nil { + t.Fatalf("retry Submit: %v", err) + } + if st.updateCalls != 1 || reducer.calls != 2 || st.run.Status != domain.ReviewRunDelivered || st.run.DeliveredAt == nil { + t.Fatalf("retry should not rewrite result and should stamp delivery: update=%d reducer=%d run=%+v", st.updateCalls, reducer.calls, st.run) + } +} + +func TestSubmitCompletedRetryRejectsDifferentRecordedFields(t *testing.T) { + tests := []struct { + name string + body string + githubReviewID string + }{ + {name: "different body", body: "different", githubReviewID: "987"}, + {name: "different review id", body: "fix it", githubReviewID: "654"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + st := &fakeStore{ok: true, run: domain.ReviewRun{ + ID: "run-1", SessionID: "mer-1", PRURL: "pr1", TargetSHA: "sha1", + Status: domain.ReviewRunComplete, Verdict: domain.VerdictChangesRequested, + Body: "fix it", GithubReviewID: "987", + }} + reducer := &fakeReducer{outcome: lifecycle.ReviewDeliverySent} + svc := New(nil, st, WithLifecycleReducer(reducer)) + + if _, err := svc.Submit(context.Background(), "mer-1", "run-1", domain.VerdictChangesRequested, tt.body, tt.githubReviewID); !errors.Is(err, ErrInvalid) { + t.Fatalf("err = %v, want ErrInvalid", err) + } + if st.updateCalls != 0 || st.markCalls != 0 || reducer.calls != 0 { + t.Fatalf("mismatched retry should not rewrite or deliver: update=%d mark=%d reducer=%d", st.updateCalls, st.markCalls, reducer.calls) + } + }) + } +} diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index 234581550..b9e7fbd15 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -146,6 +146,7 @@ type ReviewRun struct { Body string CreatedAt time.Time GithubReviewID string + DeliveredAt sql.NullTime } type Session struct { diff --git a/backend/internal/storage/sqlite/gen/review.sql.go b/backend/internal/storage/sqlite/gen/review.sql.go index 1053a8a9e..f9d08f344 100644 --- a/backend/internal/storage/sqlite/gen/review.sql.go +++ b/backend/internal/storage/sqlite/gen/review.sql.go @@ -7,6 +7,7 @@ package gen import ( "context" + "database/sql" "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -34,7 +35,7 @@ func (q *Queries) GetReviewBySession(ctx context.Context, sessionID domain.Sessi } const getReviewRun = `-- name: GetReviewRun :one -SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id +SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id, delivered_at FROM review_run WHERE id = ? ` @@ -53,12 +54,13 @@ func (q *Queries) GetReviewRun(ctx context.Context, id string) (ReviewRun, error &i.Body, &i.CreatedAt, &i.GithubReviewID, + &i.DeliveredAt, ) return i, err } const getReviewRunBySessionAndSHA = `-- name: GetReviewRunBySessionAndSHA :one -SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id +SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id, delivered_at FROM review_run WHERE session_id = ? AND target_sha = ? ORDER BY created_at DESC LIMIT 1 ` @@ -82,6 +84,7 @@ func (q *Queries) GetReviewRunBySessionAndSHA(ctx context.Context, arg GetReview &i.Body, &i.CreatedAt, &i.GithubReviewID, + &i.DeliveredAt, ) return i, err } @@ -123,7 +126,7 @@ func (q *Queries) InsertReviewRun(ctx context.Context, arg InsertReviewRunParams } const listReviewRunsBySession = `-- name: ListReviewRunsBySession :many -SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id +SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id, delivered_at FROM review_run WHERE session_id = ? ORDER BY created_at DESC ` @@ -148,6 +151,7 @@ func (q *Queries) ListReviewRunsBySession(ctx context.Context, sessionID domain. &i.Body, &i.CreatedAt, &i.GithubReviewID, + &i.DeliveredAt, ); err != nil { return nil, err } @@ -162,6 +166,58 @@ func (q *Queries) ListReviewRunsBySession(ctx context.Context, sessionID domain. return items, nil } +const markReviewRunDelivered = `-- name: MarkReviewRunDelivered :execrows +UPDATE review_run SET status = 'delivered', delivered_at = ? WHERE id = ? AND status = 'complete' AND delivered_at IS NULL +` + +type MarkReviewRunDeliveredParams struct { + DeliveredAt sql.NullTime + ID string +} + +func (q *Queries) MarkReviewRunDelivered(ctx context.Context, arg MarkReviewRunDeliveredParams) (int64, error) { + result, err := q.db.ExecContext(ctx, markReviewRunDelivered, arg.DeliveredAt, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const supersedeReviewRun = `-- name: SupersedeReviewRun :execrows +UPDATE review_run SET status = 'failed', body = ? WHERE id = ? AND verdict = '' AND status != 'failed' +` + +type SupersedeReviewRunParams struct { + Body string + ID string +} + +func (q *Queries) SupersedeReviewRun(ctx context.Context, arg SupersedeReviewRunParams) (int64, error) { + result, err := q.db.ExecContext(ctx, supersedeReviewRun, arg.Body, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const supersedeStaleRunningReviewRuns = `-- name: SupersedeStaleRunningReviewRuns :execrows +UPDATE review_run SET status = 'failed', body = ? WHERE session_id = ? AND target_sha != ? AND status = 'running' AND verdict = '' +` + +type SupersedeStaleRunningReviewRunsParams struct { + Body string + SessionID domain.SessionID + TargetSha string +} + +func (q *Queries) SupersedeStaleRunningReviewRuns(ctx context.Context, arg SupersedeStaleRunningReviewRunsParams) (int64, error) { + result, err := q.db.ExecContext(ctx, supersedeStaleRunningReviewRuns, arg.Body, arg.SessionID, arg.TargetSha) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const updateReviewRunResult = `-- name: UpdateReviewRunResult :execrows UPDATE review_run SET status = ?, verdict = ?, body = ?, github_review_id = ? WHERE id = ? AND status = 'running' ` diff --git a/backend/internal/storage/sqlite/migrations/0018_review_run_delivered_at.sql b/backend/internal/storage/sqlite/migrations/0018_review_run_delivered_at.sql new file mode 100644 index 000000000..8be4f1c0b --- /dev/null +++ b/backend/internal/storage/sqlite/migrations/0018_review_run_delivered_at.sql @@ -0,0 +1,14 @@ +-- AO-internal review changes-requested nudges are delivered through lifecycle +-- sendOnce after the review result is recorded. This nullable timestamp marks +-- passes whose worker nudge was durably delivered, so retries after a daemon +-- restart do not send the same review pass twice. + +-- +goose Up +-- +goose StatementBegin +ALTER TABLE review_run ADD COLUMN delivered_at TIMESTAMP; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE review_run DROP COLUMN delivered_at; +-- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/queries/review.sql b/backend/internal/storage/sqlite/queries/review.sql index c540f6284..2dbc3a087 100644 --- a/backend/internal/storage/sqlite/queries/review.sql +++ b/backend/internal/storage/sqlite/queries/review.sql @@ -18,14 +18,23 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); -- name: UpdateReviewRunResult :execrows UPDATE review_run SET status = ?, verdict = ?, body = ?, github_review_id = ? WHERE id = ? AND status = 'running'; +-- name: SupersedeReviewRun :execrows +UPDATE review_run SET status = 'failed', body = ? WHERE id = ? AND verdict = '' AND status != 'failed'; + +-- name: SupersedeStaleRunningReviewRuns :execrows +UPDATE review_run SET status = 'failed', body = ? WHERE session_id = ? AND target_sha != ? AND status = 'running' AND verdict = ''; + +-- name: MarkReviewRunDelivered :execrows +UPDATE review_run SET status = 'delivered', delivered_at = ? WHERE id = ? AND status = 'complete' AND delivered_at IS NULL; + -- name: GetReviewRun :one -SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id +SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id, delivered_at FROM review_run WHERE id = ?; -- name: GetReviewRunBySessionAndSHA :one -SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id +SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id, delivered_at FROM review_run WHERE session_id = ? AND target_sha = ? ORDER BY created_at DESC LIMIT 1; -- name: ListReviewRunsBySession :many -SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id +SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id, delivered_at FROM review_run WHERE session_id = ? ORDER BY created_at DESC; diff --git a/backend/internal/storage/sqlite/store/review_store.go b/backend/internal/storage/sqlite/store/review_store.go index 9d5c09607..cf5fd6460 100644 --- a/backend/internal/storage/sqlite/store/review_store.go +++ b/backend/internal/storage/sqlite/store/review_store.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" @@ -82,6 +83,48 @@ func (s *Store) UpdateReviewRunResult(ctx context.Context, id string, status dom return n > 0, nil } +// SupersedeReviewRun marks an unverdicted non-failed pass failed so a new pass +// for the same commit can be recorded. +func (s *Store) SupersedeReviewRun(ctx context.Context, id, body string) (bool, error) { + s.writeMu.Lock() + defer s.writeMu.Unlock() + n, err := s.qw.SupersedeReviewRun(ctx, gen.SupersedeReviewRunParams{ + Body: body, + ID: id, + }) + if err != nil { + return false, err + } + return n > 0, nil +} + +// SupersedeStaleRunningReviewRuns marks older running unverdicted passes for a +// worker failed before starting a review for a newer commit. +func (s *Store) SupersedeStaleRunningReviewRuns(ctx context.Context, sessionID domain.SessionID, targetSHA, body string) (int64, error) { + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.qw.SupersedeStaleRunningReviewRuns(ctx, gen.SupersedeStaleRunningReviewRunsParams{ + Body: body, + SessionID: sessionID, + TargetSha: targetSHA, + }) +} + +// MarkReviewRunDelivered records that lifecycle delivered the worker nudge for +// a completed AO-internal review pass. +func (s *Store) MarkReviewRunDelivered(ctx context.Context, id string, deliveredAt time.Time) (bool, error) { + s.writeMu.Lock() + defer s.writeMu.Unlock() + n, err := s.qw.MarkReviewRunDelivered(ctx, gen.MarkReviewRunDeliveredParams{ + DeliveredAt: sql.NullTime{Time: deliveredAt, Valid: true}, + ID: id, + }) + if err != nil { + return false, err + } + return n > 0, nil +} + // GetReviewRun returns one review pass by id. func (s *Store) GetReviewRun(ctx context.Context, id string) (domain.ReviewRun, bool, error) { row, err := s.qr.GetReviewRun(ctx, id) @@ -135,6 +178,11 @@ func reviewFromRow(r gen.Review) domain.Review { } func reviewRunFromRow(r gen.ReviewRun) domain.ReviewRun { + var deliveredAt *time.Time + if r.DeliveredAt.Valid { + t := r.DeliveredAt.Time + deliveredAt = &t + } return domain.ReviewRun{ ID: r.ID, ReviewID: r.ReviewID, @@ -147,5 +195,6 @@ func reviewRunFromRow(r gen.ReviewRun) domain.ReviewRun { Body: r.Body, GithubReviewID: r.GithubReviewID, CreatedAt: r.CreatedAt, + DeliveredAt: deliveredAt, } } diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 9f895e642..cde40026e 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -680,6 +680,8 @@ export interface components { body: string; /** Format: date-time */ createdAt: string; + /** Format: date-time */ + deliveredAt?: null | string; githubReviewId: string; harness: string; id: string;