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"
"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
func mergeInPrompt ( target , mergeTitle 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-14 21:21:54 -07:00
"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 )
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 ) {
sessions , err := m . store . ListSessions ( ctx , task . Id )
if err != nil {
return api . AgentSession {}, err
}
2026-10-14 21:21:54 -07:00
prompt := mergeInPrompt ( target , task . MergeTitle )
2026-10-14 21:01:38 -07:00
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 12:11:35 -07:00
func ( m * Manager ) Merge ( ctx context . Context , task api . Task , project api . Project , user api . User ) ( 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 )
}
m . mergeMu . Lock ()
defer m . mergeMu . Unlock ()
2026-10-14 21:01:38 -07:00
2026-10-15 12:11:35 -07:00
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 {
2026-10-14 22:45:02 -07:00
err := git . FetchFastForward ( project . RepoPath , project . DefaultBranch )
if err != nil {
2026-10-15 12:11:35 -07:00
return err
2026-10-14 22:45:02 -07:00
}
2026-10-15 10:16:32 -07:00
err = m . store . SetMergingBy ( ctx , task . Id , user . Login )
if err != nil {
2026-10-15 12:11:35 -07:00
return err
2026-10-15 10:16:32 -07:00
}
2026-10-15 12:11:35 -07:00
m . mergeHolders [ project . Id ] = task . Id
2026-10-14 21:01:38 -07:00
2026-10-15 12:11:35 -07:00
_ , err = m . MergeIn ( ctx , task , user , project . DefaultBranch )
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-15 12:11:35 -07:00
delete ( m . mergeHolders , project . Id )
2026-10-14 21:01:38 -07:00
}
2026-10-15 12:11:35 -07:00
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 )
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-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
}
commit , err := git . SquashCommit ( project . RepoPath , task . WorktreePath , project . DefaultBranch , squashMessage ( task . MergeTitle , coAuthors ), [] 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 ,
})
if err != nil {
return err
}
2026-10-14 22:45:02 -07:00
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 )
}
}
2026-10-14 21:01:38 -07:00
err = m . RemoveTask ( ctx , task , project , login )
if err != nil {
return err
}
2026-10-14 22:45:02 -07:00
_ , err = m . store . MergeTask ( ctx , taskID , commit , pushed , login )
2026-10-14 21:01:38 -07:00
return err
}
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-15 09:40:30 -07:00
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
}
2026-10-15 12:11:35 -07:00
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
2026-10-15 09:40:30 -07:00
}
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-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 )
}
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
}