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,364 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
)
|
||||
|
||||
func TestEngineDeleteBarrierRejectsNewTaskOperations(t *testing.T) {
|
||||
e := NewEngine(nil)
|
||||
const taskID = "42"
|
||||
|
||||
if !e.beginTaskOperation(taskID) {
|
||||
t.Fatal("first task operation should be admitted")
|
||||
}
|
||||
if !e.BeginDelete(taskID) {
|
||||
t.Fatal("first delete should install the barrier")
|
||||
}
|
||||
if !e.IsDeleting(taskID) {
|
||||
t.Fatal("delete barrier should be visible")
|
||||
}
|
||||
if !e.IsPaused(taskID) {
|
||||
t.Fatal("delete should temporarily pause task execution")
|
||||
}
|
||||
if e.beginTaskOperation(taskID) {
|
||||
t.Fatal("operation started after BeginDelete returned")
|
||||
}
|
||||
if got := e.inflightCount(taskID); got != 1 {
|
||||
t.Fatalf("existing operation must remain drainable, inflight=%d", got)
|
||||
}
|
||||
e.decInflight(taskID)
|
||||
|
||||
if e.BeginDelete(taskID) {
|
||||
t.Fatal("duplicate delete must not acquire the barrier")
|
||||
}
|
||||
e.AbortDelete(taskID, false)
|
||||
if e.IsPaused(taskID) {
|
||||
t.Fatal("aborted delete should restore a previously running task")
|
||||
}
|
||||
if !e.beginTaskOperation(taskID) {
|
||||
t.Fatal("aborting deletion should reopen operation admission")
|
||||
}
|
||||
e.decInflight(taskID)
|
||||
}
|
||||
|
||||
func TestAbortDeletePreservesExistingPause(t *testing.T) {
|
||||
e := NewEngine(nil)
|
||||
e.Pause("42", agent.AbortPausedByUser)
|
||||
if !e.BeginDelete("42") {
|
||||
t.Fatal("delete barrier should be installed")
|
||||
}
|
||||
e.AbortDelete("42", true)
|
||||
if !e.IsPaused("42") {
|
||||
t.Fatal("aborted delete must preserve a pre-existing user pause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeginTaskDeleteSerializesWithLifecycleLock(t *testing.T) {
|
||||
s := &Server{engine: NewEngine(nil)}
|
||||
s.concMu.Lock()
|
||||
done := make(chan bool, 1)
|
||||
go func() { done <- s.beginTaskDelete("42") }()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("delete barrier bypassed the lifecycle lock")
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
if s.engine.IsDeleting("42") {
|
||||
t.Fatal("delete barrier was installed before concMu was released")
|
||||
}
|
||||
s.concMu.Unlock()
|
||||
select {
|
||||
case ok := <-done:
|
||||
if !ok {
|
||||
t.Fatal("delete barrier was not installed after lifecycle lock release")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for delete barrier")
|
||||
}
|
||||
s.engine.AbortDelete("42", false)
|
||||
}
|
||||
|
||||
func TestDeletingRunningTaskKeepsConcurrencySlotUntilCommit(t *testing.T) {
|
||||
task := &Task{ID: "42", Status: "running"}
|
||||
s := &Server{engine: NewEngine(nil)}
|
||||
if !s.engine.BeginDelete(task.ID) {
|
||||
t.Fatal("install delete barrier")
|
||||
}
|
||||
if !s.occupiesConcurrencySlot(task) {
|
||||
t.Fatal("temporary delete pause released the running task's slot before commit")
|
||||
}
|
||||
s.engine.AbortDelete(task.ID, false)
|
||||
}
|
||||
|
||||
func TestTaskLifecycleRechecksDeleteBarrierAfterConcLock(t *testing.T) {
|
||||
task := &Task{ID: "42", notify: make(chan struct{}, 1)}
|
||||
m := &Manager{tasks: map[string]*Task{task.ID: task}}
|
||||
s := &Server{m: m, engine: NewEngine(m)}
|
||||
|
||||
s.concMu.Lock()
|
||||
started := make(chan struct{})
|
||||
pauseDone := make(chan error, 1)
|
||||
go func() {
|
||||
close(started)
|
||||
_, err := s.applyTaskControl(task, "pause")
|
||||
pauseDone <- err
|
||||
}()
|
||||
<-started
|
||||
if !s.engine.BeginDelete(task.ID) {
|
||||
t.Fatal("install delete barrier")
|
||||
}
|
||||
s.concMu.Unlock()
|
||||
if err := <-pauseDone; err == nil || !strings.Contains(err.Error(), errTaskCtrlDeleting) {
|
||||
t.Fatalf("pause passed a delete barrier installed while waiting for concMu: %v", err)
|
||||
}
|
||||
s.engine.AbortDelete(task.ID, false)
|
||||
|
||||
s.concMu.Lock()
|
||||
started = make(chan struct{})
|
||||
admitDone := make(chan error, 1)
|
||||
go func() {
|
||||
close(started)
|
||||
_, err := s.admitTask(task, "resume")
|
||||
admitDone <- err
|
||||
}()
|
||||
<-started
|
||||
if !s.engine.BeginDelete(task.ID) {
|
||||
t.Fatal("install second delete barrier")
|
||||
}
|
||||
s.concMu.Unlock()
|
||||
if err := <-admitDone; err == nil || !strings.Contains(err.Error(), "being deleted") {
|
||||
t.Fatalf("admission passed a delete barrier installed while waiting for concMu: %v", err)
|
||||
}
|
||||
s.engine.AbortDelete(task.ID, false)
|
||||
}
|
||||
|
||||
func TestAbortTaskDeleteUsesPersistedPauseAndQueueState(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("delete abort state", "restore persisted barrier", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _, _ = m.DeleteTask(task.ID, DeleteTaskOptions{}) }()
|
||||
s := &Server{m: m, engine: NewEngine(m)}
|
||||
|
||||
// An engine-only pause is not authoritative. The committed task is running,
|
||||
// so aborting deletion must clear this stale in-memory barrier.
|
||||
s.engine.Pause(task.ID, agent.AbortPausedByUser)
|
||||
if !s.beginTaskDelete(task.ID) {
|
||||
t.Fatal("install first delete barrier")
|
||||
}
|
||||
s.abortTaskDelete(task.ID)
|
||||
if s.engine.IsPaused(task.ID) {
|
||||
t.Fatal("stale engine pause survived although the persisted task is running")
|
||||
}
|
||||
|
||||
// A queued row must retain its execution barrier even when BeginDelete saw the
|
||||
// engine as running. This specifically guards against restoring a stale snapshot.
|
||||
if err := m.EnqueueTask(task.ID, "resume"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.beginTaskDelete(task.ID) {
|
||||
t.Fatal("install second delete barrier")
|
||||
}
|
||||
s.abortTaskDelete(task.ID)
|
||||
if !s.engine.IsPaused(task.ID) {
|
||||
t.Fatal("persisted queued task lost its execution barrier after abort")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopTaskCancelsRuntimeAndClearsLifecycleState(t *testing.T) {
|
||||
e := NewEngine(nil)
|
||||
const taskID = "42"
|
||||
e.deleteMu.RLock()
|
||||
rt := e.registerTaskRoutines(context.Background(), taskID, 1)
|
||||
e.started.Store(taskID, true)
|
||||
e.lastAct.Store(taskID, int64(1))
|
||||
e.paused.Store(taskID, true)
|
||||
e.plannerRound.Store(taskID, 3)
|
||||
e.coordStarted.Store(taskID, true)
|
||||
e.deleteMu.RUnlock()
|
||||
|
||||
done := make(chan struct{})
|
||||
runTaskRoutine(rt, func(ctx context.Context) {
|
||||
<-ctx.Done()
|
||||
close(done)
|
||||
})
|
||||
e.StopTask(taskID)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
default:
|
||||
t.Fatal("task runtime was not cancelled before StopTask returned")
|
||||
}
|
||||
if e.Started(taskID) || e.IsPaused(taskID) || e.IsDeleting(taskID) {
|
||||
t.Fatalf("task lifecycle maps were not cleared: started=%v paused=%v deleting=%v",
|
||||
e.Started(taskID), e.IsPaused(taskID), e.IsDeleting(taskID))
|
||||
}
|
||||
if _, ok := e.lastAct.Load(taskID); ok {
|
||||
t.Fatal("last activity state was retained")
|
||||
}
|
||||
if _, ok := e.plannerRound.Load(taskID); ok {
|
||||
t.Fatal("planner round state was retained")
|
||||
}
|
||||
if _, ok := e.coordStarted.Load(taskID); ok {
|
||||
t.Fatal("deadline coordinator state was retained")
|
||||
}
|
||||
e.runtimeMu.Lock()
|
||||
_, retained := e.runtimes[taskID]
|
||||
e.runtimeMu.Unlock()
|
||||
if retained {
|
||||
t.Fatal("task runtime retained after StopTask")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalTaskID(t *testing.T) {
|
||||
for raw, want := range map[string]string{"42": "42", "00042": "42", "+42": "42"} {
|
||||
if got, ok := canonicalTaskID(raw); !ok || got != want {
|
||||
t.Fatalf("canonicalTaskID(%q)=(%q,%v), want (%q,true)", raw, got, ok, want)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"", "0", "-1", "abc", "42x"} {
|
||||
if got, ok := canonicalTaskID(raw); ok {
|
||||
t.Fatalf("canonicalTaskID(%q)=(%q,true), want invalid", raw, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelChatThenWaitTaskQuiescent(t *testing.T) {
|
||||
e := NewEngine(nil)
|
||||
chatCtx, chatCancel := context.WithCancelCause(context.Background())
|
||||
s := &Server{
|
||||
engine: e,
|
||||
chatBusy: map[string]bool{"7": true},
|
||||
chatCancel: map[string]context.CancelCauseFunc{"7": chatCancel},
|
||||
}
|
||||
if !e.beginTaskOperation("7") {
|
||||
t.Fatal("task operation should be admitted")
|
||||
}
|
||||
|
||||
cancelObserved := make(chan struct{})
|
||||
releaseChat := make(chan struct{})
|
||||
go func() {
|
||||
<-chatCtx.Done()
|
||||
close(cancelObserved)
|
||||
<-releaseChat
|
||||
s.finishTaskChat("7", chatCancel)
|
||||
}()
|
||||
if !s.cancelTaskChat("7", agent.AbortTaskDeleted) {
|
||||
t.Fatal("expected active chat to be cancelled")
|
||||
}
|
||||
<-cancelObserved
|
||||
|
||||
waitDone := make(chan error, 1)
|
||||
waitCtx, waitCancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer waitCancel()
|
||||
go func() { waitDone <- s.waitTaskQuiescent(waitCtx, "7") }()
|
||||
|
||||
select {
|
||||
case err := <-waitDone:
|
||||
t.Fatalf("wait returned while chat and engine operation were active: %v", err)
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
close(releaseChat)
|
||||
select {
|
||||
case err := <-waitDone:
|
||||
t.Fatalf("wait returned while engine operation was active: %v", err)
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
e.decInflight("7")
|
||||
if err := <-waitDone; err != nil {
|
||||
t.Fatalf("wait for quiescence: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitTaskQuiescentHonorsContext(t *testing.T) {
|
||||
s := &Server{
|
||||
engine: NewEngine(nil),
|
||||
chatBusy: map[string]bool{"9": true},
|
||||
chatCancel: map[string]context.CancelCauseFunc{},
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := s.waitTaskQuiescent(ctx, "9"); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("expected deadline exceeded, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskChatStatusUsesAuthoritativeBusyState(t *testing.T) {
|
||||
m := &Manager{tasks: map[string]*Task{"7": {ID: "7"}}}
|
||||
s := &Server{
|
||||
m: m,
|
||||
chatBusy: map[string]bool{"7": true},
|
||||
chatCancel: map[string]context.CancelCauseFunc{},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/tasks/7/chat/status", nil)
|
||||
req.SetPathValue("id", "7")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
s.taskChatStatus(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s, want 200", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got struct {
|
||||
Running bool `json:"running"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Running {
|
||||
t.Fatal("busy main-agent turn was reported idle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskChatUploadRejectsDeleteBarrierBeforeCreatingDirectory(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
m := &Manager{dir: dataDir, tasks: map[string]*Task{"7": {ID: "7"}}}
|
||||
s := &Server{m: m, engine: NewEngine(m)}
|
||||
if !s.engine.BeginDelete("7") {
|
||||
t.Fatal("delete barrier should be installed")
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/chat/upload?scope=task&id=7", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
s.chatUpload(rec, req)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("expected 409 while deleting, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dataDir, "tasks", "7")); !os.IsNotExist(err) {
|
||||
t.Fatalf("upload must not recreate task directory, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommittedTaskDeleteReturnsSuccessWithCleanupWarning(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
result := DeleteTaskResult{Deleted: "7", FilesDeleted: true}
|
||||
err := &taskDeleteCommittedError{err: errors.New("remove staged task files: permission denied")}
|
||||
|
||||
writeCommittedTaskDelete(rec, result, err)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s, want 200", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got DeleteTaskResult
|
||||
if decodeErr := json.NewDecoder(rec.Body).Decode(&got); decodeErr != nil {
|
||||
t.Fatal(decodeErr)
|
||||
}
|
||||
if got.Deleted != "7" || !got.FilesDeleted || !strings.Contains(got.CleanupWarning, "cleanup incomplete") {
|
||||
t.Fatalf("unexpected response: %+v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user