Review every new snapshot with an agent from the other vendor
This commit is contained in:
@@ -160,7 +160,7 @@ func (m *Manager) codexItemCompleted(ctx context.Context, l *live, s *codex.Sess
|
||||
return err
|
||||
}
|
||||
|
||||
if l.owner != "" {
|
||||
if !l.implementer() {
|
||||
return nil
|
||||
}
|
||||
text := m.deliverMidTurn(ctx, l)
|
||||
@@ -222,7 +222,7 @@ func (m *Manager) codexTurnCompleted(ctx context.Context, l *live, s *codex.Sess
|
||||
final := l.lastText
|
||||
m.mu.Unlock()
|
||||
|
||||
if turn.Status == codex.TurnStatusCompleted && l.owner == "" && m.takeBounce(l, final) {
|
||||
if turn.Status == codex.TurnStatusCompleted && l.implementer() && m.takeBounce(l, final) {
|
||||
err := m.emit(ctx, l, "byre", "user.message", map[string]any{"text": proseQuestionNudge, "mid_turn": true})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -297,6 +297,7 @@ func (m *Manager) newItem(l *live, id, thread string, kind api.InboxItemKind, pr
|
||||
State: api.Open,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Responses: []api.InboxResponse{},
|
||||
Reviews: []api.Review{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ const leadGuidance = `You are the human's lead agent in byre, shown in the left
|
||||
- Don't move the pane for things that don't need the human, and don't pull it away from what they are looking at unless the new thing is more urgent. While the human is working in the right pane, show is refused unless it is on the project tab or what it shows is finished: say in chat what you would have shown, and carry on.
|
||||
- Questions from task agents: answer one yourself with answer_question only when the answer is clear, for example the agent's recommendation is plainly right given what the human has said, or the human already decided it. Otherwise show it to the human with your recommendation and wait. When the human tells you an answer, give it with answer_question. Say which questions you answered and how.
|
||||
- Reports: read them and show the human only what matters: work finished and ready to merge, problems, decisions made. Stay quiet about routine progress.
|
||||
- Reviews: after each new snapshot of a task, a reviewer agent from the other vendor reviews the change. The results are on the task's reviews tab, read_transcript shows each review's findings, and a question shows the review of the turn that asked it. They are for the human to look at when they want; mention one only when its findings matter to what the human is deciding.
|
||||
- Dig into context with list_tasks, read_transcript and read_inbox, and with your own Read, Grep and Bash on a task's worktree path (from list_tasks). Don't edit files in task worktrees; ask the task's agent with send_message.
|
||||
- You can create tasks, start TODO items, message, interrupt and compact task agents, and merge or discard tasks. Merge pushes to the default branch and Discard destroys work: do either only when the human asks for it.
|
||||
- Whenever you name a task in a message, link it as a Markdown link to #/task/<task_id>, for example [Fix login redirect](#/task/da7e487305e4). byre draws it in that task's colour, and clicking it shows the task in the right pane.
|
||||
|
||||
@@ -297,15 +297,15 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
|
||||
|
||||
tool("show",
|
||||
"Point the human's right pane at a task, or at a project with no task: tabs are the tabs to put first, in order (the rest follow), and tab is the one to select. "+
|
||||
"Tabs: transcript, changes, files, terminal (a shell in the task's worktree), preview (the dev servers the task's agent exposed with expose_port), inbox (the task's open items), project (the project's dashboard: its active tasks and its TODO.md items); with only project_id, only project shows anything. "+
|
||||
"Tabs: transcript, changes, files, terminal (a shell in the task's worktree), preview (the dev servers the task's agent exposed with expose_port), inbox (the task's open items), reviews (the task's reviews by reviewer agents), project (the project's dashboard: its active tasks and its TODO.md items); with only project_id, only project shows anything. "+
|
||||
"Omit both task_id and project_id to clear the pane. "+
|
||||
"If the pane already shows that task (or project) on that tab, only the tab order changes and you are told so, even while the human is working in the pane. "+
|
||||
"Otherwise refused while the human is working in the right pane, unless it is on the project tab or what it shows is finished (a merged or discarded task, or an inbox tab with nothing left).",
|
||||
`{"type": "object", "properties": {
|
||||
"task_id": {"type": "string"},
|
||||
"project_id": {"type": "string", "description": "Ignored when task_id is given."},
|
||||
"tabs": {"type": "array", "items": {"type": "string", "enum": ["transcript", "changes", "files", "terminal", "preview", "inbox", "project"]}},
|
||||
"tab": {"type": "string", "enum": ["transcript", "changes", "files", "terminal", "preview", "inbox", "project"]}
|
||||
"tabs": {"type": "array", "items": {"type": "string", "enum": ["transcript", "changes", "files", "terminal", "preview", "inbox", "reviews", "project"]}},
|
||||
"tab": {"type": "string", "enum": ["transcript", "changes", "files", "terminal", "preview", "inbox", "reviews", "project"]}
|
||||
}, "required": ["tabs", "tab"]}`,
|
||||
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
|
||||
f := api.Focus{}
|
||||
@@ -566,9 +566,16 @@ func (m *Manager) transcriptText(ctx context.Context, taskID string, after *int6
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
reviewers, err := m.store.ReviewerSessionIDs(ctx, task.Id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
b := &strings.Builder{}
|
||||
for _, ev := range events {
|
||||
if ev.AgentSessionId != nil && reviewers[*ev.AgentSessionId] {
|
||||
continue
|
||||
}
|
||||
line := transcriptLine(ev, task.WorktreePath)
|
||||
if line != "" {
|
||||
fmt.Fprintf(b, "#%d %s\n", ev.Seq, line)
|
||||
@@ -625,6 +632,13 @@ func transcriptLine(ev api.Event, root string) string {
|
||||
}
|
||||
case "task.merged", "task.discarded":
|
||||
return fmt.Sprintf("%s by %s", ev.Kind, ev.Actor)
|
||||
case "review.finished":
|
||||
r := api.Review{}
|
||||
err := remarshal(p, &r)
|
||||
if err != nil {
|
||||
return "review finished: " + err.Error()
|
||||
}
|
||||
return reviewLine(r)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ type Manager struct {
|
||||
mergeMu sync.Mutex
|
||||
mergeHolders map[string]string
|
||||
|
||||
reviewMu sync.Mutex
|
||||
|
||||
bad *badLines
|
||||
watchStop chan struct{}
|
||||
}
|
||||
@@ -69,6 +71,7 @@ type live struct {
|
||||
taskID string
|
||||
projectID string
|
||||
owner string
|
||||
reviewID string
|
||||
stopping bool
|
||||
interrupted bool
|
||||
asked bool
|
||||
@@ -84,6 +87,10 @@ type live struct {
|
||||
contextWindow int
|
||||
}
|
||||
|
||||
func (l *live) implementer() bool {
|
||||
return l.owner == "" && l.reviewID == ""
|
||||
}
|
||||
|
||||
func New(st *store.Store, worktreeRoot, leadRoot, codexHome string) *Manager {
|
||||
m := &Manager{
|
||||
store: st,
|
||||
@@ -274,7 +281,7 @@ func (m *Manager) Recover(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sess.Owner == "" {
|
||||
if sess.Role == api.Implementer {
|
||||
m.abandonMerge(task.Id, task.ProjectId)
|
||||
}
|
||||
continue
|
||||
@@ -296,6 +303,11 @@ func (m *Manager) Recover(ctx context.Context) error {
|
||||
m.logUndelivered(ctx, sess.Id, "idle at startup")
|
||||
}
|
||||
|
||||
err = m.recoverReviews(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go m.deliverAllLeads()
|
||||
go m.startHeldMerges(context.Background())
|
||||
return nil
|
||||
@@ -431,7 +443,7 @@ func (m *Manager) message(ctx context.Context, l *live, actor, text string, imag
|
||||
}
|
||||
|
||||
payload := map[string]any{"text": text}
|
||||
if l.owner == "" {
|
||||
if l.implementer() {
|
||||
head, err := git.Head(l.dir)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -503,6 +515,17 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
|
||||
}
|
||||
tools := []mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.createTaskTool(l), m.setMergeTitleTool(l), m.exposePortTool(l)}
|
||||
env := portEnv(task)
|
||||
if sess.Role == api.Reviewer {
|
||||
r, err := m.store.ReviewBySession(ctx, sess.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.reviewID = r.Id
|
||||
l.dir = m.reviewDir(task.ProjectId, r.Id)
|
||||
g = reviewGuidance
|
||||
tools = []mcp.Tool{m.submitReviewTool(l)}
|
||||
env = nil
|
||||
}
|
||||
if l.owner != "" {
|
||||
l.dir = filepath.Join(m.leadRoot, url.PathEscape(l.owner))
|
||||
err = os.MkdirAll(l.dir, 0o755)
|
||||
@@ -563,6 +586,10 @@ func (m *Manager) pump(l *live) {
|
||||
if err != nil {
|
||||
slog.Error("record session exit", "session", l.id, "err", err)
|
||||
}
|
||||
if l.reviewID != "" {
|
||||
m.finishReviewer(l, "the reviewer's process exited mid-turn")
|
||||
return
|
||||
}
|
||||
if l.owner == "" {
|
||||
m.abandonMerge(l.taskID, l.projectID)
|
||||
m.fileError(l, "The agent process exited mid-turn. Send a message to resume it.")
|
||||
@@ -698,6 +725,9 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error {
|
||||
if l.owner != "" {
|
||||
return m.leadTurnEnded(ctx, l, res)
|
||||
}
|
||||
if l.reviewID != "" {
|
||||
return m.reviewTurnEnded(ctx, l, res)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
interrupted := l.interrupted
|
||||
|
||||
@@ -329,6 +329,7 @@ func (m *Manager) RemoveTask(ctx context.Context, task api.Task, project api.Pro
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.cancelReviews(ctx, task, project)
|
||||
err = m.dropSnapshots(project.RepoPath, task.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -61,7 +61,7 @@ func (m *Manager) startClaude(ctx context.Context, sess api.AgentSession, l *liv
|
||||
"Stop": {{Func: m.bounceProseQuestion(l)}},
|
||||
"PostToolUse": {{Func: m.afterTool(l)}},
|
||||
}
|
||||
if l.owner != "" {
|
||||
if !l.implementer() {
|
||||
hooks = map[string][]claude.HookMatcher{"UserPromptSubmit": {{Func: remindGuidance(g)}}}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/git"
|
||||
"g.fc.run/misc/byre/internal/mcp"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
const maxReviewFiles = 200
|
||||
|
||||
var reviewModels = map[api.AgentSessionVendor]string{api.Claude: "claude-opus-5-5", api.Codex: "gpt-6.1-sol"}
|
||||
|
||||
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 the change it just made, and a human may read your review in byre's web UI.
|
||||
- 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.
|
||||
- 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.`
|
||||
|
||||
func reviewSchema() json.RawMessage {
|
||||
b, err := json.Marshal(map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"summary": map[string]any{"type": "string", "description": "A few sentences on the change and what matters most, in Markdown."},
|
||||
"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"},
|
||||
},
|
||||
"required": []any{"severity", "title", "detail"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []any{"summary", "findings"},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (m *Manager) afterSnapshot(snap *api.Snapshot) {
|
||||
if snap == nil {
|
||||
return
|
||||
}
|
||||
ctx := context.Background()
|
||||
m.reviewMu.Lock()
|
||||
defer m.reviewMu.Unlock()
|
||||
|
||||
task, err := m.store.GetTask(ctx, snap.TaskId)
|
||||
if err != nil {
|
||||
slog.Error("load task to review", "task", snap.TaskId, "err", err)
|
||||
return
|
||||
}
|
||||
if task.Status != api.TaskStatusActive {
|
||||
return
|
||||
}
|
||||
err = m.startReviewer(ctx, task, *snap)
|
||||
if err != nil {
|
||||
slog.Error("start review", "task", task.Id, "snapshot", snap.N, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) reviewDir(projectID, reviewID string) string {
|
||||
return filepath.Join(m.worktreeRoot, projectID, "review-"+reviewID)
|
||||
}
|
||||
|
||||
func (m *Manager) implementerVendor(ctx context.Context, taskID string) api.AgentSessionVendor {
|
||||
sessions, err := m.store.ListSessions(ctx, taskID)
|
||||
if err != nil || len(sessions) == 0 {
|
||||
return api.Claude
|
||||
}
|
||||
return sessions[len(sessions)-1].Vendor
|
||||
}
|
||||
|
||||
func reviewVendor(implementer api.AgentSessionVendor) api.AgentSessionVendor {
|
||||
if implementer == api.Codex {
|
||||
return api.Claude
|
||||
}
|
||||
return api.Codex
|
||||
}
|
||||
|
||||
func (m *Manager) startReviewer(ctx context.Context, task api.Task, snap api.Snapshot) error {
|
||||
project, err := m.store.GetProject(ctx, task.ProjectId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vendor := reviewVendor(m.implementerVendor(ctx, task.Id))
|
||||
model := reviewModels[vendor]
|
||||
prompt, err := m.reviewPrompt(ctx, task, project, snap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reviewID := store.NewID()
|
||||
dir := m.reviewDir(project.Id, reviewID)
|
||||
err = git.AddDetachedWorktree(project.RepoPath, dir, snap.CommitSha)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
id := newUUID()
|
||||
sess := api.AgentSession{
|
||||
Id: id,
|
||||
TaskId: task.Id,
|
||||
Vendor: vendor,
|
||||
Model: model,
|
||||
VendorSessionId: initialVendorSessionID(vendor, id),
|
||||
State: api.AgentSessionStateIdle,
|
||||
Role: api.Reviewer,
|
||||
CreatedBy: task.CreatedBy,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
err = m.store.CreateSession(ctx, sess, project.Id)
|
||||
if err != nil {
|
||||
m.removeReviewDir(project, reviewID)
|
||||
return err
|
||||
}
|
||||
err = m.store.CreateReview(ctx, api.Review{
|
||||
Id: reviewID,
|
||||
TaskId: task.Id,
|
||||
SnapshotN: snap.N,
|
||||
AgentSessionId: sess.Id,
|
||||
Vendor: string(vendor),
|
||||
Model: model,
|
||||
State: api.ReviewStateRunning,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
m.endReviewer(ctx, sess.Id, project, reviewID)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = m.turn(ctx, sess, task, "byre", prompt, nil)
|
||||
if err != nil {
|
||||
m.endReviewer(ctx, sess.Id, project, reviewID)
|
||||
m.failReview(ctx, reviewID, "the reviewer didn't start: "+err.Error())
|
||||
return err
|
||||
}
|
||||
slog.Info("review started", "review", reviewID, "task", task.Id, "snapshot", snap.N, "vendor", vendor, "model", model)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) reviewPrompt(ctx context.Context, task api.Task, project api.Project, snap api.Snapshot) (string, error) {
|
||||
previous := ""
|
||||
if snap.N > 1 {
|
||||
prev, err := m.store.GetSnapshot(ctx, task.Id, snap.N-1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
previous = prev.CommitSha
|
||||
} else {
|
||||
parent, err := git.Parent(project.RepoPath, snap.CommitSha)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
previous = parent
|
||||
}
|
||||
changes, err := git.Compare(project.RepoPath, project.DefaultBranch, snap.CommitSha, snap.TreeSha, previous)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
whole, err := git.Compare(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 change the agent working on the byre task %q just made: look for bugs, security problems, missing or wrong tests, and anything that doesn't do what the task asked.\n\n", task.Title)
|
||||
fmt.Fprintf(b, "It is snapshot %d of the task, commit %s, checked out in your working directory. The change is `git diff %s %s`; %d files changed:\n",
|
||||
snap.N, snap.CommitSha, previous, snap.CommitSha, 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)
|
||||
}
|
||||
fmt.Fprintf(b, "\nFor context, all of the task's work so far is `git diff %s %s` (from the merge-base with %s, %d files).\n", whole.Base, snap.CommitSha, project.DefaultBranch, len(whole.Files))
|
||||
if brief != "" {
|
||||
fmt.Fprintf(b, "\nThe task's brief, the first message its agent got:\n\n%s\n", quote(clip(brief, 4000)))
|
||||
}
|
||||
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 _, ok := result["summary"].(string); !ok {
|
||||
return "", errors.New("submit_review rejected, nothing was recorded: summary must be a string")
|
||||
}
|
||||
if _, ok := result["findings"].([]any); !ok {
|
||||
return "", errors.New("submit_review rejected, nothing was recorded: findings must be a list")
|
||||
}
|
||||
|
||||
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) 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.endReviewer(ctx, l.id, project, l.reviewID)
|
||||
m.failReview(ctx, l.reviewID, reason)
|
||||
}
|
||||
|
||||
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) removeReviewDir(project api.Project, reviewID string) {
|
||||
err := git.RemoveDetachedWorktree(project.RepoPath, m.reviewDir(project.Id, reviewID))
|
||||
if err != nil {
|
||||
slog.Error("remove review worktree", "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) cancelReviews(ctx context.Context, task api.Task, project api.Project) {
|
||||
m.reviewMu.Lock()
|
||||
defer m.reviewMu.Unlock()
|
||||
reviews, err := m.store.ListReviews(ctx, task.Id)
|
||||
if err != nil {
|
||||
slog.Error("list reviews to cancel", "task", task.Id, "err", err)
|
||||
return
|
||||
}
|
||||
for _, r := range reviews {
|
||||
if r.State != api.ReviewStateRunning {
|
||||
continue
|
||||
}
|
||||
m.endReviewer(ctx, r.AgentSessionId, project, r.Id)
|
||||
m.failReview(ctx, r.Id, "the task was closed")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) recoverReviews(ctx context.Context) error {
|
||||
running, err := m.store.RunningReviews(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range running {
|
||||
sess, err := m.store.GetSession(ctx, r.AgentSessionId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sess.State == api.AgentSessionStateRunning {
|
||||
continue
|
||||
}
|
||||
task, err := m.store.GetTask(ctx, r.TaskId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
project, err := m.store.GetProject(ctx, task.ProjectId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.endReviewer(ctx, r.AgentSessionId, project, r.Id)
|
||||
m.failReview(ctx, r.Id, "byre restarted before the reviewer finished")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func reviewLine(r api.Review) string {
|
||||
if r.State == api.ReviewStateFailed {
|
||||
return fmt.Sprintf("review of snapshot %d failed: %s", r.SnapshotN, clip(r.Error, 300))
|
||||
}
|
||||
res := map[string]any{}
|
||||
if r.Result != nil {
|
||||
res = *r.Result
|
||||
}
|
||||
b := &strings.Builder{}
|
||||
fmt.Fprintf(b, "review of snapshot %d: %s", r.SnapshotN, clip(strings.TrimSpace(fmt.Sprint(res["summary"])), 600))
|
||||
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, "\n - [%v] %v%s: %s", fm["severity"], fm["title"], where, clip(strings.TrimSpace(fmt.Sprint(fm["detail"])), 300))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
)
|
||||
|
||||
func TestReviewVendor(t *testing.T) {
|
||||
if got := reviewVendor(api.Claude); got != api.Codex {
|
||||
t.Fatalf("reviewer for a claude task: %s", got)
|
||||
}
|
||||
if got := reviewVendor(api.Codex); got != api.Claude {
|
||||
t.Fatalf("reviewer for a codex task: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewLine(t *testing.T) {
|
||||
result := map[string]any{
|
||||
"summary": "Mostly fine.",
|
||||
"findings": []any{
|
||||
map[string]any{"severity": "high", "title": "Nil map write", "detail": "Panics on first use.", "path": "a.go", "line": float64(12)},
|
||||
map[string]any{"severity": "low", "title": "Typo", "detail": "Spelling."},
|
||||
},
|
||||
}
|
||||
got := reviewLine(api.Review{SnapshotN: 3, State: api.ReviewStateDone, Result: &result})
|
||||
want := "review of snapshot 3: Mostly fine.\n" +
|
||||
" - [high] Nil map write (a.go:12): Panics on first use.\n" +
|
||||
" - [low] Typo: Spelling."
|
||||
if got != want {
|
||||
t.Fatalf("reviewLine:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
|
||||
failed := reviewLine(api.Review{SnapshotN: 4, State: api.ReviewStateFailed, Error: "boom"})
|
||||
if failed != "review of snapshot 4 failed: boom" {
|
||||
t.Fatalf("failed reviewLine %q", failed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package agents_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
)
|
||||
|
||||
const reviewAndAskPrompt = "Create a file hi.txt containing exactly the word hi. " +
|
||||
"Then ask the human with byre's ask_human tool whether to keep the file: blocking, with the choices `- (*) Keep it` and `- ( ) Delete it`. Then end your turn."
|
||||
|
||||
func TestSnapshotReview(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
m := e.manager()
|
||||
|
||||
sess, err := m.Create(e.ctx, e.task, e.user, reviewAndAskPrompt, testModel)
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
e.waitIdle()
|
||||
|
||||
events := e.waitFor("review finished", func(ev api.Event) bool { return ev.Kind == "review.finished" })
|
||||
finished := events[len(events)-1]
|
||||
review, err := e.store.GetReview(e.ctx, finished.Payload["id"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("get review: %v", err)
|
||||
}
|
||||
if review.State != api.ReviewStateDone || review.SnapshotN != 1 || review.Vendor != string(api.Codex) || review.Result == nil {
|
||||
t.Fatalf("review %+v", review)
|
||||
}
|
||||
if _, ok := (*review.Result)["summary"].(string); !ok {
|
||||
t.Fatalf("review result has no summary: %+v", *review.Result)
|
||||
}
|
||||
|
||||
questions := e.openQuestions(sess.Id)
|
||||
if len(questions) != 1 || len(questions[0].Reviews) != 1 || questions[0].Reviews[0].Id != review.Id {
|
||||
t.Fatalf("question doesn't show its turn's review: %+v", questions)
|
||||
}
|
||||
|
||||
sessions, err := e.store.ListSessions(e.ctx, e.task.Id)
|
||||
if err != nil || len(sessions) != 1 || sessions[0].Id != sess.Id {
|
||||
t.Fatalf("task sessions %+v %v, want only the implementer", sessions, err)
|
||||
}
|
||||
|
||||
dir := filepath.Join(e.worktrees, e.project.Id, "review-"+review.Id)
|
||||
for range 100 {
|
||||
_, err = os.Stat(dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("review worktree %s still there: %v", dir, err)
|
||||
}
|
||||
reviewer, err := e.store.GetSession(e.ctx, review.AgentSessionId)
|
||||
if err != nil || reviewer.State != api.AgentSessionStateCompleted || reviewer.Role != api.Reviewer {
|
||||
t.Fatalf("reviewer session %+v %v", reviewer, err)
|
||||
}
|
||||
}
|
||||
@@ -60,10 +60,11 @@ func (m *Manager) runSnapshots(l *live, st *snapState, req *snapRequest) {
|
||||
closed := st.closed
|
||||
m.snapMu.Unlock()
|
||||
if !closed {
|
||||
err := m.takeSnapshot(context.Background(), l, req.trigger, req.seq)
|
||||
snap, err := m.takeSnapshot(context.Background(), l, req.trigger, req.seq)
|
||||
if err != nil {
|
||||
slog.Error("take snapshot", "task", l.taskID, "trigger", req.trigger, "err", err)
|
||||
}
|
||||
go m.afterSnapshot(snap)
|
||||
}
|
||||
st.mu.Unlock()
|
||||
|
||||
@@ -79,26 +80,26 @@ func (m *Manager) runSnapshots(l *live, st *snapState, req *snapRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) takeSnapshot(ctx context.Context, l *live, trigger api.SnapshotTrigger, seq int64) error {
|
||||
func (m *Manager) takeSnapshot(ctx context.Context, l *live, trigger api.SnapshotTrigger, seq int64) (*api.Snapshot, error) {
|
||||
if seq == 0 {
|
||||
var err error
|
||||
seq, err = m.store.LastTaskSeq(ctx, l.taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
tree, err := git.Snapshot(l.dir)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
last, err := m.store.LastSnapshot(ctx, l.taskID)
|
||||
if err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if err == nil && last.TreeSha == tree {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
n := last.N + 1
|
||||
@@ -109,10 +110,10 @@ func (m *Manager) takeSnapshot(ctx context.Context, l *live, trigger api.Snapsho
|
||||
"GIT_COMMITTER_EMAIL=" + l.gitEmail,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return m.store.CreateSnapshot(ctx, api.Snapshot{
|
||||
snap := api.Snapshot{
|
||||
Id: store.NewID(),
|
||||
TaskId: l.taskID,
|
||||
AgentSessionId: l.id,
|
||||
@@ -122,7 +123,12 @@ func (m *Manager) takeSnapshot(ctx context.Context, l *live, trigger api.Snapsho
|
||||
EventSeq: seq,
|
||||
Trigger: trigger,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}, l.projectID)
|
||||
}
|
||||
err = m.store.CreateSnapshot(ctx, snap, l.projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &snap, nil
|
||||
}
|
||||
|
||||
func (m *Manager) dropSnapshots(repo, taskID string) error {
|
||||
|
||||
@@ -92,9 +92,9 @@ func TestSnapshots(t *testing.T) {
|
||||
|
||||
write("tracked.txt", "two\n")
|
||||
write("untracked.txt", "new\n")
|
||||
err = m.takeSnapshot(ctx, l, api.SnapshotTriggerMessage, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
taken, err := m.takeSnapshot(ctx, l, api.SnapshotTriggerMessage, 0)
|
||||
if err != nil || taken == nil || taken.N != 1 {
|
||||
t.Fatalf("snapshot: %+v %v", taken, err)
|
||||
}
|
||||
snaps := snapshots()
|
||||
if len(snaps) != 1 || snaps[0].N != 1 || snaps[0].Trigger != api.SnapshotTriggerMessage || snaps[0].AgentSessionId != "s1" {
|
||||
@@ -128,9 +128,9 @@ func TestSnapshots(t *testing.T) {
|
||||
t.Fatalf("snapshot staged files in the real index: %s", staged)
|
||||
}
|
||||
|
||||
err = m.takeSnapshot(ctx, l, api.SnapshotTriggerMessage, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("unchanged snapshot: %v", err)
|
||||
taken, err = m.takeSnapshot(ctx, l, api.SnapshotTriggerMessage, 1)
|
||||
if err != nil || taken != nil {
|
||||
t.Fatalf("unchanged snapshot: %+v %v", taken, err)
|
||||
}
|
||||
if snaps := snapshots(); len(snaps) != 1 {
|
||||
t.Fatalf("an unchanged tree made a snapshot: %+v", snaps)
|
||||
|
||||
Reference in New Issue
Block a user