Add Merge & continue and Other… to ready cards

This commit is contained in:
Ian Gulliver
2026-10-20 11:01:59 -07:00
parent 25f275e012
commit ef98b1179d
13 changed files with 363 additions and 88 deletions
+120 -1
View File
@@ -5,10 +5,12 @@ import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"g.fc.run/ai/byre/internal/api"
"g.fc.run/ai/byre/internal/git"
"g.fc.run/ai/byre/internal/mcp"
"g.fc.run/ai/byre/internal/store"
)
type mergeToMainArgs struct {
@@ -52,7 +54,11 @@ func (m *Manager) mergeToMain(ctx context.Context, l *live, raw json.RawMessage)
if err != nil {
return "", err
}
user, err := m.store.GetUser(ctx, sess.CreatedBy)
login := sess.CreatedBy
if by := m.landingBy(l.taskID, l.id); by != "" {
login = by
}
user, err := m.store.GetUser(ctx, login)
if err != nil {
return "", err
}
@@ -168,3 +174,116 @@ func (m *Manager) pushToMain(ctx context.Context, l *live, task api.Task, projec
}
return commit, nil
}
func landPrompt(target, title string) string {
return fmt.Sprintf("The human asked to merge this task's work so far into %[1]s and keep the task open (Merge & continue); byre holds the task's other agents until this turn ends. "+
"First merge %[1]s in with `git merge --no-ff --no-commit %[1]s`, resolve any conflicts keeping both sides' intent, regenerate generated files instead of hand-merging them, and check that the build and tests pass. "+
"If resolving the conflicts took substantive changes rather than combining both sides as written, stop there: end your turn with a description of those changes, so the review covers them before anything lands. "+
"Otherwise call merge_to_main with title %[2]q, which commits the merge and lands the work on %[1]s, then end your turn with one line.", target, title)
}
func (m *Manager) MergeToMain(ctx context.Context, task api.Task, project api.Project, user api.User, title string) (api.Task, error) {
if user.GitName == "" || user.GitEmail == "" {
return api.Task{}, ErrNoGitIdentity
}
title, err := cleanMergeTitle(title)
if err != nil {
return api.Task{}, err
}
if task.Merging || task.MergeQueuedAt != nil || task.MergingIntoParent || task.Discarding {
return api.Task{}, fmt.Errorf("%w: task %s is %s", ErrConflict, task.Id, task.State)
}
sess, found, err := m.liveSession(ctx, task.Id)
if err != nil {
return api.Task{}, err
}
if !found {
return api.Task{}, fmt.Errorf("%w: task %s has no agent", ErrConflict, task.Id)
}
unlock := m.lockDelivery(sess.Id)
defer unlock()
m.mu.Lock()
holder := m.landing[task.Id]
if holder.session == "" {
m.landing[task.Id] = landing{session: sess.Id, login: user.Login}
}
m.mu.Unlock()
if holder.session != "" {
return api.Task{}, fmt.Errorf("%w: task %s is already merging and continuing", ErrConflict, task.Id)
}
sessions, err := m.store.ListSessions(ctx, task.Id)
if err == nil {
for _, s := range sessions {
if s.Role == api.Implementer && s.State == api.AgentSessionStateRunning {
err = fmt.Errorf("%w: an agent on this task is mid-turn; merge when they're all idle", ErrConflict)
}
}
}
if err == nil {
m.clearMergeAfterReview(ctx, task.Id)
_, err = m.Send(ctx, sess.Id, "byre", landPrompt(project.DefaultBranch, title), "")
}
if err != nil {
m.endLanding(task.Id, sess.Id)
return api.Task{}, err
}
err = m.store.ClearReady(ctx, task.Id, user.Login)
if err != nil && !errors.Is(err, store.ErrNotFound) {
return api.Task{}, err
}
return m.store.GetTask(ctx, task.Id)
}
type landing struct {
session string
login string
}
func (m *Manager) heldForLanding(taskID, sessionID string) bool {
m.mu.Lock()
defer m.mu.Unlock()
holder := m.landing[taskID].session
return holder != "" && holder != sessionID
}
func (m *Manager) landingBy(taskID, sessionID string) string {
m.mu.Lock()
defer m.mu.Unlock()
if m.landing[taskID].session != sessionID {
return ""
}
return m.landing[taskID].login
}
func (m *Manager) endLanding(taskID, sessionID string) {
m.mu.Lock()
held := m.landing[taskID].session == sessionID
if held {
delete(m.landing, taskID)
}
m.mu.Unlock()
if held {
go m.releaseTeam(taskID, sessionID)
}
}
func (m *Manager) releaseTeam(taskID, landingID string) {
ctx := context.Background()
sessions, err := m.store.ListSessions(ctx, taskID)
if err != nil {
slog.Error("list sessions after merge and continue", "task", taskID, "err", err)
return
}
for _, s := range sessions {
if s.Id == landingID || s.Role != api.Implementer || s.State != api.AgentSessionStateIdle || m.interrupted(s.Id) {
continue
}
err = m.Deliver(ctx, s.Id)
if err != nil {
slog.Error("deliver after merge and continue", "session", s.Id, "err", err)
}
}
}