1370 lines
33 KiB
Go
1370 lines
33 KiB
Go
package agents
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"g.fc.run/ai/byre/internal/api"
|
|
"g.fc.run/ai/byre/internal/claude"
|
|
"g.fc.run/ai/byre/internal/clients"
|
|
"g.fc.run/ai/byre/internal/git"
|
|
"g.fc.run/ai/byre/internal/mcp"
|
|
"g.fc.run/ai/byre/internal/store"
|
|
"g.fc.run/ai/byre/internal/terminal"
|
|
"g.fc.run/ai/byre/internal/workflow"
|
|
)
|
|
|
|
var (
|
|
ErrConflict = errors.New("conflict")
|
|
ErrInvalid = errors.New("invalid")
|
|
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 {
|
|
store *store.Store
|
|
worktreeRoot string
|
|
leadRoot string
|
|
handsRoot string
|
|
codexHome string
|
|
antigravityRoot string
|
|
status func(context.Context) (api.Status, error)
|
|
addProject func(ctx context.Context, name, repoPath, createdBy string) (api.Project, error)
|
|
clients *clients.Registry
|
|
terminals *terminal.Terminals
|
|
|
|
mu sync.Mutex
|
|
live map[string]*live
|
|
starting map[string]*start
|
|
closing bool
|
|
rateLimits map[api.AgentSessionVendor]map[string]any
|
|
leadAnswered map[string]bool
|
|
paneFocus map[string]api.PaneFocus
|
|
paneReported map[string]time.Time
|
|
paneMoved map[string]time.Time
|
|
humanMoved map[string]time.Time
|
|
moveRefused map[string]bool
|
|
startedTodo map[string]time.Time
|
|
startRefused map[string]bool
|
|
finishing map[string]bool
|
|
handoffModel map[string]string
|
|
mergeTurns map[string]mergeTurn
|
|
|
|
deliverMu sync.Mutex
|
|
deliverLocks map[string]*sessionLock
|
|
leadMu sync.Mutex
|
|
signals chan []api.Event
|
|
|
|
snapMu sync.Mutex
|
|
snaps map[string]*snapState
|
|
|
|
mergeMu sync.Mutex
|
|
merges map[string]*projectMerge
|
|
|
|
reviewMu sync.Mutex
|
|
|
|
workflowMu sync.Mutex
|
|
|
|
bad *badLines
|
|
watchStop chan struct{}
|
|
}
|
|
|
|
type live struct {
|
|
sess agentProcess
|
|
id string
|
|
dir string
|
|
model string
|
|
gitName string
|
|
gitEmail string
|
|
vendor api.AgentSessionVendor
|
|
taskID string
|
|
projectID string
|
|
owner string
|
|
member string
|
|
notes string
|
|
reviewID string
|
|
stage string
|
|
stopping bool
|
|
interrupted bool
|
|
fetching bool
|
|
asked bool
|
|
bounced bool
|
|
compacting bool
|
|
wantCompact bool
|
|
rateLimited map[string]any
|
|
lastText string
|
|
subagents map[string]bool
|
|
|
|
contextModel string
|
|
contextUsed int
|
|
contextWindow int
|
|
contextWarned bool
|
|
}
|
|
|
|
type start struct {
|
|
done chan struct{}
|
|
cancel context.CancelFunc
|
|
l *live
|
|
err error
|
|
stopping bool
|
|
}
|
|
|
|
func (l *live) implementer() bool {
|
|
return l.owner == "" && l.reviewID == ""
|
|
}
|
|
|
|
func New(st *store.Store, worktreeRoot, leadRoot, handsRoot, codexHome, antigravityRoot string, status func(context.Context) (api.Status, error), addProject func(ctx context.Context, name, repoPath, createdBy string) (api.Project, error), clientRegistry *clients.Registry, terminals *terminal.Terminals) *Manager {
|
|
m := &Manager{
|
|
store: st,
|
|
worktreeRoot: worktreeRoot,
|
|
leadRoot: leadRoot,
|
|
handsRoot: handsRoot,
|
|
codexHome: codexHome,
|
|
antigravityRoot: antigravityRoot,
|
|
status: status,
|
|
addProject: addProject,
|
|
clients: clientRegistry,
|
|
terminals: terminals,
|
|
live: map[string]*live{},
|
|
starting: map[string]*start{},
|
|
deliverLocks: map[string]*sessionLock{},
|
|
rateLimits: map[api.AgentSessionVendor]map[string]any{},
|
|
leadAnswered: map[string]bool{},
|
|
paneFocus: map[string]api.PaneFocus{},
|
|
paneReported: map[string]time.Time{},
|
|
paneMoved: map[string]time.Time{},
|
|
humanMoved: map[string]time.Time{},
|
|
moveRefused: map[string]bool{},
|
|
startedTodo: map[string]time.Time{},
|
|
startRefused: map[string]bool{},
|
|
finishing: map[string]bool{},
|
|
handoffModel: map[string]string{},
|
|
mergeTurns: map[string]mergeTurn{},
|
|
signals: make(chan []api.Event, 1024),
|
|
snaps: map[string]*snapState{},
|
|
merges: map[string]*projectMerge{},
|
|
bad: newBadLines(),
|
|
watchStop: make(chan struct{}),
|
|
}
|
|
go m.signalLoop()
|
|
go m.readCodexRateLimit()
|
|
go m.readAgyRateLimit()
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, member, prompt, model, actor string, images []string) (api.AgentSession, error) {
|
|
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)
|
|
}
|
|
|
|
sess, err := m.newSession(ctx, task, creator, member, 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, member, model string) (api.AgentSession, error) {
|
|
existing, err := m.store.ListSessions(ctx, task.Id)
|
|
if err != nil {
|
|
return api.AgentSession{}, err
|
|
}
|
|
for _, s := range existing {
|
|
if s.State != api.AgentSessionStateCompleted && s.Member == member {
|
|
return api.AgentSession{}, fmt.Errorf("%w: task %s already has session %s", ErrConflict, task.Id, s.Id)
|
|
}
|
|
}
|
|
|
|
id := newUUID()
|
|
vendor := workflow.VendorOf(model)
|
|
sess := api.AgentSession{
|
|
Id: id,
|
|
TaskId: task.Id,
|
|
Vendor: vendor,
|
|
Model: model,
|
|
VendorSessionId: initialVendorSessionID(vendor, id),
|
|
State: api.AgentSessionStateIdle,
|
|
Role: api.Implementer,
|
|
Member: member,
|
|
CreatedBy: creator.Login,
|
|
CreatedAt: time.Now().UTC(),
|
|
}
|
|
|
|
err = m.store.CreateSession(ctx, sess, task.ProjectId)
|
|
return sess, err
|
|
}
|
|
|
|
func (m *Manager) Send(ctx context.Context, sessionID, actor, text, model string, images ...string) (api.AgentSession, error) {
|
|
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)
|
|
}
|
|
|
|
if model != "" && model != sess.Model {
|
|
err = checkModel(sess, model)
|
|
if err != nil {
|
|
return api.AgentSession{}, err
|
|
}
|
|
err = m.store.SetSessionModel(ctx, sess.Id, model, actor)
|
|
if err != nil {
|
|
return api.AgentSession{}, err
|
|
}
|
|
sess.Model = model
|
|
}
|
|
|
|
task, err := m.taskFor(ctx, sess)
|
|
if err != nil {
|
|
return api.AgentSession{}, err
|
|
}
|
|
|
|
return m.turn(ctx, sess, task, actor, text, images)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
m.mu.Lock()
|
|
l.interrupted = true
|
|
fetching := l.fetching
|
|
m.mu.Unlock()
|
|
|
|
if fetching {
|
|
return sess, nil
|
|
}
|
|
|
|
ictx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
|
|
err = l.sess.interrupt(ictx)
|
|
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
|
|
}
|
|
err = m.retire(ctx, sess, actor)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) retire(ctx context.Context, sess api.AgentSession, actor string) error {
|
|
m.stopLive(sess.Id)
|
|
return m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateCompleted, actor, nil)
|
|
}
|
|
|
|
func (m *Manager) stopLive(sessionID string) {
|
|
m.mu.Lock()
|
|
l := m.live[sessionID]
|
|
if l != nil {
|
|
l.stopping = true
|
|
}
|
|
st := m.starting[sessionID]
|
|
if st != nil {
|
|
st.stopping = true
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
if l != nil {
|
|
l.sess.kill()
|
|
}
|
|
}
|
|
|
|
func (m *Manager) Recover(ctx context.Context) error {
|
|
err := m.recoverMerges(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = m.recoverFinalizing(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
staged, err := m.recoverStages(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
stale, err := m.store.SessionsWithSubagents(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, sess := range stale {
|
|
err = m.store.SetSubagents(ctx, sess.Id, 0, "byre")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
sessions, err := m.store.SessionsInState(ctx, api.AgentSessionStateRunning)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = m.recoverMergeTurns(ctx, sessions)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, sess := range sessions {
|
|
if staged[sess.Id] {
|
|
continue
|
|
}
|
|
task, err := m.taskFor(ctx, sess)
|
|
if err != nil {
|
|
slog.Error("resume session: load task", "session", sess.Id, "task", sess.TaskId, "err", err)
|
|
continue
|
|
}
|
|
|
|
l, err := m.ensureLive(ctx, sess, task)
|
|
if err != nil {
|
|
ferr := m.failResume(ctx, sess, task, err)
|
|
if ferr != nil {
|
|
return ferr
|
|
}
|
|
continue
|
|
}
|
|
|
|
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)
|
|
if err != nil {
|
|
ferr := m.failResume(ctx, sess, task, err)
|
|
if ferr != nil {
|
|
return ferr
|
|
}
|
|
continue
|
|
}
|
|
slog.Info("resumed session", "session", sess.Id, "task", task.Id, "owner", sess.Owner)
|
|
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")
|
|
}
|
|
|
|
err = m.recoverReviews(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = m.recoverDiscards(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
go m.deliverAllLeads()
|
|
go m.deliverAllQueued(context.Background())
|
|
go m.startHeldMerges(context.Background())
|
|
go m.recoverMergeAfterReviews(context.Background())
|
|
return nil
|
|
}
|
|
|
|
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.endMergeTurn(sess.Id, true)
|
|
m.logUndelivered(ctx, sess.Id, "resume failed")
|
|
derr := m.store.DropStageOutput(ctx, sess.Id)
|
|
if derr != nil {
|
|
return derr
|
|
}
|
|
next, _, serr := m.store.EndTurn(ctx, sess.Id, "byre", map[string]any{"error": err.Error()}, nil, false, false)
|
|
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.leadsTask(ctx, task.Id, sess.Id):
|
|
m.abandonMerge(task.Id, task.ProjectId)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
func (m *Manager) Close() {
|
|
close(m.watchStop)
|
|
|
|
m.mu.Lock()
|
|
m.closing = true
|
|
all := []*live{}
|
|
for _, l := range m.live {
|
|
all = append(all, l)
|
|
}
|
|
pending := []*start{}
|
|
for _, st := range m.starting {
|
|
pending = append(pending, st)
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
for _, st := range pending {
|
|
st.cancel()
|
|
}
|
|
for _, l := range all {
|
|
l.sess.kill()
|
|
}
|
|
for _, st := range pending {
|
|
<-st.done
|
|
}
|
|
|
|
err := m.parkLeads(context.Background())
|
|
if err != nil {
|
|
slog.Error("park lead sessions", "err", err)
|
|
}
|
|
}
|
|
|
|
func (m *Manager) LiveCount() int {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return len(m.live)
|
|
}
|
|
|
|
type Usage struct {
|
|
Running []api.TurnRef
|
|
RateLimits map[string]map[string]any
|
|
}
|
|
|
|
func (m *Manager) Usage(ctx context.Context) (Usage, error) {
|
|
u := Usage{
|
|
Running: []api.TurnRef{},
|
|
RateLimits: map[string]map[string]any{},
|
|
}
|
|
|
|
running, err := m.store.SessionsInState(ctx, api.AgentSessionStateRunning)
|
|
if err != nil {
|
|
return u, err
|
|
}
|
|
for _, sess := range running {
|
|
if sess.Owner != "" {
|
|
continue
|
|
}
|
|
u.Running = append(u.Running, api.TurnRef{SessionId: sess.Id, TaskId: sess.TaskId, Vendor: string(sess.Vendor)})
|
|
}
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for vendor, info := range m.rateLimits {
|
|
u.RateLimits[string(vendor)] = info
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, images []string) (api.AgentSession, error) {
|
|
err := transition(ctx, m.store, sess.Id, api.AgentSessionStateIdle, api.AgentSessionStateRunning, actor)
|
|
if err != nil {
|
|
return api.AgentSession{}, err
|
|
}
|
|
return m.launch(ctx, sess, task, actor, text, images)
|
|
}
|
|
|
|
func (m *Manager) launch(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, images []string) (api.AgentSession, error) {
|
|
l, err := m.ensureLive(ctx, sess, task)
|
|
if err == nil {
|
|
err = m.syncModel(ctx, l, sess.Model)
|
|
}
|
|
if err != nil {
|
|
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)
|
|
return api.AgentSession{}, err
|
|
}
|
|
|
|
err = l.sess.send(ctx, text, imgs)
|
|
if err != nil {
|
|
m.endMergeTurn(sess.Id, false)
|
|
return api.AgentSession{}, err
|
|
}
|
|
|
|
return m.store.GetSession(ctx, sess.Id)
|
|
}
|
|
|
|
func (m *Manager) failStart(ctx context.Context, sessionID, actor string, err error) {
|
|
m.endMergeTurn(sessionID, false)
|
|
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)
|
|
}
|
|
}
|
|
|
|
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()
|
|
|
|
err := l.sess.setModel(cctx, model)
|
|
if err != nil {
|
|
return fmt.Errorf("set model %s: %w", model, err)
|
|
}
|
|
l.model = model
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (m *Manager) message(ctx context.Context, l *live, actor, text string, images []string) error {
|
|
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) {
|
|
if l.implementer() && isMergeInPrompt(actor, text) {
|
|
m.takeMergeTurn(l)
|
|
}
|
|
imgs := []image{}
|
|
for _, id := range images {
|
|
b, data, err := m.store.ReadBlob(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
imgs = append(imgs, image{mediaType: b.ContentType, data: data})
|
|
}
|
|
|
|
payload := map[string]any{"text": text}
|
|
if l.implementer() {
|
|
head, err := git.Head(l.dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
payload["head"] = head
|
|
}
|
|
if len(images) > 0 {
|
|
payload["images"] = images
|
|
}
|
|
compacting := isHandoffPrompt(actor, text)
|
|
if compacting {
|
|
payload["handoff"] = true
|
|
}
|
|
if isContinuation(actor, text) {
|
|
payload["continuation"] = true
|
|
}
|
|
err := m.emit(ctx, l, actor, "user.message", payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
m.mu.Lock()
|
|
l.interrupted = false
|
|
l.asked = false
|
|
l.bounced = false
|
|
l.wantCompact = false
|
|
l.compacting = compacting
|
|
l.rateLimited = nil
|
|
l.lastText = ""
|
|
m.mu.Unlock()
|
|
|
|
return imgs, nil
|
|
}
|
|
|
|
func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task api.Task) (*live, error) {
|
|
m.mu.Lock()
|
|
if m.closing {
|
|
m.mu.Unlock()
|
|
return nil, errors.New("shutting down")
|
|
}
|
|
l := m.live[sess.Id]
|
|
if l != nil {
|
|
m.mu.Unlock()
|
|
return l, nil
|
|
}
|
|
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()
|
|
|
|
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) {
|
|
creator, err := m.store.GetUser(ctx, sess.CreatedBy)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
l := &live{
|
|
id: sess.Id,
|
|
dir: task.WorktreePath,
|
|
model: sess.Model,
|
|
gitName: creator.GitName,
|
|
gitEmail: creator.GitEmail,
|
|
vendor: sess.Vendor,
|
|
taskID: task.Id,
|
|
projectID: task.ProjectId,
|
|
owner: sess.Owner,
|
|
member: sess.Member,
|
|
subagents: map[string]bool{},
|
|
}
|
|
|
|
g, err := m.taskGuidance(ctx, task)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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.takeSnapshotTool(l), m.terminalReadTool(l), m.terminalTypeTool(l)}, append(m.subtaskTools(l), m.clientTools(l)...)...)
|
|
lead := true
|
|
if sess.Role == api.Implementer {
|
|
g += longTitleGuidance(task)
|
|
notes, err := m.notesText(ctx, task.CreatedBy, task.ProjectId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
l.notes = "\n\n" + taskNotesGuidance + "\n\n" + notes
|
|
tools = append(tools, m.suggestNoteTool(l))
|
|
}
|
|
if sess.Role == api.Implementer && sess.Member != "" {
|
|
sessions, err := m.store.ListSessions(ctx, task.Id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
lead = sess.Member == teamLead(sessions)
|
|
g += teamGuidance(sessions, sess.Member, task.WorkflowStage != nil)
|
|
tools = append(tools, m.boardTools(l)...)
|
|
}
|
|
if sess.Role == api.Implementer && task.WorkflowStage != nil && lead {
|
|
frozen, err := m.store.TaskWorkflow(ctx, task.Id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
l.stage = *task.WorkflowStage
|
|
g += stageGuidance(frozen, l.stage)
|
|
tools = append(tools, m.finishStageTool(l))
|
|
}
|
|
env := portEnv(task)
|
|
stdio := []mcp.Stdio{}
|
|
if sess.Role == api.Implementer {
|
|
media := MediaDir(task.WorktreePath)
|
|
err = os.MkdirAll(media, 0o755)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
g += screenshotGuidance
|
|
hands, err := m.handsMCP(sess.Id, media)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stdio = []mcp.Stdio{hands, playwrightMCP(media)}
|
|
}
|
|
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
|
|
stdio = []mcp.Stdio{playwrightMCP(m.reviewMediaDir(task.ProjectId, r.Id))}
|
|
}
|
|
if l.owner != "" {
|
|
l.dir = filepath.Join(m.leadRoot, url.PathEscape(l.owner))
|
|
err = os.MkdirAll(l.dir, 0o755)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
g = leadGuidance
|
|
notes, err := m.notesText(ctx, l.owner, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
l.notes = "\n\n" + notes
|
|
tools = m.leadTools(l)
|
|
env = nil
|
|
}
|
|
servers := []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: tools}}
|
|
|
|
sctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
|
defer cancel()
|
|
|
|
switch l.vendor {
|
|
case api.Codex:
|
|
l.sess, err = m.startCodex(sctx, sess, l, g, servers, stdio, env)
|
|
case api.Antigravity:
|
|
l.sess, err = m.startAntigravity(sctx, sess, l, g, servers, stdio, env)
|
|
default:
|
|
l.sess, err = m.startClaude(sctx, sess, l, g, servers, stdio, env)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return l, nil
|
|
}
|
|
|
|
func (m *Manager) pump(l *live) {
|
|
ctx := context.Background()
|
|
|
|
l.sess.drive(m, l)
|
|
|
|
err := os.RemoveAll(m.handsDir(l.id))
|
|
if err != nil {
|
|
slog.Error("remove session's byre-hands", "session", l.id, "err", err)
|
|
}
|
|
|
|
m.mu.Lock()
|
|
delete(m.live, l.id)
|
|
closing := m.closing
|
|
quiet := closing || l.stopping
|
|
m.mu.Unlock()
|
|
|
|
if len(l.subagents) > 0 && !closing {
|
|
err := m.store.SetSubagents(ctx, l.id, 0, "byre")
|
|
if err != nil {
|
|
slog.Error("clear exited session's subagents", "session", l.id, "err", err)
|
|
}
|
|
}
|
|
|
|
if quiet {
|
|
m.logUndelivered(ctx, l.id, "agent process ended while closing or stopping")
|
|
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
|
|
}
|
|
|
|
m.logUndelivered(ctx, l.id, "agent process exited mid-turn")
|
|
if l.stage != "" {
|
|
err = m.store.DropStageOutput(ctx, l.id)
|
|
if err != nil {
|
|
slog.Error("drop stage output", "session", l.id, "err", err)
|
|
}
|
|
}
|
|
m.endMergeTurn(l.id, true)
|
|
next, _, err := m.store.EndTurn(ctx, l.id, agentActor, map[string]any{"error": "agent process exited mid-turn"}, nil, false, false)
|
|
if err != nil {
|
|
slog.Error("record session exit", "session", l.id, "err", err)
|
|
}
|
|
if l.reviewID != "" {
|
|
m.finishReviewer(l, "the reviewer's process exited mid-turn")
|
|
return
|
|
}
|
|
switch next {
|
|
case store.DiscardClosing:
|
|
m.finishDiscard(l.taskID)
|
|
return
|
|
case store.DiscardHeld:
|
|
m.startDiscard(l.taskID)
|
|
return
|
|
}
|
|
if l.owner == "" {
|
|
if m.leadsTask(ctx, l.taskID, l.id) {
|
|
m.abandonMerge(l.taskID, l.projectID)
|
|
}
|
|
m.recordError(l, "The agent process exited mid-turn. Send a message to resume it.")
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
parent, err := ev.ParentToolUseID()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
uuid, err := ev.UUID()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, b := range blocks {
|
|
err = m.handleAssistantBlock(ctx, l, b, parent, uuid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if parent != "" {
|
|
return nil
|
|
}
|
|
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)
|
|
|
|
case "user":
|
|
blocks, err := ev.Blocks()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
parent, err := ev.ParentToolUseID()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
uuid, err := ev.UUID()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, b := range blocks {
|
|
if b.Type != "tool_result" {
|
|
continue
|
|
}
|
|
content, err := decode(b.Content)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = m.emit(ctx, l, agentActor, "agent.tool_result", withParent(map[string]any{
|
|
"tool_use_id": b.ToolUseID,
|
|
"is_error": b.IsError,
|
|
"content": content,
|
|
}, parent, uuid))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
|
|
case "result":
|
|
res, err := ev.Result()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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
|
|
}
|
|
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,
|
|
})
|
|
|
|
case "stream_event":
|
|
text, err := ev.TextDelta()
|
|
if err != nil || text == "" {
|
|
return err
|
|
}
|
|
m.store.Broadcast(m.event(l, agentActor, "agent.delta", map[string]any{"text": text}))
|
|
return nil
|
|
|
|
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
|
|
}
|
|
|
|
addClaudeWindowMins(info.RateLimitInfo)
|
|
|
|
m.mu.Lock()
|
|
m.rateLimits[l.vendor] = info.RateLimitInfo
|
|
if info.RateLimitInfo["status"] == "rejected" {
|
|
l.rateLimited = info.RateLimitInfo
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
case "system":
|
|
if ev.Subtype == "init" {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
raw := map[string]any{}
|
|
err := json.Unmarshal(ev.Raw, &raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = m.emit(ctx, l, agentActor, "agent.vendor", raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return m.trackSubagents(ctx, l, raw)
|
|
}
|
|
|
|
func (m *Manager) trackSubagents(ctx context.Context, l *live, raw map[string]any) error {
|
|
if raw["type"] != "system" {
|
|
return nil
|
|
}
|
|
id, _ := raw["task_id"].(string)
|
|
n := len(l.subagents)
|
|
switch raw["subtype"] {
|
|
case "task_started":
|
|
if raw["task_type"] == "local_agent" {
|
|
l.subagents[id] = true
|
|
}
|
|
case "task_notification":
|
|
delete(l.subagents, id)
|
|
}
|
|
if len(l.subagents) == n {
|
|
return nil
|
|
}
|
|
return m.store.SetSubagents(ctx, l.id, len(l.subagents), agentActor)
|
|
}
|
|
|
|
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)
|
|
}
|
|
if l.reviewID != "" {
|
|
return m.reviewTurnEnded(ctx, l, res)
|
|
}
|
|
|
|
m.mu.Lock()
|
|
interrupted := l.interrupted
|
|
rateLimited := l.rateLimited
|
|
compacting := l.compacting
|
|
asked := l.asked
|
|
m.mu.Unlock()
|
|
|
|
finished := !res.isError && !interrupted && strings.TrimSpace(res.result) != ""
|
|
|
|
payload := res.payload
|
|
if l.member != "" {
|
|
payload["member"] = l.member
|
|
}
|
|
owner := m.ownsTask(l.id)
|
|
keep := false
|
|
if owner {
|
|
defer func() {
|
|
if !keep {
|
|
m.endMergeTurn(l.id, false)
|
|
}
|
|
}()
|
|
if !finished {
|
|
err := git.AbortMerge(l.dir)
|
|
if err != nil {
|
|
slog.Error("abort unfinished merge", "session", l.id, "err", err)
|
|
}
|
|
}
|
|
}
|
|
unlock := func() {}
|
|
if !owner {
|
|
locked := m.lockTask(l.taskID)
|
|
once := sync.OnceFunc(locked)
|
|
unlock = once
|
|
defer once()
|
|
task, err := m.store.GetTask(ctx, l.taskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if task.Status != api.TaskStatusActive {
|
|
return nil
|
|
}
|
|
}
|
|
sha, err := git.CommitAll(l.dir, commitMessage(res), l.gitName, l.gitEmail)
|
|
if err != nil {
|
|
slog.Error("commit turn", "session", l.id, "err", err)
|
|
m.recordError(l, fmt.Sprintf("byre couldn't commit the turn's work, so the turn counts as unfinished: %v", err))
|
|
finished = false
|
|
if owner {
|
|
err = git.AbortMerge(l.dir)
|
|
if err != nil {
|
|
slog.Error("abort unfinished merge", "session", l.id, "err", err)
|
|
}
|
|
}
|
|
}
|
|
handoff := compacting && finished
|
|
if sha != "" {
|
|
payload["commit"] = sha
|
|
}
|
|
discard, err := m.store.GetDiscard(ctx, l.taskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mergingBy := ""
|
|
if finished && !handoff {
|
|
mergingBy, err = m.store.MergingBy(ctx, l.taskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
snapshot := discard.State == store.DiscardNone
|
|
review := snapshot && mergingBy == ""
|
|
switch {
|
|
case handoff:
|
|
payload["handoff"] = true
|
|
case finished && !compacting && !asked && review:
|
|
payload["summary"] = true
|
|
}
|
|
go m.closeMergedSubtasks(l.taskID, l.dir)
|
|
if payload["summary"] == true && snapshot {
|
|
go m.reportToParent(l.taskID, res.result)
|
|
}
|
|
|
|
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.recordRateLimited(l, rateLimited, failure)
|
|
default:
|
|
m.recordError(l, failure)
|
|
}
|
|
|
|
stageFinished := false
|
|
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 && stageFinished {
|
|
err = m.recordStage(ctx, l)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
unlock()
|
|
|
|
held := len(l.subagents) > 0 || interrupted
|
|
turn := m.event(l, agentActor, "turn.ended", payload)
|
|
next, finalizeBy, err := m.store.EndTurn(ctx, l.id, agentActor, nil, &turn, review && !held, finished && !handoff)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if snapshot && !held {
|
|
m.snapshotSoon(liveTarget(l), api.SnapshotTriggerTurnEnd, 0, review, false)
|
|
}
|
|
|
|
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
|
|
}
|
|
if handoff {
|
|
m.logUndelivered(ctx, l.id, "turn ended in handoff")
|
|
go m.continueFromHandoff(l.id, res.result)
|
|
return nil
|
|
}
|
|
if finalizeBy != "" {
|
|
m.logUndelivered(ctx, l.id, "turn ended in merge")
|
|
keep = owner
|
|
go m.finishMerge(l, finalizeBy)
|
|
return nil
|
|
}
|
|
mergeBy := ""
|
|
if m.leadsTask(ctx, l.taskID, l.id) {
|
|
mergeBy = m.takeMerge(l.taskID)
|
|
}
|
|
switch {
|
|
case mergeBy != "":
|
|
go m.releaseMerge(l.projectID, l.taskID)
|
|
if !res.isError || interrupted {
|
|
m.recordError(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.")
|
|
}
|
|
default:
|
|
go m.nextMerge(l.projectID)
|
|
}
|
|
if stageFinished {
|
|
go m.advanceStage(l)
|
|
return nil
|
|
}
|
|
go m.afterTurn(context.WithoutCancel(ctx), l)
|
|
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
|
|
}
|
|
err = m.Deliver(ctx, l.id)
|
|
if err != nil {
|
|
slog.Error("deliver inbox responses", "session", l.id, "err", err)
|
|
}
|
|
m.deliverTeam(ctx, l)
|
|
}
|
|
|
|
func commitMessage(res endResult) string {
|
|
line, _, _ := strings.Cut(strings.TrimSpace(res.result), "\n")
|
|
sentence, _, found := strings.Cut(line, ". ")
|
|
if found {
|
|
sentence += "."
|
|
}
|
|
sentence = strings.TrimSpace(strings.ReplaceAll(sentence, "**", ""))
|
|
if sentence == "" {
|
|
return fmt.Sprintf("Work from a turn that ended without a summary (%s)", res.subtype)
|
|
}
|
|
runes := []rune(sentence)
|
|
if len(runes) > 100 {
|
|
return string(runes[:99]) + "…"
|
|
}
|
|
return sentence
|
|
}
|
|
|
|
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})
|
|
}
|
|
|
|
func (m *Manager) handleAssistantBlock(ctx context.Context, l *live, b claude.Block, parent, uuid string) error {
|
|
switch b.Type {
|
|
case "text":
|
|
return m.emit(ctx, l, agentActor, "agent.message", withParent(map[string]any{"text": b.Text}, parent, uuid))
|
|
|
|
case "thinking":
|
|
if b.Thinking == "" {
|
|
return nil
|
|
}
|
|
return m.emit(ctx, l, agentActor, "agent.thinking", withParent(map[string]any{"text": b.Thinking}, parent, uuid))
|
|
|
|
case "tool_use":
|
|
input, err := decode(b.Input)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return m.emit(ctx, l, agentActor, "agent.tool_call", withParent(map[string]any{
|
|
"id": b.ID,
|
|
"name": b.Name,
|
|
"input": input,
|
|
}, parent, uuid))
|
|
}
|
|
|
|
return m.emit(ctx, l, agentActor, "agent.vendor", withParent(store.ToPayload(b), parent, uuid))
|
|
}
|
|
|
|
func withParent(payload map[string]any, parent, uuid string) map[string]any {
|
|
if parent != "" {
|
|
payload["parent_tool_use_id"] = parent
|
|
return payload
|
|
}
|
|
if uuid != "" {
|
|
payload["vendor_uuid"] = uuid
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func (m *Manager) emit(ctx context.Context, l *live, actor, kind string, payload map[string]any) error {
|
|
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{
|
|
Kind: kind,
|
|
AgentSessionId: &l.id,
|
|
Actor: actor,
|
|
Payload: payload,
|
|
}
|
|
if l.owner != "" {
|
|
ev.Owner = &l.owner
|
|
return ev
|
|
}
|
|
ev.ProjectId = &l.projectID
|
|
ev.TaskId = &l.taskID
|
|
return ev
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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:])
|
|
}
|