2026-10-13 22:30:39 -07:00
package agents
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"log/slog"
2026-10-15 09:40:30 -07:00
"net/url"
"os"
"path/filepath"
2026-10-14 19:35:34 -07:00
"strings"
2026-10-13 22:30:39 -07:00
"sync"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/claude"
2026-10-14 19:35:34 -07:00
"g.fc.run/misc/byre/internal/git"
2026-10-13 22:30:39 -07:00
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/store"
2026-10-15 21:17:58 -07:00
"g.fc.run/misc/byre/internal/workflow"
2026-10-13 22:30:39 -07:00
)
var (
ErrConflict = errors . New ( "conflict" )
2026-10-15 09:40:30 -07:00
ErrInvalid = errors . New ( "invalid" )
2026-10-13 22:30:39 -07:00
ErrNoGitIdentity = errors . New ( "set your git name and email (PUT /api/me) before starting agents" )
)
const agentActor = "agent"
const restartNotice = "byre restarted while you were mid-turn, so your last turn was cut off. Re-check the working tree (git status and the files you were editing) before continuing where you left off."
type Manager struct {
2026-10-14 19:35:34 -07:00
store * store . Store
worktreeRoot string
2026-10-15 09:40:30 -07:00
leadRoot string
2026-10-15 14:55:58 -07:00
codexHome string
2026-10-13 22:30:39 -07:00
2026-10-15 09:40:30 -07:00
mu sync . Mutex
live map [ string ] * live
2026-10-16 12:56:53 -07:00
starting map [ string ] * start
2026-10-15 09:40:30 -07:00
closing bool
rateLimits map [ api . AgentSessionVendor ] map [ string ] any
leadAnswered map [ string ] bool
2026-10-16 09:51:32 -07:00
paneFocus map [ string ] api . PaneFocus
paneReported map [ string ] time . Time
paneMoved map [ string ] time . Time
2026-10-16 13:10:44 -07:00
humanMoved map [ string ] time . Time
2026-10-16 08:36:08 -07:00
startedTodo map [ string ] time . Time
2026-10-16 10:08:08 -07:00
finishing map [ string ] bool
2026-10-14 07:02:56 -07:00
2026-10-16 12:56:53 -07:00
deliverMu sync . Mutex
deliverLocks map [ string ] * sessionLock
leadMu sync . Mutex
signals chan [] api . Event
2026-10-14 22:35:53 -07:00
2026-10-14 22:52:02 -07:00
snapMu sync . Mutex
snaps map [ string ] * snapState
2026-10-16 14:20:16 -07:00
mergeMu sync . Mutex
merges map [ string ] * projectMerge
2026-10-15 12:11:35 -07:00
2026-10-15 16:33:17 -07:00
reviewMu sync . Mutex
2026-10-15 22:53:54 -07:00
workflowMu sync . Mutex
2026-10-14 22:35:53 -07:00
bad * badLines
watchStop chan struct {}
2026-10-13 22:30:39 -07:00
}
type live struct {
2026-10-15 14:55:58 -07:00
sess agentProcess
2026-10-14 07:02:56 -07:00
id string
2026-10-14 19:35:34 -07:00
dir string
model string
gitName string
gitEmail string
2026-10-14 09:40:12 -07:00
vendor api . AgentSessionVendor
2026-10-14 07:02:56 -07:00
taskID string
projectID string
2026-10-15 09:40:30 -07:00
owner string
2026-10-16 10:50:29 -07:00
notes string
2026-10-15 16:33:17 -07:00
reviewID string
2026-10-15 22:53:54 -07:00
stage string
2026-10-14 07:02:56 -07:00
stopping bool
interrupted bool
2026-10-14 19:35:34 -07:00
asked bool
bounced bool
compacting bool
2026-10-15 09:40:30 -07:00
wantCompact bool
2026-10-14 09:40:12 -07:00
rateLimited map [ string ] any
2026-10-15 14:55:58 -07:00
lastText string
2026-10-15 11:36:51 -07:00
2026-10-14 16:03:46 -07:00
contextModel string
contextUsed int
contextWindow int
2026-10-15 22:18:55 -07:00
contextWarned bool
2026-10-13 22:30:39 -07:00
}
2026-10-16 12:56:53 -07:00
type start struct {
done chan struct {}
cancel context . CancelFunc
l * live
err error
stopping bool
}
2026-10-15 16:33:17 -07:00
func ( l * live ) implementer () bool {
return l . owner == "" && l . reviewID == ""
}
2026-10-15 14:55:58 -07:00
func New ( st * store . Store , worktreeRoot , leadRoot , codexHome string ) * Manager {
2026-10-15 09:40:30 -07:00
m := & Manager {
2026-10-15 13:15:49 -07:00
store : st ,
worktreeRoot : worktreeRoot ,
leadRoot : leadRoot ,
2026-10-15 14:55:58 -07:00
codexHome : codexHome ,
2026-10-15 13:15:49 -07:00
live : map [ string ] * live {},
2026-10-16 12:56:53 -07:00
starting : map [ string ] * start {},
deliverLocks : map [ string ] * sessionLock {},
2026-10-15 13:15:49 -07:00
rateLimits : map [ api . AgentSessionVendor ] map [ string ] any {},
leadAnswered : map [ string ] bool {},
2026-10-16 09:51:32 -07:00
paneFocus : map [ string ] api . PaneFocus {},
paneReported : map [ string ] time . Time {},
paneMoved : map [ string ] time . Time {},
2026-10-16 13:10:44 -07:00
humanMoved : map [ string ] time . Time {},
2026-10-16 08:36:08 -07:00
startedTodo : map [ string ] time . Time {},
2026-10-16 10:08:08 -07:00
finishing : map [ string ] bool {},
2026-10-16 11:15:32 -07:00
signals : make ( chan [] api . Event , 1024 ),
2026-10-15 13:15:49 -07:00
snaps : map [ string ] * snapState {},
2026-10-16 14:20:16 -07:00
merges : map [ string ] * projectMerge {},
2026-10-15 13:15:49 -07:00
bad : newBadLines (),
watchStop : make ( chan struct {}),
2026-10-14 09:40:12 -07:00
}
2026-10-15 09:40:30 -07:00
go m . signalLoop ()
2026-10-15 22:30:32 -07:00
go m . readCodexRateLimit ()
2026-10-15 09:40:30 -07:00
return m
}
func ( m * Manager ) taskFor ( ctx context . Context , sess api . AgentSession ) ( api . Task , error ) {
if sess . Owner != "" {
return api . Task {}, nil
}
return m . store . GetTask ( ctx , sess . TaskId )
2026-10-13 22:30:39 -07:00
}
2026-10-14 20:07:26 -07:00
func ( m * Manager ) Create ( ctx context . Context , task api . Task , creator api . User , prompt , model string , images ... string ) ( api . AgentSession , error ) {
return m . create ( ctx , task , creator , prompt , model , creator . Login , images )
2026-10-14 19:35:34 -07:00
}
2026-10-14 20:07:26 -07:00
func ( m * Manager ) create ( ctx context . Context , task api . Task , creator api . User , prompt , model , actor string , images [] string ) ( api . AgentSession , error ) {
2026-10-13 22:30:39 -07:00
if creator . GitName == "" || creator . GitEmail == "" {
return api . AgentSession {}, ErrNoGitIdentity
}
if task . Status != api . TaskStatusActive {
return api . AgentSession {}, fmt . Errorf ( "%w: task %s is %s" , ErrConflict , task . Id , task . Status )
}
2026-10-16 10:08:08 -07:00
sess , err := m . newSession ( ctx , task , creator , model )
if err != nil {
return api . AgentSession {}, err
}
return m . turn ( ctx , sess , task , actor , prompt , images )
}
func ( m * Manager ) newSession ( ctx context . Context , task api . Task , creator api . User , model string ) ( api . AgentSession , error ) {
2026-10-13 22:30:39 -07:00
existing , err := m . store . ListSessions ( ctx , task . Id )
if err != nil {
return api . AgentSession {}, err
}
for _ , s := range existing {
if s . State != api . AgentSessionStateCompleted {
return api . AgentSession {}, fmt . Errorf ( "%w: task %s already has session %s" , ErrConflict , task . Id , s . Id )
}
}
id := newUUID ()
2026-10-15 21:17:58 -07:00
vendor := workflow . VendorOf ( model )
2026-10-13 22:30:39 -07:00
sess := api . AgentSession {
Id : id ,
TaskId : task . Id ,
2026-10-15 14:55:58 -07:00
Vendor : vendor ,
2026-10-13 22:30:39 -07:00
Model : model ,
2026-10-15 14:55:58 -07:00
VendorSessionId : initialVendorSessionID ( vendor , id ),
2026-10-13 22:30:39 -07:00
State : api . AgentSessionStateIdle ,
Role : api . Implementer ,
CreatedBy : creator . Login ,
CreatedAt : time . Now (). UTC (),
}
err = m . store . CreateSession ( ctx , sess , task . ProjectId )
2026-10-16 10:08:08 -07:00
return sess , err
2026-10-13 22:30:39 -07:00
}
2026-10-14 20:07:26 -07:00
func ( m * Manager ) Send ( ctx context . Context , sessionID , actor , text , model string , images ... string ) ( api . AgentSession , error ) {
2026-10-13 22:30:39 -07:00
sess , err := m . store . GetSession ( ctx , sessionID )
if err != nil {
return api . AgentSession {}, err
}
if sess . State != api . AgentSessionStateIdle {
return api . AgentSession {}, fmt . Errorf ( "%w: session %s is %s" , ErrConflict , sess . Id , sess . State )
}
2026-10-14 19:35:34 -07:00
if model != "" && model != sess . Model {
2026-10-15 14:55:58 -07:00
err = checkModel ( sess , model )
if err != nil {
return api . AgentSession {}, err
}
2026-10-14 19:35:34 -07:00
err = m . store . SetSessionModel ( ctx , sess . Id , model , actor )
if err != nil {
return api . AgentSession {}, err
}
sess . Model = model
}
2026-10-15 09:40:30 -07:00
task , err := m . taskFor ( ctx , sess )
2026-10-13 22:30:39 -07:00
if err != nil {
return api . AgentSession {}, err
}
2026-10-14 20:07:26 -07:00
return m . turn ( ctx , sess , task , actor , text , images )
2026-10-13 22:30:39 -07:00
}
func ( m * Manager ) Interrupt ( ctx context . Context , sessionID string ) ( api . AgentSession , error ) {
sess , err := m . store . GetSession ( ctx , sessionID )
if err != nil {
return api . AgentSession {}, err
}
m . mu . Lock ()
l := m . live [ sessionID ]
m . mu . Unlock ()
if sess . State != api . AgentSessionStateRunning || l == nil {
return api . AgentSession {}, fmt . Errorf ( "%w: session %s has no turn running" , ErrConflict , sess . Id )
}
2026-10-14 07:02:56 -07:00
m . mu . Lock ()
l . interrupted = true
m . mu . Unlock ()
2026-10-13 22:30:39 -07:00
ictx , cancel := context . WithTimeout ( ctx , 10 * time . Second )
defer cancel ()
2026-10-15 14:55:58 -07:00
err = l . sess . interrupt ( ictx )
2026-10-13 22:30:39 -07:00
if err != nil {
return api . AgentSession {}, err
}
return sess , nil
}
func ( m * Manager ) StopTask ( ctx context . Context , taskID , actor string ) error {
sessions , err := m . store . ListSessions ( ctx , taskID )
if err != nil {
return err
}
for _ , sess := range sessions {
if sess . State == api . AgentSessionStateCompleted {
continue
}
2026-10-14 19:35:34 -07:00
err = m . retire ( ctx , sess , actor )
if err != nil {
return err
2026-10-13 22:30:39 -07:00
}
2026-10-14 19:35:34 -07:00
}
2026-10-13 22:30:39 -07:00
2026-10-15 14:55:58 -07:00
return nil
2026-10-14 19:35:34 -07:00
}
2026-10-13 22:30:39 -07:00
2026-10-14 19:35:34 -07:00
func ( m * Manager ) retire ( ctx context . Context , sess api . AgentSession , actor string ) error {
2026-10-16 10:08:08 -07:00
m . stopLive ( sess . Id )
return m . store . SetSessionState ( ctx , sess . Id , api . AgentSessionStateCompleted , actor , nil )
}
func ( m * Manager ) stopLive ( sessionID string ) {
2026-10-14 19:35:34 -07:00
m . mu . Lock ()
2026-10-16 10:08:08 -07:00
l := m . live [ sessionID ]
2026-10-14 19:35:34 -07:00
if l != nil {
l . stopping = true
}
2026-10-16 12:56:53 -07:00
st := m . starting [ sessionID ]
if st != nil {
st . stopping = true
}
2026-10-14 19:35:34 -07:00
m . mu . Unlock ()
2026-10-14 09:40:12 -07:00
2026-10-14 19:35:34 -07:00
if l != nil {
2026-10-15 14:55:58 -07:00
l . sess . kill ()
2026-10-13 22:30:39 -07:00
}
}
func ( m * Manager ) Recover ( ctx context . Context ) error {
2026-10-15 12:11:35 -07:00
err := m . recoverMerges ( ctx )
if err != nil {
return err
}
2026-10-16 12:46:28 -07:00
err = m . recoverFinalizing ( ctx )
if err != nil {
return err
}
2026-10-15 22:11:25 -07:00
staged , err := m . recoverStages ( ctx )
if err != nil {
return err
}
2026-10-13 22:30:39 -07:00
sessions , err := m . store . SessionsInState ( ctx , api . AgentSessionStateRunning )
if err != nil {
return err
}
for _ , sess := range sessions {
2026-10-15 22:11:25 -07:00
if staged [ sess . Id ] {
continue
}
2026-10-15 09:40:30 -07:00
task , err := m . taskFor ( ctx , sess )
2026-10-13 22:30:39 -07:00
if err != nil {
2026-10-16 12:43:54 -07:00
slog . Error ( "resume session: load task" , "session" , sess . Id , "task" , sess . TaskId , "err" , err )
continue
2026-10-13 22:30:39 -07:00
}
2026-10-14 09:40:12 -07:00
l , err := m . ensureLive ( ctx , sess , task )
2026-10-13 22:30:39 -07:00
if err != nil {
2026-10-16 12:43:54 -07:00
ferr := m . failResume ( ctx , sess , task , err )
if ferr != nil {
return ferr
2026-10-15 10:16:32 -07:00
}
2026-10-13 22:30:39 -07:00
continue
}
2026-10-16 10:08:08 -07:00
notice := restartNotice
if sess . Role == api . Implementer {
discard , err := m . store . GetDiscard ( ctx , task . Id )
if err != nil {
return err
}
if discard . State == store . DiscardRunning && discard . Session == sess . Id {
notice = discardRestartPrompt
}
}
err = m . message ( ctx , l , "byre" , notice , nil )
2026-10-13 22:30:39 -07:00
if err != nil {
2026-10-16 12:43:54 -07:00
ferr := m . failResume ( ctx , sess , task , err )
if ferr != nil {
return ferr
}
continue
2026-10-13 22:30:39 -07:00
}
2026-10-15 09:40:30 -07:00
slog . Info ( "resumed session" , "session" , sess . Id , "task" , task . Id , "owner" , sess . Owner )
2026-10-15 10:20:23 -07:00
m . logUndelivered ( ctx , sess . Id , "resumed with restart notice" )
}
idle , err := m . store . SessionsInState ( ctx , api . AgentSessionStateIdle )
if err != nil {
return err
}
for _ , sess := range idle {
m . logUndelivered ( ctx , sess . Id , "idle at startup" )
2026-10-13 22:30:39 -07:00
}
2026-10-15 16:33:17 -07:00
err = m . recoverReviews ( ctx )
if err != nil {
return err
}
2026-10-16 10:08:08 -07:00
err = m . recoverDiscards ( ctx )
if err != nil {
return err
}
2026-10-15 09:40:30 -07:00
go m . deliverAllLeads ()
2026-10-15 18:33:18 -07:00
go m . deliverAllQueued ( context . Background ())
2026-10-15 12:11:35 -07:00
go m . startHeldMerges ( context . Background ())
2026-10-16 22:34:14 -07:00
go m . recoverMergeAfterReviews ( context . Background ())
2026-10-15 14:55:58 -07:00
return nil
2026-10-13 22:30:39 -07:00
}
2026-10-16 12:43:54 -07:00
func ( m * Manager ) failResume ( ctx context . Context , sess api . AgentSession , task api . Task , err error ) error {
slog . Error ( "resume session" , "session" , sess . Id , "err" , err )
m . logUndelivered ( ctx , sess . Id , "resume failed" )
derr := m . store . DropStageOutput ( ctx , sess . Id )
if derr != nil {
return derr
}
2026-10-16 12:46:28 -07:00
next , _ , serr := m . store . EndTurn ( ctx , sess . Id , "byre" , map [ string ] any { "error" : err . Error ()}, nil , false , false )
2026-10-16 12:43:54 -07:00
if serr != nil {
return serr
}
switch {
case next == store . DiscardClosing :
go m . finishDiscard ( task . Id )
case next == store . DiscardHeld :
go m . startDiscard ( task . Id )
case sess . Role == api . Implementer :
m . abandonMerge ( task . Id , task . ProjectId )
}
return nil
}
2026-10-15 10:20:23 -07:00
func ( m * Manager ) logUndelivered ( ctx context . Context , sessionID , reason string ) {
pending , err := m . store . Undelivered ( ctx , sessionID )
if err != nil {
slog . Error ( "list undelivered responses" , "session" , sessionID , "err" , err )
return
}
if len ( pending ) == 0 {
return
}
slog . Info ( "undelivered responses" , "session" , sessionID , "reason" , reason , "responses" , responseIDs ( pending ))
}
2026-10-13 22:30:39 -07:00
func ( m * Manager ) Close () {
2026-10-14 22:35:53 -07:00
close ( m . watchStop )
2026-10-13 22:30:39 -07:00
m . mu . Lock ()
m . closing = true
all := [] * live {}
for _ , l := range m . live {
all = append ( all , l )
}
2026-10-16 12:56:53 -07:00
pending := [] * start {}
for _ , st := range m . starting {
pending = append ( pending , st )
}
2026-10-13 22:30:39 -07:00
m . mu . Unlock ()
2026-10-16 12:56:53 -07:00
for _ , st := range pending {
st . cancel ()
}
2026-10-13 22:30:39 -07:00
for _ , l := range all {
2026-10-15 14:55:58 -07:00
l . sess . kill ()
2026-10-13 22:30:39 -07:00
}
2026-10-16 12:56:53 -07:00
for _ , st := range pending {
<- st . done
}
2026-10-15 09:40:30 -07:00
err := m . parkLeads ( context . Background ())
if err != nil {
slog . Error ( "park lead sessions" , "err" , err )
}
2026-10-13 22:30:39 -07:00
}
func ( m * Manager ) LiveCount () int {
m . mu . Lock ()
defer m . mu . Unlock ()
return len ( m . live )
}
2026-10-14 09:40:12 -07:00
type Usage struct {
2026-10-14 21:06:30 -07:00
Running [] api . TurnRef
2026-10-14 09:40:12 -07:00
RateLimits map [ string ] map [ string ] any
}
func ( m * Manager ) Usage ( ctx context . Context ) ( Usage , error ) {
2026-10-14 21:06:30 -07:00
u := Usage {
Running : [] api . TurnRef {},
RateLimits : map [ string ] map [ string ] any {},
}
2026-10-14 09:40:12 -07:00
2026-10-14 21:06:30 -07:00
running , err := m . store . SessionsInState ( ctx , api . AgentSessionStateRunning )
2026-10-14 09:40:12 -07:00
if err != nil {
return u , err
}
2026-10-14 21:06:30 -07:00
for _ , sess := range running {
2026-10-15 09:40:30 -07:00
if sess . Owner != "" {
continue
}
2026-10-14 21:06:30 -07:00
u . Running = append ( u . Running , api . TurnRef { SessionId : sess . Id , TaskId : sess . TaskId , Vendor : string ( sess . Vendor )})
2026-10-14 09:40:12 -07:00
}
m . mu . Lock ()
defer m . mu . Unlock ()
for vendor , info := range m . rateLimits {
u . RateLimits [ string ( vendor )] = info
}
return u , nil
}
2026-10-14 20:07:26 -07:00
func ( m * Manager ) turn ( ctx context . Context , sess api . AgentSession , task api . Task , actor , text string , images [] string ) ( api . AgentSession , error ) {
2026-10-15 14:55:58 -07:00
err := transition ( ctx , m . store , sess . Id , api . AgentSessionStateIdle , api . AgentSessionStateRunning , actor )
2026-10-14 09:40:12 -07:00
if err != nil {
return api . AgentSession {}, err
}
2026-10-16 10:08:08 -07:00
return m . launch ( ctx , sess , task , actor , text , images )
}
2026-10-14 09:40:12 -07:00
2026-10-16 10:08:08 -07:00
func ( m * Manager ) launch ( ctx context . Context , sess api . AgentSession , task api . Task , actor , text string , images [] string ) ( api . AgentSession , error ) {
2026-10-14 09:40:12 -07:00
l , err := m . ensureLive ( ctx , sess , task )
2026-10-14 19:35:34 -07:00
if err == nil {
err = m . syncModel ( ctx , l , sess . Model )
}
2026-10-13 22:30:39 -07:00
if err != nil {
2026-10-16 12:36:05 -07:00
m . failStart ( ctx , sess . Id , actor , err )
return api . AgentSession {}, err
}
imgs , err := m . prepareMessage ( ctx , l , actor , text , images )
if err != nil {
m . failStart ( ctx , sess . Id , actor , err )
2026-10-13 22:30:39 -07:00
return api . AgentSession {}, err
}
2026-10-16 12:36:05 -07:00
err = l . sess . send ( ctx , text , imgs )
2026-10-13 22:30:39 -07:00
if err != nil {
return api . AgentSession {}, err
}
return m . store . GetSession ( ctx , sess . Id )
}
2026-10-16 12:36:05 -07:00
func ( m * Manager ) failStart ( ctx context . Context , sessionID , actor string , err error ) {
cctx , cancel := context . WithTimeout ( context . WithoutCancel ( ctx ), 10 * time . Second )
defer cancel ()
serr := m . store . SetSessionState ( cctx , sessionID , api . AgentSessionStateIdle , actor , map [ string ] any { "error" : err . Error ()})
if serr != nil {
slog . Error ( "record failed start" , "session" , sessionID , "err" , serr )
}
}
2026-10-14 19:35:34 -07:00
func ( m * Manager ) syncModel ( ctx context . Context , l * live , model string ) error {
if model == l . model {
return nil
}
cctx , cancel := context . WithTimeout ( ctx , 10 * time . Second )
defer cancel ()
2026-10-15 14:55:58 -07:00
err := l . sess . setModel ( cctx , model )
2026-10-14 19:35:34 -07:00
if err != nil {
return fmt . Errorf ( "set model %s: %w" , model , err )
}
l . model = model
return nil
}
2026-10-14 09:40:12 -07:00
func transition ( ctx context . Context , st * store . Store , id string , from , to api . AgentSessionState , actor string ) error {
err := st . TransitionSession ( ctx , id , from , to , actor )
if errors . Is ( err , store . ErrStateChanged ) {
return fmt . Errorf ( "%w: %w" , ErrConflict , err )
}
return err
}
2026-10-14 20:07:26 -07:00
func ( m * Manager ) message ( ctx context . Context , l * live , actor , text string , images [] string ) error {
2026-10-16 12:36:05 -07:00
imgs , err := m . prepareMessage ( ctx , l , actor , text , images )
if err != nil {
return err
}
return l . sess . send ( ctx , text , imgs )
}
func ( m * Manager ) prepareMessage ( ctx context . Context , l * live , actor , text string , images [] string ) ([] image , error ) {
2026-10-15 14:55:58 -07:00
imgs := [] image {}
2026-10-14 20:07:26 -07:00
for _ , id := range images {
b , data , err := m . store . ReadBlob ( ctx , id )
if err != nil {
2026-10-16 12:36:05 -07:00
return nil , err
2026-10-14 20:07:26 -07:00
}
2026-10-15 14:55:58 -07:00
imgs = append ( imgs , image { mediaType : b . ContentType , data : data })
2026-10-14 20:07:26 -07:00
}
2026-10-15 09:40:30 -07:00
payload := map [ string ] any { "text" : text }
2026-10-15 16:33:17 -07:00
if l . implementer () {
2026-10-15 09:40:30 -07:00
head , err := git . Head ( l . dir )
if err != nil {
2026-10-16 12:36:05 -07:00
return nil , err
2026-10-15 09:40:30 -07:00
}
payload [ "head" ] = head
2026-10-14 19:35:34 -07:00
}
2026-10-14 20:07:26 -07:00
if len ( images ) > 0 {
payload [ "images" ] = images
}
2026-10-15 10:30:24 -07:00
compacting := isHandoffPrompt ( actor , text )
if compacting {
payload [ "handoff" ] = true
}
2026-10-15 11:16:50 -07:00
if isContinuation ( actor , text ) {
payload [ "continuation" ] = true
}
2026-10-15 09:40:30 -07:00
err := m . emit ( ctx , l , actor , "user.message" , payload )
2026-10-13 22:30:39 -07:00
if err != nil {
2026-10-16 12:36:05 -07:00
return nil , err
2026-10-13 22:30:39 -07:00
}
2026-10-14 07:02:56 -07:00
m . mu . Lock ()
l . interrupted = false
2026-10-14 19:35:34 -07:00
l . asked = false
l . bounced = false
2026-10-15 09:40:30 -07:00
l . wantCompact = false
2026-10-15 10:30:24 -07:00
l . compacting = compacting
2026-10-14 09:40:12 -07:00
l . rateLimited = nil
2026-10-15 14:55:58 -07:00
l . lastText = ""
2026-10-14 07:02:56 -07:00
m . mu . Unlock ()
2026-10-16 12:36:05 -07:00
return imgs , nil
2026-10-13 22:30:39 -07:00
}
2026-10-14 09:40:12 -07:00
func ( m * Manager ) ensureLive ( ctx context . Context , sess api . AgentSession , task api . Task ) ( * live , error ) {
2026-10-13 22:30:39 -07:00
m . mu . Lock ()
if m . closing {
2026-10-16 12:56:53 -07:00
m . mu . Unlock ()
2026-10-13 22:30:39 -07:00
return nil , errors . New ( "shutting down" )
}
l := m . live [ sess . Id ]
if l != nil {
2026-10-16 12:56:53 -07:00
m . mu . Unlock ()
2026-10-13 22:30:39 -07:00
return l , nil
}
2026-10-16 12:56:53 -07:00
st := m . starting [ sess . Id ]
if st != nil {
m . mu . Unlock ()
select {
case <- st . done :
return st . l , st . err
case <- ctx . Done ():
return nil , ctx . Err ()
}
}
sctx , cancel := context . WithCancel ( ctx )
st = & start { done : make ( chan struct {}), cancel : cancel }
m . starting [ sess . Id ] = st
m . mu . Unlock ()
2026-10-13 22:30:39 -07:00
2026-10-16 12:56:53 -07:00
l , err := m . startLive ( sctx , sess , task )
cancel ()
m . mu . Lock ()
delete ( m . starting , sess . Id )
closing := m . closing
stopping := st . stopping
if err == nil && ! closing {
l . stopping = stopping
m . live [ sess . Id ] = l
}
m . mu . Unlock ()
if err == nil && closing {
go l . sess . discard ()
l . sess . kill ()
l , err = nil , errors . New ( "shutting down" )
}
st . l , st . err = l , err
close ( st . done )
if err != nil {
return nil , err
}
go m . pump ( l )
if stopping {
l . sess . kill ()
}
return l , nil
}
func ( m * Manager ) startLive ( ctx context . Context , sess api . AgentSession , task api . Task ) ( * live , error ) {
2026-10-13 22:30:39 -07:00
creator , err := m . store . GetUser ( ctx , sess . CreatedBy )
if err != nil {
return nil , err
}
2026-10-16 12:56:53 -07:00
l := & live {
2026-10-14 19:35:34 -07:00
id : sess . Id ,
dir : task . WorktreePath ,
model : sess . Model ,
gitName : creator . GitName ,
gitEmail : creator . GitEmail ,
vendor : sess . Vendor ,
taskID : task . Id ,
projectID : task . ProjectId ,
2026-10-15 09:40:30 -07:00
owner : sess . Owner ,
2026-10-14 19:35:34 -07:00
}
2026-10-13 22:30:39 -07:00
2026-10-15 11:36:51 -07:00
g , err := m . taskGuidance ( ctx , task )
if err != nil {
return nil , err
}
2026-10-16 10:36:38 -07:00
tools := append ([] mcp . Tool { m . askTool ( l ), m . cancelQuestionTool ( l ), m . setMergeTitleTool ( l ), m . stopMergeTool ( l ), m . setTaskSummaryTool ( l ), m . searchClosedTool ( l ), m . exposePortTool ( l )}, m . subtaskTools ( l ) ... )
2026-10-16 13:04:35 -07:00
if sess . Role == api . Implementer {
g += longTitleGuidance ( task )
}
2026-10-15 22:53:54 -07:00
if sess . Role == api . Implementer && task . WorkflowStage != nil {
2026-10-15 22:11:25 -07:00
frozen , err := m . store . TaskWorkflow ( ctx , task . Id )
if err != nil {
return nil , err
}
2026-10-15 22:53:54 -07:00
l . stage = * task . WorkflowStage
2026-10-15 22:11:25 -07:00
g += stageGuidance ( frozen , l . stage )
tools = append ( tools , m . finishStageTool ( l ))
}
2026-10-15 14:55:58 -07:00
env := portEnv ( task )
2026-10-15 19:02:33 -07:00
stdio := [] mcp . Stdio {}
2026-10-15 16:33:17 -07:00
if sess . Role == api . Reviewer {
r , err := m . store . ReviewBySession ( ctx , sess . Id )
if err != nil {
return nil , err
}
l . reviewID = r . Id
l . dir = m . reviewDir ( task . ProjectId , r . Id )
g = reviewGuidance
tools = [] mcp . Tool { m . submitReviewTool ( l )}
env = nil
2026-10-15 19:02:33 -07:00
stdio = [] mcp . Stdio { playwrightMCP ( m . reviewMediaDir ( task . ProjectId , r . Id ))}
2026-10-15 16:33:17 -07:00
}
2026-10-15 09:40:30 -07:00
if l . owner != "" {
l . dir = filepath . Join ( m . leadRoot , url . PathEscape ( l . owner ))
err = os . MkdirAll ( l . dir , 0 o755 )
if err != nil {
return nil , err
}
g = leadGuidance
2026-10-16 10:50:29 -07:00
notes , err := m . notesText ( ctx , l . owner )
if err != nil {
return nil , err
}
l . notes = "\n\n" + notes
2026-10-15 09:40:30 -07:00
tools = m . leadTools ( l )
2026-10-15 14:55:58 -07:00
env = nil
2026-10-13 22:30:39 -07:00
}
2026-10-15 14:55:58 -07:00
servers := [] * mcp . Server {{ Name : "byre" , Version : "0.0.0" , Tools : tools }}
2026-10-13 22:30:39 -07:00
sctx , cancel := context . WithTimeout ( ctx , 60 * time . Second )
defer cancel ()
2026-10-15 14:55:58 -07:00
switch l . vendor {
case api . Codex :
2026-10-15 19:02:33 -07:00
l . sess , err = m . startCodex ( sctx , sess , l , g , servers , stdio , env )
2026-10-15 14:55:58 -07:00
default :
2026-10-15 19:02:33 -07:00
l . sess , err = m . startClaude ( sctx , sess , l , g , servers , stdio , env )
2026-10-15 14:55:58 -07:00
}
2026-10-13 22:30:39 -07:00
if err != nil {
2026-10-15 14:55:58 -07:00
return nil , err
2026-10-13 22:30:39 -07:00
}
return l , nil
}
func ( m * Manager ) pump ( l * live ) {
ctx := context . Background ()
2026-10-15 14:55:58 -07:00
l . sess . drive ( m , l )
2026-10-13 22:30:39 -07:00
m . mu . Lock ()
delete ( m . live , l . id )
quiet := m . closing || l . stopping
m . mu . Unlock ()
if quiet {
2026-10-15 10:20:23 -07:00
m . logUndelivered ( ctx , l . id , "agent process ended while closing or stopping" )
2026-10-13 22:30:39 -07:00
return
}
sess , err := m . store . GetSession ( ctx , l . id )
if err != nil {
slog . Error ( "load exited session" , "session" , l . id , "err" , err )
return
}
if sess . State != api . AgentSessionStateRunning {
return
}
2026-10-15 10:20:23 -07:00
m . logUndelivered ( ctx , l . id , "agent process exited mid-turn" )
2026-10-15 22:53:54 -07:00
if l . stage != "" {
2026-10-15 22:11:25 -07:00
err = m . store . DropStageOutput ( ctx , l . id )
if err != nil {
slog . Error ( "drop stage output" , "session" , l . id , "err" , err )
}
}
2026-10-16 12:46:28 -07:00
next , _ , err := m . store . EndTurn ( ctx , l . id , agentActor , map [ string ] any { "error" : "agent process exited mid-turn" }, nil , false , false )
2026-10-13 22:30:39 -07:00
if err != nil {
slog . Error ( "record session exit" , "session" , l . id , "err" , err )
}
2026-10-15 16:33:17 -07:00
if l . reviewID != "" {
m . finishReviewer ( l , "the reviewer's process exited mid-turn" )
return
}
2026-10-16 10:08:08 -07:00
switch next {
case store . DiscardClosing :
m . finishDiscard ( l . taskID )
return
case store . DiscardHeld :
m . startDiscard ( l . taskID )
return
}
2026-10-15 09:40:30 -07:00
if l . owner == "" {
2026-10-15 12:11:35 -07:00
m . abandonMerge ( l . taskID , l . projectID )
2026-10-15 09:40:30 -07:00
m . fileError ( l , "The agent process exited mid-turn. Send a message to resume it." )
}
2026-10-13 22:30:39 -07:00
}
func ( m * Manager ) handle ( ctx context . Context , l * live , ev claude . Event ) error {
switch ev . Type {
case "assistant" :
blocks , err := ev . Blocks ()
if err != nil {
return err
}
2026-10-15 11:08:53 -07:00
parent , err := ev . ParentToolUseID ()
if err != nil {
return err
}
2026-10-15 11:36:51 -07:00
uuid , err := ev . UUID ()
if err != nil {
return err
}
2026-10-13 22:30:39 -07:00
for _ , b := range blocks {
2026-10-15 11:36:51 -07:00
err = m . handleAssistantBlock ( ctx , l , b , parent , uuid )
2026-10-13 22:30:39 -07:00
if err != nil {
return err
}
}
2026-10-15 11:08:53 -07:00
if parent != "" {
return nil
}
2026-10-14 16:03:46 -07:00
usage , err := ev . ContextUsage ()
if err != nil {
return err
}
if usage . Tokens == 0 {
return nil
}
l . contextModel = usage . Model
return m . updateContext ( ctx , l , usage . Tokens , l . contextWindow )
2026-10-13 22:30:39 -07:00
case "user" :
blocks , err := ev . Blocks ()
if err != nil {
return err
}
2026-10-15 11:08:53 -07:00
parent , err := ev . ParentToolUseID ()
if err != nil {
return err
}
2026-10-15 11:36:51 -07:00
uuid , err := ev . UUID ()
if err != nil {
return err
}
2026-10-13 22:30:39 -07:00
for _ , b := range blocks {
if b . Type != "tool_result" {
continue
}
content , err := decode ( b . Content )
if err != nil {
return err
}
2026-10-15 11:08:53 -07:00
err = m . emit ( ctx , l , agentActor , "agent.tool_result" , withParent ( map [ string ] any {
2026-10-13 22:30:39 -07:00
"tool_use_id" : b . ToolUseID ,
"is_error" : b . IsError ,
"content" : content ,
2026-10-15 11:36:51 -07:00
}, parent , uuid ))
2026-10-13 22:30:39 -07:00
if err != nil {
return err
}
}
return nil
case "result" :
res , err := ev . Result ()
if err != nil {
return err
}
2026-10-16 09:16:26 -07:00
m . mu . Lock ()
interrupted := l . interrupted
m . mu . Unlock ()
if res . NumTurns == 0 && ! res . IsError && res . Result == "" && ! interrupted {
slog . Info ( "ignored result with no model turns" , "session" , l . id , "subtype" , res . Subtype , "duration_ms" , res . DurationMS )
break
}
2026-10-15 14:55:58 -07:00
return m . turnEnded ( ctx , l , endResult {
subtype : res . Subtype ,
isError : res . IsError ,
result : res . Result ,
payload : store . ToPayload ( res ),
window : res . ModelUsage [ l . contextModel ]. ContextWindow ,
})
2026-10-13 22:30:39 -07:00
2026-10-14 19:35:34 -07:00
case "stream_event" :
text , err := ev . TextDelta ()
if err != nil || text == "" {
return err
}
2026-10-15 09:40:30 -07:00
m . store . Broadcast ( m . event ( l , agentActor , "agent.delta" , map [ string ] any { "text" : text }))
2026-10-14 19:35:34 -07:00
return nil
2026-10-14 09:40:12 -07:00
case "rate_limit_event" :
info := struct {
RateLimitInfo map [ string ] any `json:"rate_limit_info"`
}{}
err := json . Unmarshal ( ev . Raw , & info )
if err != nil {
return err
}
2026-10-16 10:47:53 -07:00
addClaudeWindowMins ( info . RateLimitInfo )
2026-10-14 09:40:12 -07:00
m . mu . Lock ()
m . rateLimits [ l . vendor ] = info . RateLimitInfo
if info . RateLimitInfo [ "status" ] == "rejected" {
l . rateLimited = info . RateLimitInfo
}
m . mu . Unlock ()
2026-10-13 22:30:39 -07:00
case "system" :
if ev . Subtype == "init" {
return nil
}
}
raw := map [ string ] any {}
err := json . Unmarshal ( ev . Raw , & raw )
if err != nil {
return err
}
return m . emit ( ctx , l , agentActor , "agent.vendor" , raw )
}
2026-10-15 14:55:58 -07:00
type endResult struct {
subtype string
isError bool
result string
payload map [ string ] any
window int
}
func ( m * Manager ) turnEnded ( ctx context . Context , l * live , res endResult ) error {
if l . owner != "" {
return m . leadTurnEnded ( ctx , l , res )
}
2026-10-15 16:33:17 -07:00
if l . reviewID != "" {
return m . reviewTurnEnded ( ctx , l , res )
}
2026-10-15 14:55:58 -07:00
m . mu . Lock ()
interrupted := l . interrupted
rateLimited := l . rateLimited
compacting := l . compacting
2026-10-16 08:35:49 -07:00
asked := l . asked
2026-10-15 14:55:58 -07:00
m . mu . Unlock ()
finished := ! res . isError && ! interrupted && strings . TrimSpace ( res . result ) != ""
payload := res . payload
sha , err := git . CommitAll ( l . dir , commitMessage ( res ), l . gitName , l . gitEmail )
if err != nil {
slog . Error ( "commit turn" , "session" , l . id , "err" , err )
2026-10-16 12:49:45 -07:00
m . fileError ( l , fmt . Sprintf ( "byre couldn't commit the turn's work, so the turn counts as unfinished: %v" , err ))
finished = false
2026-10-15 14:55:58 -07:00
}
2026-10-16 12:49:45 -07:00
handoff := compacting && finished
2026-10-15 14:55:58 -07:00
if sha != "" {
payload [ "commit" ] = sha
}
2026-10-16 10:08:08 -07:00
discard , err := m . store . GetDiscard ( ctx , l . taskID )
if err != nil {
return err
}
2026-10-15 20:24:15 -07:00
mergingBy := ""
if finished && ! handoff {
mergingBy , err = m . store . MergingBy ( ctx , l . taskID )
if err != nil {
return err
}
}
2026-10-16 11:15:32 -07:00
snapshot := discard . State == store . DiscardNone
review := snapshot && mergingBy == ""
2026-10-16 11:28:48 -07:00
switch {
case handoff :
payload [ "handoff" ] = true
case finished && ! compacting && ! asked && review :
payload [ "summary" ] = true
}
2026-10-16 11:15:32 -07:00
go m . closeMergedSubtasks ( l . taskID , l . dir )
if payload [ "summary" ] == true && snapshot {
go m . reportToParent ( l . taskID , res . result )
2026-10-16 10:08:08 -07:00
}
2026-10-15 14:55:58 -07:00
if res . window > 0 {
err = m . updateContext ( ctx , l , l . contextUsed , res . window )
if err != nil {
return err
}
}
failure := fmt . Sprintf ( "The turn ended with an error (%s): %s" , res . subtype , res . result )
switch {
case ! res . isError || interrupted :
case rateLimited != nil :
m . fileRateLimited ( l , rateLimited , failure )
default :
m . fileError ( l , failure )
}
2026-10-15 22:11:25 -07:00
stageFinished := false
2026-10-16 10:08:08 -07:00
if l . stage != "" {
if finished && ! handoff && mergingBy == "" {
stageFinished , err = m . store . FinishStage ( ctx , l . id )
}
if err == nil && ! stageFinished {
err = m . store . DropStageOutput ( ctx , l . id )
}
if err != nil {
return err
}
2026-10-15 22:11:25 -07:00
}
2026-10-16 11:15:32 -07:00
turn := m . event ( l , agentActor , "turn.ended" , payload )
2026-10-16 12:46:28 -07:00
next , finalizeBy , err := m . store . EndTurn ( ctx , l . id , agentActor , nil , & turn , review , finished && ! handoff )
2026-10-15 14:55:58 -07:00
if err != nil {
return err
}
2026-10-16 11:15:32 -07:00
if snapshot {
m . snapshotSoon ( l , api . SnapshotTriggerTurnEnd , 0 , review )
}
2026-10-15 14:55:58 -07:00
2026-10-16 10:08:08 -07:00
switch next {
case store . DiscardClosing :
m . logUndelivered ( ctx , l . id , "turn ended in discard" )
go m . finishDiscard ( l . taskID )
return nil
case store . DiscardHeld :
go m . nextMerge ( l . projectID )
if stageFinished {
go m . advanceStage ( l )
return nil
}
go m . startDiscard ( l . taskID )
return nil
}
2026-10-15 14:55:58 -07:00
if handoff {
m . logUndelivered ( ctx , l . id , "turn ended in handoff" )
go m . continueFromHandoff ( l . id , res . result )
return nil
}
2026-10-16 12:46:28 -07:00
if finalizeBy != "" {
2026-10-15 14:55:58 -07:00
m . logUndelivered ( ctx , l . id , "turn ended in merge" )
2026-10-16 12:46:28 -07:00
go m . finishMerge ( l , finalizeBy )
2026-10-15 14:55:58 -07:00
return nil
2026-10-16 12:46:28 -07:00
}
mergeBy := m . takeMerge ( l . taskID )
switch {
2026-10-15 14:55:58 -07:00
case mergeBy != "" :
go m . releaseMerge ( l . projectID , l . taskID )
2026-10-16 09:16:26 -07:00
if ! res . isError || interrupted {
m . fileError ( l , "The Merge turn ended unfinished (interrupted, or with no final message), so byre didn't squash or push the task. Merge again to retry." )
}
2026-10-15 14:55:58 -07:00
default :
go m . nextMerge ( l . projectID )
}
2026-10-15 22:11:25 -07:00
if stageFinished {
go m . advanceStage ( l )
2026-10-15 22:53:54 -07:00
return nil
2026-10-15 22:11:25 -07:00
}
2026-10-16 14:21:23 -07:00
go m . afterTurn ( context . WithoutCancel ( ctx ), l )
2026-10-16 10:08:08 -07:00
return nil
}
func ( m * Manager ) afterTurn ( ctx context . Context , l * live ) {
discard , err := m . store . GetDiscard ( ctx , l . taskID )
if err != nil {
slog . Error ( "read discard state" , "task" , l . taskID , "err" , err )
return
}
if discard . State == store . DiscardHeld {
m . startDiscard ( l . taskID )
return
}
2026-10-15 14:55:58 -07:00
err = m . Deliver ( ctx , l . id )
if err != nil {
slog . Error ( "deliver inbox responses" , "session" , l . id , "err" , err )
}
}
func commitMessage ( res endResult ) string {
line , _ , _ := strings . Cut ( strings . TrimSpace ( res . result ), "\n" )
2026-10-14 19:35:34 -07:00
sentence , _ , found := strings . Cut ( line , ". " )
if found {
sentence += "."
}
sentence = strings . TrimSpace ( strings . ReplaceAll ( sentence , "**" , "" ))
if sentence == "" {
2026-10-15 14:55:58 -07:00
return fmt . Sprintf ( "Work from a turn that ended without a summary (%s)" , res . subtype )
2026-10-14 19:35:34 -07:00
}
runes := [] rune ( sentence )
if len ( runes ) > 100 {
return string ( runes [: 99 ]) + "…"
}
return sentence
}
2026-10-14 16:03:46 -07:00
func ( m * Manager ) updateContext ( ctx context . Context , l * live , used , window int ) error {
if used == l . contextUsed && window == l . contextWindow {
return nil
}
l . contextUsed = used
l . contextWindow = window
return m . emit ( ctx , l , agentActor , "session.context" , map [ string ] any { "model" : l . contextModel , "used" : used , "window" : window })
}
2026-10-15 11:36:51 -07:00
func ( m * Manager ) handleAssistantBlock ( ctx context . Context , l * live , b claude . Block , parent , uuid string ) error {
2026-10-13 22:30:39 -07:00
switch b . Type {
case "text" :
2026-10-15 11:36:51 -07:00
return m . emit ( ctx , l , agentActor , "agent.message" , withParent ( map [ string ] any { "text" : b . Text }, parent , uuid ))
2026-10-13 22:30:39 -07:00
case "thinking" :
if b . Thinking == "" {
return nil
}
2026-10-15 11:36:51 -07:00
return m . emit ( ctx , l , agentActor , "agent.thinking" , withParent ( map [ string ] any { "text" : b . Thinking }, parent , uuid ))
2026-10-13 22:30:39 -07:00
case "tool_use" :
input , err := decode ( b . Input )
if err != nil {
return err
}
2026-10-15 11:08:53 -07:00
return m . emit ( ctx , l , agentActor , "agent.tool_call" , withParent ( map [ string ] any {
2026-10-13 22:30:39 -07:00
"id" : b . ID ,
"name" : b . Name ,
"input" : input ,
2026-10-15 11:36:51 -07:00
}, parent , uuid ))
2026-10-13 22:30:39 -07:00
}
2026-10-15 11:36:51 -07:00
return m . emit ( ctx , l , agentActor , "agent.vendor" , withParent ( store . ToPayload ( b ), parent , uuid ))
2026-10-15 11:08:53 -07:00
}
2026-10-15 11:36:51 -07:00
func withParent ( payload map [ string ] any , parent , uuid string ) map [ string ] any {
2026-10-15 11:08:53 -07:00
if parent != "" {
payload [ "parent_tool_use_id" ] = parent
2026-10-15 11:36:51 -07:00
return payload
}
if uuid != "" {
payload [ "vendor_uuid" ] = uuid
2026-10-15 11:08:53 -07:00
}
return payload
2026-10-13 22:30:39 -07:00
}
func ( m * Manager ) emit ( ctx context . Context , l * live , actor , kind string , payload map [ string ] any ) error {
2026-10-15 09:40:30 -07:00
return m . store . AppendEvent ( ctx , m . event ( l , actor , kind , payload ))
}
func ( m * Manager ) event ( l * live , actor , kind string , payload map [ string ] any ) api . Event {
ev := api . Event {
2026-10-13 22:30:39 -07:00
Kind : kind ,
AgentSessionId : & l . id ,
Actor : actor ,
Payload : payload ,
2026-10-15 09:40:30 -07:00
}
if l . owner != "" {
ev . Owner = & l . owner
return ev
}
ev . ProjectId = & l . projectID
ev . TaskId = & l . taskID
return ev
2026-10-13 22:30:39 -07:00
}
2026-10-16 10:47:53 -07:00
func addClaudeWindowMins ( info map [ string ] any ) {
windows , _ := info [ "unifiedWindows" ].( map [ string ] any )
for name , w := range windows {
window , ok := w .( map [ string ] any )
if ! ok {
continue
}
switch {
case strings . HasPrefix ( name , "five_hour" ):
window [ "windowMins" ] = 5 * 60
case strings . HasPrefix ( name , "seven_day" ):
window [ "windowMins" ] = 7 * 24 * 60
}
}
}
2026-10-13 22:30:39 -07:00
func decode ( raw json . RawMessage ) ( any , error ) {
if len ( raw ) == 0 {
return nil , nil
}
var v any
err := json . Unmarshal ( raw , & v )
return v , err
}
func newUUID () string {
b := make ([] byte , 16 )
rand . Read ( b )
b [ 6 ] = b [ 6 ] & 0x0f | 0x40
b [ 8 ] = b [ 8 ] & 0x3f | 0x80
return fmt . Sprintf ( "%x-%x-%x-%x-%x" , b [ 0 : 4 ], b [ 4 : 6 ], b [ 6 : 8 ], b [ 8 : 10 ], b [ 10 :])
}