386 lines
10 KiB
Go
386 lines
10 KiB
Go
package agents
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"slices"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"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 maxMergeTitle = 72
|
|
|
|
func mergeInPrompt(target, mergeTitle string) string {
|
|
return fmt.Sprintf("Merge %[1]s into this branch: run `git merge --no-ff --no-commit %[1]s`. "+
|
|
"Resolve any conflicts keeping both sides' intent; regenerate generated files instead of hand-merging them. "+
|
|
"Check that the build and tests pass. Leave the merge uncommitted: byre concludes it when the turn ends. "+
|
|
"Finally, if the merge commit title (currently \"%[2]s\") no longer describes all the work on this branch, set a better one with set_merge_title.", target, mergeTitle)
|
|
}
|
|
|
|
func (m *Manager) MergeIn(ctx context.Context, task api.Task, user api.User, target string) (api.AgentSession, error) {
|
|
sessions, err := m.store.ListSessions(ctx, task.Id)
|
|
if err != nil {
|
|
return api.AgentSession{}, err
|
|
}
|
|
|
|
prompt := mergeInPrompt(target, task.MergeTitle)
|
|
for _, sess := range sessions {
|
|
if sess.State != api.AgentSessionStateCompleted {
|
|
return m.Send(ctx, sess.Id, "byre", prompt, "")
|
|
}
|
|
}
|
|
|
|
return m.create(ctx, task, user, prompt, "", "byre", nil)
|
|
}
|
|
|
|
func (m *Manager) Merge(ctx context.Context, task api.Task, project api.Project, user api.User) (api.Task, error) {
|
|
if user.GitName == "" || user.GitEmail == "" {
|
|
return api.Task{}, ErrNoGitIdentity
|
|
}
|
|
if task.MergeQueuedAt != nil {
|
|
return api.Task{}, fmt.Errorf("%w: task %s is already waiting to merge", ErrConflict, task.Id)
|
|
}
|
|
|
|
m.mergeMu.Lock()
|
|
defer m.mergeMu.Unlock()
|
|
|
|
switch m.mergeHolders[project.Id] {
|
|
case task.Id:
|
|
return api.Task{}, fmt.Errorf("%w: task %s is already merging", ErrConflict, task.Id)
|
|
case "":
|
|
err := m.startMerge(ctx, task, project, user)
|
|
if err != nil {
|
|
return api.Task{}, err
|
|
}
|
|
default:
|
|
err := m.store.QueueMerge(ctx, task.Id, user.Login)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return api.Task{}, fmt.Errorf("%w: task %s is closed or already waiting to merge", ErrConflict, task.Id)
|
|
}
|
|
if err != nil {
|
|
return api.Task{}, err
|
|
}
|
|
}
|
|
return m.store.GetTask(ctx, task.Id)
|
|
}
|
|
|
|
func (m *Manager) startMerge(ctx context.Context, task api.Task, project api.Project, user api.User) error {
|
|
err := git.FetchFastForward(project.RepoPath, project.DefaultBranch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = m.store.SetMergingBy(ctx, task.Id, user.Login)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.mergeHolders[project.Id] = task.Id
|
|
|
|
_, err = m.MergeIn(ctx, task, user, project.DefaultBranch)
|
|
if err != nil {
|
|
m.takeMerge(task.Id)
|
|
delete(m.mergeHolders, project.Id)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (m *Manager) recoverMerges(ctx context.Context) error {
|
|
merging, err := m.store.MergingTasks(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
m.mergeMu.Lock()
|
|
defer m.mergeMu.Unlock()
|
|
for _, t := range merging {
|
|
m.mergeHolders[t.ProjectId] = t.Id
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) startHeldMerges(ctx context.Context) {
|
|
projects, err := m.store.ProjectsWithQueuedMerges(ctx)
|
|
if err != nil {
|
|
slog.Error("list projects with held merges", "err", err)
|
|
return
|
|
}
|
|
for _, p := range projects {
|
|
m.nextMerge(p)
|
|
}
|
|
}
|
|
|
|
func (m *Manager) abandonMerge(taskID, projectID string) {
|
|
if m.takeMerge(taskID) == "" {
|
|
return
|
|
}
|
|
go m.releaseMerge(projectID, taskID)
|
|
}
|
|
|
|
func (m *Manager) releaseMerge(projectID, taskID string) {
|
|
m.mergeMu.Lock()
|
|
defer m.mergeMu.Unlock()
|
|
if m.mergeHolders[projectID] == taskID {
|
|
delete(m.mergeHolders, projectID)
|
|
}
|
|
m.startNextMerge(projectID)
|
|
}
|
|
|
|
func (m *Manager) nextMerge(projectID string) {
|
|
m.mergeMu.Lock()
|
|
defer m.mergeMu.Unlock()
|
|
m.startNextMerge(projectID)
|
|
}
|
|
|
|
func (m *Manager) startNextMerge(projectID string) {
|
|
if m.mergeHolders[projectID] != "" {
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
held, err := m.store.QueuedMerges(ctx, projectID)
|
|
if err != nil {
|
|
slog.Error("list held merges", "project", projectID, "err", err)
|
|
return
|
|
}
|
|
|
|
for _, task := range held {
|
|
sessions, err := m.store.ListSessions(ctx, task.Id)
|
|
if err != nil {
|
|
slog.Error("list sessions for held merge", "task", task.Id, "err", err)
|
|
continue
|
|
}
|
|
if slices.ContainsFunc(sessions, func(s api.AgentSession) bool {
|
|
return s.State == api.AgentSessionStateRunning || s.State == api.AgentSessionStateQueued
|
|
}) {
|
|
continue
|
|
}
|
|
|
|
login, err := m.store.TakeQueuedMerge(ctx, task.Id)
|
|
if err != nil {
|
|
slog.Error("take held merge", "task", task.Id, "err", err)
|
|
continue
|
|
}
|
|
if login == "" {
|
|
continue
|
|
}
|
|
|
|
err = m.startHeldMerge(ctx, task, login)
|
|
if err == nil {
|
|
slog.Info("started held merge", "task", task.Id, "by", login)
|
|
return
|
|
}
|
|
slog.Error("start held merge", "task", task.Id, "err", err)
|
|
m.fileTaskError(ctx, task, sessions, "Starting the held merge failed: "+err.Error())
|
|
}
|
|
}
|
|
|
|
func (m *Manager) startHeldMerge(ctx context.Context, task api.Task, login string) error {
|
|
project, err := m.store.GetProject(ctx, task.ProjectId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
user, err := m.store.GetUser(ctx, login)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if user.GitName == "" || user.GitEmail == "" {
|
|
return ErrNoGitIdentity
|
|
}
|
|
return m.startMerge(ctx, task, project, user)
|
|
}
|
|
|
|
func (m *Manager) fileTaskError(ctx context.Context, task api.Task, sessions []api.AgentSession, msg string) {
|
|
if len(sessions) == 0 {
|
|
slog.Error("no session to file task error on", "task", task.Id, "error", msg)
|
|
return
|
|
}
|
|
last := sessions[len(sessions)-1]
|
|
m.fileError(&live{id: last.Id, taskID: task.Id, projectID: task.ProjectId}, msg)
|
|
}
|
|
|
|
func (m *Manager) takeMerge(taskID string) string {
|
|
login, err := m.store.TakeMergingBy(context.Background(), taskID)
|
|
if err != nil {
|
|
slog.Error("take merge intent", "task", taskID, "err", err)
|
|
}
|
|
return login
|
|
}
|
|
|
|
func (m *Manager) finishMerge(l *live, login string) {
|
|
defer m.releaseMerge(l.projectID, l.taskID)
|
|
|
|
ctx := context.Background()
|
|
err := m.squashAndClose(ctx, l.taskID, login)
|
|
if err == nil {
|
|
return
|
|
}
|
|
|
|
slog.Error("finish merge", "task", l.taskID, "err", err)
|
|
m.fileError(l, "Merging the task failed: "+err.Error())
|
|
err = m.Deliver(ctx, l.id)
|
|
if err != nil {
|
|
slog.Error("deliver inbox responses", "session", l.id, "err", err)
|
|
}
|
|
}
|
|
|
|
func (m *Manager) squashAndClose(ctx context.Context, taskID, login string) error {
|
|
task, err := m.store.GetTask(ctx, taskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
project, err := m.store.GetProject(ctx, task.ProjectId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
user, err := m.store.GetUser(ctx, login)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
coAuthors, err := m.CoAuthors(ctx, taskID, login)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
commit, err := git.SquashCommit(project.RepoPath, task.WorktreePath, project.DefaultBranch, squashMessage(task.MergeTitle, coAuthors), []string{
|
|
"GIT_AUTHOR_NAME=" + user.GitName,
|
|
"GIT_AUTHOR_EMAIL=" + user.GitEmail,
|
|
"GIT_COMMITTER_NAME=" + user.GitName,
|
|
"GIT_COMMITTER_EMAIL=" + user.GitEmail,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pushed := ""
|
|
if commit != "" {
|
|
err = git.Push(project.RepoPath, commit, project.DefaultBranch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pushed = git.Remote + "/" + project.DefaultBranch
|
|
err = git.FastForward(project.RepoPath, commit)
|
|
if err != nil {
|
|
return fmt.Errorf("pushed %s to %s, but fast-forwarding the project checkout failed: %w", commit, pushed, err)
|
|
}
|
|
}
|
|
|
|
err = m.RemoveTask(ctx, task, project, login)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = m.store.MergeTask(ctx, taskID, commit, pushed, login)
|
|
return err
|
|
}
|
|
|
|
func (m *Manager) CoAuthors(ctx context.Context, taskID, login string) ([]api.Participant, error) {
|
|
participants, err := m.store.Participants(ctx, taskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
coAuthors := []api.Participant{}
|
|
for _, p := range participants {
|
|
if p.Login != login {
|
|
coAuthors = append(coAuthors, p)
|
|
}
|
|
}
|
|
return coAuthors, nil
|
|
}
|
|
|
|
func squashMessage(title string, coAuthors []api.Participant) string {
|
|
trailers := []string{}
|
|
for _, p := range coAuthors {
|
|
if p.GitName == "" || p.GitEmail == "" {
|
|
continue
|
|
}
|
|
trailers = append(trailers, fmt.Sprintf("Co-authored-by: %s <%s>", p.GitName, p.GitEmail))
|
|
}
|
|
if len(trailers) == 0 {
|
|
return title
|
|
}
|
|
return title + "\n\n" + strings.Join(trailers, "\n")
|
|
}
|
|
|
|
func (m *Manager) Discard(ctx context.Context, task api.Task, project api.Project, actor string) (api.Task, error) {
|
|
err := m.RemoveTask(ctx, task, project, actor)
|
|
if err != nil {
|
|
return api.Task{}, err
|
|
}
|
|
t, err := m.store.DiscardTask(ctx, task.Id, actor)
|
|
if err != nil {
|
|
return api.Task{}, err
|
|
}
|
|
go m.releaseMerge(project.Id, task.Id)
|
|
return t, nil
|
|
}
|
|
|
|
func (m *Manager) RemoveTask(ctx context.Context, task api.Task, project api.Project, actor string) error {
|
|
err := m.StopTask(ctx, task.Id, actor)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = m.dropSnapshots(project.RepoPath, task.Id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return git.RemoveWorktree(project.RepoPath, task.WorktreePath, task.Branch)
|
|
}
|
|
|
|
type setMergeTitleArgs struct {
|
|
Title string `json:"title"`
|
|
}
|
|
|
|
func (m *Manager) setMergeTitleTool(l *live) mcp.Tool {
|
|
return mcp.Tool{
|
|
Name: "set_merge_title",
|
|
Description: "Set the title of the single commit this task becomes when it is merged onto the default branch. " +
|
|
"It starts as the task title. Write one line in the imperative mood that describes all the work on the branch, " +
|
|
"for example \"Add retry with backoff to webhook delivery\". It replaces any earlier title.",
|
|
InputSchema: json.RawMessage(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"title": {"type": "string", "description": "One line, at most 72 characters."}
|
|
},
|
|
"required": ["title"]
|
|
}`),
|
|
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
|
return m.setMergeTitle(ctx, l, raw)
|
|
},
|
|
}
|
|
}
|
|
|
|
func (m *Manager) setMergeTitle(ctx context.Context, l *live, raw json.RawMessage) (string, error) {
|
|
args := setMergeTitleArgs{}
|
|
err := json.Unmarshal(raw, &args)
|
|
if err != nil {
|
|
return "", fmt.Errorf("malformed set_merge_title call: %w", err)
|
|
}
|
|
|
|
title := strings.TrimSpace(args.Title)
|
|
if title == "" || strings.ContainsAny(title, "\r\n") {
|
|
return "", errors.New("title must be one non-empty line")
|
|
}
|
|
if utf8.RuneCountInString(title) > maxMergeTitle {
|
|
return "", fmt.Errorf("title is %d characters; keep it to %d", utf8.RuneCountInString(title), maxMergeTitle)
|
|
}
|
|
|
|
err = m.store.SetMergeTitle(ctx, l.taskID, title, agentActor)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return "", errors.New("this task is no longer active")
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("Merge commit title set to %q.", title), nil
|
|
}
|