Files
byre/internal/agents/review.go
T

723 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agents
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"g.fc.run/ai/byre/internal/api"
"g.fc.run/ai/byre/internal/git"
"g.fc.run/ai/byre/internal/ids"
"g.fc.run/ai/byre/internal/mcp"
"g.fc.run/ai/byre/internal/store"
"g.fc.run/ai/byre/internal/workflow"
)
const maxReviewFiles = 200
var severities = []string{"critical", "high", "medium", "low", "info"}
const reviewGuidance = `You are a reviewer running inside byre. Another agent is doing a task; you review all of its work so far, and a human may read your review in byre's app.
- Your working directory is a detached checkout of the snapshot under review. Read, build and run whatever you need there, but don't edit, commit or push: the checkout is discarded when you finish, and the task's own worktree belongs to its agent.
- Report with byre's submit_review tool, exactly once, then end your turn with one line. A review that isn't submitted is lost.
- Start the review with ` + "`change`" + `: what the change does, in a few sentences, for someone who hasn't read it.
- Each finding says what is wrong, where (path and line), why it matters and what to do about it. Leave out praise and nits. No findings is a fine result.
- With no findings, the summary is exactly "No significant findings." and nothing else. With findings, the summary is a bulleted Markdown list, one line per finding, and nothing else.
- Show rather than quote. Give a finding's file and lines and byre shows that part of the diff, so don't paste code. When a picture would explain something better than prose, such as a data flow, call graph, state machine or schema the change alters, draw it in a ` + "```mermaid" + ` block. When the change alters something you can see, you can run it (on any free port on 127.0.0.1) and attach screenshots taken with the Playwright tools. Most reviews need neither; leave out a diagram or screenshot that adds nothing to the text.`
func reviewSchema() json.RawMessage {
images := map[string]any{
"type": "array",
"description": "Screenshots, only when they show something the text can't.",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "A PNG, JPEG, GIF or WebP file of at most 5 MiB you saved: absolute, or relative to your working directory. byre keeps a copy."},
"caption": map[string]any{"type": "string", "description": "One line saying what it shows."},
},
"required": []any{"path"},
},
}
b, err := json.Marshal(map[string]any{
"type": "object",
"properties": map[string]any{
"change": map[string]any{"type": "string", "description": "What the change does, in a few sentences of Markdown, for someone who hasn't read it."},
"summary": map[string]any{"type": "string", "description": "Exactly \"No significant findings.\" when findings is empty; otherwise a bulleted Markdown list with one line per finding, and nothing else."},
"findings": map[string]any{
"type": "array",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"severity": map[string]any{"type": "string", "enum": severities},
"category": map[string]any{"type": "string", "description": "A short label, such as correctness, security or tests."},
"title": map[string]any{"type": "string", "description": "One line."},
"detail": map[string]any{"type": "string", "description": "What is wrong, why it matters and what to do, in Markdown."},
"path": map[string]any{"type": "string", "description": "Repo-relative path of the file, if the finding is about one."},
"line": map[string]any{"type": "integer", "description": "Line in the file as of this snapshot; byre shows the diff around it."},
"end_line": map[string]any{"type": "integer", "description": "Last line, when the finding covers a range."},
"images": images,
},
"required": []any{"severity", "title", "detail"},
},
},
"images": images,
},
"required": []any{"change", "summary", "findings"},
})
if err != nil {
panic(err)
}
return b
}
func (m *Manager) reviewDir(projectID, reviewID string) string {
return filepath.Join(m.worktreeRoot, projectID, "review-"+reviewID)
}
func (m *Manager) reviewMediaDir(projectID, reviewID string) string {
return m.reviewDir(projectID, reviewID) + "-media"
}
func playwrightMCP(outputDir string) mcp.Stdio {
return mcp.Stdio{
Name: "playwright",
Command: "npx",
Args: []string{"--yes", "@playwright/mcp@0.0.83", "--headless", "--isolated", "--browser", "chrome", "--file-paths", "absolute", "--output-dir", outputDir},
}
}
func (m *Manager) startReviews(ref store.SnapshotRef) {
ctx := context.Background()
m.reviewMu.Lock()
defer m.reviewMu.Unlock()
err := m.beginReviews(ctx, ref)
if err != nil {
slog.Error("start review", "task", ref.TaskID, "snapshot", ref.N, "err", err)
}
}
func (m *Manager) beginReviews(ctx context.Context, ref store.SnapshotRef) error {
task, project, err := m.activeTask(ctx, ref.TaskID)
if err != nil {
return err
}
snap, err := m.store.GetSnapshot(ctx, task.Id, ref.N)
if err != nil {
return err
}
now := time.Now().UTC()
finished := func(state api.ReviewState, stage, problem string) error {
return m.store.CreateReview(ctx, api.Review{Id: ids.New(ids.Review), TaskId: task.Id, SnapshotN: snap.N, Stage: stage, State: state, Error: problem, CreatedAt: now, FinishedAt: &now}, "")
}
if project.ReviewWorkflow == "" {
return finished(api.ReviewStateFailed, "", fmt.Sprintf("project %s has no review workflow", project.Name))
}
changes, err := git.Compare(ctx, project.RepoPath, project.DefaultBranch, snap.CommitSha, snap.TreeSha, "")
if err != nil {
return finished(api.ReviewStateFailed, project.ReviewWorkflow, fmt.Sprintf("byre couldn't compare snapshot %d with %s: %v", snap.N, project.DefaultBranch, err))
}
if len(changes.Files) == 0 {
slog.Info("review skipped, snapshot has no diff", "task", task.Id, "snapshot", snap.N)
return finished(api.ReviewStateSkipped, "", "")
}
frozen, err := m.store.FreezeWorkflow(ctx, project.ReviewWorkflow)
if err == nil {
err = reviewProblem(frozen.Definition)
}
if err != nil {
return finished(api.ReviewStateFailed, project.ReviewWorkflow, fmt.Sprintf("project %s reviews with workflow %q, which can't run: %v", project.Name, project.ReviewWorkflow, err))
}
stages := []store.StageStart{}
for _, stage := range workflow.Starts(frozen.Definition) {
stages = append(stages, store.StageStart{TaskID: task.Id, SnapshotN: snap.N, DefinitionID: frozen.DefinitionID, Definition: frozen.Definition, Stage: stage})
}
return m.startReviewers(ctx, task, project, snap, stages)
}
func (m *Manager) startReviewStage(s store.StageStart) {
ctx := context.Background()
m.reviewMu.Lock()
defer m.reviewMu.Unlock()
err := m.beginReviewStage(ctx, s)
if err != nil {
slog.Error("start review stage", "task", s.TaskID, "snapshot", s.SnapshotN, "stage", s.Stage, "err", err)
}
}
func (m *Manager) beginReviewStage(ctx context.Context, s store.StageStart) error {
task, project, err := m.activeTask(ctx, s.TaskID)
if err != nil {
return err
}
snap, err := m.store.GetSnapshot(ctx, task.Id, s.SnapshotN)
if err != nil {
return err
}
return m.startReviewers(ctx, task, project, snap, []store.StageStart{s})
}
func (m *Manager) startReviewers(ctx context.Context, task api.Task, project api.Project, snap api.Snapshot, stages []store.StageStart) error {
if len(stages) == 0 {
return nil
}
now := time.Now().UTC()
reviewers := []store.Reviewer{}
failed := []api.Review{}
prompts := map[string]string{}
for _, s := range stages {
model := workflow.Get(s.Definition, s.Stage).Model
vendor := vendorOf(model)
r := api.Review{Id: ids.New(ids.Review), TaskId: task.Id, SnapshotN: snap.N, Stage: s.Stage, Vendor: string(vendor), Model: model, CreatedAt: now}
prompt, err := m.reviewPrompt(ctx, task, project, snap, s.Definition, s.Stage, s.Inputs)
if err != nil {
r.State = api.ReviewStateFailed
r.Error = "the reviewer didn't start: " + err.Error()
r.FinishedAt = &now
failed = append(failed, r)
continue
}
prompts[r.Id] = prompt
reviewers = append(reviewers, store.Reviewer{
Session: api.AgentSession{
Id: ids.New(ids.Session),
TaskId: task.Id,
Vendor: vendor,
Model: model,
VendorSessionId: initialVendorSessionID(vendor),
State: api.AgentSessionStateRunning,
Role: api.Reviewer,
CreatedBy: task.CreatedBy,
CreatedAt: now,
},
Review: r,
})
}
err := m.store.StartReviewers(ctx, project.Id, stages[0].DefinitionID, reviewers, failed)
if err != nil {
return err
}
for _, rv := range reviewers {
m.launchReviewer(ctx, task, project, snap, rv, prompts[rv.Review.Id])
}
return nil
}
func (m *Manager) launchReviewer(ctx context.Context, task api.Task, project api.Project, snap api.Snapshot, rv store.Reviewer, prompt string) {
r := rv.Review
err := git.AddDetachedWorktree(ctx, project.RepoPath, m.reviewDir(project.Id, r.Id), snap.CommitSha)
if err == nil {
err = os.MkdirAll(m.reviewMediaDir(project.Id, r.Id), 0o755)
}
if err == nil {
_, err = m.launch(ctx, rv.Session, task, "byre", prompt, "", "", nil)
}
if err != nil {
slog.Error("start reviewer", "review", r.Id, "task", task.Id, "stage", r.Stage, "err", err)
m.failReview(ctx, r.Id, "the reviewer didn't start: "+err.Error())
m.endReviewer(ctx, rv.Session.Id, project, r.Id)
return
}
slog.Info("review started", "review", r.Id, "task", task.Id, "snapshot", snap.N, "stage", r.Stage, "vendor", r.Vendor, "model", r.Model)
}
func (m *Manager) reviewPrompt(ctx context.Context, task api.Task, project api.Project, snap api.Snapshot, def api.WorkflowDefinition, stage string, inputs []api.Review) (string, error) {
changes, err := git.Compare(ctx, project.RepoPath, project.DefaultBranch, snap.CommitSha, snap.TreeSha, "")
if err != nil {
return "", err
}
brief, err := m.store.FirstTaskMessage(ctx, task.Id)
if err != nil {
return "", err
}
b := &strings.Builder{}
fmt.Fprintf(b, "Review the work of the agent on the byre task %q, as reviewer %q of the project's review workflow %q.\n\n%s\n\n", task.Title, stage, project.ReviewWorkflow, strings.TrimSpace(workflow.Get(def, stage).Prompt))
fmt.Fprintf(b, "It is snapshot %d of the task, commit %s, checked out in your working directory. The change is all of the task's work, `git diff %s %s` (from the merge-base with %s); %d files changed:\n",
snap.N, snap.CommitSha, changes.Base, snap.CommitSha, project.DefaultBranch, len(changes.Files))
for i, f := range changes.Files {
if i == maxReviewFiles {
fmt.Fprintf(b, "- … and %d more\n", len(changes.Files)-maxReviewFiles)
break
}
fmt.Fprintf(b, "- %s %s (+%d −%d)\n", f.Status, f.Path, f.Additions, f.Deletions)
}
if brief != "" {
fmt.Fprintf(b, "\nThe task's brief, the first message its agent got:\n\n%s\n", quote(clip(brief, 4000)))
}
for _, in := range inputs {
fmt.Fprintf(b, "\n## From reviewer %q\n\n%s\n", in.Stage, reviewText(in))
}
b.WriteString("\nWhen you're done, call submit_review once with your review, then end your turn.")
return b.String(), nil
}
func quote(s string) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
for i, l := range lines {
lines[i] = "> " + l
}
return strings.Join(lines, "\n")
}
func (m *Manager) submitReviewTool(l *live) mcp.Tool {
return mcp.Tool{
Name: "submit_review",
Description: "Submit your review. Call it exactly once, when you're done; its arguments are the review the human reads.",
InputSchema: reviewSchema(),
Handler: func(ctx context.Context, args json.RawMessage) (string, error) {
return m.submitReview(ctx, l, args)
},
}
}
func (m *Manager) submitReview(ctx context.Context, l *live, raw json.RawMessage) (string, error) {
result := map[string]any{}
err := json.Unmarshal(raw, &result)
if err != nil {
return "", fmt.Errorf("submit_review takes a JSON object: %w", err)
}
if c, ok := result["change"].(string); !ok || strings.TrimSpace(c) == "" {
return "", errors.New("submit_review rejected, nothing was recorded: change must say what the change does")
}
if _, ok := result["summary"].(string); !ok {
return "", errors.New("submit_review rejected, nothing was recorded: summary must be a string")
}
findings, ok := result["findings"].([]any)
if !ok {
return "", errors.New("submit_review rejected, nothing was recorded: findings must be a list")
}
holders := []map[string]any{result}
for _, f := range findings {
fm, ok := f.(map[string]any)
if !ok {
return "", errors.New("submit_review rejected, nothing was recorded: each finding must be an object")
}
holders = append(holders, fm)
}
pending := []reviewImage{}
for _, holder := range holders {
imgs, err := m.readReviewImages(l, holder["images"])
if err != nil {
return "", fmt.Errorf("submit_review rejected, nothing was recorded: %w", err)
}
pending = append(pending, imgs...)
}
for _, img := range pending {
b, err := m.store.CreateBlob(ctx, img.contentType, img.data, agentActor)
if err != nil {
return "", err
}
img.entry["blob"] = b.Id
delete(img.entry, "path")
}
err = m.store.FinishReview(ctx, l.reviewID, api.ReviewStateDone, result, "")
if errors.Is(err, store.ErrNotFound) {
return "", errors.New("this review was already submitted or has ended")
}
if err != nil {
return "", err
}
return "Review recorded. End your turn now with one line.", nil
}
func (m *Manager) settleReviews(ref store.SnapshotRef) {
ctx := context.Background()
m.settle(ctx, ref)
err := m.store.FinishSettling(ctx, ref.TaskID, ref.N)
if err != nil {
slog.Error("finish settling reviews", "task", ref.TaskID, "snapshot", ref.N, "err", err)
return
}
m.poke()
}
func (m *Manager) settle(ctx context.Context, ref store.SnapshotRef) {
reviews, err := m.store.SnapshotReviews(ctx, ref.TaskID, ref.N)
if err != nil {
slog.Error("list reviews to settle", "task", ref.TaskID, "err", err)
return
}
reviewIDs := []string{}
for _, r := range reviews {
reviewIDs = append(reviewIDs, r.Id)
}
m.deliverFindings(ctx, ref, reviews)
mar, err := m.store.TakeMergeAfterReview(ctx, ref.TaskID, reviewIDs)
if err != nil {
slog.Error("take merge after review", "task", ref.TaskID, "err", err)
return
}
if mar.By != "" {
m.mergeAfterReview(ctx, ref, reviews, mar)
}
}
func (m *Manager) deliverFindings(ctx context.Context, ref store.SnapshotRef, reviews []api.Review) {
text := findingsMessage(ref.N, reviews)
if text == "" {
return
}
latest, err := m.store.LastReviewable(ctx, ref.TaskID)
if err != nil || latest.N != ref.N {
return
}
s, found, err := m.liveSession(ctx, ref.TaskID)
if err != nil {
slog.Error("list sessions to deliver findings", "task", ref.TaskID, "err", err)
return
}
if !found {
slog.Info("review findings not delivered, task has no live agent", "task", ref.TaskID, "snapshot", ref.N)
return
}
_, _, err = m.Message(ctx, s.Id, "byre", text, "", "", "")
if err != nil {
slog.Error("deliver review findings", "task", ref.TaskID, "snapshot", ref.N, "session", s.Id, "err", err)
return
}
slog.Info("review findings delivered", "task", ref.TaskID, "snapshot", ref.N, "session", s.Id)
}
const findingsPreamble = "The review of snapshot #"
func isFindingsMessage(actor, text string) bool {
return actor == "byre" && strings.HasPrefix(text, findingsPreamble)
}
func findingsMessage(n int, reviews []api.Review) string {
b := &strings.Builder{}
for _, r := range reviews {
if r.State != api.ReviewStateDone || r.Result == nil {
continue
}
findings, _ := (*r.Result)["findings"].([]any)
if len(findings) == 0 {
continue
}
fmt.Fprintf(b, "\n\n## Reviewer %q", r.Stage)
for i, f := range findings {
fm, _ := f.(map[string]any)
where := ""
if p, ok := fm["path"].(string); ok && p != "" {
where = p
if line, ok := fm["line"].(float64); ok && line > 0 {
where += fmt.Sprintf(":%d", int(line))
}
where = " (`" + where + "`)"
}
fmt.Fprintf(b, "\n\n**Finding %d: [%v] %v**%s\n\n%s", i+1, fm["severity"], fm["title"], where, strings.TrimSpace(fmt.Sprint(fm["detail"])))
}
}
if b.Len() == 0 {
return ""
}
return fmt.Sprintf(findingsPreamble+"%d of your work has finished, and its reviewers raised the findings below. Reviewers can be wrong or miss context: check each one against the code and your brief. "+
"Before you change anything, answer each finding with respond_to_finding (snapshot %d, the reviewer's name and the finding's number): fixing if it holds up and you'll act on it, ignoring with your reason if you're leaving it. "+
"The human sees your answers on the findings and can overrule an ignore.", n, n) + b.String()
}
type findingResponseArgs struct {
Snapshot int `json:"snapshot"`
Reviewer string `json:"reviewer"`
Finding int `json:"finding"`
Response api.FindingResponseResponse `json:"response"`
Reason string `json:"reason"`
}
func (m *Manager) respondToFindingTool(l *live) mcp.Tool {
return mcp.Tool{
Name: "respond_to_finding",
Description: "Answer one finding of a review of your work, as soon as you've decided what to do about it: fixing when you'll act on it, ignoring when you're leaving it. " +
"The human sees your answer on the finding; an ignore keeps the human's fix button, so they can overrule you. Call it again to change your answer.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"snapshot": {"type": "integer", "description": "The reviewed snapshot's number."},
"reviewer": {"type": "string", "description": "The reviewer's name, as the findings message gives it."},
"finding": {"type": "integer", "description": "The finding's number in that reviewer's review, from 1."},
"response": {"type": "string", "enum": ["fixing", "ignoring"]},
"reason": {"type": "string", "description": "One line: what you'll do about it, or why you're leaving it."}
},
"required": ["snapshot", "reviewer", "finding", "response", "reason"]
}`),
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
args := findingResponseArgs{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", fmt.Errorf("malformed respond_to_finding call: %w", err)
}
if !args.Response.Valid() {
return "", fmt.Errorf("%w: response must be fixing or ignoring", ErrInvalid)
}
if args.Finding < 1 {
return "", fmt.Errorf("%w: finding numbers start at 1", ErrInvalid)
}
reason := strings.TrimSpace(args.Reason)
if reason == "" {
return "", fmt.Errorf("%w: reason must not be empty", ErrInvalid)
}
reviews, err := m.store.SnapshotReviews(ctx, l.taskID, args.Snapshot)
if err != nil {
return "", err
}
reviewID := ""
for _, r := range reviews {
if r.Stage == args.Reviewer && r.State == api.ReviewStateDone {
reviewID = r.Id
}
}
if reviewID == "" {
return "", fmt.Errorf("%w: snapshot %d has no finished review by reviewer %q", ErrInvalid, args.Snapshot, args.Reviewer)
}
err = m.store.SetFindingResponse(ctx, reviewID, args.Finding-1, api.FindingResponse{Response: args.Response, Reason: reason}, agentActor)
if errors.Is(err, store.ErrNotFound) {
return "", fmt.Errorf("%w: reviewer %q's review of snapshot %d has no finding %d", ErrInvalid, args.Reviewer, args.Snapshot, args.Finding)
}
if err != nil {
return "", err
}
return fmt.Sprintf("Recorded: %s finding %d of reviewer %q's review of snapshot %d.", args.Response, args.Finding, args.Reviewer, args.Snapshot), nil
},
}
}
type reviewImage struct {
entry map[string]any
contentType string
data []byte
}
func (m *Manager) readReviewImages(l *live, raw any) ([]reviewImage, error) {
if raw == nil {
return nil, nil
}
list, ok := raw.([]any)
if !ok {
return nil, errors.New("images must be a list")
}
out := []reviewImage{}
for _, item := range list {
entry, ok := item.(map[string]any)
if !ok {
return nil, errors.New("each image must be an object with a path")
}
p, ok := entry["path"].(string)
if !ok || p == "" {
return nil, errors.New("each image needs a path")
}
if !filepath.IsAbs(p) {
p = filepath.Join(l.dir, p)
}
info, err := os.Stat(p)
if err != nil {
return nil, fmt.Errorf("image %s: %w", p, err)
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("image %s is not a file", p)
}
if info.Size() > store.MaxBlobBytes {
return nil, fmt.Errorf("image %s is %d bytes; images are limited to %d MiB", p, info.Size(), store.MaxBlobBytes>>20)
}
data, err := os.ReadFile(p)
if err != nil {
return nil, fmt.Errorf("image %s: %w", p, err)
}
contentType := http.DetectContentType(data)
if !store.ImageTypes[contentType] {
return nil, fmt.Errorf("image %s is %s; use PNG, JPEG, GIF or WebP", p, contentType)
}
out = append(out, reviewImage{entry: entry, contentType: contentType, data: data})
}
return out, nil
}
func (m *Manager) reviewTurnEnded(ctx context.Context, l *live, res endResult) error {
m.mu.Lock()
interrupted := l.interrupted
m.mu.Unlock()
err := m.emit(ctx, l, agentActor, "turn.ended", res.payload)
if err != nil {
return err
}
if res.window > 0 {
err = m.updateContext(ctx, l, l.contextUsed, res.window)
if err != nil {
return err
}
}
reason := fmt.Sprintf("the reviewer ended its turn without calling submit_review: %s", clip(strings.TrimSpace(res.result), 500))
switch {
case interrupted:
reason = "the reviewer was interrupted"
case res.isError:
reason = fmt.Sprintf("the reviewer's turn ended with an error (%s): %s", res.subtype, clip(res.result, 500))
}
go m.finishReviewer(l, reason)
return nil
}
func (m *Manager) finishReviewer(l *live, reason string) {
ctx := context.Background()
project, err := m.store.GetProject(ctx, l.projectID)
if err != nil {
slog.Error("load project to end reviewer", "review", l.reviewID, "err", err)
return
}
m.failReview(ctx, l.reviewID, reason)
m.endReviewer(ctx, l.id, project, l.reviewID)
}
func (m *Manager) endReviewer(ctx context.Context, sessionID string, project api.Project, reviewID string) {
sess, err := m.store.GetSession(ctx, sessionID)
if err != nil {
slog.Error("load reviewer session", "session", sessionID, "err", err)
}
if err == nil && sess.State != api.AgentSessionStateCompleted {
err = m.retire(ctx, sess, "byre")
if err != nil {
slog.Error("retire reviewer session", "session", sessionID, "err", err)
}
}
m.removeReviewDir(project, reviewID)
}
func (m *Manager) cleanUpReviewer(r store.EndedReviewer) {
m.stopLive(r.SessionID)
m.removeReviewDir(api.Project{Id: r.ProjectID, RepoPath: r.RepoPath}, r.ReviewID)
}
func (m *Manager) removeReviewDir(project api.Project, reviewID string) {
err := git.RemoveDetachedWorktree(context.Background(), project.RepoPath, m.reviewDir(project.Id, reviewID))
if err != nil {
slog.Error("remove review worktree", "review", reviewID, "err", err)
}
err = os.RemoveAll(m.reviewMediaDir(project.Id, reviewID))
if err != nil {
slog.Error("remove review media", "review", reviewID, "err", err)
}
}
func (m *Manager) failReview(ctx context.Context, reviewID, reason string) {
err := m.store.FinishReview(ctx, reviewID, api.ReviewStateFailed, nil, reason)
if errors.Is(err, store.ErrNotFound) {
return
}
if err != nil {
slog.Error("record failed review", "review", reviewID, "err", err)
return
}
slog.Info("review failed", "review", reviewID, "reason", reason)
}
func (m *Manager) StopReview(ctx context.Context, reviewID, reason string) (api.Review, error) {
m.reviewMu.Lock()
defer m.reviewMu.Unlock()
r, err := m.store.GetReview(ctx, reviewID)
if err != nil {
return api.Review{}, err
}
if r.State != api.ReviewStateRunning {
return api.Review{}, fmt.Errorf("%w: review %s is %s, not running", ErrConflict, r.Id, r.State)
}
task, err := m.store.GetTask(ctx, r.TaskId)
if err != nil {
return api.Review{}, err
}
project, err := m.store.GetProject(ctx, task.ProjectId)
if err != nil {
return api.Review{}, err
}
m.failReview(ctx, r.Id, reason)
m.endReviewer(ctx, r.AgentSessionId, project, r.Id)
return m.store.GetReview(ctx, r.Id)
}
func (m *Manager) stopStuckReviewers(ctx context.Context, h Health) {
for _, p := range h.Processes {
if p.Health != api.HealthStuck {
continue
}
m.mu.Lock()
l := m.live[p.SessionId]
reviewID := ""
if l != nil {
reviewID = l.reviewID
}
m.mu.Unlock()
if reviewID == "" {
continue
}
_, err := m.StopReview(ctx, reviewID, fmt.Sprintf("the reviewer went %d minutes without an event", int(StuckAfter/time.Minute)))
if err != nil && !errors.Is(err, ErrConflict) {
slog.Error("stop stuck reviewer", "review", reviewID, "session", p.SessionId, "err", err)
}
}
}
func reviewName(r api.Review) string {
if r.Stage == "" {
return fmt.Sprintf("review of snapshot %d", r.SnapshotN)
}
return fmt.Sprintf("%q review of snapshot %d", r.Stage, r.SnapshotN)
}
func reviewLine(r api.Review) string {
if r.State == api.ReviewStateFailed {
return fmt.Sprintf("%s failed: %s", reviewName(r), clip(r.Error, 300))
}
if r.State == api.ReviewStateSkipped {
return fmt.Sprintf("%s skipped: no changes", reviewName(r))
}
res := map[string]any{}
if r.Result != nil {
res = *r.Result
}
b := &strings.Builder{}
fmt.Fprintf(b, "%s: %s", reviewName(r), clip(strings.TrimSpace(fmt.Sprint(res["summary"])), 600))
if c, ok := res["change"].(string); ok && c != "" {
fmt.Fprintf(b, "\n change: %s", clip(strings.TrimSpace(c), 600))
}
writeFindings(b, res, "\n ", 300)
return b.String()
}
func reviewText(r api.Review) string {
res := map[string]any{}
if r.Result != nil {
res = *r.Result
}
b := &strings.Builder{}
b.WriteString(strings.TrimSpace(fmt.Sprint(res["summary"])))
writeFindings(b, res, "\n", 4000)
return b.String()
}
func writeFindings(b *strings.Builder, res map[string]any, lead string, limit int) {
findings, _ := res["findings"].([]any)
for _, f := range findings {
fm, _ := f.(map[string]any)
where := ""
if p, ok := fm["path"].(string); ok && p != "" {
where = " (" + p
if line, ok := fm["line"].(float64); ok && line > 0 {
where += fmt.Sprintf(":%d", int(line))
}
where += ")"
}
fmt.Fprintf(b, "%s- [%v] %v%s: %s", lead, fm["severity"], fm["title"], where, clip(strings.TrimSpace(fmt.Sprint(fm["detail"])), limit))
}
}