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