import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { AgentLifecycleService } from "./agent-lifecycle.service"; import { AgentSpawnerService } from "./agent-spawner.service"; import { ValkeyService } from "../valkey/valkey.service"; import type { AgentState } from "../valkey/types"; describe("AgentLifecycleService", () => { let service: AgentLifecycleService; let mockValkeyService: { getAgentState: ReturnType; setAgentState: ReturnType; updateAgentStatus: ReturnType; publishEvent: ReturnType; listAgents: ReturnType; }; let mockSpawnerService: { scheduleSessionCleanup: ReturnType; }; const mockAgentId = "test-agent-123"; const mockTaskId = "test-task-456"; beforeEach(() => { // Create mocks mockValkeyService = { getAgentState: vi.fn(), setAgentState: vi.fn(), updateAgentStatus: vi.fn(), publishEvent: vi.fn(), listAgents: vi.fn(), }; mockSpawnerService = { scheduleSessionCleanup: vi.fn(), }; // Create service with mocks service = new AgentLifecycleService( mockValkeyService as unknown as ValkeyService, mockSpawnerService as unknown as AgentSpawnerService ); }); afterEach(() => { vi.clearAllMocks(); }); describe("transitionToRunning", () => { it("should transition from spawning to running", async () => { const mockState: AgentState = { agentId: mockAgentId, status: "spawning", taskId: mockTaskId, }; mockValkeyService.getAgentState.mockResolvedValue(mockState); mockValkeyService.updateAgentStatus.mockResolvedValue({ ...mockState, status: "running", startedAt: "2026-02-02T10:00:00Z", }); const result = await service.transitionToRunning(mockAgentId); expect(result.status).toBe("running"); expect(result.startedAt).toBeDefined(); expect(mockValkeyService.updateAgentStatus).toHaveBeenCalledWith( mockAgentId, "running", undefined ); expect(mockValkeyService.publishEvent).toHaveBeenCalledWith( expect.objectContaining({ type: "agent.running", agentId: mockAgentId, taskId: mockTaskId, }) ); }); it("should throw error if agent not found", async () => { mockValkeyService.getAgentState.mockResolvedValue(null); await expect(service.transitionToRunning(mockAgentId)).rejects.toThrow( `Agent ${mockAgentId} not found` ); }); it("should throw error for invalid transition from running", async () => { const mockState: AgentState = { agentId: mockAgentId, status: "running", taskId: mockTaskId, }; mockValkeyService.getAgentState.mockResolvedValue(mockState); await expect(service.transitionToRunning(mockAgentId)).rejects.toThrow( "Invalid state transition from running to running" ); }); it("should throw error for invalid transition from completed", async () => { const mockState: AgentState = { agentId: mockAgentId, status: "completed", taskId: mockTaskId, }; mockValkeyService.getAgentState.mockResolvedValue(mockState); await expect(service.transitionToRunning(mockAgentId)).rejects.toThrow( "Invalid state transition from completed to running" ); }); }); describe("transitionToCompleted", () => { it("should transition from running to completed", async () => { const mockState: AgentState = { agentId: mockAgentId, status: "running", taskId: mockTaskId, startedAt: "2026-02-02T10:00:00Z", }; mockValkeyService.getAgentState.mockResolvedValue(mockState); mockValkeyService.updateAgentStatus.mockResolvedValue({ ...mockState, status: "completed", completedAt: expect.any(String), }); const result = await service.transitionToCompleted(mockAgentId); expect(result.status).toBe("completed"); expect(result.completedAt).toBeDefined(); expect(mockValkeyService.updateAgentStatus).toHaveBeenCalledWith( mockAgentId, "completed", undefined ); expect(mockValkeyService.publishEvent).toHaveBeenCalledWith( expect.objectContaining({ type: "agent.completed", agentId: mockAgentId, taskId: mockTaskId, }) ); }); it("should throw error if agent not found", async () => { mockValkeyService.getAgentState.mockResolvedValue(null); await expect(service.transitionToCompleted(mockAgentId)).rejects.toThrow( `Agent ${mockAgentId} not found` ); }); it("should throw error for invalid transition from spawning", async () => { const mockState: AgentState = { agentId: mockAgentId, status: "spawning", taskId: mockTaskId, }; mockValkeyService.getAgentState.mockResolvedValue(mockState); await expect(service.transitionToCompleted(mockAgentId)).rejects.toThrow( "Invalid state transition from spawning to completed" ); }); }); describe("transitionToFailed", () => { it("should transition from spawning to failed with error", async () => { const mockState: AgentState = { agentId: mockAgentId, status: "spawning", taskId: mockTaskId, }; const errorMessage = "Failed to spawn agent"; mockValkeyService.getAgentState.mockResolvedValue(mockState); mockValkeyService.updateAgentStatus.mockResolvedValue({ ...mockState, status: "failed", error: errorMessage, completedAt: expect.any(String), }); const result = await service.transitionToFailed(mockAgentId, errorMessage); expect(result.status).toBe("failed"); expect(result.error).toBe(errorMessage); expect(result.completedAt).toBeDefined(); expect(mockValkeyService.updateAgentStatus).toHaveBeenCalledWith( mockAgentId, "failed", errorMessage ); expect(mockValkeyService.publishEvent).toHaveBeenCalledWith( expect.objectContaining({ type: "agent.failed", agentId: mockAgentId, taskId: mockTaskId, error: errorMessage, }) ); }); it("should transition from running to failed with error", async () => { const mockState: AgentState = { agentId: mockAgentId, status: "running", taskId: mockTaskId, startedAt: "2026-02-02T10:00:00Z", }; const errorMessage = "Runtime error occurred"; mockValkeyService.getAgentState.mockResolvedValue(mockState); mockValkeyService.updateAgentStatus.mockResolvedValue({ ...mockState, status: "failed", error: errorMessage, completedAt: expect.any(String), }); const result = await service.transitionToFailed(mockAgentId, errorMessage); expect(result.status).toBe("failed"); expect(result.error).toBe(errorMessage); }); it("should throw error if agent not found", async () => { mockValkeyService.getAgentState.mockResolvedValue(null); await expect(service.transitionToFailed(mockAgentId, "Error")).rejects.toThrow( `Agent ${mockAgentId} not found` ); }); it("should throw error for invalid transition from completed", async () => { const mockState: AgentState = { agentId: mockAgentId, status: "completed", taskId: mockTaskId, }; mockValkeyService.getAgentState.mockResolvedValue(mockState); await expect(service.transitionToFailed(mockAgentId, "Error")).rejects.toThrow( "Invalid state transition from completed to failed" ); }); }); describe("transitionToKilled", () => { it("should transition from spawning to killed", async () => { const mockState: AgentState = { agentId: mockAgentId, status: "spawning", taskId: mockTaskId, }; mockValkeyService.getAgentState.mockResolvedValue(mockState); mockValkeyService.updateAgentStatus.mockResolvedValue({ ...mockState, status: "killed", completedAt: expect.any(String), }); const result = await service.transitionToKilled(mockAgentId); expect(result.status).toBe("killed"); expect(result.completedAt).toBeDefined(); expect(mockValkeyService.updateAgentStatus).toHaveBeenCalledWith( mockAgentId, "killed", undefined ); expect(mockValkeyService.publishEvent).toHaveBeenCalledWith( expect.objectContaining({ type: "agent.killed", agentId: mockAgentId, taskId: mockTaskId, }) ); }); it("should transition from running to killed", async () => { const mockState: AgentState = { agentId: mockAgentId, status: "running", taskId: mockTaskId, startedAt: "2026-02-02T10:00:00Z", }; mockValkeyService.getAgentState.mockResolvedValue(mockState); mockValkeyService.updateAgentStatus.mockResolvedValue({ ...mockState, status: "killed", completedAt: expect.any(String), }); const result = await service.transitionToKilled(mockAgentId); expect(result.status).toBe("killed"); }); it("should throw error if agent not found", async () => { mockValkeyService.getAgentState.mockResolvedValue(null); await expect(service.transitionToKilled(mockAgentId)).rejects.toThrow( `Agent ${mockAgentId} not found` ); }); it("should throw error for invalid transition from completed", async () => { const mockState: AgentState = { agentId: mockAgentId, status: "completed", taskId: mockTaskId, }; mockValkeyService.getAgentState.mockResolvedValue(mockState); await expect(service.transitionToKilled(mockAgentId)).rejects.toThrow( "Invalid state transition from completed to killed" ); }); }); describe("getAgentLifecycleState", () => { it("should return agent state from Valkey", async () => { const mockState: AgentState = { agentId: mockAgentId, status: "running", taskId: mockTaskId, startedAt: "2026-02-02T10:00:00Z", }; mockValkeyService.getAgentState.mockResolvedValue(mockState); const result = await service.getAgentLifecycleState(mockAgentId); expect(result).toEqual(mockState); expect(mockValkeyService.getAgentState).toHaveBeenCalledWith(mockAgentId); }); it("should return null if agent not found", async () => { mockValkeyService.getAgentState.mockResolvedValue(null); const result = await service.getAgentLifecycleState(mockAgentId); expect(result).toBeNull(); }); }); describe("listAgentLifecycleStates", () => { it("should return all agent states from Valkey", async () => { const mockStates: AgentState[] = [ { agentId: "agent-1", status: "running", taskId: "task-1", startedAt: "2026-02-02T10:00:00Z", }, { agentId: "agent-2", status: "completed", taskId: "task-2", startedAt: "2026-02-02T09:00:00Z", completedAt: "2026-02-02T10:00:00Z", }, ]; mockValkeyService.listAgents.mockResolvedValue(mockStates); const result = await service.listAgentLifecycleStates(); expect(result).toEqual(mockStates); expect(mockValkeyService.listAgents).toHaveBeenCalled(); }); it("should return empty array if no agents", async () => { mockValkeyService.listAgents.mockResolvedValue([]); const result = await service.listAgentLifecycleStates(); expect(result).toEqual([]); }); }); describe("state persistence", () => { it("should update completedAt timestamp on terminal states", async () => { const mockState: AgentState = { agentId: mockAgentId, status: "running", taskId: mockTaskId, startedAt: "2026-02-02T10:00:00Z", }; mockValkeyService.getAgentState.mockResolvedValue(mockState); let capturedState: AgentState | undefined; mockValkeyService.updateAgentStatus.mockImplementation(async (agentId, status, error) => { capturedState = { ...mockState, status, error, completedAt: new Date().toISOString(), }; return capturedState; }); await service.transitionToCompleted(mockAgentId); expect(capturedState?.completedAt).toBeDefined(); }); it("should preserve startedAt timestamp through transitions", async () => { const startedAt = "2026-02-02T10:00:00Z"; const mockState: AgentState = { agentId: mockAgentId, status: "running", taskId: mockTaskId, startedAt, }; mockValkeyService.getAgentState.mockResolvedValue(mockState); mockValkeyService.updateAgentStatus.mockResolvedValue({ ...mockState, status: "completed", completedAt: "2026-02-02T11:00:00Z", }); const result = await service.transitionToCompleted(mockAgentId); expect(result.startedAt).toBe(startedAt); }); it("should set startedAt if not already set when transitioning to running", async () => { const mockState: AgentState = { agentId: mockAgentId, status: "spawning", taskId: mockTaskId, }; mockValkeyService.getAgentState.mockResolvedValue(mockState); mockValkeyService.updateAgentStatus.mockResolvedValue({ ...mockState, status: "running", // No startedAt in response }); mockValkeyService.setAgentState.mockResolvedValue(undefined); await service.transitionToRunning(mockAgentId); expect(mockValkeyService.setAgentState).toHaveBeenCalledWith( expect.objectContaining({ agentId: mockAgentId, status: "running", startedAt: expect.any(String), }) ); }); it("should not set startedAt if already present in response", async () => { const mockState: AgentState = { agentId: mockAgentId, status: "spawning", taskId: mockTaskId, }; mockValkeyService.getAgentState.mockResolvedValue(mockState); mockValkeyService.updateAgentStatus.mockResolvedValue({ ...mockState, status: "running", startedAt: "2026-02-02T10:00:00Z", }); await service.transitionToRunning(mockAgentId); // Should not call setAgentState since startedAt is already present expect(mockValkeyService.setAgentState).not.toHaveBeenCalled(); }); it("should set completedAt if not already set when transitioning to completed", async () => { const mockState: AgentState = { agentId: mockAgentId, status: "running", taskId: mockTaskId, startedAt: "2026-02-02T10:00:00Z", }; mockValkeyService.getAgentState.mockResolvedValue(mockState); mockValkeyService.updateAgentStatus.mockResolvedValue({ ...mockState, status: "completed", // No completedAt in response }); mockValkeyService.setAgentState.mockResolvedValue(undefined); await service.transitionToCompleted(mockAgentId); expect(mockValkeyService.setAgentState).toHaveBeenCalledWith( expect.objectContaining({ agentId: mockAgentId, status: "completed", completedAt: expect.any(String), }) ); }); it("should set completedAt if not already set when transitioning to failed", async () => { const mockState: AgentState = { agentId: mockAgentId, status: "running", taskId: mockTaskId, startedAt: "2026-02-02T10:00:00Z", }; mockValkeyService.getAgentState.mockResolvedValue(mockState); mockValkeyService.updateAgentStatus.mockResolvedValue({ ...mockState, status: "failed", error: "Test error", // No completedAt in response }); mockValkeyService.setAgentState.mockResolvedValue(undefined); await service.transitionToFailed(mockAgentId, "Test error"); expect(mockValkeyService.setAgentState).toHaveBeenCalledWith( expect.objectContaining({ agentId: mockAgentId, status: "failed", completedAt: expect.any(String), }) ); }); it("should set completedAt if not already set when transitioning to killed", async () => { const mockState: AgentState = { agentId: mockAgentId, status: "running", taskId: mockTaskId, startedAt: "2026-02-02T10:00:00Z", }; mockValkeyService.getAgentState.mockResolvedValue(mockState); mockValkeyService.updateAgentStatus.mockResolvedValue({ ...mockState, status: "killed", // No completedAt in response }); mockValkeyService.setAgentState.mockResolvedValue(undefined); await service.transitionToKilled(mockAgentId); expect(mockValkeyService.setAgentState).toHaveBeenCalledWith( expect.objectContaining({ agentId: mockAgentId, status: "killed", completedAt: expect.any(String), }) ); }); }); describe("event emission", () => { it("should emit events with correct structure", async () => { const mockState: AgentState = { agentId: mockAgentId, status: "spawning", taskId: mockTaskId, }; mockValkeyService.getAgentState.mockResolvedValue(mockState); mockValkeyService.updateAgentStatus.mockResolvedValue({ ...mockState, status: "running", startedAt: "2026-02-02T10:00:00Z", }); await service.transitionToRunning(mockAgentId); expect(mockValkeyService.publishEvent).toHaveBeenCalledWith( expect.objectContaining({ type: "agent.running", agentId: mockAgentId, taskId: mockTaskId, timestamp: expect.any(String), }) ); }); it("should include error in failed event", async () => { const mockState: AgentState = { agentId: mockAgentId, status: "running", taskId: mockTaskId, }; const errorMessage = "Test error"; mockValkeyService.getAgentState.mockResolvedValue(mockState); mockValkeyService.updateAgentStatus.mockResolvedValue({ ...mockState, status: "failed", error: errorMessage, }); await service.transitionToFailed(mockAgentId, errorMessage); expect(mockValkeyService.publishEvent).toHaveBeenCalledWith( expect.objectContaining({ type: "agent.failed", agentId: mockAgentId, taskId: mockTaskId, error: errorMessage, }) ); }); }); describe("session cleanup on terminal states", () => { it("should schedule session cleanup when transitioning to completed", async () => { const mockState: AgentState = { agentId: mockAgentId, status: "running", taskId: mockTaskId, startedAt: "2026-02-02T10:00:00Z", }; mockValkeyService.getAgentState.mockResolvedValue(mockState); mockValkeyService.updateAgentStatus.mockResolvedValue({ ...mockState, status: "completed", completedAt: "2026-02-02T11:00:00Z", }); await service.transitionToCompleted(mockAgentId); expect(mockSpawnerService.scheduleSessionCleanup).toHaveBeenCalledWith(mockAgentId); }); it("should schedule session cleanup when transitioning to failed", async () => { const mockState: AgentState = { agentId: mockAgentId, status: "running", taskId: mockTaskId, startedAt: "2026-02-02T10:00:00Z", }; const errorMessage = "Runtime error occurred"; mockValkeyService.getAgentState.mockResolvedValue(mockState); mockValkeyService.updateAgentStatus.mockResolvedValue({ ...mockState, status: "failed", error: errorMessage, completedAt: "2026-02-02T11:00:00Z", }); await service.transitionToFailed(mockAgentId, errorMessage); expect(mockSpawnerService.scheduleSessionCleanup).toHaveBeenCalledWith(mockAgentId); }); it("should schedule session cleanup when transitioning to killed", async () => { const mockState: AgentState = { agentId: mockAgentId, status: "running", taskId: mockTaskId, startedAt: "2026-02-02T10:00:00Z", }; mockValkeyService.getAgentState.mockResolvedValue(mockState); mockValkeyService.updateAgentStatus.mockResolvedValue({ ...mockState, status: "killed", completedAt: "2026-02-02T11:00:00Z", }); await service.transitionToKilled(mockAgentId); expect(mockSpawnerService.scheduleSessionCleanup).toHaveBeenCalledWith(mockAgentId); }); it("should not schedule session cleanup when transitioning to running", async () => { const mockState: AgentState = { agentId: mockAgentId, status: "spawning", taskId: mockTaskId, }; mockValkeyService.getAgentState.mockResolvedValue(mockState); mockValkeyService.updateAgentStatus.mockResolvedValue({ ...mockState, status: "running", startedAt: "2026-02-02T10:00:00Z", }); await service.transitionToRunning(mockAgentId); expect(mockSpawnerService.scheduleSessionCleanup).not.toHaveBeenCalled(); }); }); describe("TOCTOU race prevention (CQ-ORCH-5)", () => { it("should serialize concurrent transitions to the same agent", async () => { const executionOrder: string[] = []; // Simulate state that changes after first transition completes let currentStatus: "spawning" | "running" | "completed" = "spawning"; mockValkeyService.getAgentState.mockImplementation(async () => { return { agentId: mockAgentId, status: currentStatus, taskId: mockTaskId, } as AgentState; }); mockValkeyService.updateAgentStatus.mockImplementation( async (_agentId: string, status: string) => { // Simulate delay to allow interleaving if lock is broken await new Promise((resolve) => { setTimeout(resolve, 10); }); currentStatus = status as "spawning" | "running" | "completed"; executionOrder.push(`updated-to-${status}`); return { agentId: mockAgentId, status, taskId: mockTaskId, startedAt: "2026-02-02T10:00:00Z", ...(status === "completed" && { completedAt: "2026-02-02T11:00:00Z" }), } as AgentState; } ); // Launch both transitions concurrently const [result1, result2] = await Promise.allSettled([ service.transitionToRunning(mockAgentId), service.transitionToCompleted(mockAgentId), ]); // First should succeed (spawning -> running) expect(result1.status).toBe("fulfilled"); // Second should also succeed (running -> completed) because the lock // serializes them: first one completes, updates state to running, // then second reads the updated state and transitions to completed expect(result2.status).toBe("fulfilled"); // Verify they executed in order, not interleaved expect(executionOrder).toEqual(["updated-to-running", "updated-to-completed"]); }); it("should reject second concurrent transition if first makes it invalid", async () => { let currentStatus: "running" | "completed" | "killed" = "running"; mockValkeyService.getAgentState.mockImplementation(async () => { return { agentId: mockAgentId, status: currentStatus, taskId: mockTaskId, startedAt: "2026-02-02T10:00:00Z", } as AgentState; }); mockValkeyService.updateAgentStatus.mockImplementation( async (_agentId: string, status: string) => { await new Promise((resolve) => { setTimeout(resolve, 10); }); currentStatus = status as "running" | "completed" | "killed"; return { agentId: mockAgentId, status, taskId: mockTaskId, startedAt: "2026-02-02T10:00:00Z", completedAt: "2026-02-02T11:00:00Z", } as AgentState; } ); // Both try to transition from running to a terminal state concurrently const [result1, result2] = await Promise.allSettled([ service.transitionToCompleted(mockAgentId), service.transitionToKilled(mockAgentId), ]); // First should succeed (running -> completed) expect(result1.status).toBe("fulfilled"); // Second should fail because after first completes, // agent is in "completed" state which cannot transition to "killed" expect(result2.status).toBe("rejected"); if (result2.status === "rejected") { expect(result2.reason).toBeInstanceOf(Error); expect((result2.reason as Error).message).toContain("Invalid state transition"); } }); it("should allow concurrent transitions to different agents", async () => { const agent1Id = "agent-1"; const agent2Id = "agent-2"; const executionOrder: string[] = []; mockValkeyService.getAgentState.mockImplementation(async (agentId: string) => { return { agentId, status: "spawning", taskId: `task-for-${agentId}`, } as AgentState; }); mockValkeyService.updateAgentStatus.mockImplementation( async (agentId: string, status: string) => { executionOrder.push(`${agentId}-start`); await new Promise((resolve) => { setTimeout(resolve, 10); }); executionOrder.push(`${agentId}-end`); return { agentId, status, taskId: `task-for-${agentId}`, startedAt: "2026-02-02T10:00:00Z", } as AgentState; } ); // Both should run concurrently since they target different agents const [result1, result2] = await Promise.allSettled([ service.transitionToRunning(agent1Id), service.transitionToRunning(agent2Id), ]); expect(result1.status).toBe("fulfilled"); expect(result2.status).toBe("fulfilled"); // Both should start before either finishes (concurrent, not serialized) // The execution order should show interleaving expect(executionOrder).toContain("agent-1-start"); expect(executionOrder).toContain("agent-2-start"); }); it("should release lock even when transition throws an error", async () => { let callCount = 0; mockValkeyService.getAgentState.mockImplementation(async () => { callCount++; if (callCount === 1) { // First call: throw error return null; } // Second call: return valid state return { agentId: mockAgentId, status: "spawning", taskId: mockTaskId, } as AgentState; }); mockValkeyService.updateAgentStatus.mockResolvedValue({ agentId: mockAgentId, status: "running", taskId: mockTaskId, startedAt: "2026-02-02T10:00:00Z", }); // First transition should fail (agent not found) await expect(service.transitionToRunning(mockAgentId)).rejects.toThrow( `Agent ${mockAgentId} not found` ); // Second transition should succeed (lock was released despite error) const result = await service.transitionToRunning(mockAgentId); expect(result.status).toBe("running"); }); it("should handle three concurrent transitions sequentially for same agent", async () => { const executionOrder: string[] = []; let currentStatus: "spawning" | "running" | "completed" | "failed" = "spawning"; mockValkeyService.getAgentState.mockImplementation(async () => { return { agentId: mockAgentId, status: currentStatus, taskId: mockTaskId, ...(currentStatus !== "spawning" && { startedAt: "2026-02-02T10:00:00Z" }), } as AgentState; }); mockValkeyService.updateAgentStatus.mockImplementation( async (_agentId: string, status: string) => { executionOrder.push(`update-${status}`); await new Promise((resolve) => { setTimeout(resolve, 5); }); currentStatus = status as "spawning" | "running" | "completed" | "failed"; return { agentId: mockAgentId, status, taskId: mockTaskId, startedAt: "2026-02-02T10:00:00Z", ...(["completed", "failed"].includes(status) && { completedAt: "2026-02-02T11:00:00Z", }), } as AgentState; } ); // Launch three transitions at once: spawning->running->completed, plus a failed attempt const [r1, r2, r3] = await Promise.allSettled([ service.transitionToRunning(mockAgentId), service.transitionToCompleted(mockAgentId), service.transitionToFailed(mockAgentId, "late error"), ]); // First: spawning -> running (succeeds) expect(r1.status).toBe("fulfilled"); // Second: running -> completed (succeeds, serialized after first) expect(r2.status).toBe("fulfilled"); // Third: completed -> failed (fails, completed is terminal) expect(r3.status).toBe("rejected"); // Verify sequential execution expect(executionOrder[0]).toBe("update-running"); expect(executionOrder[1]).toBe("update-completed"); // Third never gets to update because validation fails expect(executionOrder).toHaveLength(2); }); }); });