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
This commit is contained in:
parent
cbd2a1baba
commit
f85b0d2ffe
|
|
@ -121,12 +121,8 @@ func startSession(cfg config.Config, runtime *zellij.Runtime, store *sqlite.Stor
|
||||||
PRs: store,
|
PRs: store,
|
||||||
Projects: store,
|
Projects: store,
|
||||||
Launcher: reviewcore.NewLauncher(reviewers, runtime),
|
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
|
return sessionSvc, reviewSvc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,9 @@ type ReviewRun struct {
|
||||||
// submit time. It is empty when the reviewer could not post to the provider.
|
// 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
|
// 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.
|
// worker so the worker knows exactly which review to address and reply to.
|
||||||
GithubReviewID string `json:"githubReviewId"`
|
GithubReviewID string `json:"githubReviewId"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
DeliveredAt *time.Time `json:"deliveredAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReviewRunStatus is the lifecycle state of a single review pass.
|
// ReviewRunStatus is the lifecycle state of a single review pass.
|
||||||
|
|
@ -55,9 +56,10 @@ type ReviewRunStatus string
|
||||||
|
|
||||||
// Review run statuses.
|
// Review run statuses.
|
||||||
const (
|
const (
|
||||||
ReviewRunRunning ReviewRunStatus = "running"
|
ReviewRunRunning ReviewRunStatus = "running"
|
||||||
ReviewRunComplete ReviewRunStatus = "complete"
|
ReviewRunComplete ReviewRunStatus = "complete"
|
||||||
ReviewRunFailed ReviewRunStatus = "failed"
|
ReviewRunDelivered ReviewRunStatus = "delivered"
|
||||||
|
ReviewRunFailed ReviewRunStatus = "failed"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ReviewVerdict is the outcome a reviewer reports. The empty verdict marks a
|
// ReviewVerdict is the outcome a reviewer reports. The empty verdict marks a
|
||||||
|
|
|
||||||
|
|
@ -1897,6 +1897,11 @@ components:
|
||||||
createdAt:
|
createdAt:
|
||||||
format: date-time
|
format: date-time
|
||||||
type: string
|
type: string
|
||||||
|
deliveredAt:
|
||||||
|
format: date-time
|
||||||
|
type:
|
||||||
|
- "null"
|
||||||
|
- string
|
||||||
githubReviewId:
|
githubReviewId:
|
||||||
type: string
|
type: string
|
||||||
harness:
|
harness:
|
||||||
|
|
|
||||||
|
|
@ -222,7 +222,7 @@ func TestPRObservation_CIFailingNudgesAgentWithLogs(t *testing.T) {
|
||||||
func TestPRObservation_ReviewCommentsNudgeAgent(t *testing.T) {
|
func TestPRObservation_ReviewCommentsNudgeAgent(t *testing.T) {
|
||||||
m, st, msg := newManager()
|
m, st, msg := newManager()
|
||||||
st.sessions["mer-1"] = working("mer-1")
|
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 {
|
if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil {
|
||||||
t.Fatal(err)
|
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) {
|
func TestApplyTrackerFacts_TerminalStateMarksTerminated(t *testing.T) {
|
||||||
for _, state := range []domain.NormalizedIssueState{domain.IssueDone, domain.IssueCancelled} {
|
for _, state := range []domain.NormalizedIssueState{domain.IssueDone, domain.IssueCancelled} {
|
||||||
t.Run(string(state), func(t *testing.T) {
|
t.Run(string(state), func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ package lifecycle
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||||
|
|
@ -13,6 +15,33 @@ import (
|
||||||
|
|
||||||
const reviewMaxNudge = 3
|
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 {
|
type reactionState struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
seen map[string]string
|
seen map[string]string
|
||||||
|
|
@ -108,6 +137,40 @@ func (m *Manager) ApplyPRObservation(ctx context.Context, id domain.SessionID, o
|
||||||
return nil
|
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
|
// 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
|
// 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.
|
// PRs, or with any open PR, is not complete.
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
package review
|
package review
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
stdctx "context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -18,7 +18,6 @@ import (
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
"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.
|
// 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
|
// Store is the persistence surface the engine needs. *sqlite.Store satisfies it
|
||||||
// in production; tests use a fake.
|
// in production; tests use a fake.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
UpsertReview(ctx context.Context, r domain.Review) error
|
UpsertReview(ctx stdctx.Context, r domain.Review) error
|
||||||
GetReviewBySession(ctx context.Context, id domain.SessionID) (domain.Review, bool, error)
|
GetReviewBySession(ctx stdctx.Context, id domain.SessionID) (domain.Review, bool, error)
|
||||||
InsertReviewRun(ctx context.Context, r domain.ReviewRun) error
|
InsertReviewRun(ctx stdctx.Context, r domain.ReviewRun) error
|
||||||
UpdateReviewRunResult(ctx context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body, githubReviewID string) (bool, error)
|
UpdateReviewRunResult(ctx stdctx.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body, githubReviewID string) (bool, error)
|
||||||
GetReviewRun(ctx context.Context, id string) (domain.ReviewRun, bool, error)
|
SupersedeReviewRun(ctx stdctx.Context, id, body string) (bool, error)
|
||||||
GetReviewRunBySessionAndSHA(ctx context.Context, id domain.SessionID, targetSHA string) (domain.ReviewRun, bool, error)
|
SupersedeStaleRunningReviewRuns(ctx stdctx.Context, sessionID domain.SessionID, targetSHA, body string) (int64, error)
|
||||||
ListReviewRunsBySession(ctx context.Context, id domain.SessionID) ([]domain.ReviewRun, 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.
|
// Sessions resolves the worker session under review.
|
||||||
type Sessions interface {
|
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.
|
// PRs resolves the PR a worker owns.
|
||||||
type PRs interface {
|
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.
|
// Projects resolves the per-project reviewer config.
|
||||||
type Projects interface {
|
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.
|
// Deps wires the engine.
|
||||||
type Deps struct {
|
type Deps struct {
|
||||||
Store Store
|
Store Store
|
||||||
Sessions Sessions
|
Sessions Sessions
|
||||||
PRs PRs
|
PRs PRs
|
||||||
Projects Projects
|
Projects Projects
|
||||||
Launcher Launcher
|
Launcher Launcher
|
||||||
Messenger ports.AgentMessenger
|
|
||||||
|
|
||||||
// Clock and NewID are injectable for deterministic tests.
|
// Clock and NewID are injectable for deterministic tests.
|
||||||
Clock func() time.Time
|
Clock func() time.Time
|
||||||
|
|
@ -70,14 +70,13 @@ type Deps struct {
|
||||||
|
|
||||||
// Engine is the core code-review engine.
|
// Engine is the core code-review engine.
|
||||||
type Engine struct {
|
type Engine struct {
|
||||||
store Store
|
store Store
|
||||||
sessions Sessions
|
sessions Sessions
|
||||||
prs PRs
|
prs PRs
|
||||||
projects Projects
|
projects Projects
|
||||||
launcher Launcher
|
launcher Launcher
|
||||||
messenger ports.AgentMessenger
|
clock func() time.Time
|
||||||
clock func() time.Time
|
newID func() string
|
||||||
newID func() string
|
|
||||||
|
|
||||||
// triggerMu guards triggerLocks; triggerLocks holds one mutex per worker
|
// triggerMu guards triggerLocks; triggerLocks holds one mutex per worker
|
||||||
// session so concurrent Trigger calls for the same worker serialise (see
|
// session so concurrent Trigger calls for the same worker serialise (see
|
||||||
|
|
@ -102,7 +101,6 @@ func New(d Deps) *Engine {
|
||||||
prs: d.PRs,
|
prs: d.PRs,
|
||||||
projects: d.Projects,
|
projects: d.Projects,
|
||||||
launcher: d.Launcher,
|
launcher: d.Launcher,
|
||||||
messenger: d.Messenger,
|
|
||||||
clock: clock,
|
clock: clock,
|
||||||
newID: newID,
|
newID: newID,
|
||||||
triggerLocks: make(map[domain.SessionID]*sync.Mutex),
|
triggerLocks: make(map[domain.SessionID]*sync.Mutex),
|
||||||
|
|
@ -151,7 +149,7 @@ type SessionReviews struct {
|
||||||
// new commit; if not, a fresh reviewer is spawned;
|
// new commit; if not, a fresh reviewer is spawned;
|
||||||
// - the run is recorded before launch so startup failures leave a visible
|
// - the run is recorded before launch so startup failures leave a visible
|
||||||
// failed pass instead of an empty gap.
|
// 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 == "" {
|
if workerID == "" {
|
||||||
return TriggerResult{}, fmt.Errorf("%w: worker session id is required", ErrInvalid)
|
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
|
return TriggerResult{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idempotency: return a non-failed pass as-is. Failed passes stay visible
|
// Idempotency: a pass for this commit is reusable while it is still running
|
||||||
// but can be retried after the user fixes the underlying issue.
|
// 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 {
|
if existing, ok, err := e.store.GetReviewRunBySessionAndSHA(ctx, workerID, targetSHA); err != nil {
|
||||||
return TriggerResult{}, err
|
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
|
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)
|
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
|
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.
|
// 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 == "" {
|
if workerID == "" {
|
||||||
return SessionReviews{}, fmt.Errorf("%w: worker session id is required", ErrInvalid)
|
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
|
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)
|
prs, err := e.prs.ListPRsBySession(ctx, workerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.PullRequest{}, err
|
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
|
// reviewerHarness resolves which harness reviews the worker's PR: a configured
|
||||||
// reviewer wins, otherwise the worker's own harness is reused (falling back to
|
// reviewer wins, otherwise the worker's own harness is reused (falling back to
|
||||||
// claude-code), per domain.ResolveReviewerHarness.
|
// 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
|
var cfg domain.ProjectConfig
|
||||||
if e.projects != nil {
|
if e.projects != nil {
|
||||||
if proj, ok, err := e.projects.GetProject(ctx, string(worker.ProjectID)); err != 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
|
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)
|
existing, ok, err := e.store.GetReviewBySession(ctx, worker.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.Review{}, err
|
return domain.Review{}, err
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,30 @@ func (f *fakeStore) UpdateReviewRunResult(_ context.Context, id string, status d
|
||||||
}
|
}
|
||||||
return false, nil
|
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) {
|
func (f *fakeStore) GetReviewRun(_ context.Context, id string) (domain.ReviewRun, bool, error) {
|
||||||
for _, r := range f.runs {
|
for _, r := range f.runs {
|
||||||
if r.ID == id {
|
if r.ID == id {
|
||||||
|
|
@ -129,20 +153,8 @@ func (f *fakeLauncher) Notify(_ context.Context, handleID string, spec LaunchSpe
|
||||||
f.gotSpec = spec
|
f.gotSpec = spec
|
||||||
return f.notifyErr
|
return f.notifyErr
|
||||||
}
|
}
|
||||||
func (f *fakeLauncher) Alive(_ context.Context, _ string) (bool, error) { return f.alive, nil }
|
func (f *fakeLauncher) Alive(_ context.Context, _ string) (bool, error) {
|
||||||
|
return f.alive || f.spawned, 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 liveWorker() domain.SessionRecord {
|
func liveWorker() domain.SessionRecord {
|
||||||
|
|
@ -222,7 +234,6 @@ func TestTriggerConcurrentSameWorkerSpawnsOnce(t *testing.T) {
|
||||||
created++
|
created++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Exactly one trigger does the work; the rest reuse its run.
|
|
||||||
if created != 1 {
|
if created != 1 {
|
||||||
t.Errorf("Created=true count = %d, want exactly 1", created)
|
t.Errorf("Created=true count = %d, want exactly 1", created)
|
||||||
}
|
}
|
||||||
|
|
@ -259,9 +270,12 @@ func TestTriggerFallsBackToExistingRunOnUniqueConflict(t *testing.T) {
|
||||||
func TestTriggerIsIdempotentForSameCommit(t *testing.T) {
|
func TestTriggerIsIdempotentForSameCommit(t *testing.T) {
|
||||||
store := &fakeStore{
|
store := &fakeStore{
|
||||||
review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"},
|
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)
|
eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prAt("sha1"), fakeProjects{}, launcher)
|
||||||
|
|
||||||
res, err := eng.Trigger(context.Background(), "mer-1")
|
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) {
|
func TestTriggerNotifiesLiveReviewerOnNewCommit(t *testing.T) {
|
||||||
store := &fakeStore{
|
store := &fakeStore{
|
||||||
review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"},
|
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) {
|
func TestTriggerSpawnsWhenReviewerDead(t *testing.T) {
|
||||||
store := &fakeStore{
|
store := &fakeStore{
|
||||||
review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"},
|
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) {
|
func TestListReturnsHandleAndRuns(t *testing.T) {
|
||||||
store := &fakeStore{
|
store := &fakeStore{
|
||||||
review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"},
|
review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"},
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,11 @@ package review
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||||
|
"github.com/aoagents/agent-orchestrator/backend/internal/lifecycle"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||||
reviewcore "github.com/aoagents/agent-orchestrator/backend/internal/review"
|
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.
|
// Service is the API-facing review service. It delegates to the core engine.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
engine *reviewcore.Engine
|
engine *reviewcore.Engine
|
||||||
|
store Store
|
||||||
|
lifecycle Reducer
|
||||||
|
clock func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ Manager = (*Service)(nil)
|
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.
|
// New wraps a core review engine as the API-facing service.
|
||||||
func New(engine *reviewcore.Engine) *Service {
|
func New(engine *reviewcore.Engine, store Store, opts ...Option) *Service {
|
||||||
return &Service{engine: engine}
|
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.
|
// 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.
|
// 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) {
|
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.
|
// List returns a worker's review state.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -146,6 +146,7 @@ type ReviewRun struct {
|
||||||
Body string
|
Body string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
GithubReviewID string
|
GithubReviewID string
|
||||||
|
DeliveredAt sql.NullTime
|
||||||
}
|
}
|
||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ package gen
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
"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
|
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 = ?
|
FROM review_run WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
@ -53,12 +54,13 @@ func (q *Queries) GetReviewRun(ctx context.Context, id string) (ReviewRun, error
|
||||||
&i.Body,
|
&i.Body,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.GithubReviewID,
|
&i.GithubReviewID,
|
||||||
|
&i.DeliveredAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getReviewRunBySessionAndSHA = `-- name: GetReviewRunBySessionAndSHA :one
|
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
|
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.Body,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.GithubReviewID,
|
&i.GithubReviewID,
|
||||||
|
&i.DeliveredAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +126,7 @@ func (q *Queries) InsertReviewRun(ctx context.Context, arg InsertReviewRunParams
|
||||||
}
|
}
|
||||||
|
|
||||||
const listReviewRunsBySession = `-- name: ListReviewRunsBySession :many
|
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
|
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.Body,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.GithubReviewID,
|
&i.GithubReviewID,
|
||||||
|
&i.DeliveredAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -162,6 +166,58 @@ func (q *Queries) ListReviewRunsBySession(ctx context.Context, sessionID domain.
|
||||||
return items, nil
|
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
|
const updateReviewRunResult = `-- name: UpdateReviewRunResult :execrows
|
||||||
UPDATE review_run SET status = ?, verdict = ?, body = ?, github_review_id = ? WHERE id = ? AND status = 'running'
|
UPDATE review_run SET status = ?, verdict = ?, body = ?, github_review_id = ? WHERE id = ? AND status = 'running'
|
||||||
`
|
`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -18,14 +18,23 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||||
-- name: UpdateReviewRunResult :execrows
|
-- name: UpdateReviewRunResult :execrows
|
||||||
UPDATE review_run SET status = ?, verdict = ?, body = ?, github_review_id = ? WHERE id = ? AND status = 'running';
|
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
|
-- 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 = ?;
|
FROM review_run WHERE id = ?;
|
||||||
|
|
||||||
-- name: GetReviewRunBySessionAndSHA :one
|
-- 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;
|
FROM review_run WHERE session_id = ? AND target_sha = ? ORDER BY created_at DESC LIMIT 1;
|
||||||
|
|
||||||
-- name: ListReviewRunsBySession :many
|
-- 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;
|
FROM review_run WHERE session_id = ? ORDER BY created_at DESC;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen"
|
"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
|
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.
|
// GetReviewRun returns one review pass by id.
|
||||||
func (s *Store) GetReviewRun(ctx context.Context, id string) (domain.ReviewRun, bool, error) {
|
func (s *Store) GetReviewRun(ctx context.Context, id string) (domain.ReviewRun, bool, error) {
|
||||||
row, err := s.qr.GetReviewRun(ctx, id)
|
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 {
|
func reviewRunFromRow(r gen.ReviewRun) domain.ReviewRun {
|
||||||
|
var deliveredAt *time.Time
|
||||||
|
if r.DeliveredAt.Valid {
|
||||||
|
t := r.DeliveredAt.Time
|
||||||
|
deliveredAt = &t
|
||||||
|
}
|
||||||
return domain.ReviewRun{
|
return domain.ReviewRun{
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
ReviewID: r.ReviewID,
|
ReviewID: r.ReviewID,
|
||||||
|
|
@ -147,5 +195,6 @@ func reviewRunFromRow(r gen.ReviewRun) domain.ReviewRun {
|
||||||
Body: r.Body,
|
Body: r.Body,
|
||||||
GithubReviewID: r.GithubReviewID,
|
GithubReviewID: r.GithubReviewID,
|
||||||
CreatedAt: r.CreatedAt,
|
CreatedAt: r.CreatedAt,
|
||||||
|
DeliveredAt: deliveredAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -680,6 +680,8 @@ export interface components {
|
||||||
body: string;
|
body: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
deliveredAt?: null | string;
|
||||||
githubReviewId: string;
|
githubReviewId: string;
|
||||||
harness: string;
|
harness: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue