buttons
This commit is contained in:
@@ -46,6 +46,7 @@ type Manager struct {
|
||||
live map[string]*live
|
||||
closing bool
|
||||
rateLimits map[api.AgentSessionVendor]map[string]any
|
||||
merges map[string]string
|
||||
|
||||
deliverMu sync.Mutex
|
||||
capMu sync.Mutex
|
||||
@@ -80,6 +81,7 @@ func New(st *store.Store, limits Limits, worktreeRoot string) *Manager {
|
||||
worktreeRoot: worktreeRoot,
|
||||
live: map[string]*live{},
|
||||
rateLimits: map[api.AgentSessionVendor]map[string]any{},
|
||||
merges: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,6 +720,11 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
|
||||
go m.continueFromHandoff(l.id, res.Result)
|
||||
return nil
|
||||
}
|
||||
mergeBy, merging := m.takeMerge(l.taskID)
|
||||
if merging && finished {
|
||||
go m.finishMerge(l, mergeBy)
|
||||
return nil
|
||||
}
|
||||
err = m.Deliver(ctx, l.id)
|
||||
if err != nil {
|
||||
slog.Error("deliver inbox responses", "session", l.id, "err", err)
|
||||
|
||||
@@ -530,6 +530,110 @@ func TestCompactCarriesHandoff(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeIn(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
m := e.manager()
|
||||
|
||||
_, err := m.Create(e.ctx, e.task, e.user, `Create a file task.txt containing exactly the word task.`, testModel)
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
e.waitIdle()
|
||||
|
||||
err = os.WriteFile(filepath.Join(e.repo, "main.txt"), []byte("main\n"), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("write main.txt: %v", err)
|
||||
}
|
||||
gitOut(t, e.repo, "add", "main.txt")
|
||||
gitOut(t, e.repo, "commit", "--message", "main change")
|
||||
mainHead := gitOut(t, e.repo, "rev-parse", "HEAD")
|
||||
|
||||
_, err = m.MergeIn(e.ctx, e.task, e.user, "main")
|
||||
if err != nil {
|
||||
t.Fatalf("merge in: %v", err)
|
||||
}
|
||||
e.waitIdle()
|
||||
|
||||
parents := strings.Fields(gitOut(t, e.task.WorktreePath, "log", "-1", "--format=%P"))
|
||||
if len(parents) != 2 || parents[1] != mainHead {
|
||||
t.Fatalf("HEAD parents %v, want a merge of main %s", parents, mainHead)
|
||||
}
|
||||
for _, f := range []string{"task.txt", "main.txt"} {
|
||||
_, err = os.Stat(filepath.Join(e.task.WorktreePath, f))
|
||||
if err != nil {
|
||||
t.Fatalf("%s missing after merge: %v", f, err)
|
||||
}
|
||||
}
|
||||
if status := gitOut(t, e.task.WorktreePath, "status", "--porcelain", "--untracked-files=all"); status != "" {
|
||||
t.Fatalf("worktree dirty after the merge turn:\n%s", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeResolvesConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
m := e.manager()
|
||||
|
||||
_, err := m.Create(e.ctx, e.task, e.user, `Create a file notes.txt containing exactly the line "task".`, testModel)
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
e.waitIdle()
|
||||
err = os.WriteFile(filepath.Join(e.task.WorktreePath, "uncommitted"), []byte("three\n"), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("write uncommitted: %v", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(filepath.Join(e.repo, "notes.txt"), []byte("main\n"), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("write notes.txt: %v", err)
|
||||
}
|
||||
gitOut(t, e.repo, "add", "notes.txt")
|
||||
gitOut(t, e.repo, "commit", "--message", "main change")
|
||||
moved := gitOut(t, e.repo, "rev-parse", "HEAD")
|
||||
|
||||
_, err = m.Merge(e.ctx, e.task, e.project, e.user)
|
||||
if err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
e.waitFor("task merged", func(ev api.Event) bool { return ev.Kind == "task.merged" })
|
||||
|
||||
task, err := e.store.GetTask(e.ctx, e.task.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("get task: %v", err)
|
||||
}
|
||||
if task.Status != api.TaskStatusMerged || task.MergeCommit == nil {
|
||||
t.Fatalf("task after merge %+v", task)
|
||||
}
|
||||
if head := gitOut(t, e.repo, "rev-parse", "main"); head != *task.MergeCommit {
|
||||
t.Fatalf("main at %s, want merge commit %s", head, *task.MergeCommit)
|
||||
}
|
||||
if parent := gitOut(t, e.repo, "rev-parse", "main^"); parent != moved {
|
||||
t.Fatalf("merge commit parent %s, want %s", parent, moved)
|
||||
}
|
||||
if log := gitOut(t, e.repo, "log", "-1", "--format=%s|%an|%cn"); log != task.Title+"|"+gitName+"|"+gitName {
|
||||
t.Fatalf("unexpected merge commit %s", log)
|
||||
}
|
||||
notes, err := os.ReadFile(filepath.Join(e.repo, "notes.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("read notes.txt: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(notes), "task") || !strings.Contains(string(notes), "main") || strings.Contains(string(notes), "<<<<<<<") {
|
||||
t.Fatalf("notes.txt on main doesn't hold both sides:\n%s", notes)
|
||||
}
|
||||
_, err = os.Stat(filepath.Join(e.repo, "uncommitted"))
|
||||
if err != nil {
|
||||
t.Fatalf("uncommitted file not on main: %v", err)
|
||||
}
|
||||
if status := gitOut(t, e.repo, "status", "--porcelain"); status != "" {
|
||||
t.Fatalf("main checkout dirty after merge: %s", status)
|
||||
}
|
||||
if branches := gitOut(t, e.repo, "branch", "--list", e.task.Branch); branches != "" {
|
||||
t.Fatalf("branch still exists: %s", branches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTaskTool(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/git"
|
||||
)
|
||||
|
||||
func mergeInPrompt(target 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.", target)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
for _, sess := range sessions {
|
||||
if sess.State != api.AgentSessionStateCompleted {
|
||||
return m.Send(ctx, sess.Id, "byre", mergeInPrompt(target), "")
|
||||
}
|
||||
}
|
||||
|
||||
return m.create(ctx, task, user, mergeInPrompt(target), "", "byre", nil)
|
||||
}
|
||||
|
||||
func (m *Manager) Merge(ctx context.Context, task api.Task, project api.Project, user api.User) (api.AgentSession, error) {
|
||||
if user.GitName == "" || user.GitEmail == "" {
|
||||
return api.AgentSession{}, ErrNoGitIdentity
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.merges[task.Id] = user.Login
|
||||
m.mu.Unlock()
|
||||
|
||||
sess, err := m.MergeIn(ctx, task, user, project.DefaultBranch)
|
||||
if err != nil {
|
||||
m.mu.Lock()
|
||||
delete(m.merges, task.Id)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
return sess, err
|
||||
}
|
||||
|
||||
func (m *Manager) takeMerge(taskID string) (string, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
login, ok := m.merges[taskID]
|
||||
delete(m.merges, taskID)
|
||||
return login, ok
|
||||
}
|
||||
|
||||
func (m *Manager) finishMerge(l *live, login string) {
|
||||
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, "The agent merged the default branch in, but squashing onto it 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
|
||||
}
|
||||
|
||||
commit, err := git.SquashMerge(project.RepoPath, task.WorktreePath, project.DefaultBranch, task.Title, []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
|
||||
}
|
||||
|
||||
err = m.RemoveTask(ctx, task, project, login)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = m.store.MergeTask(ctx, taskID, commit, login)
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return git.RemoveWorktree(project.RepoPath, task.WorktreePath, task.Branch)
|
||||
}
|
||||
Reference in New Issue
Block a user