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-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
}
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
}
2026-10-14 22:45:02 -07:00
err := git . FetchFastForward ( project . RepoPath , project . DefaultBranch )
if err != nil {
return api . AgentSession {}, err
}
2026-10-14 21:01:38 -07:00
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 )
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
}
return m . store . DiscardTask ( ctx , task . Id , actor )
}
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
}