Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/internal/daemon/lifecycle_wiring.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ func startSession(cfg config.Config, runtime runtimeselect.Runtime, store *sqlit
Launcher: reviewcore.NewLauncher(reviewers, runtime),
})
reviewSvc := reviewsvc.New(reviewEngine, store, reviewsvc.WithLifecycleReducer(lcm))
lcm.SetReviewTrigger(func(ctx context.Context, id domain.SessionID) error {
_, err := reviewSvc.Trigger(ctx, id)
return err
})
return sessionSvc, reviewSvc, mgr, nil
}

Expand Down
2 changes: 2 additions & 0 deletions backend/internal/httpd/apispec/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1863,6 +1863,8 @@ components:
type: integer
prUrl:
type: string
previousRun:
$ref: '#/components/schemas/ReviewRun'
status:
enum:
- needs_review
Expand Down
21 changes: 16 additions & 5 deletions backend/internal/lifecycle/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ type sessionStore interface {
// when no open PR remains and at least one merged) and to suppress
// merge-conflict nudges on PRs stacked behind an open parent.
ListPRsBySession(ctx context.Context, id domain.SessionID) ([]domain.PullRequest, error)
GetReviewBySession(ctx context.Context, id domain.SessionID) (domain.Review, bool, error)
ListReviewRunsBySession(ctx context.Context, id domain.SessionID) ([]domain.ReviewRun, error)
// GetPRLastNudgeSignature / UpdatePRLastNudgeSignature persist the
// reaction-dedup map so nudges survive a daemon restart.
GetPRLastNudgeSignature(ctx context.Context, prURL string) (string, error)
Expand All @@ -34,6 +36,8 @@ type notificationSink interface {
Notify(ctx context.Context, intent ports.NotificationIntent) error
}

type reviewTriggerFunc func(context.Context, domain.SessionID) error

// Option customizes a Manager.
type Option func(*Manager)

Expand All @@ -47,18 +51,25 @@ func WithTelemetry(sink ports.EventSink) Option {
return func(m *Manager) { m.telemetry = sink }
}

// SetReviewTrigger wires the review service after daemon construction has built
// both lifecycle and review components.
func (m *Manager) SetReviewTrigger(trigger reviewTriggerFunc) {
m.reviewTrigger = trigger
}

// Manager reduces runtime, activity, spawn, and termination observations into durable session facts.
// It also owns agent nudges caused by PR observations, including merge-conflict, CI-failure, and review-feedback prompts.
type Manager struct {
store sessionStore
messenger ports.AgentMessenger
notifications notificationSink

mu sync.Mutex
window time.Duration
clock func() time.Time
react reactionState
telemetry ports.EventSink
mu sync.Mutex
window time.Duration
clock func() time.Time
react reactionState
telemetry ports.EventSink
reviewTrigger reviewTriggerFunc
}

// New builds a Lifecycle Manager over the session store it writes and the messenger it uses for agent nudges.
Expand Down
75 changes: 75 additions & 0 deletions backend/internal/lifecycle/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ var ctx = context.Background()
type fakeStore struct {
sessions map[domain.SessionID]domain.SessionRecord
prs map[domain.SessionID][]domain.PullRequest
review *domain.Review
reviewRuns []domain.ReviewRun
signatures map[string]string

signatureWriteErr error
Expand All @@ -35,6 +37,17 @@ func (f *fakeStore) ListPRsBySession(_ context.Context, id domain.SessionID) ([]
return f.prs[id], nil
}

func (f *fakeStore) GetReviewBySession(_ context.Context, _ domain.SessionID) (domain.Review, bool, error) {
if f.review == nil {
return domain.Review{}, false, nil
}
return *f.review, true, nil
}

func (f *fakeStore) ListReviewRunsBySession(_ context.Context, _ domain.SessionID) ([]domain.ReviewRun, error) {
return append([]domain.ReviewRun(nil), f.reviewRuns...), nil
}

func (f *fakeStore) UpdateSession(_ context.Context, rec domain.SessionRecord) error {
f.sessions[rec.ID] = rec
return nil
Expand Down Expand Up @@ -271,6 +284,68 @@ func TestPRObservation_ReviewNudgeSanitizesCommentControlChars(t *testing.T) {
}
}

func TestPRObservation_AutoTriggersReviewWhenHeadAdvances(t *testing.T) {
m, st, _ := newManager()
st.sessions["mer-1"] = working("mer-1")
st.prs["mer-1"] = []domain.PullRequest{{URL: "pr1", HeadSHA: "sha2"}}
st.review = &domain.Review{ID: "review-1", SessionID: "mer-1"}
st.reviewRuns = []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunComplete, Verdict: domain.VerdictChangesRequested}}
var triggered []domain.SessionID
m.SetReviewTrigger(func(_ context.Context, id domain.SessionID) error {
triggered = append(triggered, id)
return nil
})

if err := m.ApplyPRObservation(ctx, "mer-1", ports.PRObservation{Fetched: true, URL: "pr1"}); err != nil {
t.Fatal(err)
}
if len(triggered) != 1 || triggered[0] != "mer-1" {
t.Fatalf("triggered = %#v, want mer-1", triggered)
}
}

