460 lines
15 KiB
Go
460 lines
15 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 (
|
|
stdctx "context"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"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.
|
|
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 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, prURL, targetSHA, body string) (int64, error)
|
|
GetReviewRun(ctx stdctx.Context, id string) (domain.ReviewRun, bool, error)
|
|
GetReviewRunBySessionPRAndSHA(ctx stdctx.Context, id domain.SessionID, prURL, 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 stdctx.Context, id domain.SessionID) (domain.SessionRecord, bool, error)
|
|
}
|
|
|
|
// PRs resolves the PR a worker owns.
|
|
type PRs interface {
|
|
ListPRsBySession(ctx stdctx.Context, id domain.SessionID) ([]domain.PullRequest, error)
|
|
}
|
|
|
|
// Projects resolves the per-project reviewer config.
|
|
type Projects interface {
|
|
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
|
|
|
|
// 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
|
|
Reviews []PRReviewState
|
|
CreatedRuns []domain.ReviewRun
|
|
}
|
|
|
|
// 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
|
|
Reviews []PRReviewState
|
|
}
|
|
|
|
// Trigger starts reviews for every PR on the worker session that needs review.
|
|
// It reuses running/up-to-date runs, retries failed/current changes-requested
|
|
// heads, and uses one reviewer pane for every new run in the batch.
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
prs, err := e.prs.ListPRsBySession(ctx, workerID)
|
|
if err != nil {
|
|
return TriggerResult{}, err
|
|
}
|
|
if len(prs) == 0 {
|
|
return TriggerResult{}, fmt.Errorf("%w: worker %q has no PR to review", ErrInvalid, workerID)
|
|
}
|
|
runs, err := e.store.ListReviewRunsBySession(ctx, workerID)
|
|
if err != nil {
|
|
return TriggerResult{}, err
|
|
}
|
|
reviews := Plan(prs, runs)
|
|
|
|
reviewRow, hasReview, err := e.store.GetReviewBySession(ctx, workerID)
|
|
if err != nil {
|
|
return TriggerResult{}, err
|
|
}
|
|
|
|
harness, err := e.reviewerHarness(ctx, worker)
|
|
if err != nil {
|
|
return TriggerResult{}, err
|
|
}
|
|
|
|
now := e.clock()
|
|
reviewRow, err = e.upsertReview(ctx, worker, harness, reviewRow.ReviewerHandleID, now)
|
|
if err != nil {
|
|
return TriggerResult{}, err
|
|
}
|
|
|
|
var created []domain.ReviewRun
|
|
batchID := ""
|
|
stoppedStaleReviewer := false
|
|
for _, reviewState := range reviews {
|
|
if reviewState.Status != ReviewStateNeedsReview && reviewState.Status != ReviewStateChangesRequested {
|
|
continue
|
|
}
|
|
if reviewState.LatestRun != nil && reviewState.LatestRun.Status != domain.ReviewRunFailed && reviewState.LatestRun.Status != domain.ReviewRunRunning && reviewState.LatestRun.Verdict == domain.VerdictNone {
|
|
superseded, err := e.store.SupersedeReviewRun(ctx, reviewState.LatestRun.ID, "superseded by a new review trigger")
|
|
if err != nil {
|
|
return TriggerResult{}, err
|
|
}
|
|
if !superseded {
|
|
if latest, ok, err := e.store.GetReviewRun(ctx, reviewState.LatestRun.ID); err != nil {
|
|
return TriggerResult{}, err
|
|
} else if ok {
|
|
reviews = replaceReviewLatestRun(reviews, reviewState.PRURL, reviewState.TargetSHA, latest)
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
stale, err := e.store.SupersedeStaleRunningReviewRuns(ctx, workerID, reviewState.PRURL, reviewState.TargetSHA, "superseded by a review trigger for a newer commit")
|
|
if err != nil {
|
|
return TriggerResult{}, err
|
|
}
|
|
if stale > 0 {
|
|
stoppedStaleReviewer = true
|
|
}
|
|
if batchID == "" {
|
|
batchID = e.newID()
|
|
}
|
|
run := domain.ReviewRun{
|
|
ID: e.newID(),
|
|
ReviewID: reviewRow.ID,
|
|
SessionID: workerID,
|
|
BatchID: batchID,
|
|
Harness: harness,
|
|
PRURL: reviewState.PRURL,
|
|
TargetSHA: reviewState.TargetSHA,
|
|
Status: domain.ReviewRunRunning,
|
|
Verdict: domain.VerdictNone,
|
|
CreatedAt: now,
|
|
}
|
|
if err := e.store.InsertReviewRun(ctx, run); err != nil {
|
|
if errors.Is(err, domain.ErrDuplicateReviewRun) {
|
|
if existing, ok, getErr := e.store.GetReviewRunBySessionPRAndSHA(ctx, workerID, reviewState.PRURL, reviewState.TargetSHA); getErr != nil {
|
|
return TriggerResult{}, getErr
|
|
} else if ok {
|
|
reviews = replaceReviewLatestRun(reviews, reviewState.PRURL, reviewState.TargetSHA, existing)
|
|
continue
|
|
}
|
|
}
|
|
return TriggerResult{}, err
|
|
}
|
|
created = append(created, run)
|
|
reviews = replaceReviewLatestRun(reviews, reviewState.PRURL, reviewState.TargetSHA, run)
|
|
}
|
|
if len(created) == 0 {
|
|
return TriggerResult{Run: firstReusableRun(reviews), ReviewerHandleID: reviewRow.ReviewerHandleID, Created: false, Reviews: reviews}, nil
|
|
}
|
|
|
|
failRuns := func(start int, err error) error {
|
|
for _, run := range created[start:] {
|
|
if _, updateErr := e.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunFailed, domain.VerdictNone, err.Error(), ""); updateErr != nil {
|
|
return updateErr
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
handleID := ""
|
|
queue := reviewQueue(created)
|
|
if hasReview && reviewRow.ReviewerHandleID != "" {
|
|
alive, err := e.launcher.Alive(ctx, reviewRow.ReviewerHandleID)
|
|
if err != nil {
|
|
return TriggerResult{}, failRuns(0, err)
|
|
}
|
|
if alive && stoppedStaleReviewer {
|
|
if err := e.launcher.Stop(ctx, reviewRow.ReviewerHandleID); err != nil {
|
|
return TriggerResult{}, failRuns(0, fmt.Errorf("stop stale reviewer: %w", err))
|
|
}
|
|
} else if alive {
|
|
handleID = reviewRow.ReviewerHandleID
|
|
}
|
|
}
|
|
if handleID == "" {
|
|
h, err := e.launcher.Spawn(ctx, reviewLaunchSpec(worker, harness, created[0], queue, 0))
|
|
if err != nil {
|
|
return TriggerResult{}, failRuns(0, fmt.Errorf("launch reviewer: %w", err))
|
|
}
|
|
handleID = h
|
|
} else {
|
|
if err := e.launcher.Notify(ctx, handleID, reviewLaunchSpec(worker, harness, created[0], queue, 0)); err != nil {
|
|
return TriggerResult{}, failRuns(0, fmt.Errorf("notify reviewer: %w", err))
|
|
}
|
|
}
|
|
reviewRow, err = e.upsertReview(ctx, worker, harness, handleID, now)
|
|
if err != nil {
|
|
return TriggerResult{}, err
|
|
}
|
|
for i := range created {
|
|
created[i].ReviewID = reviewRow.ID
|
|
}
|
|
return TriggerResult{Run: created[0], ReviewerHandleID: handleID, Created: true, Reviews: reviews, CreatedRuns: created}, nil
|
|
}
|
|
|
|
func reviewLaunchSpec(worker domain.SessionRecord, harness domain.ReviewerHarness, run domain.ReviewRun, queue []ports.ReviewTask, index int) LaunchSpec {
|
|
return LaunchSpec{
|
|
RunID: run.ID,
|
|
WorkerID: worker.ID,
|
|
Harness: harness,
|
|
WorkspacePath: worker.Metadata.WorkspacePath,
|
|
PRURL: run.PRURL,
|
|
TargetSHA: run.TargetSHA,
|
|
ReviewQueue: queue,
|
|
ReviewIndex: index,
|
|
}
|
|
}
|
|
|
|
func reviewQueue(runs []domain.ReviewRun) []ports.ReviewTask {
|
|
queue := make([]ports.ReviewTask, 0, len(runs))
|
|
for _, run := range runs {
|
|
queue = append(queue, ports.ReviewTask{
|
|
RunID: run.ID,
|
|
PRURL: run.PRURL,
|
|
TargetSHA: run.TargetSHA,
|
|
})
|
|
}
|
|
return queue
|
|
}
|
|
|
|
func replaceReviewLatestRun(reviews []PRReviewState, prURL, targetSHA string, run domain.ReviewRun) []PRReviewState {
|
|
for i := range reviews {
|
|
if reviews[i].PRURL == prURL && reviews[i].TargetSHA == targetSHA {
|
|
reviews[i].LatestRun = &run
|
|
if run.Status == domain.ReviewRunRunning {
|
|
reviews[i].Status = ReviewStateRunning
|
|
}
|
|
break
|
|
}
|
|
}
|
|
return reviews
|
|
}
|
|
|
|
func firstReusableRun(reviews []PRReviewState) domain.ReviewRun {
|
|
// Legacy compatibility only: in the multi-PR model the authoritative state
|
|
// is Reviews. When no run is created, this field is just a best-effort
|
|
// non-empty run for older clients.
|
|
for _, review := range reviews {
|
|
if review.LatestRun != nil {
|
|
return *review.LatestRun
|
|
}
|
|
}
|
|
return domain.ReviewRun{}
|
|
}
|
|
|
|
// List returns a worker's review state: the live reviewer handle and its passes.
|
|
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)
|
|
}
|
|
unlock := e.lockWorker(workerID)
|
|
defer unlock()
|
|
|
|
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
|
|
}
|
|
if handle != "" && e.launcher != nil {
|
|
alive, err := e.launcher.Alive(ctx, handle)
|
|
if err != nil {
|
|
return SessionReviews{}, err
|
|
}
|
|
if !alive {
|
|
runs, err = e.failRunningRuns(ctx, runs, "reviewer stopped before submitting review")
|
|
if err != nil {
|
|
return SessionReviews{}, err
|
|
}
|
|
}
|
|
}
|
|
prs, err := e.prs.ListPRsBySession(ctx, workerID)
|
|
if err != nil {
|
|
return SessionReviews{}, err
|
|
}
|
|
return SessionReviews{ReviewerHandleID: handle, Runs: runs, Reviews: Plan(prs, runs)}, nil
|
|
}
|
|
|
|
func (e *Engine) failRunningRuns(ctx stdctx.Context, runs []domain.ReviewRun, body string) ([]domain.ReviewRun, error) {
|
|
for i := range runs {
|
|
if runs[i].Status != domain.ReviewRunRunning {
|
|
continue
|
|
}
|
|
updated, err := e.store.UpdateReviewRunResult(ctx, runs[i].ID, domain.ReviewRunFailed, domain.VerdictNone, body, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if updated {
|
|
runs[i].Status = domain.ReviewRunFailed
|
|
runs[i].Verdict = domain.VerdictNone
|
|
runs[i].Body = body
|
|
}
|
|
}
|
|
return runs, 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 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 {
|
|
return "", err
|
|
} else if ok {
|
|
cfg = proj.Config
|
|
}
|
|
}
|
|
return cfg.ResolveReviewerHarness(worker.Harness), nil
|
|
}
|
|
|
|
func (e *Engine) upsertReview(ctx stdctx.Context, worker domain.SessionRecord, harness domain.ReviewerHarness, 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: "",
|
|
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
|
|
}
|