2026-10-14 22:52:02 -07:00
package agents
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"sync"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/claude"
"g.fc.run/misc/byre/internal/git"
2026-10-17 07:43:33 -07:00
"g.fc.run/misc/byre/internal/mcp"
2026-10-14 22:52:02 -07:00
"g.fc.run/misc/byre/internal/store"
)
2026-10-15 11:36:51 -07:00
type snapRequest struct {
2026-10-18 08:48:57 -07:00
target snapTarget
2026-10-15 11:36:51 -07:00
trigger api . SnapshotTrigger
seq int64
2026-10-15 20:24:15 -07:00
review bool
2026-10-17 07:43:33 -07:00
force bool
}
type snapTarget struct {
taskID string
projectID string
sessionID string
dir string
gitName string
gitEmail string
}
func liveTarget ( l * live ) snapTarget {
return snapTarget { taskID : l . taskID , projectID : l . projectID , sessionID : l . id , dir : l . dir , gitName : l . gitName , gitEmail : l . gitEmail }
2026-10-15 11:36:51 -07:00
}
2026-10-14 22:52:02 -07:00
type snapState struct {
mu sync . Mutex
running bool
2026-10-15 11:36:51 -07:00
pending * snapRequest
2026-10-14 22:52:02 -07:00
closed bool
}
func ( m * Manager ) snapStateLocked ( taskID string ) * snapState {
st := m . snaps [ taskID ]
if st == nil {
st = & snapState {}
m . snaps [ taskID ] = st
}
return st
}
2026-10-17 07:43:33 -07:00
func ( m * Manager ) snapshotSoon ( t snapTarget , trigger api . SnapshotTrigger , seq int64 , review , force bool ) {
2026-10-14 22:52:02 -07:00
m . snapMu . Lock ()
defer m . snapMu . Unlock ()
2026-10-17 07:43:33 -07:00
st := m . snapStateLocked ( t . taskID )
2026-10-14 22:52:02 -07:00
if st . closed {
2026-10-17 07:43:33 -07:00
m . dropRequest ( t . taskID , & snapRequest { review : review })
2026-10-14 22:52:02 -07:00
return
}
2026-10-18 08:48:57 -07:00
req := & snapRequest { target : t , trigger : trigger , seq : seq , review : review , force : force }
2026-10-14 22:52:02 -07:00
if st . running {
2026-10-17 07:43:33 -07:00
if p := st . pending ; p != nil && p . force {
req . force = true
req . trigger = p . trigger
}
m . dropRequest ( t . taskID , st . pending )
2026-10-15 11:36:51 -07:00
st . pending = req
2026-10-14 22:52:02 -07:00
return
}
st . running = true
2026-10-18 08:48:57 -07:00
go m . runSnapshots ( st , req )
2026-10-14 22:52:02 -07:00
}
2026-10-18 08:48:57 -07:00
func ( m * Manager ) runSnapshots ( st * snapState , req * snapRequest ) {
2026-10-14 22:52:02 -07:00
for {
2026-10-18 08:48:57 -07:00
t := req . target
unlock := m . lockTask ( t . taskID )
2026-10-14 22:52:02 -07:00
st . mu . Lock ()
m . snapMu . Lock ()
closed := st . closed
m . snapMu . Unlock ()
if ! closed {
2026-10-17 07:43:33 -07:00
snap , err := m . takeSnapshot ( context . Background (), t , req . trigger , req . seq , req . force )
2026-10-14 22:52:02 -07:00
if err != nil {
2026-10-17 07:43:33 -07:00
slog . Error ( "take snapshot" , "task" , t . taskID , "trigger" , req . trigger , "err" , err )
2026-10-14 22:52:02 -07:00
}
2026-10-15 20:24:15 -07:00
if req . review {
2026-10-16 11:15:32 -07:00
go func () {
m . afterSnapshot ( snap )
2026-10-17 07:43:33 -07:00
m . endReviewRequest ( t . taskID )
2026-10-16 11:15:32 -07:00
}()
2026-10-15 20:24:15 -07:00
}
2026-10-16 11:15:32 -07:00
} else {
2026-10-17 07:43:33 -07:00
m . dropRequest ( t . taskID , req )
2026-10-14 22:52:02 -07:00
}
st . mu . Unlock ()
2026-10-18 08:48:57 -07:00
unlock ()
2026-10-14 22:52:02 -07:00
m . snapMu . Lock ()
2026-10-15 11:36:51 -07:00
req = st . pending
st . pending = nil
if req == nil || st . closed {
2026-10-17 07:43:33 -07:00
m . dropRequest ( t . taskID , req )
2026-10-14 22:52:02 -07:00
st . running = false
m . snapMu . Unlock ()
return
}
m . snapMu . Unlock ()
}
}
2026-10-16 11:15:32 -07:00
func ( m * Manager ) dropRequest ( taskID string , req * snapRequest ) {
if req != nil && req . review {
m . endReviewRequest ( taskID )
}
}
func ( m * Manager ) endReviewRequest ( taskID string ) {
err := m . store . EndReviewRequest ( context . Background (), taskID )
if err != nil {
slog . Error ( "end review request" , "task" , taskID , "err" , err )
}
}
2026-10-17 07:43:33 -07:00
func ( m * Manager ) takeSnapshot ( ctx context . Context , t snapTarget , trigger api . SnapshotTrigger , seq int64 , force bool ) ( * api . Snapshot , error ) {
2026-10-15 11:36:51 -07:00
if seq == 0 {
var err error
2026-10-17 07:43:33 -07:00
seq , err = m . store . LastTaskSeq ( ctx , t . taskID )
2026-10-15 11:36:51 -07:00
if err != nil {
2026-10-15 16:33:17 -07:00
return nil , err
2026-10-15 11:36:51 -07:00
}
2026-10-14 22:52:02 -07:00
}
2026-10-17 07:43:33 -07:00
tree , err := git . Snapshot ( t . dir )
2026-10-14 22:52:02 -07:00
if err != nil {
2026-10-15 16:33:17 -07:00
return nil , err
2026-10-14 22:52:02 -07:00
}
2026-10-17 07:43:33 -07:00
last , err := m . store . LastSnapshot ( ctx , t . taskID )
2026-10-14 22:52:02 -07:00
if err != nil && ! errors . Is ( err , store . ErrNotFound ) {
2026-10-15 16:33:17 -07:00
return nil , err
2026-10-14 22:52:02 -07:00
}
2026-10-17 07:43:33 -07:00
if err == nil && last . TreeSha == tree && ! force {
2026-10-15 16:33:17 -07:00
return nil , nil
2026-10-14 22:52:02 -07:00
}
n := last . N + 1
2026-10-17 07:43:33 -07:00
commit , err := git . CommitSnapshot ( t . dir , tree , git . SnapshotRef ( t . taskID , n ), fmt . Sprintf ( "byre snapshot %d (%s)" , n , trigger ), [] string {
"GIT_AUTHOR_NAME=" + t . gitName ,
"GIT_AUTHOR_EMAIL=" + t . gitEmail ,
"GIT_COMMITTER_NAME=" + t . gitName ,
"GIT_COMMITTER_EMAIL=" + t . gitEmail ,
2026-10-14 22:52:02 -07:00
})
if err != nil {
2026-10-15 16:33:17 -07:00
return nil , err
2026-10-14 22:52:02 -07:00
}
2026-10-15 16:33:17 -07:00
snap := api . Snapshot {
2026-10-14 22:52:02 -07:00
Id : store . NewID (),
2026-10-17 07:43:33 -07:00
TaskId : t . taskID ,
AgentSessionId : t . sessionID ,
2026-10-14 22:52:02 -07:00
N : n ,
CommitSha : commit ,
TreeSha : tree ,
EventSeq : seq ,
Trigger : trigger ,
CreatedAt : time . Now (). UTC (),
2026-10-15 16:33:17 -07:00
}
2026-10-17 07:43:33 -07:00
err = m . store . CreateSnapshot ( ctx , snap , t . projectID )
2026-10-15 16:33:17 -07:00
if err != nil {
return nil , err
}
return & snap , nil
2026-10-14 22:52:02 -07:00
}
2026-10-17 07:43:33 -07:00
func ( m * Manager ) RequestSnapshot ( ctx context . Context , taskID string ) error {
task , project , err := m . activeTask ( ctx , taskID )
if err != nil {
return err
}
discard , err := m . store . GetDiscard ( ctx , task . Id )
if err != nil {
return err
}
if discard . State != store . DiscardNone {
return fmt . Errorf ( "%w: task %s is being discarded" , ErrConflict , task . Id )
}
mergingBy , err := m . store . MergingBy ( ctx , task . Id )
if err != nil {
return err
}
if mergingBy != "" {
return fmt . Errorf ( "%w: task %s is merging" , ErrConflict , task . Id )
}
sessions , err := m . store . ListSessions ( ctx , task . Id )
if err != nil {
return err
}
if len ( sessions ) == 0 {
return fmt . Errorf ( "%w: task %s has no agent session to attribute a snapshot to" , ErrConflict , task . Id )
}
sess := sessions [ len ( sessions ) - 1 ]
creator , err := m . store . GetUser ( ctx , sess . CreatedBy )
if err != nil {
return err
}
err = m . store . BeginReviewRequest ( ctx , task . Id )
if err != nil {
return err
}
t := snapTarget { taskID : task . Id , projectID : project . Id , sessionID : sess . Id , dir : task . WorktreePath , gitName : creator . GitName , gitEmail : creator . GitEmail }
m . snapshotSoon ( t , api . SnapshotTriggerRequested , 0 , true , true )
return nil
}
func ( m * Manager ) takeSnapshotTool ( l * live ) mcp . Tool {
return mcp . Tool {
Name : "take_snapshot" ,
Description : takeSnapshotDescription + " It snapshots your own task's worktree." ,
InputSchema : json . RawMessage ( `{"type": "object", "properties": {}}` ),
Handler : func ( ctx context . Context , args json . RawMessage ) ( string , error ) {
err := m . RequestSnapshot ( ctx , l . taskID )
if err != nil {
return "" , err
}
return "Snapshot requested. A reviewer reviews it against the default branch, and any findings come to you as a message from byre." , nil
},
}
}
const takeSnapshotDescription = "Snapshot a task's worktree now, uncommitted work included, and start a review of it: a reviewer on the other vendor reviews all of the task's work against the default branch. " +
"byre already does this at the end of every turn that changes files; call this to review work mid-turn, or to retry a review that failed (it snapshots even when nothing changed since the last snapshot)."
2026-10-14 22:52:02 -07:00
func ( m * Manager ) dropSnapshots ( repo , taskID string ) error {
m . snapMu . Lock ()
st := m . snapStateLocked ( taskID )
st . closed = true
m . snapMu . Unlock ()
st . mu . Lock ()
defer st . mu . Unlock ()
return git . DeleteSnapshotRefs ( repo , taskID )
}
2026-10-15 10:20:23 -07:00
func ( m * Manager ) afterTool ( l * live ) claude . HookFunc {
2026-10-14 22:52:02 -07:00
return func ( ctx context . Context , input json . RawMessage ) ( map [ string ] any , error ) {
2026-10-16 14:18:40 -07:00
mt := m . deliverMidTurn ( ctx , l )
if mt . text == "" {
2026-10-15 10:20:23 -07:00
return map [ string ] any {}, nil
}
return map [ string ] any {
"hookSpecificOutput" : map [ string ] any {
"hookEventName" : "PostToolUse" ,
2026-10-16 14:18:40 -07:00
"additionalContext" : mt . text ,
2026-10-15 10:20:23 -07:00
},
}, nil
2026-10-14 22:52:02 -07:00
}
}