func TestPRObservation_AutoReviewSkipsCurrentRunningHead(t *testing.T) {
m, st, _ := newManager()
st.sessions["mer-1"] = working("mer-1")
st.prs["mer-1"] = []domain.PullRequest{{URL: "pr1", HeadSHA: "sha2"}}
st.review = &domain.Review{ID: "review-1", SessionID: "mer-1"}
st.reviewRuns = []domain.ReviewRun{
{ID: "run-1", SessionID: "mer-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunComplete, Verdict: domain.VerdictChangesRequested},
{ID: "run-2", SessionID: "mer-1", PRURL: "pr1", TargetSHA: "sha2", Status: domain.ReviewRunRunning},
}
calls := 0
m.SetReviewTrigger(func(context.Context, domain.SessionID) error {
calls++
return nil
})

if err := m.ApplyPRObservation(ctx, "mer-1", ports.PRObservation{Fetched: true, URL: "pr1"}); err != nil {
t.Fatal(err)
}
if calls != 0 {
t.Fatalf("trigger calls = %d, want 0", calls)
}
}

func TestPRObservation_AutoReviewRequiresReviewRow(t *testing.T) {
m, st, _ := newManager()
st.sessions["mer-1"] = working("mer-1")
st.prs["mer-1"] = []domain.PullRequest{{URL: "pr1", HeadSHA: "sha2"}}
st.reviewRuns = []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunComplete, Verdict: domain.VerdictApproved}}
calls := 0
m.SetReviewTrigger(func(context.Context, domain.SessionID) error {
calls++
return nil
})

if err := m.ApplyPRObservation(ctx, "mer-1", ports.PRObservation{Fetched: true, URL: "pr1"}); err != nil {
t.Fatal(err)
}
if calls != 0 {
t.Fatalf("trigger calls = %d, want 0", calls)
}
}

