bffbc8e96f
* ci: add harness file linter * ci: address harness linter review feedback * ci: cover codex harness config linting * ci: tighten harness lint opt-ins * ci: expand harness lint coverage * ci: address harness linter review feedback * ci: fix harness linter review followups * ci: lint claude settings harness config * ci: tighten harness linter coverage * ci: fail malformed harness toml --------- Co-authored-by: root <root@kandev.ts.net>
269 lines
9.5 KiB
Go
269 lines
9.5 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/kandev/kandev/internal/agentruntime"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
"github.com/kandev/kandev/internal/task/repository"
|
|
)
|
|
|
|
// --- isCleanableSessionState ---
|
|
|
|
func TestIsCleanableSessionState(t *testing.T) {
|
|
cleanable := []models.TaskSessionState{
|
|
models.TaskSessionStateCancelled,
|
|
models.TaskSessionStateCompleted,
|
|
models.TaskSessionStateFailed,
|
|
models.TaskSessionStateIdle,
|
|
}
|
|
for _, s := range cleanable {
|
|
if !isCleanableSessionState(s) {
|
|
t.Errorf("expected %q to be cleanable", s)
|
|
}
|
|
}
|
|
|
|
nonCleanable := []models.TaskSessionState{
|
|
models.TaskSessionStateRunning,
|
|
models.TaskSessionStateCreated,
|
|
models.TaskSessionStateStarting,
|
|
}
|
|
for _, s := range nonCleanable {
|
|
if isCleanableSessionState(s) {
|
|
t.Errorf("expected %q to NOT be cleanable", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestIsCleanableSessionState_IdleIncluded guards against a future change that
|
|
// accidentally excludes IDLE (the orchestrator's same-named function does NOT
|
|
// include IDLE, but in the cleanup path IDLE sessions have no live execution).
|
|
func TestIsCleanableSessionState_IdleIncluded(t *testing.T) {
|
|
if !isCleanableSessionState(models.TaskSessionStateIdle) {
|
|
t.Error("IDLE must be cleanable: an idle session has no live execution and StopSession will return ErrExecutionNotFound")
|
|
}
|
|
}
|
|
|
|
// --- buildStopTargets ---
|
|
|
|
// stubExecutors is a minimal repository.ExecutorRepository implementation for tests.
|
|
type stubExecutors struct {
|
|
repository.ExecutorRepository
|
|
runningByTaskID []*models.ExecutorRunning
|
|
runningByTaskErr error
|
|
runningBySession *models.ExecutorRunning
|
|
runningBySessErr error
|
|
}
|
|
|
|
func (s *stubExecutors) ListExecutorsRunningByTaskID(_ context.Context, _ string) ([]*models.ExecutorRunning, error) {
|
|
return s.runningByTaskID, s.runningByTaskErr
|
|
}
|
|
|
|
func (s *stubExecutors) GetExecutorRunningBySessionID(_ context.Context, _ string) (*models.ExecutorRunning, error) {
|
|
return s.runningBySession, s.runningBySessErr
|
|
}
|
|
|
|
func (s *stubExecutors) DeleteExecutorRunningBySessionID(_ context.Context, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *stubExecutors) HasExecutorRunningRow(_ context.Context, _ string) (bool, error) {
|
|
return s.runningBySession != nil, nil
|
|
}
|
|
|
|
func TestBuildStopTargets_TerminalExecutorRow(t *testing.T) {
|
|
svc, _, _ := createTestService(t)
|
|
svc.executors = &stubExecutors{
|
|
runningByTaskID: []*models.ExecutorRunning{
|
|
{SessionID: "sess-1", AgentExecutionID: "exec-1"},
|
|
},
|
|
}
|
|
|
|
// Session is CANCELLED — stop target must be marked terminal.
|
|
sessions := []*models.TaskSession{
|
|
{ID: "sess-1", State: models.TaskSessionStateCancelled, AgentExecutionID: "exec-1"},
|
|
}
|
|
|
|
targets, err := svc.buildStopTargets(context.Background(), "task-1", sessions)
|
|
if err != nil {
|
|
t.Fatalf("buildStopTargets error: %v", err)
|
|
}
|
|
if len(targets) != 1 {
|
|
t.Fatalf("expected 1 target, got %d", len(targets))
|
|
}
|
|
if !targets[0].terminal {
|
|
t.Error("expected target to be marked terminal for a CANCELLED session")
|
|
}
|
|
}
|
|
|
|
func TestBuildStopTargets_NonTerminalExecutorRow(t *testing.T) {
|
|
svc, _, _ := createTestService(t)
|
|
svc.executors = &stubExecutors{
|
|
runningByTaskID: []*models.ExecutorRunning{
|
|
{SessionID: "sess-2", AgentExecutionID: "exec-2"},
|
|
},
|
|
}
|
|
|
|
// Session is RUNNING — stop target must NOT be terminal.
|
|
sessions := []*models.TaskSession{
|
|
{ID: "sess-2", State: models.TaskSessionStateRunning, AgentExecutionID: "exec-2"},
|
|
}
|
|
|
|
targets, err := svc.buildStopTargets(context.Background(), "task-2", sessions)
|
|
if err != nil {
|
|
t.Fatalf("buildStopTargets error: %v", err)
|
|
}
|
|
if len(targets) != 1 {
|
|
t.Fatalf("expected 1 target, got %d", len(targets))
|
|
}
|
|
if targets[0].terminal {
|
|
t.Error("expected target to NOT be terminal for a RUNNING session")
|
|
}
|
|
}
|
|
|
|
func TestBuildStopTargets_TerminalSessionWithoutExecutorRow(t *testing.T) {
|
|
svc, _, _ := createTestService(t)
|
|
svc.executors = &stubExecutors{
|
|
runningByTaskID: nil, // no executor_running rows
|
|
}
|
|
|
|
// Session is COMPLETED with no executor_running row → no stop target created.
|
|
sessions := []*models.TaskSession{
|
|
{ID: "sess-3", State: models.TaskSessionStateCompleted, AgentExecutionID: "exec-3"},
|
|
}
|
|
|
|
targets, err := svc.buildStopTargets(context.Background(), "task-3", sessions)
|
|
if err != nil {
|
|
t.Fatalf("buildStopTargets error: %v", err)
|
|
}
|
|
if len(targets) != 0 {
|
|
t.Errorf("expected 0 targets for terminal session without executor row, got %d", len(targets))
|
|
}
|
|
}
|
|
|
|
// --- stopTaskRuntimeTargets ---
|
|
|
|
// stubStopper is a minimal TaskExecutionStopper for tests.
|
|
type stubStopper struct {
|
|
stopSessionErr error
|
|
}
|
|
|
|
func (s *stubStopper) StopTask(_ context.Context, _, _ string, _ bool) error { return nil }
|
|
func (s *stubStopper) StopExecution(_ context.Context, _, _ string, _ bool) error { return nil }
|
|
func (s *stubStopper) StopSession(_ context.Context, _, _ string, _ bool) error {
|
|
return s.stopSessionErr
|
|
}
|
|
|
|
func TestStopTaskRuntimeTargets_TerminalStopFailureDoesNotBlockCleanup(t *testing.T) {
|
|
svc, _, _ := createTestService(t)
|
|
svc.executors = &stubExecutors{}
|
|
svc.executionStopper = &stubStopper{stopSessionErr: errors.New("ErrExecutionNotFound")}
|
|
|
|
targets := []taskStopTarget{
|
|
{sessionID: "sess-cancelled", terminal: true},
|
|
}
|
|
|
|
failed := svc.stopTaskRuntimeTargets(context.Background(), "task-a", targets, "archive", "stop failed")
|
|
if len(failed) != 0 {
|
|
t.Errorf("terminal stop failure must not add to failedStops; got %v", failed)
|
|
}
|
|
}
|
|
|
|
func TestStopTaskRuntimeTargets_NonTerminalStopFailureBlocksCleanup(t *testing.T) {
|
|
svc, _, _ := createTestService(t)
|
|
svc.executors = &stubExecutors{}
|
|
svc.executionStopper = &stubStopper{stopSessionErr: errors.New("unexpected error")}
|
|
|
|
targets := []taskStopTarget{
|
|
{sessionID: "sess-running", terminal: false},
|
|
}
|
|
|
|
failed := svc.stopTaskRuntimeTargets(context.Background(), "task-b", targets, "archive", "stop failed")
|
|
if _, ok := failed["sess-running"]; !ok {
|
|
t.Error("non-terminal stop failure must add session to failedStops")
|
|
}
|
|
}
|
|
|
|
// --- CleanupTaskResources cascade regression tests ---
|
|
//
|
|
// These tests reproduce the archive cleanup regression: ArchiveTaskTree calls
|
|
// cancelActiveRuns (sessions → CANCELLED) before CleanupTaskResources, leaving
|
|
// executor_running rows whose StopExecution returns ErrExecutionNotFound. The
|
|
// stop failure must not block environment teardown for terminal sessions.
|
|
|
|
func seedCascadeFixtures(t *testing.T, repo interface {
|
|
CreateWorkspace(context.Context, *models.Workspace) error
|
|
CreateWorkflow(context.Context, *models.Workflow) error
|
|
CreateTask(context.Context, *models.Task) error
|
|
CreateTaskSession(context.Context, *models.TaskSession) error
|
|
UpsertExecutorRunning(context.Context, *models.ExecutorRunning) error
|
|
}, wsID, wfID, taskID, sessID, execID string, state models.TaskSessionState) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: wsID, Name: "WS"})
|
|
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: wfID, WorkspaceID: wsID, Name: "WF"})
|
|
_ = repo.CreateTask(ctx, &models.Task{ID: taskID, WorkspaceID: wsID, WorkflowID: wfID, WorkflowStepID: "step-1", Title: "T", Priority: "medium"})
|
|
_ = repo.CreateTaskSession(ctx, &models.TaskSession{
|
|
ID: sessID,
|
|
TaskID: taskID,
|
|
State: state,
|
|
AgentExecutionID: execID,
|
|
})
|
|
if err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{
|
|
ID: sessID,
|
|
SessionID: sessID,
|
|
TaskID: taskID,
|
|
ExecutorID: "executor-1",
|
|
AgentExecutionID: execID,
|
|
Runtime: agentruntime.RuntimeStandalone,
|
|
Status: models.ExecutorRunningStatusStarting,
|
|
}); err != nil {
|
|
t.Fatalf("seed executor_running: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestCleanupTaskResources_TerminalSessionStopFailureDoesNotBlockCleanup is a
|
|
// regression test for the archive cleanup bug: a CANCELLED session with a stale
|
|
// executor_running row must have its row removed even when StopExecution fails.
|
|
func TestCleanupTaskResources_TerminalSessionStopFailureDoesNotBlockCleanup(t *testing.T) {
|
|
svc, _, repo := createTestService(t)
|
|
|
|
stopper := newRecordingTaskExecutionStopper()
|
|
stopper.stopExecutionErr = errors.New("execution not found")
|
|
svc.SetExecutionStopper(stopper)
|
|
svc.setCleanupDoneForTestHook(make(chan struct{}, 1))
|
|
|
|
seedCascadeFixtures(t, repo, "ws-c1", "wf-c1", "task-c1", "sess-cancelled", "exec-cancelled", models.TaskSessionStateCancelled)
|
|
|
|
svc.CleanupTaskResources(context.Background(), "task-c1", false)
|
|
waitForCleanupDone(t, svc)
|
|
|
|
_, err := repo.GetExecutorRunningBySessionID(context.Background(), "sess-cancelled")
|
|
if err == nil {
|
|
t.Error("executor_running row must be removed after cleanup of terminal (CANCELLED) session — stop failure must not block teardown")
|
|
}
|
|
}
|
|
|
|
// TestCleanupTaskResources_NonTerminalSessionStopFailureBlocksCleanup is the
|
|
// companion case: a RUNNING session whose StopExecution fails must keep its
|
|
// executor_running row so the runtime is not torn down unexpectedly.
|
|
func TestCleanupTaskResources_NonTerminalSessionStopFailureBlocksCleanup(t *testing.T) {
|
|
svc, _, repo := createTestService(t)
|
|
|
|
stopper := newRecordingTaskExecutionStopper()
|
|
stopper.stopExecutionErr = errors.New("stop failed")
|
|
svc.SetExecutionStopper(stopper)
|
|
svc.setCleanupDoneForTestHook(make(chan struct{}, 1))
|
|
|
|
seedCascadeFixtures(t, repo, "ws-c2", "wf-c2", "task-c2", "sess-running", "exec-running", models.TaskSessionStateRunning)
|
|
|
|
svc.CleanupTaskResources(context.Background(), "task-c2", false)
|
|
waitForCleanupDone(t, svc)
|
|
|
|
if _, err := repo.GetExecutorRunningBySessionID(context.Background(), "sess-running"); err != nil {
|
|
t.Error("executor_running row must be preserved when stop fails for a non-terminal (RUNNING) session")
|
|
}
|
|
}
|