diff --git a/server/chat_llm_resolve_test.go b/server/chat_llm_resolve_test.go index 35431ce..b4851b8 100644 --- a/server/chat_llm_resolve_test.go +++ b/server/chat_llm_resolve_test.go @@ -18,7 +18,9 @@ func TestChatUnavailableReasonDistinguishesStates(t *testing.T) { } defer m.Close() td := t.TempDir() - s := New(context.Background(), m, td, td, td) + ctx, cancel := context.WithCancel(context.Background()) + s := New(ctx, m, td, td, td) + defer stopTestServer(t, s, cancel) // Start from a clean profile table; other tests in the shared DB may have left rows. existing, _ := m.pg.ListProfiles() @@ -68,7 +70,9 @@ func TestResolveChatAgentHonoursConversationProfile(t *testing.T) { } defer m.Close() td := t.TempDir() - s := New(context.Background(), m, td, td, td) + ctx, cancel := context.WithCancel(context.Background()) + s := New(ctx, m, td, td, td) + defer stopTestServer(t, s, cancel) // Force the global fallback to nil so a non-nil result can ONLY come from the // conversation's own profile — this is exactly the situation the user hit diff --git a/server/core_test.go b/server/core_test.go index a8cb1b5..0fc0da8 100644 --- a/server/core_test.go +++ b/server/core_test.go @@ -24,7 +24,9 @@ func TestCoreTaskLifecyclePG(t *testing.T) { } defer m.Close() td := t.TempDir() - s := New(context.Background(), m, td, td, td) + ctx, cancel := context.WithCancel(context.Background()) + s := New(ctx, m, td, td, td) + defer stopTestServer(t, s, cancel) h := s.Handler() tok, err := signJWT(s.jwtKey) if err != nil { diff --git a/server/inheritance_api_test.go b/server/inheritance_api_test.go index 5c4cc5d..627d6f7 100644 --- a/server/inheritance_api_test.go +++ b/server/inheritance_api_test.go @@ -67,7 +67,9 @@ func TestInheritedActivityDetailAndRelationDeletion(t *testing.T) { t.Fatal(err) } - s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir()) + ctx, cancel := context.WithCancel(context.Background()) + s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir()) + defer stopTestServer(t, s, cancel) token, err := signJWT(s.jwtKey) if err != nil { t.Fatal(err) diff --git a/server/lessons_test.go b/server/lessons_test.go index c56f418..8da1f68 100644 --- a/server/lessons_test.go +++ b/server/lessons_test.go @@ -50,7 +50,9 @@ SELECT id,'completed','fixed','{}'::jsonb FROM findings WHERE task_id=$1 AND nam t.Fatal(err) } - s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir()) + ctx, cancel := context.WithCancel(context.Background()) + s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir()) + defer stopTestServer(t, s, cancel) token, err := signJWT(s.jwtKey) if err != nil { t.Fatal(err) @@ -144,7 +146,9 @@ VALUES ($1,'xss','Reflected XSS','reflected in search','high','confirmed')`, tas t.Fatal(err) } - s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir()) + ctx, cancel := context.WithCancel(context.Background()) + s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir()) + defer stopTestServer(t, s, cancel) token, err := signJWT(s.jwtKey) if err != nil { t.Fatal(err) diff --git a/server/mgmt_test.go b/server/mgmt_test.go index 76c3428..ea50f47 100644 --- a/server/mgmt_test.go +++ b/server/mgmt_test.go @@ -23,7 +23,9 @@ func TestMgmtAPI(t *testing.T) { // and archive jobs. defer m.Close() td := t.TempDir() - s := New(context.Background(), m, td, td, td) + ctx, cancel := context.WithCancel(context.Background()) + s := New(ctx, m, td, td, td) + defer stopTestServer(t, s, cancel) h := s.Handler() tok, err := signJWT(s.jwtKey) if err != nil { diff --git a/server/skill_gaps_test.go b/server/skill_gaps_test.go index bd84efe..846632e 100644 --- a/server/skill_gaps_test.go +++ b/server/skill_gaps_test.go @@ -33,7 +33,9 @@ INSERT INTO skill_usage(skill,agent_key,task_id,found) VALUES t.Fatal(err) } - s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir()) + ctx, cancel := context.WithCancel(context.Background()) + s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir()) + defer stopTestServer(t, s, cancel) token, err := signJWT(s.jwtKey) if err != nil { t.Fatal(err) diff --git a/server/task_categories_test.go b/server/task_categories_test.go index 9e96fcc..29deb4e 100644 --- a/server/task_categories_test.go +++ b/server/task_categories_test.go @@ -21,7 +21,9 @@ func TestTaskCategoryBatchRoute(t *testing.T) { defer m.Close() td := t.TempDir() - s := New(context.Background(), m, td, td, td) + ctx, cancel := context.WithCancel(context.Background()) + s := New(ctx, m, td, td, td) + defer stopTestServer(t, s, cancel) h := s.Handler() token, err := signJWT(s.jwtKey) if err != nil { diff --git a/server/task_control_routes_test.go b/server/task_control_routes_test.go index 26e2ad6..33cdae1 100644 --- a/server/task_control_routes_test.go +++ b/server/task_control_routes_test.go @@ -16,7 +16,9 @@ func TestWorkerControlRoutes(t *testing.T) { defer m.Close() td := t.TempDir() - s := New(context.Background(), m, td, td, td) + ctx, cancel := context.WithCancel(context.Background()) + s := New(ctx, m, td, td, td) + defer stopTestServer(t, s, cancel) h := s.Handler() token, err := signJWT(s.jwtKey) if err != nil { diff --git a/server/task_detail_metrics_test.go b/server/task_detail_metrics_test.go index ded6e02..933943d 100644 --- a/server/task_detail_metrics_test.go +++ b/server/task_detail_metrics_test.go @@ -7,7 +7,6 @@ import ( "net/http/httptest" "strconv" "testing" - "time" ) func TestTaskDetailReturnsListMetrics(t *testing.T) { @@ -41,23 +40,7 @@ VALUES ($1,'goal','{}','met'), ($1,'goal','{}','open')`, task.ExpID); err != nil ctx, cancel := context.WithCancel(context.Background()) s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir()) - // New restores every unfinished task in the shared database and runs its - // planner and worker loops, which write transcripts under m.dir. Stop and - // join them before m.Close and the TempDir cleanup run. - defer func() { - cancel() - for _, lt := range s.m.List() { - s.engine.BeginDelete(lt.ID) - waitCtx, waitCancel := context.WithTimeout(context.Background(), 30*time.Second) - if err := s.waitTaskQuiescent(waitCtx, lt.ID); err != nil { - t.Errorf("task %s still running at teardown: %v", lt.ID, err) - } - waitCancel() - s.engine.StopTask(lt.ID) - } - s.archiveWG.Wait() - <-s.side.done - }() + defer stopTestServer(t, s, cancel) token, err := signJWT(s.jwtKey) if err != nil { t.Fatal(err) diff --git a/server/task_metadata_test.go b/server/task_metadata_test.go index 1082f03..b36a59a 100644 --- a/server/task_metadata_test.go +++ b/server/task_metadata_test.go @@ -24,7 +24,9 @@ func TestTaskMetadataPatchReturnsRenameAndPin(t *testing.T) { } taskID, _ := strconv.ParseInt(task.ID, 10, 64) defer func() { _ = m.pg.DeleteTask(taskID) }() - s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir()) + ctx, cancel := context.WithCancel(context.Background()) + s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir()) + defer stopTestServer(t, s, cancel) token, err := signJWT(s.jwtKey) if err != nil { t.Fatal(err) @@ -66,7 +68,9 @@ func TestConversationBatchDeleteReportsMissing(t *testing.T) { t.Skipf("postgres unavailable (%v) — skipping", err) } defer m.Close() - s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir()) + ctx, cancel := context.WithCancel(context.Background()) + s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir()) + defer stopTestServer(t, s, cancel) first, err := m.pg.CreateConversation("mainagent", "batch-http-first", nil) if err != nil { t.Fatal(err) diff --git a/server/task_outcome_test.go b/server/task_outcome_test.go index 833294b..c321560 100644 --- a/server/task_outcome_test.go +++ b/server/task_outcome_test.go @@ -60,7 +60,9 @@ INSERT INTO skill_usage(skill,task_id,found) VALUES ('api-recon',$1,true),('grap t.Fatal(err) } - s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir()) + ctx, cancel := context.WithCancel(context.Background()) + s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir()) + defer stopTestServer(t, s, cancel) token, err := signJWT(s.jwtKey) if err != nil { t.Fatal(err) diff --git a/server/task_templates_test.go b/server/task_templates_test.go index 529519f..82a18cd 100644 --- a/server/task_templates_test.go +++ b/server/task_templates_test.go @@ -20,7 +20,9 @@ func TestTaskTemplateHTTPCRUD(t *testing.T) { t.Skipf("postgres unavailable (%v) — skipping", err) } defer m.Close() - s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir()) + ctx, cancel := context.WithCancel(context.Background()) + s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir()) + defer stopTestServer(t, s, cancel) h := s.Handler() token, err := signJWT(s.jwtKey) if err != nil { @@ -90,7 +92,9 @@ func TestConversationPatchReturnsPinState(t *testing.T) { t.Skipf("postgres unavailable (%v) — skipping", err) } defer m.Close() - s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir()) + ctx, cancel := context.WithCancel(context.Background()) + s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir()) + defer stopTestServer(t, s, cancel) conversation, err := m.pg.CreateConversation("mainagent", "pin through http", nil) if err != nil { t.Fatal(err) diff --git a/server/testserver_test.go b/server/testserver_test.go new file mode 100644 index 0000000..4ed032f --- /dev/null +++ b/server/testserver_test.go @@ -0,0 +1,27 @@ +package server + +import ( + "context" + "testing" + "time" +) + +// stopTestServer cancels a test server's context and joins what New started. +// New restores every unfinished task in the shared database and runs its +// planner, worker and deadline loops, which write under m.dir. Call it with +// defer right after New, so it runs before m.Close and the TempDir cleanups. +func stopTestServer(t *testing.T, s *Server, cancel context.CancelFunc) { + t.Helper() + cancel() + for _, task := range s.m.List() { + s.engine.BeginDelete(task.ID) + waitCtx, waitCancel := context.WithTimeout(context.Background(), 30*time.Second) + if err := s.waitTaskQuiescent(waitCtx, task.ID); err != nil { + t.Errorf("task %s still running at teardown: %v", task.ID, err) + } + waitCancel() + s.engine.StopTask(task.ID) + } + s.archiveWG.Wait() + <-s.side.done +}