agent-orchestrator/backend/internal/review/review.go

379 lines
13 KiB
Go

// Package review holds the core code-review logic: triggering a reviewer over a
// worker's worktree, recording review runs, and accepting submitted results.
//
// It is independent of any transport. The daemon's HTTP service
// (internal/service/review) is a thin boundary over this engine today, and the
// same engine can back an in-process CLI trigger later without going through the
// API. Transport-specific concerns (DTOs, error→status mapping) stay in the
// service/controller layers; the orchestration and run-id generation live here.
package review
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/google/uuid"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
// ErrInvalid and ErrNotFound let the transport layer map failures to 422/404.
var (
ErrInvalid = errors.New("review: invalid input")
ErrNotFound = errors.New("review: not found")
)
// 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 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)
}
// Sessions resolves the worker session under review.
type Sessions interface {
GetSession(ctx context.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)
}
// Projects resolves the per-project reviewer config.
type Projects interface {
GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error)
}
// Deps wires the engine.
type Deps struct {
Store Store
Sessions Sessions
PRs PRs
Projects Projects
Launcher Launcher
// Clock and NewID are injectable for deterministic tests.
Clock func() time.Time
NewID func() string
}
// Engine is the core code-review engine.
type Engine struct {
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
// lockWorker). Distinct workers never contend.
triggerMu sync.Mutex
triggerLocks map[domain.SessionID]*sync.Mutex
}
// New wires an Engine from its dependencies, defaulting the clock and id source.
func New(d Deps) *Engine {
clock := d.Clock
if clock == nil {
clock = func() time.Time { return time.Now().UTC() }
}
newID := d.NewID
if newID == nil {
newID = uuid.NewString
}
return &Engine{
store: d.Store,
sessions: d.Sessions,
prs: d.PRs,
projects: d.Projects,
launcher: d.Launcher,
clock: clock,
newID: newID,
triggerLocks: make(map[domain.SessionID]*sync.Mutex),
}
}
// lockWorker serialises Trigger calls for a single worker session and returns
// the unlock func. Without it, two concurrent triggers for the same worker can
// both pass the per-commit idempotency check and each spawn a reviewer against
// the same deterministic handle, leaving two running runs for one commit (#242).
//
// The per-worker mutex is created on first use and kept for the lifetime of the
// engine; the entry is a single pointer, so the unbounded-by-session-count map
// is a negligible, bounded-in-practice cost.
func (e *Engine) lockWorker(id domain.SessionID) func() {
e.triggerMu.Lock()
mu, ok := e.triggerLocks[id]
if !ok {
mu = &sync.Mutex{}
e.triggerLocks[id] = mu
}
e.triggerMu.Unlock()
mu.Lock()
return mu.Unlock
}
// TriggerResult is the outcome of a trigger: the (new or existing) run, the live
// reviewer pane's handle so the UI can attach its terminal, and whether a new
// pass was started (false when an existing run for the same commit was reused).
type TriggerResult struct {
Run domain.ReviewRun
ReviewerHandleID string
Created bool
}
// SessionReviews is a worker's review state: the live reviewer handle plus its
// recorded passes, newest first.
type SessionReviews struct {
ReviewerHandleID string
Runs []domain.ReviewRun
}
// Trigger starts (or reuses) a review of a worker's PR at its current head:
// - if a run already exists for this commit, it is returned unchanged;
// - otherwise, if a live reviewer pane exists, it is messaged to review the
// new commit; if not, a fresh reviewer is spawned;
// - only after the reviewer is launched is the run recorded.
func (e *Engine) Trigger(ctx context.Context, workerID domain.SessionID) (TriggerResult, error) {
if workerID == "" {
return TriggerResult{}, fmt.Errorf("%w: worker session id is required", ErrInvalid)
}
// Serialise concurrent triggers for this worker so the idempotency check
// below (and the reviewer spawn that follows it) can't be raced into a
// double-spawn. Held across the spawn deliberately: the loser then re-reads
// the freshly-recorded run and short-circuits to Created:false.
unlock := e.lockWorker(workerID)
defer unlock()
worker, ok, err := e.sessions.GetSession(ctx, workerID)
if err != nil {
return TriggerResult{}, err
}
if !ok {
return TriggerResult{}, fmt.Errorf("%w: worker session %q", ErrNotFound, workerID)
}
if worker.IsTerminated {
return TriggerResult{}, fmt.Errorf("%w: worker session %q is terminated", ErrInvalid, workerID)
}
if worker.Metadata.WorkspacePath == "" {
return TriggerResult{}, fmt.Errorf("%w: worker session %q has no workspace to review", ErrInvalid, workerID)
}
pr, err := e.workerPR(ctx, workerID)
if err != nil {
return TriggerResult{}, err
}
targetSHA := pr.HeadSHA
review, hasReview, err := e.store.GetReviewBySession(ctx, workerID)
if err != nil {
return TriggerResult{}, err
}
// Idempotency: a pass already exists for this commit — return it as-is.
if existing, ok, err := e.store.GetReviewRunBySessionAndSHA(ctx, workerID, targetSHA); err != nil {
return TriggerResult{}, err
} else if ok {
return TriggerResult{Run: existing, ReviewerHandleID: review.ReviewerHandleID, Created: false}, nil
}
harness, err := e.reviewerHarness(ctx, worker)
if err != nil {
return TriggerResult{}, err
}
now := e.clock()
runID := e.newID()
spec := LaunchSpec{
RunID: runID,
WorkerID: workerID,
Harness: harness,
WorkspacePath: worker.Metadata.WorkspacePath,
PRURL: pr.URL,
TargetSHA: targetSHA,
}
// Reuse a live reviewer pane if there is one; otherwise spawn a fresh one.
handleID := ""
if hasReview && review.ReviewerHandleID != "" {
alive, err := e.launcher.Alive(ctx, review.ReviewerHandleID)
if err != nil {
return TriggerResult{}, err
}
if alive {
if err := e.launcher.Notify(ctx, review.ReviewerHandleID, spec); err != nil {
return TriggerResult{}, fmt.Errorf("notify reviewer: %w", err)
}
handleID = review.ReviewerHandleID
}
}
if handleID == "" {
h, err := e.launcher.Spawn(ctx, spec)
if err != nil {
return TriggerResult{}, fmt.Errorf("launch reviewer: %w", err)
}
handleID = h
}
// The reviewer is running; now record the pass.
review, err = e.upsertReview(ctx, worker, harness, pr.URL, handleID, now)
if err != nil {
return TriggerResult{}, err
}
run := domain.ReviewRun{
ID: runID,
ReviewID: review.ID,
SessionID: workerID,
Harness: harness,
PRURL: pr.URL,
TargetSHA: targetSHA,
Status: domain.ReviewRunRunning,
Verdict: domain.VerdictNone,
CreatedAt: now,
}
if err := e.store.InsertReviewRun(ctx, run); err != nil {
// The per-worker lock serialises in-process triggers, but the unique
// index (migration 0013) can still reject a run a concurrent daemon (or
// a pre-lock restart) recorded for this commit. The reviewer is already
// launched, so don't surface a raw error: re-read the recorded run and
// return it as the existing, not-newly-created pass.
if errors.Is(err, domain.ErrDuplicateReviewRun) {
if existing, ok, getErr := e.store.GetReviewRunBySessionAndSHA(ctx, workerID, targetSHA); getErr != nil {
return TriggerResult{}, getErr
} else if ok {
return TriggerResult{Run: existing, ReviewerHandleID: handleID, Created: false}, nil
}
}
return TriggerResult{}, err
}
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 and body. AO does not post the
// review — the reviewer agent posts it to the PR itself.
func (e *Engine) Submit(ctx context.Context, workerID domain.SessionID, runID string, verdict domain.ReviewVerdict, body 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)
}
updated, err := e.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunComplete, verdict, body)
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
return run, nil
}
// 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) {
if workerID == "" {
return SessionReviews{}, fmt.Errorf("%w: worker session id is required", ErrInvalid)
}
runs, err := e.store.ListReviewRunsBySession(ctx, workerID)
if err != nil {
return SessionReviews{}, err
}
var handle string
if review, ok, err := e.store.GetReviewBySession(ctx, workerID); err != nil {
return SessionReviews{}, err
} else if ok {
handle = review.ReviewerHandleID
}
return SessionReviews{ReviewerHandleID: handle, Runs: runs}, nil
}
func (e *Engine) workerPR(ctx context.Context, workerID domain.SessionID) (domain.PullRequest, error) {
prs, err := e.prs.ListPRsBySession(ctx, workerID)
if err != nil {
return domain.PullRequest{}, err
}
if len(prs) == 0 {
return domain.PullRequest{}, fmt.Errorf("%w: worker %q has no PR to review", ErrInvalid, workerID)
}
return prs[0], nil
}
// 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) {
var cfg domain.ProjectConfig
if e.projects != nil {
if proj, ok, err := e.projects.GetProject(ctx, string(worker.ProjectID)); err != nil {
return "", err
} else if ok {
cfg = proj.Config
}
}
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) {
existing, ok, err := e.store.GetReviewBySession(ctx, worker.ID)
if err != nil {
return domain.Review{}, err
}
review := domain.Review{
ID: e.newID(),
SessionID: worker.ID,
ProjectID: worker.ProjectID,
Harness: harness,
PRURL: prURL,
ReviewerHandleID: handleID,
CreatedAt: now,
UpdatedAt: now,
}
if ok {
// Reuse the existing row's identity and creation time; UpsertReview
// refreshes harness/pr_url/reviewer_handle_id/updated_at.
review.ID = existing.ID
review.CreatedAt = existing.CreatedAt
}
if err := e.store.UpsertReview(ctx, review); err != nil {
return domain.Review{}, err
}
return review, nil
}