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
+1 -1
View File
@@ -37,7 +37,7 @@ func (m *Manager) Compact(ctx context.Context, sessionID, model string) (api.Age
if sess.Owner != "" {
prompt = leadHandoffPrompt
}
err = transition(ctx, m.store, sess.Id, api.AgentSessionStateIdle, api.AgentSessionStateRunning, "byre")
err = m.startRunning(ctx, sess, task.Id, "byre")
if err != nil {
return api.AgentSession{}, err
}
+29 -6
View File
@@ -63,6 +63,7 @@ type Manager struct {
finishing map[string]bool
handoffModel map[string]string
mergeTurns map[string]mergeTurn
landing map[string]landing
deliverMu sync.Mutex
deliverLocks map[string]*sessionLock
@@ -158,6 +159,7 @@ func New(st *store.Store, worktreeRoot, leadRoot, handsRoot, codexHome, antigrav
finishing: map[string]bool{},
handoffModel: map[string]string{},
mergeTurns: map[string]mergeTurn{},
landing: map[string]landing{},
signals: make(chan []api.Event, 1024),
snaps: map[string]*snapState{},
merges: map[string]*projectMerge{},
@@ -537,13 +539,32 @@ func (m *Manager) Usage(ctx context.Context) (Usage, error) {
}
func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, images []string) (api.AgentSession, error) {
err := transition(ctx, m.store, sess.Id, api.AgentSessionStateIdle, api.AgentSessionStateRunning, actor)
err := m.startRunning(ctx, sess, task.Id, actor)
if err != nil {
return api.AgentSession{}, err
}
return m.launch(ctx, sess, task, actor, text, images)
}
func (m *Manager) startRunning(ctx context.Context, sess api.AgentSession, taskID, actor string) error {
held := fmt.Errorf("%w: held while this task merges and continues", ErrConflict)
if m.heldForLanding(taskID, sess.Id) {
return held
}
err := transition(ctx, m.store, sess.Id, api.AgentSessionStateIdle, api.AgentSessionStateRunning, actor)
if err != nil {
return err
}
if m.heldForLanding(taskID, sess.Id) {
err = transition(ctx, m.store, sess.Id, api.AgentSessionStateRunning, api.AgentSessionStateIdle, actor)
if err != nil {
return err
}
return held
}
return nil
}
func (m *Manager) launch(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, images []string) (api.AgentSession, error) {
l, err := m.ensureLive(ctx, sess, task)
if err == nil {
@@ -1070,6 +1091,8 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error {
return m.reviewTurnEnded(ctx, l, res)
}
defer m.endLanding(l.taskID, l.id)
m.mu.Lock()
interrupted := l.interrupted
rateLimited := l.rateLimited
@@ -1091,11 +1114,11 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error {
m.endMergeTurn(l.id, false)
}
}()
if !finished {
err := git.AbortMerge(l.dir)
if err != nil {
slog.Error("abort unfinished merge", "session", l.id, "err", err)
}
}
if (owner || m.landingBy(l.taskID, l.id) != "") && !finished {
err := git.AbortMerge(l.dir)
if err != nil {
slog.Error("abort unfinished merge", "session", l.id, "err", err)
}
}
unlock := func() {}
+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)
}
}
}
+1 -1
View File
@@ -53,7 +53,7 @@ func (m *Manager) Message(ctx context.Context, sessionID, actor, text, model str
return api.AgentSession{}, false, err
}
}
held := sess.State == api.AgentSessionStateIdle && sess.Role == api.Implementer && automatedActor(actor) && m.interrupted(sess.Id)
held := sess.State == api.AgentSessionStateIdle && sess.Role == api.Implementer && (automatedActor(actor) && m.interrupted(sess.Id) || m.heldForLanding(sess.TaskId, sess.Id))
if !held && (sess.State != api.AgentSessionStateRunning || sess.Role != api.Implementer) {
sess, err = m.Send(ctx, sessionID, actor, text, model, images...)
return sess, false, err
+11 -3
View File
@@ -26,6 +26,7 @@ const readyPromptUnchanged = "Your turn has ended, and this task's branch has no
type readyArgs struct {
Overview string `json:"overview"`
Merge string `json:"merge"`
}
func (m *Manager) readyForMergeTool(l *live) mcp.Tool {
@@ -33,12 +34,13 @@ func (m *Manager) readyForMergeTool(l *live) mcp.Tool {
Name: "ready_for_merge",
Description: "Tell the human this task is ready to merge, with an overview of the whole task: all of the branch's work, not just what changed since your last call or the last discussion. " +
"Write it for someone jumping between tasks who hasn't followed this one: a summary of what the change does, the tradeoffs and compromises made, what was not done, and follow-up work for later. " +
"byre shows it in the task's transcript and passes it to the human's lead agent. " +
"byre shows it in the task's transcript, with buttons to merge and complete the task or merge and continue it, and passes it to the human's lead agent. " +
"Call it only once the review of your most recent changes has come back: byre reviews the work after each turn that changes files and messages you when that review finishes, so never call it in the same turn as a change, or while a review of your latest work is pending.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"overview": {"type": "string", "description": "Markdown, with sections for the summary, tradeoffs and compromises, not done, and future TODOs; leave out a section with nothing in it."}
"overview": {"type": "string", "description": "Markdown, with sections for the summary, tradeoffs and compromises, not done, and future TODOs; leave out a section with nothing in it."},
"merge": {"type": "string", "enum": ["complete", "continue"], "description": "The button byre highlights: complete (the default) lands the work and closes the task; continue lands the work so far and keeps the task open, for when you or the human mean to do more on it."}
},
"required": ["overview"]
}`),
@@ -52,7 +54,13 @@ func (m *Manager) readyForMergeTool(l *live) mcp.Tool {
if overview == "" {
return "", fmt.Errorf("%w: overview must not be empty", ErrInvalid)
}
err = m.store.MarkReady(ctx, l.taskID, overview, agentActor)
if args.Merge == "" {
args.Merge = "complete"
}
if args.Merge != "complete" && args.Merge != "continue" {
return "", fmt.Errorf("%w: merge must be complete or continue", ErrInvalid)
}
err = m.store.MarkReady(ctx, l.taskID, overview, args.Merge, agentActor)
if errors.Is(err, store.ErrNotFound) {
return "", fmt.Errorf("%w: this task is closed", ErrConflict)
}