2026-10-14 21:01:38 -07:00
package agents
import (
"context"
2026-10-14 21:21:54 -07:00
"encoding/json"
"errors"
2026-10-14 21:01:38 -07:00
"fmt"
"log/slog"
2026-10-15 12:11:35 -07:00
"slices"
2026-10-14 21:21:54 -07:00
"strings"
2026-10-16 14:20:16 -07:00
"sync"
2026-10-14 21:21:54 -07:00
"unicode/utf8"
2026-10-14 21:01:38 -07:00
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/git"
2026-10-14 21:21:54 -07:00
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/store"
2026-10-14 21:01:38 -07:00
)
2026-10-14 21:21:54 -07:00
const maxMergeTitle = 72
2026-10-15 18:55:20 -07:00
func mergeInPrompt ( target string ) string {
2026-10-14 21:01:38 -07:00
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. " +
2026-10-15 19:36:42 -07:00
"Check that the build and tests pass. Leave the merge uncommitted: byre concludes it when the turn ends. " +
"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 of the turn's snapshot covers them." , target )
2026-10-14 21:01:38 -07:00
}
func ( m * Manager ) MergeIn ( ctx context . Context , task api . Task , user api . User , target string ) ( api . AgentSession , error ) {
2026-10-15 18:55:20 -07:00
prompt := mergeInPrompt ( target ) + fmt . Sprintf ( " Finally, if the merge commit title (currently \"%s\") no longer describes all the work on this branch, set a better one with set_merge_title." , task . MergeTitle )
return m . sendMergeIn ( ctx , task , user , prompt )
}
func ( m * Manager ) sendMergeIn ( ctx context . Context , task api . Task , user api . User , prompt string ) ( api . AgentSession , error ) {
2026-10-14 21:01:38 -07:00
sessions , err := m . store . ListSessions ( ctx , task . Id )
if err != nil {
return api . AgentSession {}, err
}
for _ , sess := range sessions {
if sess . State != api . AgentSessionStateCompleted {
2026-10-14 21:21:54 -07:00
return m . Send ( ctx , sess . Id , "byre" , prompt , "" )
2026-10-14 21:01:38 -07:00
}
}
2026-10-14 21:21:54 -07:00
return m . create ( ctx , task , user , prompt , "" , "byre" , nil )
2026-10-14 21:01:38 -07:00
}
2026-10-15 18:55:20 -07:00
func ( m * Manager ) Merge ( ctx context . Context , task api . Task , project api . Project , user api . User , title string ) ( api . Task , error ) {
2026-10-14 21:01:38 -07:00
if user . GitName == "" || user . GitEmail == "" {
2026-10-15 12:11:35 -07:00
return api . Task {}, ErrNoGitIdentity
2026-10-14 21:01:38 -07:00
}
2026-10-15 12:11:35 -07:00
if task . MergeQueuedAt != nil {
return api . Task {}, fmt . Errorf ( "%w: task %s is already waiting to merge" , ErrConflict , task . Id )
}
2026-10-16 10:36:38 -07:00
if task . MergingIntoParent {
return api . Task {}, fmt . Errorf ( "%w: task %s is merging into its parent" , ErrConflict , task . Id )
}
err := m . checkNoSubtasks ( ctx , task )
if err != nil {
return api . Task {}, err
}
title , err = cleanMergeTitle ( title )
2026-10-15 18:55:20 -07:00
if err != nil {
return api . Task {}, err
}
2026-10-15 12:11:35 -07:00
2026-10-16 14:20:16 -07:00
pm := m . projectMerge ( project . Id )
pm . mu . Lock ()
defer pm . mu . Unlock ()
2026-10-14 21:01:38 -07:00
2026-10-16 14:20:16 -07:00
if pm . holder == task . Id {
2026-10-15 12:11:35 -07:00
return api . Task {}, fmt . Errorf ( "%w: task %s is already merging" , ErrConflict , task . Id )
2026-10-15 18:55:20 -07:00
}
if title != task . MergeTitle {
err = m . storeMergeTitle ( ctx , task . Id , title , user . Login )
if err != nil {
return api . Task {}, err
}
task . MergeTitle = title
}
2026-10-16 14:20:16 -07:00
switch pm . holder {
2026-10-15 12:11:35 -07:00
case "" :
2026-10-16 14:20:16 -07:00
err := m . startMerge ( ctx , pm , task , project , user )
2026-10-15 12:11:35 -07:00
if err != nil {
return api . Task {}, err
}
default :
err := m . store . QueueMerge ( ctx , task . Id , user . Login )
if errors . Is ( err , store . ErrNotFound ) {
2026-10-16 10:08:08 -07:00
return api . Task {}, fmt . Errorf ( "%w: task %s is closed, being discarded or already waiting to merge" , ErrConflict , task . Id )
2026-10-15 12:11:35 -07:00
}
if err != nil {
return api . Task {}, err
}
}
2026-10-16 22:34:14 -07:00
m . clearMergeAfterReview ( ctx , task . Id )
2026-10-15 12:11:35 -07:00
return m . store . GetTask ( ctx , task . Id )
}
2026-10-16 14:20:16 -07:00
type projectMerge struct {
mu sync . Mutex
holder string
}
func ( m * Manager ) projectMerge ( projectID string ) * projectMerge {
m . mergeMu . Lock ()
defer m . mergeMu . Unlock ()
pm := m . merges [ projectID ]
if pm == nil {
pm = & projectMerge {}
m . merges [ projectID ] = pm
}
return pm
}
func ( m * Manager ) startMerge ( ctx context . Context , pm * projectMerge , task api . Task , project api . Project , user api . User ) error {
2026-10-17 07:34:43 -07:00
err := m . store . SetMergingBy ( ctx , task . Id , user . Login )
2026-10-16 10:08:08 -07:00
if errors . Is ( err , store . ErrNotFound ) {
return fmt . Errorf ( "%w: task %s is being discarded" , ErrConflict , task . Id )
}
2026-10-15 10:16:32 -07:00
if err != nil {
2026-10-15 12:11:35 -07:00
return err
2026-10-15 10:16:32 -07:00
}
2026-10-16 14:20:16 -07:00
pm . holder = task . Id
2026-10-14 21:01:38 -07:00
2026-10-17 07:34:43 -07:00
sess , err := m . mergeSession ( ctx , task , user )
2026-10-14 21:01:38 -07:00
if err != nil {
2026-10-15 10:16:32 -07:00
m . takeMerge ( task . Id )
2026-10-16 14:20:16 -07:00
pm . holder = ""
2026-10-17 07:34:43 -07:00
return err
}
prompt := mergeInPrompt ( project . DefaultBranch ) + fmt . Sprintf ( " When this turn ends, byre squashes this branch onto %s and pushes it; if you stop, call stop_merge first so it doesn't. " , project . DefaultBranch ) + summaryRequest
go m . launchMerge ( sess , task , project , prompt )
return nil
}
func ( m * Manager ) mergeSession ( ctx context . Context , task api . Task , user api . User ) ( api . AgentSession , error ) {
sessions , err := m . store . ListSessions ( ctx , task . Id )
if err != nil {
return api . AgentSession {}, err
}
sess := api . AgentSession {}
for _ , s := range sessions {
if s . State != api . AgentSessionStateCompleted {
sess = s
}
}
if sess . Id == "" {
sess , err = m . newSession ( ctx , task , user , "" )
if err != nil {
return api . AgentSession {}, err
}
}
err = transition ( ctx , m . store , sess . Id , api . AgentSessionStateIdle , api . AgentSessionStateRunning , "byre" )
return sess , err
}
func ( m * Manager ) launchMerge ( sess api . AgentSession , task api . Task , project api . Project , prompt string ) {
ctx := context . Background ()
err := m . deliverMerge ( ctx , sess , task , project , prompt )
if err == nil {
return
}
slog . Error ( "start merge turn" , "task" , task . Id , "err" , err )
m . failStart ( ctx , sess . Id , "byre" , err )
m . abandonMerge ( task . Id , project . Id )
}
func ( m * Manager ) deliverMerge ( ctx context . Context , sess api . AgentSession , task api . Task , project api . Project , prompt string ) error {
l , err := m . ensureLive ( ctx , sess , task )
if err != nil {
return err
}
err = m . syncModel ( ctx , l , sess . Model )
if err != nil {
return err
}
_ , err = m . prepareMessage ( ctx , l , "byre" , prompt , nil )
if err != nil {
return err
}
m . mu . Lock ()
l . fetching = true
m . mu . Unlock ()
err = git . FetchFastForward ( ctx , project . RepoPath , project . DefaultBranch )
m . mu . Lock ()
l . fetching = false
interrupted := l . interrupted
m . mu . Unlock ()
if err != nil {
return err
}
if interrupted {
return errors . New ( "interrupted before the merge started" )
2026-10-14 21:01:38 -07:00
}
2026-10-17 07:34:43 -07:00
return l . sess . send ( ctx , prompt , nil )
2026-10-15 12:11:35 -07:00
}
func ( m * Manager ) recoverMerges ( ctx context . Context ) error {
merging , err := m . store . MergingTasks ( ctx )
if err != nil {
return err
}
for _ , t := range merging {
2026-10-16 14:20:16 -07:00
pm := m . projectMerge ( t . ProjectId )
pm . mu . Lock ()
pm . holder = t . Id
pm . mu . Unlock ()
2026-10-15 12:11:35 -07:00
}
return nil
}
2026-10-16 12:46:28 -07:00
func ( m * Manager ) recoverFinalizing ( ctx context . Context ) error {
2026-10-16 13:01:17 -07:00
finalizing , err := m . store . FinalizingMerges ( ctx )
2026-10-16 12:46:28 -07:00
if err != nil {
return err
}
2026-10-16 13:01:17 -07:00
for _ , f := range finalizing {
ferr := m . resumeFinalizing ( ctx , f )
if ferr == nil {
slog . Info ( "finished merge at startup" , "task" , f . Task . Id , "commit" , f . Commit )
continue
}
slog . Error ( "finish merge at startup" , "task" , f . Task . Id , "err" , ferr )
unfinished := & unfinishedMergeError {}
if ! errors . As ( ferr , & unfinished ) {
err = m . store . ClearFinalizing ( ctx , f . Task . Id )
if err != nil {
return err
}
2026-10-16 12:46:28 -07:00
}
2026-10-16 13:01:17 -07:00
sessions , err := m . store . ListSessions ( ctx , f . Task . Id )
2026-10-16 12:46:28 -07:00
if err != nil {
return err
}
2026-10-16 13:01:17 -07:00
m . fileTaskError ( ctx , f . Task , sessions , "byre restarted while finishing this task's merge: " + ferr . Error () + "." )
2026-10-16 12:46:28 -07:00
}
return nil
}
2026-10-16 13:01:17 -07:00
func ( m * Manager ) resumeFinalizing ( ctx context . Context , f store . FinalizingMerge ) error {
project , err := m . store . GetProject ( ctx , f . Task . ProjectId )
if err != nil {
return err
}
if f . Unchanged {
return m . closeUnchanged ( ctx , f . Task , project , f . By )
}
if f . Commit == "" {
return errors . New ( "nothing was pushed. Merge again to retry" )
}
2026-10-16 14:20:16 -07:00
landed , err := pushLanded ( ctx , project , f . Commit )
2026-10-16 13:01:17 -07:00
if err != nil {
return err
}
if ! landed {
return fmt . Errorf ( "its squash commit %s is not on %s/%s, so the push didn't land. Merge again to retry" , f . Commit , git . Remote , project . DefaultBranch )
}
return m . closePushed ( ctx , f . Task , project , f . Commit , f . Notes , f . By )
}
2026-10-16 14:20:16 -07:00
func pushLanded ( ctx context . Context , project api . Project , commit string ) ( bool , error ) {
2026-10-16 13:01:17 -07:00
target := git . Remote + "/" + project . DefaultBranch
2026-10-16 14:20:16 -07:00
err := git . Fetch ( ctx , project . RepoPath )
2026-10-16 13:01:17 -07:00
if err != nil {
return false , & unfinishedMergeError { fmt . Errorf ( "checking whether %s reached %s failed: %w" , commit , target , err )}
}
landed , err := git . IsAncestor ( project . RepoPath , commit , target )
if err != nil {
return false , & unfinishedMergeError { fmt . Errorf ( "checking whether %s reached %s failed: %w" , commit , target , err )}
}
return landed , nil
}
type unfinishedMergeError struct {
err error
}
func ( e * unfinishedMergeError ) Error () string {
return e . err . Error () + "; byre finishes the merge when it next starts"
}
func ( e * unfinishedMergeError ) Unwrap () error {
return e . err
}
2026-10-15 12:11:35 -07:00
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 ) {
2026-10-16 14:20:16 -07:00
pm := m . projectMerge ( projectID )
pm . mu . Lock ()
defer pm . mu . Unlock ()
if pm . holder == taskID {
pm . holder = ""
2026-10-15 12:11:35 -07:00
}
2026-10-16 14:20:16 -07:00
m . startNextMerge ( pm , projectID )
2026-10-15 12:11:35 -07:00
}
func ( m * Manager ) nextMerge ( projectID string ) {
2026-10-16 14:20:16 -07:00
pm := m . projectMerge ( projectID )
pm . mu . Lock ()
defer pm . mu . Unlock ()
m . startNextMerge ( pm , projectID )
2026-10-15 12:11:35 -07:00
}
2026-10-16 14:20:16 -07:00
func ( m * Manager ) startNextMerge ( pm * projectMerge , projectID string ) {
if pm . holder != "" {
2026-10-15 12:11:35 -07:00
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 {
2026-10-15 14:55:58 -07:00
return s . State == api . AgentSessionStateRunning
2026-10-15 12:11:35 -07:00
}) {
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
}
2026-10-16 14:20:16 -07:00
err = m . startHeldMerge ( ctx , pm , task , login )
2026-10-15 12:11:35 -07:00
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 ())
}
}
2026-10-16 14:20:16 -07:00
func ( m * Manager ) startHeldMerge ( ctx context . Context , pm * projectMerge , task api . Task , login string ) error {
2026-10-15 12:11:35 -07:00
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
}
2026-10-16 14:20:16 -07:00
return m . startMerge ( ctx , pm , task , project , user )
2026-10-15 12:11:35 -07:00
}
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 )
2026-10-14 21:01:38 -07:00
}
2026-10-15 10:16:32 -07:00
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
2026-10-14 21:01:38 -07:00
}
func ( m * Manager ) finishMerge ( l * live , login string ) {
2026-10-15 12:11:35 -07:00
defer m . releaseMerge ( l . projectID , l . taskID )
2026-10-14 21:01:38 -07:00
ctx := context . Background ()
err := m . squashAndClose ( ctx , l . taskID , login )
if err == nil {
return
}
slog . Error ( "finish merge" , "task" , l . taskID , "err" , err )
2026-10-16 13:01:17 -07:00
unfinished := & unfinishedMergeError {}
if errors . As ( err , & unfinished ) {
m . fileError ( l , "Merging the task failed: " + err . Error () + "." )
return
}
2026-10-16 12:46:28 -07:00
cerr := m . store . ClearFinalizing ( ctx , l . taskID )
if cerr != nil {
slog . Error ( "clear merge finalizing" , "task" , l . taskID , "err" , cerr )
}
2026-10-14 22:45:02 -07:00
m . fileError ( l , "Merging the task failed: " + err . Error ())
2026-10-14 21:01:38 -07:00
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
}
2026-10-15 07:15:51 -07:00
coAuthors , err := m . CoAuthors ( ctx , taskID , login )
if err != nil {
return err
}
2026-10-15 19:44:53 -07:00
env := [] string {
2026-10-14 21:01:38 -07:00
"GIT_AUTHOR_NAME=" + user . GitName ,
"GIT_AUTHOR_EMAIL=" + user . GitEmail ,
"GIT_COMMITTER_NAME=" + user . GitName ,
"GIT_COMMITTER_EMAIL=" + user . GitEmail ,
2026-10-15 19:44:53 -07:00
}
commit , err := git . SquashCommit ( project . RepoPath , task . WorktreePath , project . DefaultBranch , squashMessage ( task . MergeTitle , coAuthors ), env )
2026-10-14 21:01:38 -07:00
if err != nil {
return err
}
2026-10-16 13:01:17 -07:00
if commit == "" {
err = m . store . SetFinalizingUnchanged ( ctx , taskID )
2026-10-15 19:44:53 -07:00
if err != nil {
return err
}
2026-10-16 13:01:17 -07:00
return m . closeUnchanged ( ctx , task , project , login )
}
note , err := m . mergeNote ( ctx , task , login )
if err != nil {
return err
}
notes , err := git . NoteCommit ( project . RepoPath , commit , note , env )
if err != nil {
return err
}
err = m . store . SetFinalizingCommit ( ctx , taskID , commit , notes )
if err != nil {
return err
}
2026-10-16 14:20:16 -07:00
err = git . Push ( ctx , project . RepoPath , commit , project . DefaultBranch , notes )
2026-10-16 13:01:17 -07:00
if errors . Is ( err , git . ErrRemoteMoved ) {
return err
}
if err != nil {
2026-10-16 14:20:16 -07:00
landed , lerr := pushLanded ( ctx , project , commit )
2026-10-16 13:01:17 -07:00
if lerr != nil {
return & unfinishedMergeError { fmt . Errorf ( "%w, and %w" , err , errors . Unwrap ( lerr ))}
2026-10-15 19:44:53 -07:00
}
2026-10-16 13:01:17 -07:00
if ! landed {
2026-10-14 22:45:02 -07:00
return err
}
2026-10-16 13:01:17 -07:00
slog . Warn ( "push reported failure but landed" , "task" , taskID , "commit" , commit , "err" , err )
2026-10-14 22:45:02 -07:00
}
2026-10-16 13:01:17 -07:00
return m . closePushed ( ctx , task , project , commit , notes , login )
}
2026-10-14 22:45:02 -07:00
2026-10-16 13:01:17 -07:00
func ( m * Manager ) closeUnchanged ( ctx context . Context , task api . Task , project api . Project , login string ) error {
err := m . RemoveTask ( ctx , task , project , login )
2026-10-14 21:01:38 -07:00
if err != nil {
2026-10-16 13:01:17 -07:00
return & unfinishedMergeError { fmt . Errorf ( "the merge changed nothing, but removing the task's worktree failed: %w" , err )}
2026-10-14 21:01:38 -07:00
}
2026-10-16 13:01:17 -07:00
_ , err = m . store . MergeTask ( ctx , task . Id , "" , "" , login )
if err != nil {
return & unfinishedMergeError { fmt . Errorf ( "the merge changed nothing, but closing the task failed: %w" , err )}
}
return nil
}
2026-10-14 21:01:38 -07:00
2026-10-16 13:01:17 -07:00
func ( m * Manager ) closePushed ( ctx context . Context , task api . Task , project api . Project , commit , notes , login string ) error {
pushed := git . Remote + "/" + project . DefaultBranch
err := git . AdvanceNotes ( project . RepoPath , notes )
if err != nil {
return & unfinishedMergeError { fmt . Errorf ( "pushed %s to %s, but updating %s in the project checkout failed: %w" , commit , pushed , git . NotesRef , err )}
}
err = git . FastForward ( project . RepoPath , commit )
if err != nil {
return & unfinishedMergeError { 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 & unfinishedMergeError { fmt . Errorf ( "pushed %s to %s, but removing the task's worktree failed: %w" , commit , pushed , err )}
}
_ , err = m . store . MergeTask ( ctx , task . Id , commit , pushed , login )
if err != nil {
return & unfinishedMergeError { fmt . Errorf ( "pushed %s to %s, but closing the task failed: %w" , commit , pushed , err )}
}
return nil
2026-10-14 21:01:38 -07:00
}
2026-10-15 07:15:51 -07:00
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" )
}
2026-10-14 21:01:38 -07:00
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
}
2026-10-15 16:33:17 -07:00
m . cancelReviews ( ctx , task , project )
2026-10-14 22:52:02 -07:00
err = m . dropSnapshots ( project . RepoPath , task . Id )
if err != nil {
return err
}
2026-10-14 21:01:38 -07:00
return git . RemoveWorktree ( project . RepoPath , task . WorktreePath , task . Branch )
}
2026-10-14 21:21:54 -07:00
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 )
}
2026-10-15 18:55:20 -07:00
title , err := cleanMergeTitle ( args . Title )
if err != nil {
return "" , err
}
err = m . storeMergeTitle ( ctx , l . taskID , title , agentActor )
if err != nil {
return "" , err
}
return fmt . Sprintf ( "Merge commit title set to %q." , title ), nil
}
2026-10-15 19:36:42 -07:00
type stopMergeArgs struct {
Reason string `json:"reason"`
}
func ( m * Manager ) stopMergeTool ( l * live ) mcp . Tool {
return mcp . Tool {
Name : "stop_merge" ,
Description : "Stop the merge byre is to make when this turn ends, so the task stays active and its snapshot is reviewed before anything is pushed. " +
"Call it during a Merge turn when resolving conflicts took substantive changes, then end your turn describing them. The human merges again after the review." ,
InputSchema : json . RawMessage ( `{
"type": "object",
"properties": {
"reason": {"type": "string", "description": "The substantive changes the resolution made, in a sentence or two."}
},
"required": ["reason"]
}` ),
Handler : func ( ctx context . Context , raw json . RawMessage ) ( string , error ) {
return m . stopMerge ( ctx , l , raw )
},
}
}
func ( m * Manager ) stopMerge ( ctx context . Context , l * live , raw json . RawMessage ) ( string , error ) {
args := stopMergeArgs {}
err := json . Unmarshal ( raw , & args )
if err != nil {
return "" , fmt . Errorf ( "malformed stop_merge call: %w" , err )
}
reason := strings . TrimSpace ( args . Reason )
if reason == "" {
return "" , fmt . Errorf ( "%w: reason must say what the resolution changed" , ErrInvalid )
}
if m . takeMerge ( l . taskID ) == "" {
return "No merge is pending for this task; just end your turn." , nil
}
go m . releaseMerge ( l . projectID , l . taskID )
err = m . emit ( ctx , l , agentActor , "task.merge_stopped" , map [ string ] any { "id" : l . taskID , "reason" : reason })
if err != nil {
return "" , err
}
return "Merge stopped: byre won't squash or push when this turn ends. End your turn now with a description of the changes." , nil
}
2026-10-15 18:55:20 -07:00
func cleanMergeTitle ( raw string ) ( string , error ) {
title := strings . TrimSpace ( raw )
2026-10-14 21:21:54 -07:00
if title == "" || strings . ContainsAny ( title , "\r\n" ) {
2026-10-15 18:55:20 -07:00
return "" , fmt . Errorf ( "%w: title must be one non-empty line" , ErrInvalid )
2026-10-14 21:21:54 -07:00
}
if utf8 . RuneCountInString ( title ) > maxMergeTitle {
2026-10-15 18:55:20 -07:00
return "" , fmt . Errorf ( "%w: title is %d characters; keep it to %d" , ErrInvalid , utf8 . RuneCountInString ( title ), maxMergeTitle )
2026-10-14 21:21:54 -07:00
}
2026-10-15 18:55:20 -07:00
return title , nil
}
2026-10-14 21:21:54 -07:00
2026-10-15 18:55:20 -07:00
func ( m * Manager ) storeMergeTitle ( ctx context . Context , taskID , title , actor string ) error {
err := m . store . SetMergeTitle ( ctx , taskID , title , actor )
2026-10-14 21:21:54 -07:00
if errors . Is ( err , store . ErrNotFound ) {
2026-10-15 18:55:20 -07:00
return fmt . Errorf ( "%w: this task is closed, or its merge was already confirmed with its title" , ErrConflict )
2026-10-14 21:21:54 -07:00
}
2026-10-15 18:55:20 -07:00
return err
2026-10-14 21:21:54 -07:00
}