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
+12 -1
View File
@@ -178,6 +178,15 @@ func (m *Manager) fileError(l *live, msg string) {
}
}
func (m *Manager) fileRateLimited(l *live, info map[string]any, failure string) {
id := store.NewID()
payload := map[string]any{"vendor": string(l.vendor), "rate_limit": info, "error": failure}
err := m.store.CreateInboxItem(context.Background(), m.newItem(l, id, id, api.InboxItemKindRateLimited, api.Blocked, true, payload))
if err != nil {
slog.Error("file rate limit", "session", l.id, "err", err)
}
}
func (m *Manager) newItem(l *live, id, thread string, kind api.InboxItemKind, priority api.InboxItemPriority, blocking bool, payload map[string]any) api.InboxItem {
return api.InboxItem{
Id: id,
@@ -228,7 +237,7 @@ func (m *Manager) Deliver(ctx context.Context, sessionID string) error {
return err
}
_, err = m.turn(ctx, sess, task, pending[0].Response.User, deliveryMessage(pending), true)
_, err = m.turn(ctx, sess, task, pending[0].Response.User, deliveryMessage(pending))
if errors.Is(err, ErrConflict) {
return nil
}
@@ -258,6 +267,8 @@ func deliveryMessage(pending []store.Pending) string {
fmt.Fprintf(b, "Your report %s\n", p.Item.Id)
case api.InboxItemKindSessionError:
fmt.Fprintf(b, "Your error %s: %s\n", p.Item.Id, p.Item.Payload["error"])
case api.InboxItemKindRateLimited:
fmt.Fprintf(b, "Your rate-limited turn %s: %s\n", p.Item.Id, p.Item.Payload["error"])
}
switch r.Type {
+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
+90 -23
View File
@@ -26,12 +26,14 @@ const (
)
type env struct {
t *testing.T
ctx context.Context
store *store.Store
events chan api.Event
user api.User
task api.Task
t *testing.T
ctx context.Context
store *store.Store
events chan api.Event
user api.User
repo string
project api.Project
task api.Task
}
func newEnv(t *testing.T) *env {
@@ -56,38 +58,45 @@ func newEnv(t *testing.T) *env {
t.Fatalf("update user: %v", err)
}
repo := scratchRepo(t)
p := api.Project{Id: store.NewID(), Name: "demo", RepoPath: repo, DefaultBranch: "main", Remote: "none", CreatedBy: login, CreatedAt: time.Now().UTC()}
err = e.store.CreateProject(ctx, p)
e.repo = scratchRepo(t)
e.project = api.Project{Id: store.NewID(), Name: "demo", RepoPath: e.repo, DefaultBranch: "main", Remote: "none", CreatedBy: login, CreatedAt: time.Now().UTC()}
err = e.store.CreateProject(ctx, e.project)
if err != nil {
t.Fatalf("create project: %v", err)
}
e.task = e.addTask()
return e
}
func (e *env) addTask() api.Task {
e.t.Helper()
id := store.NewID()
e.task = api.Task{
task := api.Task{
Id: id,
ProjectId: p.Id,
ProjectId: e.project.Id,
Title: "test",
Branch: "byre/" + id,
WorktreePath: filepath.Join(t.TempDir(), id),
WorktreePath: filepath.Join(e.t.TempDir(), id),
Status: api.TaskStatusActive,
CreatedBy: login,
CreatedAt: time.Now().UTC(),
}
e.task.BaseRef, err = git.AddWorktree(repo, e.task.WorktreePath, e.task.Branch, "main")
if err != nil {
t.Fatalf("add worktree: %v", err)
}
err = e.store.CreateTask(ctx, e.task)
if err != nil {
t.Fatalf("create task: %v", err)
}
return e
var err error
task.BaseRef, err = git.AddWorktree(e.repo, task.WorktreePath, task.Branch, "main")
if err != nil {
e.t.Fatalf("add worktree: %v", err)
}
err = e.store.CreateTask(e.ctx, task)
if err != nil {
e.t.Fatalf("create task: %v", err)
}
return task
}
func (e *env) manager() *agents.Manager {
m := agents.New(e.store)
m := agents.New(e.store, agents.DefaultLimits)
e.t.Cleanup(m.Close)
return m
}
@@ -221,7 +230,7 @@ func TestInterrupt(t *testing.T) {
func TestRecoverMidTurn(t *testing.T) {
t.Parallel()
e := newEnv(t)
first := agents.New(e.store)
first := agents.New(e.store, agents.DefaultLimits)
sess, err := first.Create(e.ctx, e.task, e.user, "Run the bash command `sleep 15` in the foreground (not in the background). After it finishes, create a file done.txt containing exactly the word done.", testModel)
if err != nil {
@@ -446,6 +455,64 @@ func TestFollowUpQuestionThread(t *testing.T) {
}
}
func TestConcurrencyQueue(t *testing.T) {
t.Parallel()
e := newEnv(t)
m := agents.New(e.store, agents.Limits{Global: 1, PerVendor: map[api.AgentSessionVendor]int{api.Claude: 1}})
t.Cleanup(m.Close)
first, err := m.Create(e.ctx, e.task, e.user, longCommandPrompt, testModel)
if err != nil {
t.Fatalf("create first: %v", err)
}
e.waitFor("first tool call", func(ev api.Event) bool { return ev.Kind == "agent.tool_call" })
second, err := m.Create(e.ctx, e.addTask(), e.user, "Reply with the single word ok.", testModel)
if err != nil {
t.Fatalf("create second: %v", err)
}
if second.State != api.AgentSessionStateQueued {
t.Fatalf("second session is %s with one slot taken, want queued", second.State)
}
usage, err := m.Usage(e.ctx)
if err != nil || usage.Running != 1 || usage.Queued != 1 {
t.Fatalf("usage %+v %v", usage, err)
}
cancelled, err := m.Interrupt(e.ctx, second.Id)
if err != nil || cancelled.State != api.AgentSessionStateIdle {
t.Fatalf("cancel queued turn: %+v %v", cancelled, err)
}
second, err = m.Send(e.ctx, second.Id, login, "Reply with the single word ok.")
if err != nil || second.State != api.AgentSessionStateQueued {
t.Fatalf("requeue: %+v %v", second, err)
}
_, err = m.Interrupt(e.ctx, first.Id)
if err != nil {
t.Fatalf("interrupt first: %v", err)
}
e.waitFor("first idle", func(ev api.Event) bool {
return ev.Kind == "session.state" && *ev.AgentSessionId == first.Id && ev.Payload["state"] == string(api.AgentSessionStateIdle)
})
events := e.waitFor("second idle", func(ev api.Event) bool {
return ev.Kind == "session.state" && *ev.AgentSessionId == second.Id && ev.Payload["state"] == string(api.AgentSessionStateIdle)
})
replied := false
for _, ev := range events {
if ev.Kind == "agent.message" && *ev.AgentSessionId == second.Id && strings.Contains(strings.ToLower(ev.Payload["text"].(string)), "ok") {
replied = true
}
}
if !replied {
t.Fatalf("queued turn never ran: %v", kinds(events))
}
}
func scratchRepo(t *testing.T) string {
dir := t.TempDir()
gitOut(t, dir, "init", "--initial-branch=main")
@@ -0,0 +1,78 @@
package agents
import (
"context"
"path/filepath"
"testing"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/claude"
"g.fc.run/misc/byre/internal/report"
"g.fc.run/misc/byre/internal/store"
)
func TestRateLimitedTurn(t *testing.T) {
ctx := context.Background()
st, err := store.Open(filepath.Join(t.TempDir(), "byre.db"), func(api.Event) {})
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
now := time.Now().UTC()
_, err = st.EnsureUser(ctx, "alice", "Alice")
if err != nil {
t.Fatalf("ensure user: %v", err)
}
p := api.Project{Id: "p1", Name: "demo", RepoPath: "/nonexistent", DefaultBranch: "main", Remote: "none", CreatedBy: "alice", CreatedAt: now}
err = st.CreateProject(ctx, p)
if err != nil {
t.Fatalf("create project: %v", err)
}
task := api.Task{Id: "t1", ProjectId: "p1", Title: "t", Branch: "b", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now}
err = st.CreateTask(ctx, task)
if err != nil {
t.Fatalf("create task: %v", err)
}
sess := api.AgentSession{Id: "s1", TaskId: "t1", Vendor: api.Claude, VendorSessionId: "s1", State: api.AgentSessionStateRunning, Role: api.Implementer, CreatedBy: "alice", CreatedAt: now}
err = st.CreateSession(ctx, sess, "p1")
if err != nil {
t.Fatalf("create session: %v", err)
}
m := New(st, DefaultLimits)
l := &live{id: "s1", vendor: api.Claude, taskID: "t1", projectID: "p1", gate: &report.Gate{}}
feed := func(typ, raw string) {
t.Helper()
err := m.handle(ctx, l, claude.Event{Type: typ, Raw: []byte(raw)})
if err != nil {
t.Fatalf("handle %s: %v", raw, err)
}
}
feed("rate_limit_event", `{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","rateLimitType":"five_hour","resetsAt":1791972000}}`)
usage, err := m.Usage(ctx)
if err != nil {
t.Fatalf("usage: %v", err)
}
if usage.RateLimits["claude"]["status"] != "allowed" {
t.Fatalf("rate limit status not recorded: %+v", usage.RateLimits)
}
feed("rate_limit_event", `{"type":"rate_limit_event","rate_limit_info":{"status":"rejected","rateLimitType":"five_hour","resetsAt":1791972000}}`)
feed("result", `{"type":"result","subtype":"success","is_error":true,"result":"API Error: rate limited","num_turns":1}`)
items, err := st.ListInbox(ctx, store.InboxFilter{SessionID: "s1", State: api.Open})
if err != nil {
t.Fatalf("list inbox: %v", err)
}
if len(items) != 1 || items[0].Kind != api.InboxItemKindRateLimited || items[0].Priority != api.Blocked {
t.Fatalf("want one blocked rate_limited item, got %+v", items)
}
limit, ok := items[0].Payload["rate_limit"].(map[string]any)
if !ok || limit["status"] != "rejected" || items[0].Payload["vendor"] != "claude" {
t.Fatalf("unexpected payload %+v", items[0].Payload)
}
}