func TestSCMObservationProjectsToExistingPRReactions(t *testing.T) {
m, st, msg := newManager()
st.sessions["mer-1"] = working("mer-1")
Expand Down
47 changes: 47 additions & 0 deletions backend/internal/lifecycle/reactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ func (m *Manager) ApplyPRObservation(ctx context.Context, id domain.SessionID, o
if rec.IsTerminated || rec.Activity.State == domain.ActivityWaitingInput {
return nil
}
if err := m.autoTriggerReviewForHeadAdvance(ctx, id, o.URL); err != nil {
return err
}
if o.CI == domain.CIFailing {
for _, ch := range o.Checks {
if ch.Status == domain.PRCheckFailed {
Expand Down Expand Up @@ -189,6 +192,50 @@ func (m *Manager) ApplyPRObservation(ctx context.Context, id domain.SessionID, o
return nil
}

func (m *Manager) autoTriggerReviewForHeadAdvance(ctx context.Context, id domain.SessionID, prURL string) error {
if m.reviewTrigger == nil || prURL == "" {
return nil
}
if _, ok, err := m.store.GetReviewBySession(ctx, id); err != nil || !ok {
return err
}
prs, err := m.store.ListPRsBySession(ctx, id)
if err != nil {
return err
}
var headSHA string
for _, pr := range prs {
if pr.URL == prURL {
headSHA = pr.HeadSHA
break
}
}
if headSHA == "" {
return nil
}
runs, err := m.store.ListReviewRunsBySession(ctx, id)
if err != nil {
return err
}
hasPreviousRun := false
for _, run := range runs {
if run.PRURL != prURL {
continue
}
if run.TargetSHA == headSHA {
if run.Status == domain.ReviewRunRunning {
return nil
}
return nil
}
hasPreviousRun = true
}
if !hasPreviousRun {
return nil
}
return m.reviewTrigger(ctx, id)
}

// 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.
Expand Down
33 changes: 27 additions & 6 deletions backend/internal/review/planner.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,21 @@ const (

// PRReviewState is one PR-scoped review decision for a worker session.
type PRReviewState struct {
PRURL string `json:"prUrl"`
PRNumber int `json:"prNumber"`
Title string `json:"title"`
TargetSHA string `json:"targetSha"`
Status StateStatus `json:"status" enum:"needs_review,running,up_to_date,changes_requested,ineligible"`
LatestRun *domain.ReviewRun `json:"latestRun,omitempty"`
PRURL string `json:"prUrl"`
PRNumber int `json:"prNumber"`
Title string `json:"title"`
TargetSHA string `json:"targetSha"`
Status StateStatus `json:"status" enum:"needs_review,running,up_to_date,changes_requested,ineligible"`
LatestRun *domain.ReviewRun `json:"latestRun,omitempty"`
PreviousRun *domain.ReviewRun `json:"previousRun,omitempty"`
}

// Plan computes per-PR review work from the currently observed PRs and existing
// review runs. It is pure so the trigger path and API list path share exactly
// the same eligibility/status rules.
func Plan(prs []domain.PullRequest, runs []domain.ReviewRun) []PRReviewState {
latest := latestRunsByPRAndSHA(runs)
latestCompletedByPR := latestCompletedRunsByPR(runs)
reviews := make([]PRReviewState, 0, len(prs))
for _, pr := range prs {
review := PRReviewState{
Expand All @@ -46,6 +48,9 @@ func Plan(prs []domain.PullRequest, runs []domain.ReviewRun) []PRReviewState {
TargetSHA: pr.HeadSHA,
Status: ReviewStateNeedsReview,
}
if run, ok := latestCompletedByPR[review.PRURL]; ok && run.TargetSHA != review.TargetSHA {
review.PreviousRun = &run
}
if pr.URL == "" || pr.HeadSHA == "" || pr.Draft || pr.Merged || pr.Closed {
review.Status = ReviewStateIneligible
if run, ok := latest[review.PRURL+"\x00"+review.TargetSHA]; ok {
Expand Down Expand Up @@ -93,3 +98,19 @@ func latestRunsByPRAndSHA(runs []domain.ReviewRun) map[string]domain.ReviewRun {
}
return latest
}

func latestCompletedRunsByPR(runs []domain.ReviewRun) map[string]domain.ReviewRun {
latest := make(map[string]domain.ReviewRun)
for _, run := range runs {
if run.PRURL == "" || run.TargetSHA == "" {
continue
}
if run.Verdict != domain.VerdictApproved && run.Verdict != domain.VerdictChangesRequested {
continue
}
if existing, ok := latest[run.PRURL]; !ok || run.CreatedAt.After(existing.CreatedAt) {
latest[run.PRURL] = run
}
}
return latest
}
14 changes: 14 additions & 0 deletions backend/internal/review/planner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,20 @@ func TestPlanStatuses(t *testing.T) {
}
}

func TestPlanIncludesPreviousCompletedRunForAdvancedHead(t *testing.T) {
now := time.Unix(100, 0).UTC()
got := Plan([]domain.PullRequest{planPR("pr1", 1, "sha3")}, []domain.ReviewRun{
{ID: "run-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunComplete, Verdict: domain.VerdictChangesRequested, CreatedAt: now},
{ID: "run-2", PRURL: "pr1", TargetSHA: "sha2", Status: domain.ReviewRunComplete, Verdict: domain.VerdictApproved, CreatedAt: now.Add(time.Second)},
})
if len(got) != 1 {
t.Fatalf("review states = %d, want 1", len(got))
}
if got[0].PreviousRun == nil || got[0].PreviousRun.ID != "run-2" {
t.Fatalf("previous run = %+v, want latest completed run-2", got[0].PreviousRun)
}
}

func planPR(url string, n int, sha string) domain.PullRequest {
return domain.PullRequest{URL: url, Number: n, HeadSHA: sha}
}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,7 @@ export interface components {
latestRun?: components["schemas"]["ReviewRun"];
prNumber: number;
prUrl: string;
previousRun?: components["schemas"]["ReviewRun"];
/** @enum {string} */
status: "needs_review" | "running" | "up_to_date" | "changes_requested" | "ineligible";
targetSha: string;
Expand Down
25 changes: 24 additions & 1 deletion frontend/src/renderer/components/SessionInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,7 @@ function ReviewPanel({

function ReviewStateRow({ reviewState }: { reviewState: PRReviewState }) {
const verdict = reviewVerdict(reviewState);
const previousVerdict = previousReviewVerdict(reviewState);
const title = reviewState.title?.trim() || `PR #${reviewState.prNumber}`;
return (
<div
Expand All @@ -564,7 +565,14 @@ function ReviewStateRow({ reviewState }: { reviewState: PRReviewState }) {
<span className="reviewer-row__number">#{reviewState.prNumber}</span>
</div>
</div>
<span className={cn("reviewer-row__verdict", `reviewer-row__verdict--${verdict.tone}`)}>{verdict.label}</span>
<span className="reviewer-row__verdict-group">
<span className={cn("reviewer-row__verdict", `reviewer-row__verdict--${verdict.tone}`)}>{verdict.label}</span>
{previousVerdict ? (
<span className={cn("reviewer-row__previous", `reviewer-row__previous--${previousVerdict.tone}`)}>
Previous: {previousVerdict.label}
</span>
) : null}
</span>
</div>
);
}
Expand Down Expand Up @@ -610,6 +618,21 @@ function reviewVerdict(reviewState: PRReviewState): {
return { label: "Not run", tone: "neutral" };
}

function previousReviewVerdict(reviewState: PRReviewState): {
label: string;
tone: "success" | "danger";
} | null {
if (reviewState.status !== "running") return null;
switch (reviewState.previousRun?.verdict) {
case "approved":
return { label: "Approved", tone: "success" };
case "changes_requested":
return { label: "Changes requested", tone: "danger" };
default:
return null;
}
}

function reviewSessionRunAction(reviewStates: PRReviewState[], isTriggering: boolean): string {
if (isTriggering || reviewStates.some((reviewState) => reviewState.status === "running")) {
return "Reviewing...";
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/renderer/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1283,6 +1283,14 @@ body.is-resizing-x [data-slot="sidebar-container"] {
color: var(--fg-muted);
}

.reviewer-row__verdict-group {
display: inline-flex;
min-width: max-content;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}

.reviewer-row__verdict--running {
color: var(--orange);
}
Expand All @@ -1295,6 +1303,21 @@ body.is-resizing-x [data-slot="sidebar-container"] {
color: var(--red);
}

.reviewer-row__previous {
white-space: nowrap;
font-size: 10px;
font-weight: 600;
color: var(--fg-passive);
}

.reviewer-row__previous--success {
color: color-mix(in srgb, var(--green) 78%, var(--fg-passive));
}

.reviewer-row__previous--danger {
color: color-mix(in srgb, var(--red) 78%, var(--fg-passive));
}

@media (max-width: 680px) {
.reviewer-row {
grid-template-columns: minmax(0, 1fr) auto;
Expand Down