Add concurrency caps, the two-pane web UI, AGENTS.md, and plan status
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user