First Commit
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,822 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
func newAdmissionTestServer(m *Manager, available func(*Task) bool) *Server {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
e := NewEngine(m)
|
||||
planner, worker := new(agent.Planner), new(agent.Worker)
|
||||
e.SetAuthoritativeAgentResolver(func(task *Task) (*agent.Planner, *agent.Worker) {
|
||||
if available == nil || available(task) {
|
||||
return planner, worker
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
return &Server{m: m, engine: e, ctx: ctx, chatBusy: map[string]bool{}, chatCancel: map[string]context.CancelCauseFunc{}}
|
||||
}
|
||||
|
||||
func restoreConcurrencySetting(t *testing.T, m *Manager) func() {
|
||||
t.Helper()
|
||||
enabled, limit := m.ConcurrencyLimit()
|
||||
if limit < 1 {
|
||||
limit = defaultConcurrencyLimit
|
||||
}
|
||||
return func() {
|
||||
if err := m.SetConcurrency(enabled, limit); err != nil {
|
||||
t.Errorf("restore concurrency setting: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdmitAlreadyRunningTaskDoesNotQueueAfterLimitDecrease(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
oldEnabled, oldLimit := m.ConcurrencyLimit()
|
||||
if oldLimit < 1 {
|
||||
oldLimit = defaultConcurrencyLimit
|
||||
}
|
||||
defer m.SetConcurrency(oldEnabled, oldLimit)
|
||||
if err := m.SetConcurrency(true, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
running, err := m.CreateTask("already admitted", "accept follow-up", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
holder, err := m.CreateTask("other admitted task", "occupy lowered limit", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = m.DeleteTask(running.ID, DeleteTaskOptions{})
|
||||
_, _ = m.DeleteTask(holder.ID, DeleteTaskOptions{})
|
||||
}()
|
||||
|
||||
s := newAdmissionTestServer(m, nil)
|
||||
// Simulate a task whose long-lived loops were admitted before the limit was
|
||||
// lowered. The other live task makes the current count exceed the new limit.
|
||||
s.engine.started.Store(running.ID, true)
|
||||
queued, err := s.admitTask(running, "resume")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if queued || running.Queued {
|
||||
t.Fatalf("already-running task was re-queued: queued=%v task=%+v", queued, running)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdmissionDoesNotOverwriteConcurrentTerminalStatus(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
defer restoreConcurrencySetting(t, m)()
|
||||
if err := m.SetConcurrency(false, defaultConcurrencyLimit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
task, err := m.CreateTask("stale admission", "preserve terminal writer", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _, _ = m.DeleteTask(task.ID, DeleteTaskOptions{}) }()
|
||||
|
||||
// Model an Engine terminal commit after admission captured its in-memory
|
||||
// snapshot but before the scheduler UPDATE. Direct DB use intentionally leaves
|
||||
// the live handle stale at its old status for this deterministic interleaving.
|
||||
taskID, _ := strconv.ParseInt(task.ID, 10, 64)
|
||||
if err := m.pg.SetStatus(taskID, "done"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := newAdmissionTestServer(m, nil)
|
||||
if _, err := s.admitTask(task, "resume"); err == nil {
|
||||
t.Fatal("stale admission overwrote a concurrent terminal transition")
|
||||
}
|
||||
stored, err := m.pg.GetTask(taskID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored == nil || stored.Status != "done" || stored.Queued || stored.Paused {
|
||||
t.Fatalf("terminal state changed by stale admission: %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdmitKeepsQueuedBootstrapMode(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
oldEnabled, oldLimit := m.ConcurrencyLimit()
|
||||
if oldLimit < 1 {
|
||||
oldLimit = defaultConcurrencyLimit
|
||||
}
|
||||
defer m.SetConcurrency(oldEnabled, oldLimit)
|
||||
if err := m.SetConcurrency(true, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
holder, err := m.CreateTask("queue holder", "occupy slot", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queuedTask, err := m.CreateTask("queued bootstrap", "must decompose goals", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = m.DeleteTask(holder.ID, DeleteTaskOptions{})
|
||||
_, _ = m.DeleteTask(queuedTask.ID, DeleteTaskOptions{})
|
||||
}()
|
||||
if err := m.EnqueueTask(queuedTask.ID, "bootstrap"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstQueuedAt := queuedTask.QueuedAt
|
||||
|
||||
s := newAdmissionTestServer(m, nil)
|
||||
queued, err := s.admitTask(queuedTask, "resume")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !queued || queuedTask.QueueMode != "bootstrap" || queuedTask.QueuedAt != firstQueuedAt {
|
||||
t.Fatalf("re-admission changed bootstrap FIFO state: queued=%v task=%+v", queued, queuedTask)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalTaskQueuedByAdmissionKeepsExecutionBarrier(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
defer restoreConcurrencySetting(t, m)()
|
||||
if err := m.SetConcurrency(true, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
holder, err := m.CreateTask("slot holder", "hold", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task, err := m.CreateTask("terminal follow-up", "must wait", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = m.DeleteTask(holder.ID, DeleteTaskOptions{})
|
||||
_, _ = m.DeleteTask(task.ID, DeleteTaskOptions{})
|
||||
}()
|
||||
intentID, err := task.Store.AddIntent(map[string]any{"summary": "follow-up"}, 10, nil, "human")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.SetTaskStatus(task.ID, "done"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := newAdmissionTestServer(m, nil)
|
||||
queued, err := s.admitTask(task, "resume")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !queued || !task.Queued || task.Status != "running" || !s.engine.IsPaused(task.ID) {
|
||||
t.Fatalf("queued terminal admission lost gate: queued=%v task=%+v enginePaused=%v", queued, task, s.engine.IsPaused(task.ID))
|
||||
}
|
||||
if execCtx := s.engine.execContextFor(context.Background(), task.ID); execCtx.Err() == nil {
|
||||
t.Fatal("queued terminal task received a live execution context")
|
||||
}
|
||||
node, err := task.Store.GetNode(intentID)
|
||||
if err != nil || node == nil || node.State != "open" {
|
||||
t.Fatalf("queued intent changed before admission: node=%+v err=%v", node, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimedOutTaskRevivalResetsClockAndSettlingAcrossFIFO(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
defer restoreConcurrencySetting(t, m)()
|
||||
if err := m.SetConcurrency(true, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
holder, err := m.CreateTask("timeout revival holder", "occupy slot", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task, err := m.CreateTask("timed out follow-up", "run with a fresh clock", nil, 120, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = m.DeleteTask(holder.ID, DeleteTaskOptions{})
|
||||
_, _ = m.DeleteTask(task.ID, DeleteTaskOptions{})
|
||||
}()
|
||||
if _, err := m.StampTaskFirstRun(task.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldClock := task.lifecycleSnapshot()
|
||||
if oldClock.FirstRunAt == 0 || oldClock.DeadlineAt == 0 {
|
||||
t.Fatalf("test setup did not stamp the original clock: %+v", oldClock)
|
||||
}
|
||||
if err := m.SetTaskStatus(task.ID, "timeout"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := newAdmissionTestServer(m, nil)
|
||||
s.engine.settling.Store(task.ID, true)
|
||||
s.engine.deadline.Store(task.ID, oldClock.DeadlineAt)
|
||||
s.engine.stamped.Store(task.ID, true)
|
||||
s.engine.coordStarted.Store(task.ID, true)
|
||||
|
||||
queued, err := s.admitTask(task, "resume")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queuedState := task.lifecycleSnapshot()
|
||||
if !queued || !queuedState.Queued || queuedState.Status != "running" ||
|
||||
queuedState.FirstRunAt != 0 || queuedState.DeadlineAt != 0 {
|
||||
t.Fatalf("timed-out task was not reset before FIFO wait: queued=%v state=%+v", queued, queuedState)
|
||||
}
|
||||
if s.engine.isSettling(task.ID) {
|
||||
t.Fatal("timed-out task retained the old settling gate while queued")
|
||||
}
|
||||
if _, ok := s.engine.deadline.Load(task.ID); ok {
|
||||
t.Fatal("timed-out task retained the old in-memory deadline")
|
||||
}
|
||||
if _, ok := s.engine.stamped.Load(task.ID); ok {
|
||||
t.Fatal("timed-out task retained the old first-run stamp")
|
||||
}
|
||||
stored, err := m.pg.GetTask(mustTaskID(t, task.ID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored == nil || stored.TimeoutSeconds != 120 || stored.FirstRunAt != nil || stored.DeadlineAt != nil {
|
||||
t.Fatalf("persisted timeout revival clock/config mismatch: %+v", stored)
|
||||
}
|
||||
|
||||
// Release the only slot and promote the revived task. The test server uses a
|
||||
// cancelled root context so no real Agent call races these state assertions.
|
||||
s.engine.Pause(holder.ID, agent.AbortPausedByUser)
|
||||
if err := m.ApplyTaskPause(holder.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.reconcileConcurrency()
|
||||
runningState := task.lifecycleSnapshot()
|
||||
if runningState.Queued || runningState.Paused || s.engine.IsPaused(task.ID) || s.engine.isSettling(task.ID) {
|
||||
t.Fatalf("revived task did not leave FIFO ready to execute: state=%+v enginePaused=%v settling=%v",
|
||||
runningState, s.engine.IsPaused(task.ID), s.engine.isSettling(task.ID))
|
||||
}
|
||||
if execCtx := s.engine.execContextFor(context.Background(), task.ID); execCtx.Err() != nil {
|
||||
t.Fatalf("revived task did not receive a live execution context: %v", context.Cause(execCtx))
|
||||
}
|
||||
if _, ok := s.engine.coordStarted.Load(task.ID); !ok {
|
||||
t.Fatal("revived timeout task did not start a fresh deadline coordinator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueuedTaskCanPauseAndResumeAtFIFOTail(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
defer restoreConcurrencySetting(t, m)()
|
||||
if err := m.SetConcurrency(true, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
holder, err := m.CreateTask("slot holder", "hold", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task, err := m.CreateTask("pause queued", "resume later", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = m.DeleteTask(holder.ID, DeleteTaskOptions{})
|
||||
_, _ = m.DeleteTask(task.ID, DeleteTaskOptions{})
|
||||
}()
|
||||
s := newAdmissionTestServer(m, nil)
|
||||
if err := m.SetTaskPaused(task.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.engine.Pause(task.ID, agent.AbortPausedByUser)
|
||||
|
||||
first, err := s.applyTaskControl(task, "resume")
|
||||
if err != nil || !first.Queued || task.QueuedAt == 0 {
|
||||
t.Fatalf("first resume: result=%+v task=%+v err=%v", first, task, err)
|
||||
}
|
||||
firstQueuedAt := task.QueuedAt
|
||||
if _, err := s.applyTaskControl(task, "pause"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !task.Paused || task.Queued || task.QueuedAt != 0 {
|
||||
t.Fatalf("pause did not leave queue: %+v", task)
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
second, err := s.applyTaskControl(task, "resume")
|
||||
if err != nil || !second.Queued {
|
||||
t.Fatalf("second resume: result=%+v task=%+v err=%v", second, task, err)
|
||||
}
|
||||
if task.QueuedAt <= firstQueuedAt {
|
||||
t.Fatalf("resumed task did not move to FIFO tail: first=%d second=%d", firstQueuedAt, task.QueuedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyFIFOIsAdmittedBeforeNewTask(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
defer restoreConcurrencySetting(t, m)()
|
||||
if err := m.SetConcurrency(true, 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
holder, err := m.CreateTask("slot holder", "hold", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
older, err := m.CreateTask("older queued", "first", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
incoming, err := m.CreateTask("new admission", "second", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = m.DeleteTask(holder.ID, DeleteTaskOptions{})
|
||||
_, _ = m.DeleteTask(older.ID, DeleteTaskOptions{})
|
||||
_, _ = m.DeleteTask(incoming.ID, DeleteTaskOptions{})
|
||||
}()
|
||||
s := newAdmissionTestServer(m, nil)
|
||||
if err := m.EnqueueTask(older.ID, "resume"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.engine.Pause(older.ID, agent.AbortPausedByOrchestrator)
|
||||
|
||||
queued, err := s.admitTask(incoming, "resume")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !queued || !incoming.Queued || incoming.QueuedAt <= older.QueuedAt {
|
||||
t.Fatalf("new task bypassed ready FIFO: queued=%v older=%+v incoming=%+v", queued, older, incoming)
|
||||
}
|
||||
s.reconcileConcurrency()
|
||||
if older.Queued || !incoming.Queued {
|
||||
t.Fatalf("FIFO promotion order wrong: older=%+v incoming=%+v", older, incoming)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnavailableTaskReleasesSlotForReadyQueue(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
defer restoreConcurrencySetting(t, m)()
|
||||
if err := m.SetConcurrency(true, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
unavailable, err := m.CreateTask("unavailable llm", "park", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ready, err := m.CreateTask("ready queued", "run", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = m.DeleteTask(unavailable.ID, DeleteTaskOptions{})
|
||||
_, _ = m.DeleteTask(ready.ID, DeleteTaskOptions{})
|
||||
}()
|
||||
s := newAdmissionTestServer(m, func(task *Task) bool { return task.ID != unavailable.ID })
|
||||
s.engine.started.Store(unavailable.ID, true)
|
||||
if err := m.EnqueueTask(ready.ID, "resume"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.engine.Pause(ready.ID, agent.AbortPausedByOrchestrator)
|
||||
|
||||
s.reconcileConcurrency()
|
||||
if !unavailable.Queued || !s.engine.IsPaused(unavailable.ID) {
|
||||
t.Fatalf("unavailable task still occupies slot: %+v", unavailable)
|
||||
}
|
||||
if ready.Queued || s.engine.IsPaused(ready.ID) {
|
||||
t.Fatalf("ready task was not promoted: %+v", ready)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnavailableTaskWaitsForActiveLLMCallBeforeParking(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
defer restoreConcurrencySetting(t, m)()
|
||||
if err := m.SetConcurrency(true, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
active, err := m.CreateTask("active profile edit", "finish current call", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waiting, err := m.CreateTask("waiting task", "respect active call", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = m.DeleteTask(active.ID, DeleteTaskOptions{})
|
||||
_, _ = m.DeleteTask(waiting.ID, DeleteTaskOptions{})
|
||||
}()
|
||||
|
||||
available := map[string]bool{active.ID: true, waiting.ID: true}
|
||||
s := newAdmissionTestServer(m, func(task *Task) bool { return available[task.ID] })
|
||||
s.engine.started.Store(active.ID, true)
|
||||
if err := m.EnqueueTask(waiting.ID, "resume"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.engine.Pause(waiting.ID, agent.AbortPausedByOrchestrator)
|
||||
|
||||
// Model a manual chain edit that makes the task's next-call resolution
|
||||
// unavailable while its old provider is still serving the current call.
|
||||
s.engine.BeginLLMCall(active.ID)
|
||||
available[active.ID] = false
|
||||
s.reconcileConcurrency()
|
||||
if active.Queued || s.engine.IsPaused(active.ID) {
|
||||
t.Fatalf("active LLM call was cancelled/parked after profile edit: %+v", active)
|
||||
}
|
||||
if !waiting.Queued {
|
||||
t.Fatalf("waiting task bypassed active provider call: %+v", waiting)
|
||||
}
|
||||
|
||||
s.engine.EndLLMCall(active.ID)
|
||||
s.reconcileConcurrency()
|
||||
if !active.Queued || !s.engine.IsPaused(active.ID) {
|
||||
t.Fatalf("unavailable task was not parked after its call ended: %+v", active)
|
||||
}
|
||||
if waiting.Queued || s.engine.IsPaused(waiting.ID) {
|
||||
t.Fatalf("ready FIFO task was not admitted after active call ended: %+v", waiting)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRerunAdmissionFailureRestoresIntentState(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
task, err := m.CreateTask("failed rerun admission", "restore intent", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
taskID, _ := strconv.ParseInt(task.ID, 10, 64)
|
||||
defer func() {
|
||||
_, _ = m.pg.Exec(`UPDATE tasks SET deleted_at=NULL WHERE id=$1`, taskID)
|
||||
_, _ = m.DeleteTask(task.ID, DeleteTaskOptions{})
|
||||
}()
|
||||
intentID, err := task.Store.AddIntent(map[string]any{"summary": "retry"}, 1, nil, "planner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := task.Store.SetIntentBlockedReason(intentID, "network_unavailable"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.SetTaskStatus(task.ID, "done"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.pg.Exec(`UPDATE tasks SET deleted_at=now() WHERE id=$1`, taskID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := newAdmissionTestServer(m, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/tasks/1/intents/1/rerun", nil)
|
||||
req.SetPathValue("id", task.ID)
|
||||
req.SetPathValue("iid", fmt.Sprintf("%d", intentID))
|
||||
rec := httptest.NewRecorder()
|
||||
s.rerunIntent(rec, req)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
node, err := task.Store.GetNode(intentID)
|
||||
if err != nil || node == nil || node.State != "blocked" || node.BlockedReason != "network_unavailable" {
|
||||
t.Fatalf("rerun rollback: node=%+v err=%v", node, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskLLMResolutionRejectsInvalidExplicitProfile(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
profileID, err := m.pg.SaveProfile(&db.LLMProfile{
|
||||
Name: fmt.Sprintf("invalid-resolution-%d", time.Now().UnixNano()),
|
||||
Format: "openai",
|
||||
Model: "invalid-model",
|
||||
APIKey: "test-key",
|
||||
Proxy: "invalid-proxy-without-scheme",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task, err := m.CreateTaskWithOptions("invalid resolution", "report unavailable", db.TaskCreateOptions{
|
||||
LLMProfileIDs: []int64{profileID},
|
||||
})
|
||||
if err != nil {
|
||||
_ = m.pg.DeleteProfile(profileID)
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = m.DeleteTask(task.ID, DeleteTaskOptions{})
|
||||
_ = m.pg.DeleteProfile(profileID)
|
||||
}()
|
||||
|
||||
s := &Server{m: m, provByProfile: map[int64]*provEntry{}}
|
||||
got, err := s.resolveTaskRoleLLM(task, "worker")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Available || got.Source != "task_chain" || got.ProfileID == nil || *got.ProfileID != profileID {
|
||||
t.Fatalf("resolution=%+v, want unavailable explicit task profile", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An Agent binding outranks the task's LLM chain; roles left unbound keep using
|
||||
// the chain, so binding one role does not move the others off it.
|
||||
func TestTaskLLMResolutionPrefersAgentBindingOverTaskChain(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
stamp := time.Now().UnixNano()
|
||||
boundID, err := m.pg.SaveProfile(&db.LLMProfile{
|
||||
Name: fmt.Sprintf("bound-resolution-%d", stamp), Format: "openai",
|
||||
Model: "bound-model", APIKey: "test-key",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chainID, err := m.pg.SaveProfile(&db.LLMProfile{
|
||||
Name: fmt.Sprintf("chain-resolution-%d", stamp), Format: "openai",
|
||||
Model: "chain-model", APIKey: "test-key",
|
||||
})
|
||||
if err != nil {
|
||||
_ = m.pg.DeleteProfile(boundID)
|
||||
t.Fatal(err)
|
||||
}
|
||||
task, err := m.CreateTaskWithOptions("binding precedence", "prefer the bound profile", db.TaskCreateOptions{
|
||||
LLMProfileIDs: []int64{chainID},
|
||||
})
|
||||
if err != nil {
|
||||
_ = m.pg.DeleteProfile(boundID)
|
||||
_ = m.pg.DeleteProfile(chainID)
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = m.DeleteTask(task.ID, DeleteTaskOptions{})
|
||||
_ = m.pg.SetAgentLLMProfile("worker", nil)
|
||||
_ = m.pg.DeleteProfile(boundID)
|
||||
_ = m.pg.DeleteProfile(chainID)
|
||||
}()
|
||||
|
||||
s := &Server{m: m, provByProfile: map[int64]*provEntry{}}
|
||||
got, err := s.resolveTaskRoleLLM(task, "worker")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Source != "task_chain" || got.Model != "chain-model" {
|
||||
t.Fatalf("resolution=%+v, want the task chain while no binding exists", got)
|
||||
}
|
||||
|
||||
if err := m.pg.SetAgentLLMProfile("worker", &boundID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = s.resolveTaskRoleLLM(task, "worker")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Available || got.Source != "agent_binding" || got.Model != "bound-model" {
|
||||
t.Fatalf("resolution=%+v, want the agent binding to outrank the task chain", got)
|
||||
}
|
||||
|
||||
got, err = s.resolveTaskRoleLLM(task, "planner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Source != "task_chain" || got.Model != "chain-model" {
|
||||
t.Fatalf("resolution=%+v, want an unbound role to keep using the task chain", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskLLMResolutionReportsDatabaseFailure(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
task, err := m.CreateTask("resolution storage failure", "return server error", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.pg.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := &Server{m: m}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/tasks/1/llm/resolution", nil)
|
||||
req.SetPathValue("id", task.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
s.taskLLMResolutionHandler(rec, req)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenStatsWithoutTaskReturnsStableShape(t *testing.T) {
|
||||
s := &Server{m: &Manager{tasks: map[string]*Task{}}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/exploration/tokens?task=missing", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
s.tokenStats(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Workers []db.TokenUsage `json:"workers"`
|
||||
Sessions []db.SessionTokenUsage `json:"sessions"`
|
||||
Total TokenTotalDTO `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.Workers == nil || payload.Sessions == nil {
|
||||
t.Fatalf("empty collections must serialize as arrays: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchControlLimitCountsDeduplicatedIDs(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
s := newAdmissionTestServer(m, nil)
|
||||
|
||||
request := func(handler http.HandlerFunc, path string, pathValues map[string]string, payload any) *httptest.ResponseRecorder {
|
||||
body, marshalErr := json.Marshal(payload)
|
||||
if marshalErr != nil {
|
||||
t.Fatal(marshalErr)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
|
||||
for key, value := range pathValues {
|
||||
req.SetPathValue(key, value)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handler(rec, req)
|
||||
return rec
|
||||
}
|
||||
rec := request(s.controlTasksBatch, "/api/tasks/control/batch", nil, map[string]any{
|
||||
"task_ids": []string{" 0009223372036854775807 ", "9223372036854775807", " bad-id ", "bad-id"},
|
||||
"action": "pause",
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("normalized task ids status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var normalizedPayload struct {
|
||||
Items []batchControlItem `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &normalizedPayload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(normalizedPayload.Items) != 2 || normalizedPayload.Items[0].ID != "9223372036854775807" ||
|
||||
normalizedPayload.Items[1].ID != "bad-id" || normalizedPayload.Items[1].Error != "bad task id" {
|
||||
t.Fatalf("normalized task response=%+v", normalizedPayload.Items)
|
||||
}
|
||||
|
||||
repeatedTasks := make([]string, maxBatchControlIDs+1)
|
||||
for i := range repeatedTasks {
|
||||
repeatedTasks[i] = "missing"
|
||||
}
|
||||
rec = request(s.controlTasksBatch, "/api/tasks/control/batch", nil, map[string]any{
|
||||
"task_ids": repeatedTasks,
|
||||
"action": "pause",
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("deduplicated task ids status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var taskPayload struct {
|
||||
Items []batchControlItem `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &taskPayload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(taskPayload.Items) != 1 || taskPayload.Items[0].ID != "missing" {
|
||||
t.Fatalf("deduplicated task response=%+v", taskPayload.Items)
|
||||
}
|
||||
|
||||
uniqueTasks := make([]string, maxBatchControlIDs+1)
|
||||
for i := range uniqueTasks {
|
||||
uniqueTasks[i] = fmt.Sprintf("missing-%d", i)
|
||||
}
|
||||
rec = request(s.controlTasksBatch, "/api/tasks/control/batch", nil, map[string]any{
|
||||
"task_ids": uniqueTasks,
|
||||
"action": "pause",
|
||||
})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unique task ids status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestPauseTaskToolPersistsAndDequeuesTask(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
task, err := m.CreateTask("orchestrated pause", "persist queued pause", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _, _ = m.DeleteTask(task.ID, DeleteTaskOptions{}) }()
|
||||
if err := m.EnqueueTask(task.ID, "bootstrap"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := newAdmissionTestServer(m, nil)
|
||||
s.engine.Pause(task.ID, agent.Causef("queued_for_admission", "queued", "test queue barrier"))
|
||||
input, err := json.Marshal(map[string]string{"task_id": task.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.toolPauseTask().Call(context.Background(), input, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !task.Paused || task.Queued || task.QueueMode != "bootstrap" || !s.engine.IsPaused(task.ID) {
|
||||
t.Fatalf("tool pause did not persist/dequeue task: %+v enginePaused=%v", task, s.engine.IsPaused(task.ID))
|
||||
}
|
||||
stored, err := m.pg.GetTask(mustTaskID(t, task.ID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored == nil || !stored.Paused || stored.Queued {
|
||||
t.Fatalf("stored task=%+v, want paused and dequeued", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPauseTaskToolUsesOrchestratorCancellationCause(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
task, err := m.CreateTask("orchestrated pause cause", "keep audit reason", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _, _ = m.DeleteTask(task.ID, DeleteTaskOptions{}) }()
|
||||
|
||||
s := newAdmissionTestServer(m, nil)
|
||||
execCtx := s.engine.execContextFor(context.Background(), task.ID)
|
||||
input, err := json.Marshal(map[string]string{"task_id": task.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.toolPauseTask().Call(context.Background(), input, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code, _, _, ok := agent.AbortReason(execCtx); !ok || code != "paused_by_orchestrator" {
|
||||
t.Fatalf("pause_task cancellation code=%q ok=%v, want paused_by_orchestrator", code, ok)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user