Why: F12 - claims counted only per-agent in-flight tasks, so two agents on one runtime could exceed the runtime cap; count errors were ignored and silently allowed claims. Evidence: queue tests pass incl. dual-bound, concurrent-claim and forced-count-failure cases (verify-u9.json, exit=0). Impact: single atomic conditional UPDATE; lease and reclaim rules kept. Refs: feature/agent-stack-audit-remediation-b2 U9
326 lines
10 KiB
Go
326 lines
10 KiB
Go
package platform
|
|
|
|
import (
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// openQueue opens a hermetic store in a temp dir and returns it with its path.
|
|
func openQueue(t *testing.T) (*Store, string) {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "queue.db")
|
|
s, err := Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = s.Close() })
|
|
return s, path
|
|
}
|
|
|
|
// newQueueRuntime registers a runtime with the given cap.
|
|
func newQueueRuntime(t *testing.T, s *Store, workspaceID, name string, cap int) Runtime {
|
|
t.Helper()
|
|
rt, err := s.RegisterRuntime(Runtime{
|
|
WorkspaceID: workspaceID, Name: name, Command: "in-process",
|
|
Protocol: "agent-native", MaxConcurrency: cap,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return rt
|
|
}
|
|
|
|
// newQueueAgent creates an agent with the given cap, pinned to runtimeID
|
|
// (an empty runtimeID leaves the agent unbound, claimable by any runtime).
|
|
func newQueueAgent(t *testing.T, s *Store, workspaceID, name, runtimeID string, cap int) Agent {
|
|
t.Helper()
|
|
agent, err := s.CreateAgent(Agent{WorkspaceID: workspaceID, Name: name, RuntimeID: runtimeID, MaxConcurrency: cap})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return agent
|
|
}
|
|
|
|
// queueTask creates an issue and queues one task for agentID.
|
|
func queueTask(t *testing.T, s *Store, workspaceID, agentID, title string) Task {
|
|
t.Helper()
|
|
issue, err := s.CreateIssue(Issue{WorkspaceID: workspaceID, Title: title, Body: title, Status: "todo"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
task, err := s.enqueue(issue, agentID, false, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return task
|
|
}
|
|
|
|
// taskStatus reads the durable status of one task.
|
|
func taskStatus(t *testing.T, s *Store, id string) string {
|
|
t.Helper()
|
|
var status string
|
|
if err := s.db.QueryRow(`SELECT status FROM task WHERE id=?`, id).Scan(&status); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return status
|
|
}
|
|
|
|
// R6: the runtime cap bounds every agent on that runtime, not one agent only.
|
|
// A per-agent cap of 4 each still yields a single in-flight task at cap 1.
|
|
func TestClaimEnforcesRuntimeCapAcrossAgents(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
bindToRT bool
|
|
}{
|
|
{name: "agents bound to the runtime", bindToRT: true},
|
|
{name: "agents not bound to the runtime", bindToRT: false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
s, _ := openQueue(t)
|
|
ws, err := s.CreateWorkspace("lab")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rt := newQueueRuntime(t, s, ws.ID, "rt", 1)
|
|
runtimeID := ""
|
|
if tc.bindToRT {
|
|
runtimeID = rt.ID
|
|
}
|
|
first := newQueueAgent(t, s, ws.ID, "a1", runtimeID, 4)
|
|
second := newQueueAgent(t, s, ws.ID, "a2", runtimeID, 4)
|
|
t1 := queueTask(t, s, ws.ID, first.ID, "one")
|
|
t2 := queueTask(t, s, ws.ID, second.ID, "two")
|
|
|
|
claimed, ok, err := s.Claim(rt.ID, time.Now())
|
|
if err != nil || !ok || claimed.ID != t1.ID || claimed.RuntimeID != rt.ID {
|
|
t.Fatalf("first claim %+v ok=%v err=%v want task %s on %s", claimed, ok, err, t1.ID, rt.ID)
|
|
}
|
|
if _, ok, err := s.Claim(rt.ID, time.Now()); err != nil || ok {
|
|
t.Fatalf("runtime cap 1 must refuse the other agent: ok=%v err=%v", ok, err)
|
|
}
|
|
if got := taskStatus(t, s, t2.ID); got != "queued" {
|
|
t.Fatalf("second task %q must stay queued while the runtime is full", got)
|
|
}
|
|
|
|
if err := s.FinishTask(claimed.ID, "completed", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
next, ok, err := s.Claim(rt.ID, time.Now())
|
|
if err != nil || !ok || next.ID != t2.ID {
|
|
t.Fatalf("claim after release %+v ok=%v err=%v want task %s", next, ok, err, t2.ID)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The agent cap holds when two runtimes compete for the same agent.
|
|
func TestClaimEnforcesAgentCapAcrossRuntimes(t *testing.T) {
|
|
s, _ := openQueue(t)
|
|
ws, err := s.CreateWorkspace("lab")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rtA := newQueueRuntime(t, s, ws.ID, "rt-a", 2)
|
|
rtB := newQueueRuntime(t, s, ws.ID, "rt-b", 2)
|
|
agent := newQueueAgent(t, s, ws.ID, "solo", "", 1)
|
|
t1 := queueTask(t, s, ws.ID, agent.ID, "one")
|
|
t2 := queueTask(t, s, ws.ID, agent.ID, "two")
|
|
|
|
claimed, ok, err := s.Claim(rtA.ID, time.Now())
|
|
if err != nil || !ok || claimed.ID != t1.ID {
|
|
t.Fatalf("rt-a claim %+v ok=%v err=%v want task %s", claimed, ok, err, t1.ID)
|
|
}
|
|
if _, ok, err := s.Claim(rtB.ID, time.Now()); err != nil || ok {
|
|
t.Fatalf("agent cap 1 must bound the second runtime: ok=%v err=%v", ok, err)
|
|
}
|
|
if got := taskStatus(t, s, t2.ID); got != "queued" {
|
|
t.Fatalf("second task %q must stay queued while the agent is busy", got)
|
|
}
|
|
}
|
|
|
|
// Concurrent claims never overshoot the runtime cap or an agent cap.
|
|
func TestClaimConcurrentNeverExceedsCaps(t *testing.T) {
|
|
s, _ := openQueue(t)
|
|
ws, err := s.CreateWorkspace("lab")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rt := newQueueRuntime(t, s, ws.ID, "rt", 2)
|
|
tight := newQueueAgent(t, s, ws.ID, "tight", rt.ID, 1)
|
|
loose := newQueueAgent(t, s, ws.ID, "loose", rt.ID, 8)
|
|
other := newQueueAgent(t, s, ws.ID, "other", rt.ID, 8)
|
|
for _, agent := range []Agent{tight, loose, other} {
|
|
queueTask(t, s, ws.ID, agent.ID, "task for "+agent.Name)
|
|
}
|
|
|
|
const workers = 12
|
|
var wg sync.WaitGroup
|
|
var mu sync.Mutex
|
|
claimed := map[string]string{} // task id -> agent id
|
|
failures := []string{}
|
|
for i := 0; i < workers; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
task, ok, err := s.Claim(rt.ID, time.Now())
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
switch {
|
|
case err != nil:
|
|
failures = append(failures, err.Error())
|
|
case ok:
|
|
claimed[task.ID] = task.AgentID
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
if len(failures) > 0 {
|
|
t.Fatalf("concurrent claims failed: %v", failures)
|
|
}
|
|
if len(claimed) == 0 {
|
|
t.Fatal("no claim landed")
|
|
}
|
|
if len(claimed) > rt.MaxConcurrency {
|
|
t.Fatalf("runtime cap %d exceeded: %v", rt.MaxConcurrency, claimed)
|
|
}
|
|
perAgent := map[string]int{}
|
|
for _, agentID := range claimed {
|
|
perAgent[agentID]++
|
|
}
|
|
caps := map[string]int{tight.ID: tight.MaxConcurrency, loose.ID: loose.MaxConcurrency, other.ID: other.MaxConcurrency}
|
|
for agentID, n := range perAgent {
|
|
if cap, known := caps[agentID]; !known {
|
|
t.Fatalf("unknown agent %s claimed", agentID)
|
|
} else if n > cap {
|
|
t.Fatalf("agent cap %d exceeded by %d for %s: %v", cap, n, agentID, claimed)
|
|
}
|
|
}
|
|
if got := perAgent[tight.ID]; got > 1 {
|
|
t.Fatalf("agent cap 1 exceeded by %d: %v", got, claimed)
|
|
}
|
|
}
|
|
|
|
// A completed or lease-expired task gives its slot back.
|
|
func TestClaimCapacityFreedByReleaseAndReclaim(t *testing.T) {
|
|
s, _ := openQueue(t)
|
|
ws, err := s.CreateWorkspace("lab")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rt := newQueueRuntime(t, s, ws.ID, "rt", 1)
|
|
agent := newQueueAgent(t, s, ws.ID, "solo", rt.ID, 1)
|
|
t1 := queueTask(t, s, ws.ID, agent.ID, "one")
|
|
t2 := queueTask(t, s, ws.ID, agent.ID, "two")
|
|
|
|
claimed, ok, err := s.Claim(rt.ID, time.Now())
|
|
if err != nil || !ok || claimed.ID != t1.ID {
|
|
t.Fatalf("first claim %+v ok=%v err=%v want task %s", claimed, ok, err, t1.ID)
|
|
}
|
|
if _, ok, err := s.Claim(rt.ID, time.Now()); err != nil || ok {
|
|
t.Fatalf("the agent is at its cap: ok=%v err=%v", ok, err)
|
|
}
|
|
|
|
if err := s.FinishTask(claimed.ID, "completed", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second, ok, err := s.Claim(rt.ID, time.Now())
|
|
if err != nil || !ok || second.ID != t2.ID {
|
|
t.Fatalf("claim after release %+v ok=%v err=%v want task %s", second, ok, err, t2.ID)
|
|
}
|
|
|
|
n, err := s.RecoverOrphans(time.Now().Add(10 * time.Minute))
|
|
if err != nil || n != 1 {
|
|
t.Fatalf("reclaim %d %v", n, err)
|
|
}
|
|
if got := taskStatus(t, s, t2.ID); got != "queued" {
|
|
t.Fatalf("reclaimed task %q must be queued again", got)
|
|
}
|
|
third, ok, err := s.Claim(rt.ID, time.Now())
|
|
if err != nil || !ok || third.ID != t2.ID || third.Attempts != 1 {
|
|
t.Fatalf("claim after reclaim %+v ok=%v err=%v want task %s with one attempt", third, ok, err, t2.ID)
|
|
}
|
|
}
|
|
|
|
// A capacity read that fails refuses the claim; it never allows it.
|
|
func TestClaimRefusesWhenReadFails(t *testing.T) {
|
|
t.Run("unreadable store", func(t *testing.T) {
|
|
s, path := openQueue(t)
|
|
ws, err := s.CreateWorkspace("lab")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rt := newQueueRuntime(t, s, ws.ID, "rt", 2)
|
|
agent := newQueueAgent(t, s, ws.ID, "a", rt.ID, 2)
|
|
task := queueTask(t, s, ws.ID, agent.ID, "one")
|
|
if err := s.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if claimed, ok, err := s.Claim(rt.ID, time.Now()); ok {
|
|
t.Fatalf("an unreadable store handed out %+v (err=%v)", claimed, err)
|
|
}
|
|
again, err := Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer again.Close()
|
|
if got := taskStatus(t, again, task.ID); got != "queued" {
|
|
t.Fatalf("task %q after a failed capacity read", got)
|
|
}
|
|
})
|
|
|
|
t.Run("agent lookup fails", func(t *testing.T) {
|
|
s, _ := openQueue(t)
|
|
ws, err := s.CreateWorkspace("lab")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rt := newQueueRuntime(t, s, ws.ID, "rt", 2)
|
|
agent := newQueueAgent(t, s, ws.ID, "a", rt.ID, 2)
|
|
task := queueTask(t, s, ws.ID, agent.ID, "one")
|
|
if _, err := s.db.Exec(`DROP TABLE agent`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
claimed, ok, err := s.Claim(rt.ID, time.Now())
|
|
if ok || err != nil {
|
|
t.Fatalf("a failed agent read must skip the candidate: %+v ok=%v err=%v", claimed, ok, err)
|
|
}
|
|
if _, err := s.db.Exec(`CREATE TABLE agent (id TEXT PRIMARY KEY, runtime_id TEXT)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := taskStatus(t, s, task.ID); got != "queued" {
|
|
t.Fatalf("task %q after a skipped candidate", got)
|
|
}
|
|
})
|
|
|
|
// The guarded count runs inside the atomic claim: when it cannot be read
|
|
// the statement must fail rather than hand the task out.
|
|
t.Run("guard count fails", func(t *testing.T) {
|
|
s, _ := openQueue(t)
|
|
ws, err := s.CreateWorkspace("lab")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rt := newQueueRuntime(t, s, ws.ID, "rt", 2)
|
|
agent := newQueueAgent(t, s, ws.ID, "a", rt.ID, 2)
|
|
task := queueTask(t, s, ws.ID, agent.ID, "one")
|
|
if _, err := s.db.Exec(`DROP TABLE agent`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lease := time.Now().UTC().Add(5 * time.Minute).Format(time.RFC3339Nano)
|
|
res, err := s.db.Exec(claimSQL, rt.ID, lease, now(), task.ID, agent.ID, 2, rt.ID, rt.ID, rt.MaxConcurrency)
|
|
if err == nil {
|
|
n, _ := res.RowsAffected()
|
|
t.Fatalf("a failed guard count claimed %d task(s)", n)
|
|
}
|
|
if _, err := s.db.Exec(`CREATE TABLE agent (id TEXT PRIMARY KEY, runtime_id TEXT)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := taskStatus(t, s, task.ID); got != "queued" {
|
|
t.Fatalf("task %q after a failed guard count", got)
|
|
}
|
|
})
|
|
}
|