Add concurrency caps, the two-pane web UI, AGENTS.md, and plan status

This commit is contained in:
Ian Gulliver
2026-10-14 09:40:12 -07:00
parent befd476598
commit e9fd7de556
60 changed files with 5419 additions and 254 deletions
+240 -28
View File
@@ -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