Add concurrency caps, the two-pane web UI, AGENTS.md, and plan status
This commit is contained in:
+240
-28
@@ -26,28 +26,48 @@ 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
|
||||
type Limits struct {
|
||||
Global int
|
||||
PerVendor map[api.AgentSessionVendor]int
|
||||
}
|
||||
|
||||
mu sync.Mutex
|
||||
live map[string]*live
|
||||
closing bool
|
||||
var DefaultLimits = Limits{
|
||||
Global: 8,
|
||||
PerVendor: map[api.AgentSessionVendor]int{api.Claude: 6},
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
store *store.Store
|
||||
limits Limits
|
||||
|
||||
mu sync.Mutex
|
||||
live map[string]*live
|
||||
closing bool
|
||||
rateLimits map[api.AgentSessionVendor]map[string]any
|
||||
|
||||
deliverMu sync.Mutex
|
||||
capMu sync.Mutex
|
||||
}
|
||||
|
||||
type live struct {
|
||||
sess *claude.Session
|
||||
gate *report.Gate
|
||||
id string
|
||||
vendor api.AgentSessionVendor
|
||||
taskID string
|
||||
projectID string
|
||||
stopping bool
|
||||
interrupted bool
|
||||
rateLimited map[string]any
|
||||
}
|
||||
|
||||
func New(st *store.Store) *Manager {
|
||||
return &Manager{store: st, live: map[string]*live{}}
|
||||
func New(st *store.Store, limits Limits) *Manager {
|
||||
return &Manager{
|
||||
store: st,
|
||||
limits: limits,
|
||||
live: map[string]*live{},
|
||||
rateLimits: map[api.AgentSessionVendor]map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Create(ctx context.Context, task api.Task, creator api.User, prompt, model string) (api.AgentSession, error) {
|
||||
@@ -87,7 +107,7 @@ func (m *Manager) Create(ctx context.Context, task api.Task, creator api.User, p
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
return m.turn(ctx, sess, task, creator.Login, prompt, false)
|
||||
return m.turn(ctx, sess, task, creator.Login, prompt)
|
||||
}
|
||||
|
||||
func (m *Manager) Send(ctx context.Context, sessionID, actor, text string) (api.AgentSession, error) {
|
||||
@@ -105,7 +125,7 @@ func (m *Manager) Send(ctx context.Context, sessionID, actor, text string) (api.
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
return m.turn(ctx, sess, task, actor, text, true)
|
||||
return m.turn(ctx, sess, task, actor, text)
|
||||
}
|
||||
|
||||
func (m *Manager) Interrupt(ctx context.Context, sessionID string) (api.AgentSession, error) {
|
||||
@@ -114,6 +134,10 @@ func (m *Manager) Interrupt(ctx context.Context, sessionID string) (api.AgentSes
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
if sess.State == api.AgentSessionStateQueued {
|
||||
return m.cancelQueued(ctx, sess)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
l := m.live[sessionID]
|
||||
m.mu.Unlock()
|
||||
@@ -159,13 +183,20 @@ func (m *Manager) StopTask(ctx context.Context, taskID, actor string) error {
|
||||
l.sess.Kill()
|
||||
}
|
||||
|
||||
if sess.State == api.AgentSessionStateQueued {
|
||||
err = m.store.DeleteQueuedTurn(ctx, sess.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateCompleted, actor, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return m.drain(ctx)
|
||||
}
|
||||
|
||||
func (m *Manager) Recover(ctx context.Context) error {
|
||||
@@ -180,7 +211,7 @@ func (m *Manager) Recover(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
l, err := m.ensureLive(ctx, sess, task, true)
|
||||
l, err := m.ensureLive(ctx, sess, task)
|
||||
if err != nil {
|
||||
slog.Error("resume session", "session", sess.Id, "err", err)
|
||||
err = m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateIdle, "byre", map[string]any{"error": err.Error()})
|
||||
@@ -197,7 +228,7 @@ func (m *Manager) Recover(ctx context.Context) error {
|
||||
slog.Info("resumed session", "session", sess.Id, "task", task.Id)
|
||||
}
|
||||
|
||||
return nil
|
||||
return m.drain(ctx)
|
||||
}
|
||||
|
||||
func (m *Manager) Close() {
|
||||
@@ -220,20 +251,84 @@ func (m *Manager) LiveCount() int {
|
||||
return len(m.live)
|
||||
}
|
||||
|
||||
func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, resume bool) (api.AgentSession, error) {
|
||||
l, err := m.ensureLive(ctx, sess, task, resume)
|
||||
type Usage struct {
|
||||
Running int
|
||||
Queued int
|
||||
RateLimits map[string]map[string]any
|
||||
}
|
||||
|
||||
func (m *Manager) Usage(ctx context.Context) (Usage, error) {
|
||||
u := Usage{RateLimits: map[string]map[string]any{}}
|
||||
|
||||
counts, err := m.store.CountRunning(ctx)
|
||||
if err != nil {
|
||||
return u, err
|
||||
}
|
||||
for _, n := range counts {
|
||||
u.Running += n
|
||||
}
|
||||
|
||||
queued, err := m.store.QueuedTurns(ctx)
|
||||
if err != nil {
|
||||
return u, err
|
||||
}
|
||||
u.Queued = len(queued)
|
||||
|
||||
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) (api.AgentSession, error) {
|
||||
m.capMu.Lock()
|
||||
defer m.capMu.Unlock()
|
||||
|
||||
room, err := m.hasRoom(ctx, sess.Vendor)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
err = m.store.TransitionSession(ctx, sess.Id, api.AgentSessionStateIdle, api.AgentSessionStateRunning, actor)
|
||||
if errors.Is(err, store.ErrStateChanged) {
|
||||
return api.AgentSession{}, fmt.Errorf("%w: %w", ErrConflict, err)
|
||||
if room {
|
||||
return m.start(ctx, sess, task, actor, text, api.AgentSessionStateIdle)
|
||||
}
|
||||
|
||||
err = transition(ctx, m.store, sess.Id, api.AgentSessionStateIdle, api.AgentSessionStateQueued, actor)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
err = m.store.EnqueueTurn(ctx, store.QueuedTurn{
|
||||
SessionID: sess.Id,
|
||||
Vendor: sess.Vendor,
|
||||
Actor: actor,
|
||||
Text: text,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
return m.store.GetSession(ctx, sess.Id)
|
||||
}
|
||||
|
||||
func (m *Manager) start(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, from api.AgentSessionState) (api.AgentSession, error) {
|
||||
err := transition(ctx, m.store, sess.Id, from, api.AgentSessionStateRunning, actor)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
l, err := m.ensureLive(ctx, sess, task)
|
||||
if err != nil {
|
||||
serr := m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateIdle, actor, map[string]any{"error": err.Error()})
|
||||
if serr != nil {
|
||||
slog.Error("record failed start", "session", sess.Id, "err", serr)
|
||||
}
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
err = m.message(ctx, l, actor, text)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
@@ -242,6 +337,89 @@ func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task
|
||||
return m.store.GetSession(ctx, sess.Id)
|
||||
}
|
||||
|
||||
func (m *Manager) hasRoom(ctx context.Context, vendor api.AgentSessionVendor) (bool, error) {
|
||||
counts, err := m.store.CountRunning(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, n := range counts {
|
||||
total += n
|
||||
}
|
||||
|
||||
return total < m.limits.Global && counts[vendor] < m.limits.PerVendor[vendor], nil
|
||||
}
|
||||
|
||||
func (m *Manager) drain(ctx context.Context) error {
|
||||
m.capMu.Lock()
|
||||
defer m.capMu.Unlock()
|
||||
|
||||
queued, err := m.store.QueuedTurns(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, q := range queued {
|
||||
room, err := m.hasRoom(ctx, q.Vendor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !room {
|
||||
continue
|
||||
}
|
||||
|
||||
sess, err := m.store.GetSession(ctx, q.SessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
task, err := m.store.GetTask(ctx, sess.TaskId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.store.DeleteQueuedTurn(ctx, q.SessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = m.start(ctx, sess, task, q.Actor, q.Text, api.AgentSessionStateQueued)
|
||||
if err != nil {
|
||||
slog.Error("start queued turn", "session", q.SessionID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) cancelQueued(ctx context.Context, sess api.AgentSession) (api.AgentSession, error) {
|
||||
m.capMu.Lock()
|
||||
defer m.capMu.Unlock()
|
||||
|
||||
err := m.store.DeleteQueuedTurn(ctx, sess.Id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return api.AgentSession{}, fmt.Errorf("%w: session %s has no queued turn", ErrConflict, sess.Id)
|
||||
}
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
err = transition(ctx, m.store, sess.Id, api.AgentSessionStateQueued, api.AgentSessionStateIdle, "byre")
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
return m.store.GetSession(ctx, sess.Id)
|
||||
}
|
||||
|
||||
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) error {
|
||||
err := m.emit(ctx, l, actor, "user.message", map[string]any{"text": text})
|
||||
if err != nil {
|
||||
@@ -250,13 +428,14 @@ func (m *Manager) message(ctx context.Context, l *live, actor, text string) erro
|
||||
|
||||
m.mu.Lock()
|
||||
l.interrupted = false
|
||||
l.rateLimited = nil
|
||||
m.mu.Unlock()
|
||||
|
||||
l.gate.Reset()
|
||||
return l.sess.Send(text)
|
||||
}
|
||||
|
||||
func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task api.Task, resume bool) (*live, error) {
|
||||
func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task api.Task) (*live, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -274,7 +453,7 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
|
||||
return nil, err
|
||||
}
|
||||
|
||||
l = &live{id: sess.Id, taskID: task.Id, projectID: task.ProjectId}
|
||||
l = &live{id: sess.Id, vendor: sess.Vendor, taskID: task.Id, projectID: task.ProjectId}
|
||||
l.gate = &report.Gate{OnReport: func(r report.Report) {
|
||||
err := m.emit(context.Background(), l, agentActor, "agent.report", store.ToPayload(r))
|
||||
if err != nil {
|
||||
@@ -293,7 +472,11 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
|
||||
"Stop": {{Func: l.gate.StopHook}},
|
||||
},
|
||||
}
|
||||
if resume {
|
||||
started, err := m.store.SessionStarted(ctx, sess.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if started {
|
||||
opts.Resume = sess.VendorSessionId
|
||||
} else {
|
||||
opts.SessionID = sess.VendorSessionId
|
||||
@@ -346,6 +529,11 @@ func (m *Manager) pump(l *live) {
|
||||
slog.Error("record session exit", "session", l.id, "err", err)
|
||||
}
|
||||
m.fileError(l, "The agent process exited mid-turn. Send a message to resume it.")
|
||||
|
||||
err = m.drain(ctx)
|
||||
if err != nil {
|
||||
slog.Error("start queued turns", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
|
||||
@@ -399,10 +587,16 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
|
||||
|
||||
m.mu.Lock()
|
||||
interrupted := l.interrupted
|
||||
rateLimited := l.rateLimited
|
||||
m.mu.Unlock()
|
||||
|
||||
if res.IsError && !interrupted {
|
||||
m.fileError(l, fmt.Sprintf("The turn ended with an error (%s): %s", res.Subtype, res.Result))
|
||||
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)
|
||||
}
|
||||
|
||||
err = m.store.SetSessionState(ctx, l.id, api.AgentSessionStateIdle, agentActor, nil)
|
||||
@@ -410,14 +604,32 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := m.Deliver(context.Background(), l.id)
|
||||
if err != nil {
|
||||
slog.Error("deliver inbox responses", "session", l.id, "err", err)
|
||||
}
|
||||
}()
|
||||
err = m.drain(ctx)
|
||||
if err != nil {
|
||||
slog.Error("start queued turns", "err", err)
|
||||
}
|
||||
err = m.Deliver(ctx, l.id)
|
||||
if err != nil {
|
||||
slog.Error("deliver inbox responses", "session", l.id, "err", err)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user