Align engine executor tests with the current retry and worktree behavior. - update implicit task-done refusal expectations to assert queued retry state resets - update pause recovery coverage to expect requeued tasks instead of stuck-killed status - stub the sandbox backend in worktree init tests and drop obsolete execSync assertions Files changed: .../executor-implicit-task-done-budget.test.ts | 11 ++++++- ...xecutor-implicit-task-done-revise-guard.test.ts | 11 ++++++- .../engine/src/__tests__/executor-pause.test.ts | 5 +-- .../engine/src/__tests__/executor-worktree.test.ts | 37 ++++++++++++++++------ 4 files changed, 50 insertions(+), 14 deletions(-) Fusion-Task-Id: FN-6068 Fusion-Task-Lineage: b8ccabaa-7e5d-42dc-b522-6f4738b28db4
2837 lines
96 KiB
TypeScript
2837 lines
96 KiB
TypeScript
// -nocheck
|
|
/* eslint-disable -eslint/no-unused-vars */
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import "./executor-test-helpers.js";
|
|
import { AgentSemaphore } from "../concurrency.js";
|
|
import { detectReviewHandoffIntent, determineRevisionResetStart } from "../executor.js";
|
|
import { TaskExecutor, buildExecutionPrompt } from "../executor.js";
|
|
import { createFnAgent } from "../pi.js";
|
|
import { reviewStep as mockedReviewStepFn } from "../reviewer.js";
|
|
import { execSync } from "node:child_process";
|
|
import { findWorktreeUser, aiMergeTask } from "../merger.js";
|
|
import { WorktreePool } from "../worktree-pool.js";
|
|
import { generateWorktreeName, slugify } from "../worktree-names.js";
|
|
import type { Task, TaskDetail } from "@fusion/core";
|
|
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
import { StepSessionExecutor } from "../step-session-executor.js";
|
|
import { executorLog } from "../logger.js";
|
|
import { withRateLimitRetry } from "../rate-limit-retry.js";
|
|
import { runVerificationCommand as mockedRunVerificationCommand } from "../verification-utils.js";
|
|
import {
|
|
createMockStore,
|
|
mockedCreateFnAgent,
|
|
mockedSessionManager,
|
|
mockedGenerateWorktreeName,
|
|
mockedFindWorktreeUser,
|
|
mockedStepSessionExecutor,
|
|
mockedWithRateLimitRetry,
|
|
mockedExecSync,
|
|
mockedExistsSync,
|
|
mockExecuteAll,
|
|
mockTerminateAllSessions,
|
|
mockCleanup,
|
|
resetExecutorMocks,
|
|
} from "./executor-test-helpers.js";
|
|
|
|
const mockedReviewStep = vi.mocked(mockedReviewStepFn);
|
|
|
|
const WAIT_FOR_ASYNC_OPTIONS = { timeout: 2000, interval: 5 };
|
|
|
|
async function waitForAsyncExpectation(assertion: () => void | Promise<void>) {
|
|
await vi.waitFor(assertion, WAIT_FOR_ASYNC_OPTIONS);
|
|
}
|
|
|
|
async function waitForStepExecutorRegistration(executor: TaskExecutor, taskId: string) {
|
|
await waitForAsyncExpectation(() => {
|
|
expect((executor as any).activeStepExecutors.has(taskId)).toBe(true);
|
|
});
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe("TaskExecutor context limit error recovery", () => {
|
|
beforeEach(() => {
|
|
resetExecutorMocks();
|
|
});
|
|
|
|
function createMockSessionForContextRecovery() {
|
|
return {
|
|
prompt: vi.fn(async () => {}),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
setThinkingLevel: vi.fn(),
|
|
steer: vi.fn(async () => {}),
|
|
sessionFile: "/tmp/test-session.json",
|
|
model: { provider: "mock", id: "mock-model", name: "Mock" },
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
};
|
|
}
|
|
|
|
it("does NOT mark task as failed when context limit error is detected and recovery succeeds", async () => {
|
|
const mockSession = createMockSessionForContextRecovery();
|
|
|
|
// Mock compactSessionContext to succeed
|
|
const { compactSessionContext } = await import("../pi.js");
|
|
vi.mocked(compactSessionContext).mockResolvedValueOnce({
|
|
summary: "Compacted conversation",
|
|
tokensBefore: 150000,
|
|
});
|
|
|
|
const store = createMockStore();
|
|
(store.getSettings as any).mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
// Directly inject an active session
|
|
(executor as any).activeSessions.set("FN-001", {
|
|
session: mockSession,
|
|
seenSteeringIds: new Set(),
|
|
});
|
|
|
|
// Simulate the catch block being invoked with a context limit error
|
|
// This would normally happen when prompt() throws
|
|
const contextError = new Error("invalid params, context window exceeds limit (2013)");
|
|
|
|
// The executor should catch this error and attempt recovery
|
|
// We can't directly test the catch block, but we can test that isContextLimitError
|
|
// now correctly identifies this error
|
|
const { isContextLimitError } = await import("../context-limit-detector.js");
|
|
expect(isContextLimitError(contextError.message)).toBe(true);
|
|
});
|
|
|
|
it("recognizes 'context window exceeds limit' as context limit error", async () => {
|
|
const { isContextLimitError } = await import("../context-limit-detector.js");
|
|
|
|
// These are the specific error formats that should be recognized
|
|
expect(isContextLimitError("invalid params, context window exceeds limit (2013)")).toBe(true);
|
|
expect(isContextLimitError("context window exceeds limit")).toBe(true);
|
|
expect(isContextLimitError("context window exceeds limit (2003)")).toBe(true);
|
|
expect(isContextLimitError("Context Window Exceeds limit")).toBe(true);
|
|
});
|
|
|
|
it("does NOT recognize generic 'limit exceeded' without context keywords", async () => {
|
|
const { isContextLimitError } = await import("../context-limit-detector.js");
|
|
|
|
// These should NOT be recognized as context limit errors
|
|
expect(isContextLimitError("limit exceeded")).toBe(false);
|
|
expect(isContextLimitError("quota exceeded")).toBe(false);
|
|
expect(isContextLimitError("rate limit exceeded")).toBe(false);
|
|
});
|
|
|
|
it("reduced-prompt retry is attempted when compact returns null", async () => {
|
|
// This test verifies the recovery flow when compactSessionContext returns null
|
|
// (no history to compact) - the code should fall through to reduced-prompt retry
|
|
const { isContextLimitError } = await import("../context-limit-detector.js");
|
|
|
|
// These error formats should trigger reduced-prompt recovery
|
|
const contextError = "context window exceeds limit (2013)";
|
|
expect(isContextLimitError(contextError)).toBe(true);
|
|
// The test passes if isContextLimitError returns true, which means
|
|
// the reduced-prompt retry path would be triggered
|
|
});
|
|
|
|
it("reduced-prompt retry prompt focuses on completing efficiently", async () => {
|
|
// Verify the reduced prompt template includes key instructions
|
|
const reducedPrompt = [
|
|
"Your previous attempt hit the context window limit.",
|
|
"Focus on completing the task efficiently with minimal context:",
|
|
"1. Review git status and git log to see what's been done",
|
|
"2. Identify the most critical remaining work",
|
|
"3. Complete it with a simpler, more focused approach",
|
|
"",
|
|
"Do not repeat what's already been done. Just complete the task and call fn_task_done.",
|
|
].join("\n");
|
|
|
|
expect(reducedPrompt).toContain("context window limit");
|
|
expect(reducedPrompt).toContain("git status");
|
|
expect(reducedPrompt).toContain("fn_task_done");
|
|
expect(reducedPrompt).toContain("Do not repeat what's already been done");
|
|
});
|
|
});
|
|
|
|
// ── Agent Spawning Tests ─────────────────────────────────────────────────
|
|
|
|
function createMockAgentStore() {
|
|
let nextId = 1;
|
|
const agents = new Map<string, any>();
|
|
|
|
return {
|
|
createAgent: vi.fn(async (input: any) => {
|
|
const agentId = `agent-${String(nextId++).padStart(8, "0")}`;
|
|
const agent = {
|
|
id: agentId,
|
|
name: input.name,
|
|
role: input.role,
|
|
state: "idle" as string,
|
|
reportsTo: input.reportsTo,
|
|
metadata: input.metadata ?? {},
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
agents.set(agentId, agent);
|
|
return agent;
|
|
}),
|
|
updateAgentState: vi.fn(async (agentId: string, newState: string) => {
|
|
const agent = agents.get(agentId);
|
|
if (agent) {
|
|
agent.state = newState;
|
|
agent.updatedAt = new Date().toISOString();
|
|
}
|
|
return agent;
|
|
}),
|
|
_agents: agents,
|
|
};
|
|
}
|
|
|
|
async function captureToolsWithAgentStore(agentStore?: any, settingsOverride?: any): Promise<{
|
|
tools: Record<string, (id: string, params: any) => Promise<any>>;
|
|
store: ReturnType<typeof createMockStore>;
|
|
executor: TaskExecutor;
|
|
}> {
|
|
const store = createMockStore();
|
|
store.updateStep.mockResolvedValue({
|
|
steps: [
|
|
{ name: "Preflight", status: "done" },
|
|
{ name: "Implement", status: "in-progress" },
|
|
{ name: "Testing", status: "pending" },
|
|
],
|
|
});
|
|
const mergedSettings = {
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
worktreeInitCommand: undefined,
|
|
...settingsOverride,
|
|
};
|
|
store.getSettings.mockResolvedValue(mergedSettings);
|
|
|
|
let capturedTools: any[] = [];
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
capturedTools = opts.customTools || [];
|
|
// Child agent sessions get a never-resolving prompt so runSpawnedChild
|
|
// doesn't complete and decrement totalSpawnedCount before limit checks.
|
|
const isChildAgent = opts.systemPrompt?.includes("child agent spawned");
|
|
return {
|
|
session: {
|
|
prompt: isChildAgent ? vi.fn(() => new Promise(() => {})) : vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
sessionManager: {
|
|
getLeafId: vi.fn().mockReturnValue("leaf-id"),
|
|
branchWithSummary: vi.fn(),
|
|
},
|
|
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
mockedExistsSync.mockReturnValue(true);
|
|
// Mock execSync for worktree operations
|
|
vi.mocked(execSync).mockReturnValue("");
|
|
|
|
const options: any = {};
|
|
if (agentStore) {
|
|
options.agentStore = agentStore;
|
|
}
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", options);
|
|
|
|
await executor.execute({
|
|
id: "FN-SPAWN",
|
|
title: "Spawn Test",
|
|
description: "Spawn test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
const tools: Record<string, any> = {};
|
|
for (const t of capturedTools) {
|
|
tools[t.name] = t.execute;
|
|
}
|
|
return { tools, store, executor };
|
|
}
|
|
|
|
describe("Agent Spawning", () => {
|
|
beforeEach(() => {
|
|
resetExecutorMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
vi.mocked(execSync).mockReturnValue("");
|
|
});
|
|
|
|
it("fn_spawn_agent tool is registered in customTools", async () => {
|
|
const { tools } = await captureToolsWithAgentStore();
|
|
expect(tools.fn_spawn_agent).toBeDefined();
|
|
expect(typeof tools.fn_spawn_agent).toBe("function");
|
|
});
|
|
|
|
it("returns error when AgentStore is not configured", async () => {
|
|
const { tools } = await captureToolsWithAgentStore(undefined);
|
|
const result = await tools.fn_spawn_agent("call1", {
|
|
name: "researcher",
|
|
role: "engineer",
|
|
task: "Research something",
|
|
});
|
|
|
|
expect(result.content[0].text).toContain("not available");
|
|
expect(result.content[0].text).toContain("no AgentStore configured");
|
|
expect(result.details.state).toBe("error");
|
|
});
|
|
|
|
it("creates agent in AgentStore with correct reportsTo", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const { tools } = await captureToolsWithAgentStore(agentStore);
|
|
|
|
const result = await tools.fn_spawn_agent("call1", {
|
|
name: "researcher",
|
|
role: "engineer",
|
|
task: "Research authentication patterns",
|
|
});
|
|
|
|
expect(agentStore.createAgent).toHaveBeenCalledOnce();
|
|
const createInput = agentStore.createAgent.mock.calls[0][0];
|
|
expect(createInput.name).toBe("researcher");
|
|
expect(createInput.role).toBe("engineer");
|
|
expect(createInput.reportsTo).toBe("FN-SPAWN");
|
|
expect(createInput.metadata.type).toBe("spawned");
|
|
expect(createInput.metadata.parentTaskId).toBe("FN-SPAWN");
|
|
});
|
|
|
|
it("returns correct SpawnAgentResult structure with state", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const { tools } = await captureToolsWithAgentStore(agentStore);
|
|
|
|
const result = await tools.fn_spawn_agent("call1", {
|
|
name: "researcher",
|
|
role: "engineer",
|
|
task: "Research authentication patterns",
|
|
});
|
|
|
|
// Parse the JSON from the text content
|
|
const parsed = JSON.parse(result.content[0].text);
|
|
expect(parsed).toHaveProperty("agentId");
|
|
expect(parsed).toHaveProperty("name", "researcher");
|
|
expect(parsed).toHaveProperty("state", "running");
|
|
expect(parsed).toHaveProperty("role", "engineer");
|
|
expect(parsed).toHaveProperty("message");
|
|
expect(parsed.message).toContain("researcher");
|
|
expect(parsed.message).toContain("Research authentication patterns");
|
|
|
|
// Also check details object
|
|
expect(result.details.agentId).toBe(parsed.agentId);
|
|
expect(result.details.state).toBe("running");
|
|
});
|
|
|
|
it("transitions agent to active state after creation", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const { tools } = await captureToolsWithAgentStore(agentStore);
|
|
|
|
await tools.fn_spawn_agent("call1", {
|
|
name: "worker",
|
|
role: "custom",
|
|
task: "Do some work",
|
|
});
|
|
|
|
// Agent is created in idle, then transitioned to active
|
|
const agentId = agentStore.createAgent.mock.calls[0][0];
|
|
expect(agentStore.updateAgentState).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
"active"
|
|
);
|
|
});
|
|
|
|
it("creates child agent session via createFnAgent", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const { tools } = await captureToolsWithAgentStore(agentStore);
|
|
|
|
await tools.fn_spawn_agent("call1", {
|
|
name: "worker",
|
|
role: "engineer",
|
|
task: "Do some work",
|
|
});
|
|
|
|
// createFnAgent is called at least twice: once for parent, once for child
|
|
expect(mockedCreateFnAgent.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
|
|
const parentCall = mockedCreateFnAgent.mock.calls.find(
|
|
(call: any) => !call[0].systemPrompt?.includes("child agent spawned")
|
|
);
|
|
// Find the child session call
|
|
const childCall = mockedCreateFnAgent.mock.calls.find(
|
|
(call: any) => call[0].systemPrompt?.includes("child agent spawned")
|
|
);
|
|
|
|
expect(parentCall).toBeDefined();
|
|
expect(childCall).toBeDefined();
|
|
expect(childCall![0].tools).toBe("coding");
|
|
expect(childCall![0].cwd).toContain("/.worktrees/swift-falcon");
|
|
expect(childCall![0].systemPrompt).toContain("FN-SPAWN");
|
|
expect(childCall![0].taskEnv).toEqual(parentCall![0].taskEnv);
|
|
});
|
|
|
|
it("respects per-parent maxSpawnedAgentsPerParent limit", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const { tools } = await captureToolsWithAgentStore(agentStore, {
|
|
maxSpawnedAgentsPerParent: 2,
|
|
});
|
|
|
|
// Spawn 2 agents (limit)
|
|
await tools.fn_spawn_agent("call1", { name: "a1", role: "engineer", task: "task 1" });
|
|
await tools.fn_spawn_agent("call2", { name: "a2", role: "engineer", task: "task 2" });
|
|
|
|
// 3rd should be rejected
|
|
const result = await tools.fn_spawn_agent("call3", { name: "a3", role: "engineer", task: "task 3" });
|
|
expect(result.content[0].text).toContain("Per-parent spawn limit reached");
|
|
expect(result.content[0].text).toContain("2/2");
|
|
expect(result.details.state).toBe("error");
|
|
});
|
|
|
|
it("respects global maxSpawnedAgentsGlobal limit", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const { tools } = await captureToolsWithAgentStore(agentStore, {
|
|
maxSpawnedAgentsGlobal: 3,
|
|
});
|
|
|
|
// Spawn 3 agents (global limit)
|
|
await tools.fn_spawn_agent("call1", { name: "a1", role: "engineer", task: "task 1" });
|
|
await tools.fn_spawn_agent("call2", { name: "a2", role: "engineer", task: "task 2" });
|
|
await tools.fn_spawn_agent("call3", { name: "a3", role: "engineer", task: "task 3" });
|
|
|
|
// 4th should hit global limit
|
|
const result = await tools.fn_spawn_agent("call4", { name: "a4", role: "engineer", task: "task 4" });
|
|
expect(result.content[0].text).toContain("Global spawn limit reached");
|
|
expect(result.content[0].text).toContain("3/3");
|
|
expect(result.details.state).toBe("error");
|
|
});
|
|
|
|
it("uses default limits when settings are not specified", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
// No spawn settings in the store — defaults should apply
|
|
const { tools } = await captureToolsWithAgentStore(agentStore);
|
|
|
|
// Should be able to spawn (defaults: 5 per parent, 20 global)
|
|
const result = await tools.fn_spawn_agent("call1", {
|
|
name: "worker",
|
|
role: "engineer",
|
|
task: "task 1",
|
|
});
|
|
const parsed = JSON.parse(result.content[0].text);
|
|
expect(parsed.state).toBe("running");
|
|
});
|
|
|
|
it("handles errors during agent creation gracefully", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
agentStore.createAgent.mockRejectedValue(new Error("DB connection failed"));
|
|
|
|
const { tools } = await captureToolsWithAgentStore(agentStore);
|
|
|
|
const result = await tools.fn_spawn_agent("call1", {
|
|
name: "worker",
|
|
role: "engineer",
|
|
task: "task 1",
|
|
});
|
|
|
|
expect(result.content[0].text).toContain("Failed to spawn agent");
|
|
expect(result.content[0].text).toContain("DB connection failed");
|
|
expect(result.details.state).toBe("error");
|
|
});
|
|
|
|
it("trims whitespace from agent name", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const { tools } = await captureToolsWithAgentStore(agentStore);
|
|
|
|
await tools.fn_spawn_agent("call1", {
|
|
name: " researcher ",
|
|
role: "engineer",
|
|
task: "task 1",
|
|
});
|
|
|
|
const createInput = agentStore.createAgent.mock.calls[0][0];
|
|
expect(createInput.name).toBe("researcher");
|
|
});
|
|
|
|
it("truncates long task descriptions in result message", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const { tools } = await captureToolsWithAgentStore(agentStore);
|
|
|
|
const longTask = "A".repeat(200);
|
|
const result = await tools.fn_spawn_agent("call1", {
|
|
name: "worker",
|
|
role: "engineer",
|
|
task: longTask,
|
|
});
|
|
|
|
const parsed = JSON.parse(result.content[0].text);
|
|
expect(parsed.message).toContain("...");
|
|
// The message should contain the first 100 chars
|
|
expect(parsed.message.length).toBeLessThan(longTask.length + 50);
|
|
});
|
|
});
|
|
|
|
describe("Agent Spawning - Child Termination", () => {
|
|
beforeEach(() => {
|
|
resetExecutorMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
vi.mocked(execSync).mockReturnValue("");
|
|
});
|
|
|
|
it("parent termination triggers all child terminations", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const mockDispose = vi.fn();
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: mockDispose,
|
|
sessionManager: {
|
|
getLeafId: vi.fn().mockReturnValue("leaf-id"),
|
|
branchWithSummary: vi.fn(),
|
|
},
|
|
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
|
|
|
await executor.execute({
|
|
id: "FN-PARENT",
|
|
title: "Parent Task",
|
|
description: "Parent",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// execute() should have completed, disposing the parent session
|
|
// and any child sessions that were spawned
|
|
expect(mockDispose).toHaveBeenCalled();
|
|
});
|
|
|
|
it("terminateChildAgent cleans up maps and decrements count", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
});
|
|
|
|
const mockDispose = vi.fn();
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: mockDispose,
|
|
sessionManager: {
|
|
getLeafId: vi.fn().mockReturnValue("leaf-id"),
|
|
branchWithSummary: vi.fn(),
|
|
},
|
|
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
|
},
|
|
} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
|
|
|
// Access internal state via any for testing
|
|
const internals = executor as any;
|
|
|
|
// Simulate spawned agent tracking state
|
|
const childId = "agent-test-child";
|
|
const mockSession = { dispose: vi.fn() };
|
|
internals.childSessions.set(childId, mockSession);
|
|
internals.spawnedAgents.set("FN-PARENT", new Set([childId]));
|
|
internals.totalSpawnedCount = 1;
|
|
|
|
// Terminate the child
|
|
await internals.terminateChildAgent(childId);
|
|
|
|
expect(mockSession.dispose).toHaveBeenCalled();
|
|
expect(internals.childSessions.has(childId)).toBe(false);
|
|
// Note: spawnedAgents cleanup is done by terminateAllChildren, not terminateChildAgent
|
|
expect(internals.totalSpawnedCount).toBe(0);
|
|
expect(agentStore.updateAgentState).toHaveBeenCalledWith(childId, "paused");
|
|
});
|
|
|
|
it("terminateChildAgent handles missing session gracefully", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const store = createMockStore();
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
|
const internals = executor as any;
|
|
|
|
internals.totalSpawnedCount = 1;
|
|
|
|
// Terminate a child that doesn't have a session in the map
|
|
await internals.terminateChildAgent("nonexistent-agent");
|
|
|
|
// Should still decrement counter and attempt state update
|
|
expect(internals.totalSpawnedCount).toBe(0);
|
|
expect(agentStore.updateAgentState).toHaveBeenCalledWith("nonexistent-agent", "paused");
|
|
});
|
|
|
|
it("terminateAllChildren handles no children gracefully", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const store = createMockStore();
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
|
const internals = executor as any;
|
|
|
|
// Should not throw when there are no children
|
|
await internals.terminateAllChildren("FN-NONE");
|
|
expect(agentStore.updateAgentState).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("terminateAllChildren terminates all children and cleans up", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
|
const internals = executor as any;
|
|
|
|
// Set up multiple children
|
|
const child1 = { dispose: vi.fn() };
|
|
const child2 = { dispose: vi.fn() };
|
|
internals.childSessions.set("c1", child1);
|
|
internals.childSessions.set("c2", child2);
|
|
internals.spawnedAgents.set("FN-PARENT", new Set(["c1", "c2"]));
|
|
internals.totalSpawnedCount = 2;
|
|
|
|
await internals.terminateAllChildren("FN-PARENT");
|
|
|
|
expect(child1.dispose).toHaveBeenCalled();
|
|
expect(child2.dispose).toHaveBeenCalled();
|
|
expect(internals.spawnedAgents.has("FN-PARENT")).toBe(false);
|
|
expect(internals.totalSpawnedCount).toBe(0);
|
|
expect(agentStore.updateAgentState).toHaveBeenCalledWith("c1", "paused");
|
|
expect(agentStore.updateAgentState).toHaveBeenCalledWith("c2", "paused");
|
|
});
|
|
|
|
it("terminateChildAgent handles AgentStore errors gracefully", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
agentStore.updateAgentState.mockRejectedValue(new Error("DB error"));
|
|
const store = createMockStore();
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
|
const internals = executor as any;
|
|
|
|
const mockSession = { dispose: vi.fn() };
|
|
internals.childSessions.set("c1", mockSession);
|
|
internals.totalSpawnedCount = 1;
|
|
|
|
// Should not throw even when AgentStore fails
|
|
await internals.terminateChildAgent("c1");
|
|
expect(mockSession.dispose).toHaveBeenCalled();
|
|
expect(internals.totalSpawnedCount).toBe(0);
|
|
});
|
|
|
|
it("terminateChildAgent auto-deletes agent immediately", async () => {
|
|
const agentStore = createMockAgentStore() as any;
|
|
agentStore.deleteAgent = vi.fn().mockResolvedValue(undefined);
|
|
const store = createMockStore();
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
|
const internals = executor as any;
|
|
|
|
const mockSession = { dispose: vi.fn() };
|
|
const childId = "agent-auto-delete-test";
|
|
internals.childSessions.set(childId, mockSession);
|
|
internals.totalSpawnedCount = 1;
|
|
|
|
await internals.terminateChildAgent(childId);
|
|
|
|
expect(mockSession.dispose).toHaveBeenCalled();
|
|
expect(agentStore.deleteAgent).toHaveBeenCalledTimes(1);
|
|
expect(agentStore.deleteAgent).toHaveBeenCalledWith(childId);
|
|
expect(internals.pendingEphemeralDeletions.has(childId)).toBe(false);
|
|
});
|
|
|
|
it("disposeEphemeralTimers clears pending deletion bookkeeping", async () => {
|
|
const agentStore = createMockAgentStore() as any;
|
|
agentStore.deleteAgent = vi.fn().mockResolvedValue(undefined);
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
|
const internals = executor as any;
|
|
|
|
internals.pendingEphemeralDeletions.add("agent-dispose-test");
|
|
executor.disposeEphemeralTimers();
|
|
|
|
expect(internals.pendingEphemeralDeletions.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("Agent Spawning - runSpawnedChild", () => {
|
|
beforeEach(() => {
|
|
resetExecutorMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
it("updates agent state to running then active on success", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
|
const internals = executor as any;
|
|
|
|
const mockSession = { dispose: vi.fn(), prompt: vi.fn().mockResolvedValue(undefined) };
|
|
internals.childSessions.set("agent-test", mockSession);
|
|
internals.totalSpawnedCount = 1;
|
|
|
|
await internals.runSpawnedChild("agent-test", mockSession, "Do the research");
|
|
|
|
// Should transition: running → active
|
|
expect(agentStore.updateAgentState).toHaveBeenCalledWith("agent-test", "running");
|
|
expect(agentStore.updateAgentState).toHaveBeenCalledWith("agent-test", "active");
|
|
// Should clean up
|
|
expect(internals.childSessions.has("agent-test")).toBe(false);
|
|
expect(internals.totalSpawnedCount).toBe(0);
|
|
});
|
|
|
|
it("updates agent state to error on failure", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
|
const internals = executor as any;
|
|
|
|
const mockSession = { dispose: vi.fn() };
|
|
internals.childSessions.set("agent-test", mockSession);
|
|
internals.totalSpawnedCount = 1;
|
|
|
|
// Make promptWithFallback throw
|
|
const { promptWithFallback } = await import("../pi.js");
|
|
vi.mocked(promptWithFallback).mockRejectedValueOnce(new Error("API error"));
|
|
|
|
await internals.runSpawnedChild("agent-test", mockSession, "Do the research");
|
|
|
|
expect(agentStore.updateAgentState).toHaveBeenCalledWith("agent-test", "running");
|
|
expect(agentStore.updateAgentState).toHaveBeenCalledWith("agent-test", "error");
|
|
// Should still clean up
|
|
expect(internals.childSessions.has("agent-test")).toBe(false);
|
|
expect(internals.totalSpawnedCount).toBe(0);
|
|
});
|
|
|
|
it("cleans up even when state update fails", async () => {
|
|
const agentStore = createMockAgentStore();
|
|
agentStore.updateAgentState.mockRejectedValue(new Error("DB down"));
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
|
const internals = executor as any;
|
|
|
|
const mockSession = { dispose: vi.fn() };
|
|
internals.childSessions.set("agent-test", mockSession);
|
|
internals.totalSpawnedCount = 1;
|
|
|
|
// Should not throw even when state updates fail
|
|
await internals.runSpawnedChild("agent-test", mockSession, "Do the research");
|
|
|
|
expect(internals.childSessions.has("agent-test")).toBe(false);
|
|
expect(internals.totalSpawnedCount).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── Agent Execution Flow Integration Tests (FN-978) ────────────────────────────
|
|
//
|
|
// These tests verify the complete execution flow: event listener registration,
|
|
// session creation, stuck detector tracking, and heartbeat recording.
|
|
describe("TaskExecutor agent execution flow (FN-978)", () => {
|
|
beforeEach(() => {
|
|
resetExecutorMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
it("registers task:moved event listener in constructor", () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
// Verify the store.on was called with "task:moved"
|
|
expect(store.on).toHaveBeenCalledWith("task:moved", expect.any(Function));
|
|
});
|
|
|
|
it("executes task when task:moved event fires with to='in-progress'", async () => {
|
|
const store = createMockStore();
|
|
const session = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
};
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({ session } as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
const task = {
|
|
id: "FN-978",
|
|
title: "Test Task",
|
|
description: "Test",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
// Trigger the task:moved event manually
|
|
store._trigger("task:moved", { task, from: "todo", to: "in-progress" });
|
|
|
|
await waitForAsyncExpectation(() => {
|
|
expect(session.prompt).toHaveBeenCalled();
|
|
});
|
|
|
|
// Verify the agent was created and prompt was called
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
cwd: expect.any(String),
|
|
systemPrompt: expect.any(String),
|
|
tools: "coding",
|
|
}),
|
|
);
|
|
expect(session.prompt).toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not execute task when task:moved event fires with to!='in-progress'", async () => {
|
|
const store = createMockStore();
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: { prompt: vi.fn(), dispose: vi.fn() },
|
|
} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
const task = {
|
|
id: "FN-978",
|
|
title: "Test Task",
|
|
description: "Test",
|
|
column: "todo",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
// Trigger the task:moved event with to='done' (should not execute)
|
|
store._trigger("task:moved", { task, from: "in-progress", to: "done" });
|
|
|
|
await Promise.resolve();
|
|
|
|
// Verify no agent was created
|
|
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
describe("merge-state reset when returning to in-progress (FN-2883)", () => {
|
|
it("resets merge state on in-review → in-progress move", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
const executeSpy = vi.spyOn(executor, "execute").mockResolvedValue(undefined);
|
|
|
|
const movedTask = {
|
|
id: "FN-2883-A",
|
|
title: "Merge retry",
|
|
description: "desc",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [
|
|
{ name: "Step 0: Preflight", status: "done" },
|
|
{ name: "Step 1: Implementation", status: "done" },
|
|
{ name: "Step 2: Testing & Verification", status: "done" },
|
|
{ name: "Step 3: Documentation & Delivery", status: "done" },
|
|
],
|
|
currentStep: 3,
|
|
log: [],
|
|
mergeDetails: { strategy: "manual" } as any,
|
|
mergeRetries: 2,
|
|
verificationFailureCount: 0,
|
|
workflowStepResults: [{ id: "wf-1", status: "passed" }],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
store.getTask.mockResolvedValue(movedTask);
|
|
store._trigger("task:moved", { task: movedTask, from: "in-review", to: "in-progress" });
|
|
await waitForAsyncExpectation(() => {
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-2883-A", expect.objectContaining({
|
|
mergeDetails: null,
|
|
mergeRetries: 0,
|
|
verificationFailureCount: 0,
|
|
workflowStepResults: [],
|
|
}));
|
|
});
|
|
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-2883-A", expect.objectContaining({
|
|
mergeDetails: null,
|
|
mergeRetries: 0,
|
|
verificationFailureCount: 0,
|
|
workflowStepResults: [],
|
|
}));
|
|
expect(store.updateStep).toHaveBeenCalledWith("FN-2883-A", 3, "pending");
|
|
await waitForAsyncExpectation(() => {
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-2883-A",
|
|
expect.stringContaining("Task returned to in-progress from in-review column"),
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
expect(executeSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it("resets merge state on done → in-progress move", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
vi.spyOn(executor, "execute").mockResolvedValue(undefined);
|
|
|
|
const movedTask = {
|
|
id: "FN-2883-B",
|
|
title: "Done rollback",
|
|
description: "desc",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [
|
|
{ name: "Step 0: Preflight", status: "done" },
|
|
{ name: "Step 1: Testing & Verification", status: "done" },
|
|
{ name: "Step 2: Documentation & Delivery", status: "done" },
|
|
],
|
|
currentStep: 2,
|
|
log: [],
|
|
mergeDetails: { strategy: "ours" } as any,
|
|
mergeRetries: 1,
|
|
verificationFailureCount: 0,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
store.getTask.mockResolvedValue(movedTask);
|
|
store._trigger("task:moved", { task: movedTask, from: "done", to: "in-progress" });
|
|
await waitForAsyncExpectation(() => {
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-2883-B", expect.objectContaining({
|
|
mergeDetails: null,
|
|
mergeRetries: 0,
|
|
verificationFailureCount: 0,
|
|
workflowStepResults: [],
|
|
}));
|
|
});
|
|
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-2883-B", expect.objectContaining({
|
|
mergeDetails: null,
|
|
mergeRetries: 0,
|
|
verificationFailureCount: 0,
|
|
workflowStepResults: [],
|
|
}));
|
|
expect(store.updateStep).toHaveBeenCalledWith("FN-2883-B", 2, "pending");
|
|
await waitForAsyncExpectation(() => {
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-2883-B",
|
|
expect.stringContaining("Task returned to in-progress from done column"),
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("preserves verificationFailureCount for merge remediation cycles even if status was cleared", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
vi.spyOn(executor, "execute").mockResolvedValue(undefined);
|
|
|
|
const movedTask = {
|
|
id: "FN-2883-D",
|
|
title: "Verification remediation",
|
|
description: "desc",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [{ name: "Step 2: Testing & Verification", status: "done" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
mergeDetails: { strategy: "manual" } as any,
|
|
mergeRetries: 0,
|
|
status: null,
|
|
verificationFailureCount: 2,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
store.getTask.mockResolvedValue(movedTask);
|
|
store._trigger("task:moved", { task: movedTask, from: "in-review", to: "in-progress" });
|
|
await waitForAsyncExpectation(() => {
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-2883-D", expect.objectContaining({
|
|
mergeDetails: null,
|
|
mergeRetries: 0,
|
|
verificationFailureCount: 2,
|
|
workflowStepResults: [],
|
|
}));
|
|
});
|
|
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-2883-D", expect.objectContaining({
|
|
mergeDetails: null,
|
|
mergeRetries: 0,
|
|
verificationFailureCount: 2,
|
|
workflowStepResults: [],
|
|
}));
|
|
});
|
|
|
|
it("does not reset merge state on todo → in-progress move", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
vi.spyOn(executor, "execute").mockResolvedValue(undefined);
|
|
|
|
const movedTask = {
|
|
id: "FN-2883-C",
|
|
title: "Fresh start",
|
|
description: "desc",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [{ name: "Step 0: Preflight", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
mergeDetails: null,
|
|
mergeRetries: 0,
|
|
verificationFailureCount: 0,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
store._trigger("task:moved", { task: movedTask, from: "todo", to: "in-progress" });
|
|
await Promise.resolve();
|
|
|
|
expect(store.updateTask).not.toHaveBeenCalledWith("FN-2883-C", expect.objectContaining({ mergeDetails: null }));
|
|
expect(store.updateStep).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("when task is moved away from in-progress", () => {
|
|
it("terminates active session and removes from activeSessions map", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
const disposeSpy = vi.fn();
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: disposeSpy,
|
|
steer: vi.fn(),
|
|
};
|
|
|
|
// Simulate an active session
|
|
(executor as any).activeSessions.set("FN-001", {
|
|
session: mockSession,
|
|
seenSteeringIds: new Set(),
|
|
});
|
|
|
|
const task = {
|
|
id: "FN-001",
|
|
title: "Test Task",
|
|
description: "Test",
|
|
column: "todo" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
// Trigger task:moved away from in-progress
|
|
store._trigger("task:moved", { task, from: "in-progress", to: "todo" });
|
|
|
|
await waitForAsyncExpectation(() => {
|
|
expect(disposeSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
// Verify session was disposed and removed from map
|
|
expect(disposeSpy).toHaveBeenCalled();
|
|
expect((executor as any).activeSessions.has("FN-001")).toBe(false);
|
|
// Verify task was added to pausedAborted set
|
|
expect((executor as any).pausedAborted.has("FN-001")).toBe(true);
|
|
});
|
|
|
|
it("terminates active step executor when task is moved away", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
const mockTerminateAllSessions = vi.fn().mockResolvedValue(undefined);
|
|
const mockStepExecutor = {
|
|
executeAll: vi.fn().mockResolvedValue([]),
|
|
terminateAllSessions: mockTerminateAllSessions,
|
|
cleanup: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
|
|
// Simulate an active step executor
|
|
(executor as any).activeStepExecutors.set("FN-002", mockStepExecutor as any);
|
|
|
|
const task = {
|
|
id: "FN-002",
|
|
title: "Test Task",
|
|
description: "Test",
|
|
column: "todo" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
// Trigger task:moved away from in-progress
|
|
store._trigger("task:moved", { task, from: "in-progress", to: "triage" });
|
|
|
|
await waitForAsyncExpectation(() => {
|
|
expect(mockTerminateAllSessions).toHaveBeenCalled();
|
|
});
|
|
|
|
// Verify terminateAllSessions was called
|
|
expect(mockTerminateAllSessions).toHaveBeenCalled();
|
|
// Verify removed from map
|
|
expect((executor as any).activeStepExecutors.has("FN-002")).toBe(false);
|
|
});
|
|
|
|
it("handles graceful no-op when no active session exists", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
// No active session set
|
|
|
|
const task = {
|
|
id: "FN-003",
|
|
title: "Test Task",
|
|
description: "Test",
|
|
column: "todo" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
// Should not throw
|
|
expect(() => {
|
|
store._trigger("task:moved", { task, from: "in-progress", to: "triage" });
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it("untracks task from stuck detector when moved away", async () => {
|
|
const store = createMockStore();
|
|
const untrackSpy = vi.fn();
|
|
const stuckDetector = {
|
|
trackTask: vi.fn(),
|
|
recordActivity: vi.fn(),
|
|
recordProgress: vi.fn(),
|
|
untrackTask: untrackSpy,
|
|
};
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {
|
|
stuckTaskDetector: stuckDetector as any,
|
|
});
|
|
|
|
const disposeSpy = vi.fn();
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: disposeSpy,
|
|
steer: vi.fn(),
|
|
};
|
|
|
|
(executor as any).activeSessions.set("FN-004", {
|
|
session: mockSession,
|
|
seenSteeringIds: new Set(),
|
|
});
|
|
|
|
const task = {
|
|
id: "FN-004",
|
|
title: "Test Task",
|
|
description: "Test",
|
|
column: "todo" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
store._trigger("task:moved", { task, from: "in-progress", to: "todo" });
|
|
|
|
await waitForAsyncExpectation(() => {
|
|
expect(untrackSpy).toHaveBeenCalledWith("FN-004");
|
|
});
|
|
});
|
|
|
|
it("adds task to pausedAborted set to prevent re-execution", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
const disposeSpy = vi.fn();
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: disposeSpy,
|
|
steer: vi.fn(),
|
|
};
|
|
|
|
(executor as any).activeSessions.set("FN-005", {
|
|
session: mockSession,
|
|
seenSteeringIds: new Set(),
|
|
});
|
|
|
|
const task = {
|
|
id: "FN-005",
|
|
title: "Test Task",
|
|
description: "Test",
|
|
column: "triage" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
store._trigger("task:moved", { task, from: "in-progress", to: "triage" });
|
|
|
|
await waitForAsyncExpectation(() => {
|
|
expect((executor as any).pausedAborted.has("FN-005")).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
it("tracks task with stuck detector after session creation", async () => {
|
|
const store = createMockStore();
|
|
const stuckDetector = {
|
|
trackTask: vi.fn(),
|
|
recordActivity: vi.fn(),
|
|
recordProgress: vi.fn(),
|
|
untrackTask: vi.fn(),
|
|
};
|
|
|
|
const session = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
};
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({ session } as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {
|
|
stuckTaskDetector: stuckDetector as any,
|
|
});
|
|
|
|
const task = {
|
|
id: "FN-978",
|
|
title: "Test Task",
|
|
description: "Test",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
await executor.execute(task);
|
|
|
|
// Verify trackTask was called with task ID
|
|
expect(stuckDetector.trackTask).toHaveBeenCalledWith("FN-978", expect.anything());
|
|
// Verify recordActivity was called (heartbeat on prompt start)
|
|
expect(stuckDetector.recordActivity).toHaveBeenCalledWith("FN-978");
|
|
// Verify untrackTask was called in the finally block
|
|
expect(stuckDetector.untrackTask).toHaveBeenCalledWith("FN-978");
|
|
});
|
|
|
|
it("records activity via AgentLogger onText callbacks", async () => {
|
|
const store = createMockStore();
|
|
const stuckDetector = {
|
|
trackTask: vi.fn(),
|
|
recordActivity: vi.fn(),
|
|
recordProgress: vi.fn(),
|
|
untrackTask: vi.fn(),
|
|
};
|
|
|
|
let capturedOnText: ((delta: string) => void) | undefined;
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
// Capture the onText callback that's passed to createFnAgent
|
|
capturedOnText = opts.onText;
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
// Simulate the agent producing text output
|
|
if (capturedOnText) {
|
|
capturedOnText("Hello world");
|
|
}
|
|
}),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const onAgentText = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", {
|
|
stuckTaskDetector: stuckDetector as any,
|
|
onAgentText,
|
|
});
|
|
|
|
const task = {
|
|
id: "FN-978",
|
|
title: "Test Task",
|
|
description: "Test",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
await executor.execute(task);
|
|
|
|
// Verify that recordActivity was called (at least once for the initial heartbeat
|
|
// and possibly more for the simulated text output)
|
|
expect(stuckDetector.recordActivity).toHaveBeenCalledWith("FN-978");
|
|
// The initial recordActivity + text callback should result in multiple calls
|
|
expect(stuckDetector.recordActivity.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
// Verify onAgentText callback was called with the delta
|
|
expect(onAgentText).toHaveBeenCalledWith("FN-978", "Hello world");
|
|
});
|
|
|
|
it("records activity via AgentLogger onToolStart callbacks", async () => {
|
|
const store = createMockStore();
|
|
const stuckDetector = {
|
|
trackTask: vi.fn(),
|
|
recordActivity: vi.fn(),
|
|
recordProgress: vi.fn(),
|
|
untrackTask: vi.fn(),
|
|
};
|
|
|
|
let capturedOnToolStart: ((name: string, args?: Record<string, unknown>) => void) | undefined;
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
capturedOnToolStart = opts.onToolStart;
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockImplementation(async () => {
|
|
// Simulate the agent calling a tool
|
|
if (capturedOnToolStart) {
|
|
capturedOnToolStart("bash", { command: "echo test" });
|
|
}
|
|
}),
|
|
dispose: vi.fn(),
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const onAgentTool = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", {
|
|
stuckTaskDetector: stuckDetector as any,
|
|
onAgentTool,
|
|
});
|
|
|
|
const task = {
|
|
id: "FN-978",
|
|
title: "Test Task",
|
|
description: "Test",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
await executor.execute(task);
|
|
|
|
// Verify that recordActivity was called for the tool usage
|
|
expect(stuckDetector.recordActivity).toHaveBeenCalledWith("FN-978");
|
|
// Verify onAgentTool callback was called with the tool name
|
|
expect(onAgentTool).toHaveBeenCalledWith("FN-978", "bash");
|
|
});
|
|
|
|
it("feeds ignored fn_task_update rebuffs into the stuck detector", async () => {
|
|
const store = createMockStore();
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-001",
|
|
steps: [{ name: "Implement", status: "done" }],
|
|
});
|
|
store.updateStep.mockResolvedValue({
|
|
stuckKillCount: 2,
|
|
steps: [{ name: "Implement", status: "done" }],
|
|
});
|
|
|
|
const stuckDetector = {
|
|
recordProgress: vi.fn(),
|
|
recordIgnoredStepUpdate: vi.fn(),
|
|
getIgnoredStepUpdateCount: vi.fn().mockReturnValue(25),
|
|
};
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {
|
|
stuckTaskDetector: stuckDetector as any,
|
|
});
|
|
(executor as any).loopRecoveryState.set("FN-001", { attempts: 1, pending: false });
|
|
|
|
const tool = (executor as any).createTaskUpdateTool(
|
|
"FN-001",
|
|
new Map(),
|
|
{ current: null },
|
|
new Map(),
|
|
stuckDetector,
|
|
);
|
|
|
|
const result = await tool.execute("call-1", { step: 1, status: "in-progress" });
|
|
|
|
expect(stuckDetector.recordIgnoredStepUpdate).toHaveBeenCalledWith("FN-001");
|
|
expect(result.content[0].text).toContain("already done");
|
|
expect(executorLog.warn).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
"FN-001: no-progress churn detected (ignoredStepUpdates=25, stuckKillStreak=2) — escalating to STUCK_NO_PROGRESS_CHURN",
|
|
),
|
|
);
|
|
});
|
|
|
|
it("marks loop recovery as observed after successful compaction", async () => {
|
|
const store = createMockStore();
|
|
const stuckDetector = {
|
|
markLoopObserved: vi.fn(),
|
|
};
|
|
const executor = new TaskExecutor(store, "/tmp/test", {
|
|
stuckTaskDetector: stuckDetector as any,
|
|
});
|
|
|
|
const session = {
|
|
compact: vi.fn(async () => ({ summary: "Compacted conversation", tokensBefore: 150000 })),
|
|
steer: vi.fn(async () => {}),
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
};
|
|
(executor as any).activeSessions.set("FN-001", {
|
|
session,
|
|
seenSteeringIds: new Set(),
|
|
});
|
|
|
|
const result = await executor.handleLoopDetected({
|
|
taskId: "FN-001",
|
|
reason: "loop",
|
|
noProgressMs: 600000,
|
|
inactivityMs: 0,
|
|
activitySinceProgress: 100,
|
|
ignoredStepUpdateCount: 0,
|
|
shouldRequeue: true,
|
|
});
|
|
|
|
expect(result).toBe(true);
|
|
expect(stuckDetector.markLoopObserved).toHaveBeenCalledWith("FN-001");
|
|
});
|
|
|
|
it("prevents duplicate execution when task:moved fires twice for same task", async () => {
|
|
const store = createMockStore();
|
|
let resolvePrompt: (() => void) | undefined;
|
|
const session = {
|
|
prompt: vi.fn().mockImplementation(
|
|
() => new Promise<void>((resolve) => {
|
|
resolvePrompt = resolve;
|
|
}),
|
|
),
|
|
dispose: vi.fn(),
|
|
};
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({ session } as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
const task = {
|
|
id: "FN-978",
|
|
title: "Test Task",
|
|
description: "Test",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
// Trigger the event twice quickly while the first execution is still in flight.
|
|
store._trigger("task:moved", { task, from: "todo", to: "in-progress" });
|
|
store._trigger("task:moved", { task, from: "todo", to: "in-progress" });
|
|
|
|
await waitForAsyncExpectation(() => {
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
|
|
expect(session.prompt).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
resolvePrompt?.();
|
|
await Promise.resolve();
|
|
|
|
// The executing guard prevents duplicate execution from the event handler.
|
|
// Verify that store.on was called with task:moved (listener registered)
|
|
expect(store.on).toHaveBeenCalledWith("task:moved", expect.any(Function));
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
|
|
expect(session.prompt).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("logs error when execute() fails in task:moved handler", async () => {
|
|
const store = createMockStore();
|
|
mockedCreateFnAgent.mockRejectedValue(new Error("model not found"));
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
|
|
const task = {
|
|
id: "FN-978",
|
|
title: "Test Task",
|
|
description: "Test",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
// Trigger the event
|
|
store._trigger("task:moved", { task, from: "todo", to: "in-progress" });
|
|
|
|
await waitForAsyncExpectation(() => {
|
|
expect(onError).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: "FN-978" }),
|
|
expect.any(Error),
|
|
);
|
|
});
|
|
|
|
// Verify the error handler was called
|
|
expect(onError).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: "FN-978" }),
|
|
expect.any(Error),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("FN-2883 fast-path guards", () => {
|
|
beforeEach(() => {
|
|
resetExecutorMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
it("execute() defensively clears stale mergeDetails for in-progress tasks", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
const cleanupSpy = vi.spyOn(executor as any, "cleanupMergeStateForReverification")
|
|
.mockResolvedValue({
|
|
id: "FN-2883-D",
|
|
title: "stale merge",
|
|
description: "desc",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Step 0", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
mergeDetails: null,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
const task = {
|
|
id: "FN-2883-D",
|
|
title: "stale merge",
|
|
description: "desc",
|
|
column: "in-progress" as const,
|
|
dependencies: [],
|
|
steps: [{ name: "Step 0", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
mergeDetails: { strategy: "theirs" } as any,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
await executor.execute(task as any);
|
|
|
|
expect(cleanupSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: "FN-2883-D" }),
|
|
expect.stringContaining("stale merge state"),
|
|
);
|
|
});
|
|
|
|
it("resumeOrphaned does not fast-path completed tasks that still have mergeDetails", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
store.listTasks.mockResolvedValue([
|
|
{
|
|
id: "FN-2883-E",
|
|
title: "orphan",
|
|
description: "desc",
|
|
column: "in-progress",
|
|
paused: false,
|
|
dependencies: [],
|
|
steps: [
|
|
{ name: "Step 0", status: "done" },
|
|
{ name: "Step 1", status: "done" },
|
|
],
|
|
currentStep: 1,
|
|
log: [],
|
|
mergeDetails: { strategy: "manual" },
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
]);
|
|
|
|
const executeSpy = vi.spyOn(executor, "execute").mockResolvedValue(undefined);
|
|
const recoverSpy = vi.spyOn(executor, "recoverCompletedTask").mockResolvedValue(false);
|
|
|
|
await executor.resumeOrphaned();
|
|
|
|
expect(recoverSpy).not.toHaveBeenCalled();
|
|
expect(executeSpy).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-2883-E" }));
|
|
});
|
|
});
|
|
|
|
describe("TaskExecutor watchdogs", () => {
|
|
beforeEach(() => {
|
|
resetExecutorMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("recovers a completed task still stuck in-progress after the completion watchdog delay", async () => {
|
|
const store = createMockStore();
|
|
const stuckTask = {
|
|
id: "FN-WD-1",
|
|
title: "Watchdog test",
|
|
description: "desc",
|
|
column: "in-progress" as const,
|
|
paused: false,
|
|
dependencies: [],
|
|
steps: [{ name: "Step 0", status: "done" as const }],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
store.getTask.mockImplementation(async () => stuckTask);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
const recoverSpy = vi.spyOn(executor, "recoverCompletedTask").mockResolvedValue(true);
|
|
|
|
(executor as any).scheduleCompletedTaskWatchdog("FN-WD-1", "fn_task_done");
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
|
|
expect(recoverSpy).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-WD-1" }));
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-WD-1",
|
|
expect.stringContaining("Watchdog: task remained in-progress 60s after fn_task_done"),
|
|
);
|
|
});
|
|
|
|
it("clears the completion watchdog once the task leaves in-progress", async () => {
|
|
const store = createMockStore();
|
|
const stuckTask = {
|
|
id: "FN-WD-2",
|
|
title: "Watchdog clear test",
|
|
description: "desc",
|
|
column: "in-progress" as const,
|
|
paused: false,
|
|
dependencies: [],
|
|
steps: [{ name: "Step 0", status: "done" as const }],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
store.getTask.mockImplementation(async () => stuckTask);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
const recoverSpy = vi.spyOn(executor, "recoverCompletedTask").mockResolvedValue(true);
|
|
|
|
(executor as any).scheduleCompletedTaskWatchdog("FN-WD-2", "fn_task_done");
|
|
store._trigger("task:moved", { task: { id: "FN-WD-2" }, from: "in-progress", to: "in-review" });
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
|
|
expect(recoverSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("retries a stalled workflow rerun handoff once after the watchdog delay", async () => {
|
|
const store = createMockStore();
|
|
const mutableTask: {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
column: "in-progress" | "todo";
|
|
paused: boolean;
|
|
worktree: string;
|
|
dependencies: string[];
|
|
steps: { name: string; status: "done" }[];
|
|
currentStep: number;
|
|
log: unknown[];
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
} = {
|
|
id: "FN-WD-3",
|
|
title: "Workflow watchdog test",
|
|
description: "desc",
|
|
column: "in-progress",
|
|
paused: false,
|
|
worktree: "/tmp/fn-wd-3",
|
|
dependencies: [],
|
|
steps: [{ name: "Step 0", status: "done" as const }],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
let inProgressAttempts = 0;
|
|
|
|
store.getTask.mockImplementation(async () => mutableTask as any);
|
|
store.updateTask.mockImplementation(async (_taskId: string, patch: any) => {
|
|
if (patch.worktree !== undefined) {
|
|
mutableTask.worktree = patch.worktree;
|
|
}
|
|
return {};
|
|
});
|
|
store.moveTask.mockImplementation(async (_taskId: string, column: string) => {
|
|
if (column === "todo") {
|
|
mutableTask.column = "todo";
|
|
return {};
|
|
}
|
|
if (column === "in-progress") {
|
|
inProgressAttempts += 1;
|
|
if (inProgressAttempts === 1) {
|
|
throw new Error("guard still unwinding");
|
|
}
|
|
mutableTask.column = "in-progress";
|
|
}
|
|
return {};
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
(executor as any).scheduleWorkflowRerun(
|
|
"FN-WD-3",
|
|
"/tmp/fn-wd-3",
|
|
"FN-WD-3: workflow step retry scheduled — moved to todo then in-progress",
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(mutableTask.column).toBe("todo");
|
|
|
|
await vi.advanceTimersByTimeAsync(15_000);
|
|
|
|
expect(inProgressAttempts).toBe(2);
|
|
expect(mutableTask.column).toBe("in-progress");
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-WD-3",
|
|
expect.stringContaining("Watchdog: workflow rerun handoff stalled for 15s"),
|
|
);
|
|
});
|
|
|
|
it("defers workflow rerun bounce while global pause is active", async () => {
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
autoMerge: false,
|
|
globalPause: true,
|
|
enginePaused: false,
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
const outcome = await (executor as any).performWorkflowRerunBounce("FN-WD-PAUSE", "/tmp/fn-wd-pause");
|
|
|
|
expect(outcome).toBe("deferred-paused");
|
|
expect(store.getTask).not.toHaveBeenCalled();
|
|
expect(store.moveTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("preserves the original executionStartedAt during a workflow rerun bounce", async () => {
|
|
const store = createMockStore();
|
|
const originalExecutionStartedAt = "2026-04-30T05:06:43.781Z";
|
|
const mutableTask: {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
column: "in-progress" | "todo";
|
|
paused: boolean;
|
|
worktree: string;
|
|
executionStartedAt: string;
|
|
dependencies: string[];
|
|
steps: { name: string; status: string }[];
|
|
currentStep: number;
|
|
log: unknown[];
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
} = {
|
|
id: "FN-WD-4",
|
|
title: "Workflow rerun timing",
|
|
description: "desc",
|
|
column: "in-progress",
|
|
paused: false,
|
|
worktree: "/tmp/fn-wd-4",
|
|
executionStartedAt: originalExecutionStartedAt,
|
|
dependencies: [],
|
|
steps: [{ name: "Step 0", status: "done" as const }],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
store.getTask.mockResolvedValue(mutableTask as any);
|
|
store.moveTask.mockImplementation(async (_taskId: string, column: string) => {
|
|
mutableTask.column = column as "in-progress" | "todo";
|
|
return { ...mutableTask };
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
const outcome = await (executor as any).performWorkflowRerunBounce("FN-WD-4", "/tmp/fn-wd-4");
|
|
|
|
expect(outcome).toBe("bounced");
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-WD-4", {
|
|
worktree: "/tmp/fn-wd-4",
|
|
executionStartedAt: originalExecutionStartedAt,
|
|
});
|
|
expect(store.moveTask.mock.calls).toEqual([
|
|
["FN-WD-4", "todo", { preserveResumeState: true, preserveWorktree: true }],
|
|
["FN-WD-4", "in-progress"],
|
|
]);
|
|
});
|
|
});
|
|
|
|
// ── StepSessionExecutor integration tests ──────────────────────────────────
|
|
|
|
describe("StepSessionExecutor integration", () => {
|
|
beforeEach(() => {
|
|
resetExecutorMocks();
|
|
mockExecuteAll.mockResolvedValue([]);
|
|
mockTerminateAllSessions.mockResolvedValue(undefined);
|
|
mockCleanup.mockResolvedValue(undefined);
|
|
});
|
|
|
|
/** Helper to create a task with steps for step-session mode testing */
|
|
function createTaskWithSteps(overrides: Partial<Task> = {}): Task {
|
|
return {
|
|
id: "FN-200",
|
|
title: "Step-session test task",
|
|
description: "Test task with steps for step-session execution",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [
|
|
{ name: "Step 0", status: "pending" },
|
|
{ name: "Step 1", status: "pending" },
|
|
],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** Helper to create a store configured for step-session mode */
|
|
function createStepSessionStore() {
|
|
const store = createMockStore();
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
runStepsInNewSessions: true,
|
|
maxParallelSteps: 2,
|
|
});
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-200",
|
|
title: "Step-session test task",
|
|
description: "Test task with steps for step-session execution",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [
|
|
{ name: "Step 0", status: "pending" },
|
|
{ name: "Step 1", status: "pending" },
|
|
],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check\n### Step 1: Implement\n- [ ] code",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
baseCommitSha: "abc123",
|
|
enabledWorkflowSteps: [],
|
|
});
|
|
return store;
|
|
}
|
|
|
|
function createTokenUsageStepSessionStore(overrides: Partial<Task> = {}) {
|
|
const store = createStepSessionStore();
|
|
const taskState: Task = {
|
|
...createTaskWithSteps({
|
|
assignedAgentId: "agent-001",
|
|
baseCommitSha: "abc123",
|
|
enabledWorkflowSteps: [],
|
|
}),
|
|
...overrides,
|
|
};
|
|
|
|
store.getTask.mockImplementation(async () => ({ ...taskState }));
|
|
store.updateTask.mockImplementation(async (_taskId: string, updates: Record<string, unknown>) => {
|
|
if (updates.tokenUsage !== undefined) {
|
|
(taskState as Task).tokenUsage = updates.tokenUsage as Task["tokenUsage"];
|
|
}
|
|
if (updates.status !== undefined) {
|
|
(taskState as Task).status = updates.status as Task["status"];
|
|
}
|
|
if (updates.error !== undefined) {
|
|
(taskState as Task).error = updates.error as Task["error"];
|
|
}
|
|
return {};
|
|
});
|
|
|
|
return { store, taskState };
|
|
}
|
|
|
|
it("uses step-session path when runStepsInNewSessions is true", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
// StepSessionExecutor constructor should have been called
|
|
expect(mockedStepSessionExecutor).toHaveBeenCalled();
|
|
// executeAll should have been called
|
|
expect(mockExecuteAll).toHaveBeenCalledOnce();
|
|
// createFnAgent should NOT have been called for step-session path
|
|
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses single-session path when runStepsInNewSessions is false (default)", async () => {
|
|
const store = createMockStore();
|
|
// Default settings: no runStepsInNewSessions
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
});
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-200",
|
|
title: "Test",
|
|
description: "Test",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [{ name: "Step 0", status: "pending" }],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
},
|
|
} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
// Should NOT use step-session executor
|
|
expect(mockedStepSessionExecutor).not.toHaveBeenCalled();
|
|
// Should use the traditional single-session agent
|
|
expect(mockedCreateFnAgent).toHaveBeenCalled();
|
|
});
|
|
|
|
it("success path moves task to in-review and calls onComplete", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
mockExecuteAll.mockResolvedValue([
|
|
{ stepIndex: 0, success: true, retries: 0 },
|
|
{ stepIndex: 1, success: true, retries: 0 },
|
|
]);
|
|
|
|
const onComplete = vi.fn();
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError });
|
|
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
expect(mockExecuteAll).toHaveBeenCalledOnce();
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-200", "in-review");
|
|
expect(onComplete).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-200" }));
|
|
expect(onError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("failure path marks task as failed with step error summary", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
mockExecuteAll.mockResolvedValue([
|
|
{ stepIndex: 0, success: true, retries: 0 },
|
|
{ stepIndex: 1, success: false, error: "compilation error", retries: 3 },
|
|
]);
|
|
|
|
const onComplete = vi.fn();
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError });
|
|
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-200", {
|
|
status: "failed",
|
|
error: "Step 1: compilation error",
|
|
});
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-200", "in-review");
|
|
expect(onError).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: "FN-200" }),
|
|
expect.objectContaining({ message: "Step 1: compilation error" }),
|
|
);
|
|
expect(onComplete).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("persists aggregated tokenUsage from step-session results", async () => {
|
|
const { store } = createTokenUsageStepSessionStore();
|
|
mockExecuteAll.mockResolvedValue([
|
|
{
|
|
stepIndex: 0,
|
|
success: true,
|
|
retries: 0,
|
|
tokenUsage: { inputTokens: 22, outputTokens: 8, cachedTokens: 3, totalTokens: 33 },
|
|
},
|
|
{
|
|
stepIndex: 1,
|
|
success: true,
|
|
retries: 0,
|
|
tokenUsage: { inputTokens: 28, outputTokens: 13, cachedTokens: 1, totalTokens: 42 },
|
|
},
|
|
]);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
const tokenUsageUpdates = store.updateTask.mock.calls
|
|
.filter(([, updates]: [string, Record<string, unknown>]) => updates.tokenUsage)
|
|
.map(([, updates]: [string, Record<string, unknown>]) => updates.tokenUsage as Record<string, unknown>);
|
|
|
|
expect(tokenUsageUpdates.length).toBeGreaterThan(0);
|
|
expect(tokenUsageUpdates[tokenUsageUpdates.length - 1]).toEqual(
|
|
expect.objectContaining({
|
|
inputTokens: 50,
|
|
outputTokens: 21,
|
|
cachedTokens: 4,
|
|
totalTokens: 75,
|
|
firstUsedAt: expect.any(String),
|
|
lastUsedAt: expect.any(String),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("persists tokenUsage incrementally during step execution before in-review transition", async () => {
|
|
const { store } = createTokenUsageStepSessionStore();
|
|
|
|
mockedStepSessionExecutor.mockImplementationOnce(function (options: any) {
|
|
return {
|
|
executeAll: vi.fn(async () => {
|
|
options.onStepComplete(0, {
|
|
stepIndex: 0,
|
|
success: true,
|
|
retries: 0,
|
|
tokenUsage: { inputTokens: 20, outputTokens: 10, cachedTokens: 2, totalTokens: 32 },
|
|
});
|
|
await Promise.resolve();
|
|
|
|
options.onStepComplete(1, {
|
|
stepIndex: 1,
|
|
success: true,
|
|
retries: 0,
|
|
tokenUsage: { inputTokens: 30, outputTokens: 5, cachedTokens: 1, totalTokens: 36 },
|
|
});
|
|
await Promise.resolve();
|
|
|
|
return [
|
|
{
|
|
stepIndex: 0,
|
|
success: true,
|
|
retries: 0,
|
|
tokenUsage: { inputTokens: 20, outputTokens: 10, cachedTokens: 2, totalTokens: 32 },
|
|
},
|
|
{
|
|
stepIndex: 1,
|
|
success: true,
|
|
retries: 0,
|
|
tokenUsage: { inputTokens: 30, outputTokens: 5, cachedTokens: 1, totalTokens: 36 },
|
|
},
|
|
];
|
|
}),
|
|
terminateAllSessions: mockTerminateAllSessions,
|
|
cleanup: mockCleanup,
|
|
};
|
|
} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
const tokenUsageUpdates = store.updateTask.mock.calls
|
|
.filter(([, updates]: [string, Record<string, unknown>]) => updates.tokenUsage)
|
|
.map(([, updates]: [string, Record<string, unknown>]) => updates.tokenUsage as Record<string, unknown>);
|
|
|
|
expect(tokenUsageUpdates).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
inputTokens: 20,
|
|
outputTokens: 10,
|
|
cachedTokens: 2,
|
|
totalTokens: 32,
|
|
}),
|
|
expect.objectContaining({
|
|
inputTokens: 50,
|
|
outputTokens: 15,
|
|
cachedTokens: 3,
|
|
totalTokens: 68,
|
|
}),
|
|
]),
|
|
);
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-200", "in-review");
|
|
});
|
|
|
|
it("persists task tokenUsage on step-session failure paths so partial usage is visible", async () => {
|
|
const { store } = createTokenUsageStepSessionStore();
|
|
mockExecuteAll.mockResolvedValue([
|
|
{
|
|
stepIndex: 0,
|
|
success: true,
|
|
retries: 0,
|
|
tokenUsage: { inputTokens: 14, outputTokens: 6, cachedTokens: 2, totalTokens: 22 },
|
|
},
|
|
{
|
|
stepIndex: 1,
|
|
success: false,
|
|
error: "lint failed",
|
|
retries: 1,
|
|
tokenUsage: { inputTokens: 7, outputTokens: 3, cachedTokens: 1, totalTokens: 11 },
|
|
},
|
|
]);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
const tokenUsageUpdates = store.updateTask.mock.calls
|
|
.filter(([, updates]: [string, Record<string, unknown>]) => updates.tokenUsage)
|
|
.map(([, updates]: [string, Record<string, unknown>]) => updates.tokenUsage as Record<string, unknown>);
|
|
|
|
expect(tokenUsageUpdates[tokenUsageUpdates.length - 1]).toEqual(
|
|
expect.objectContaining({
|
|
inputTokens: 21,
|
|
outputTokens: 9,
|
|
cachedTokens: 3,
|
|
totalTokens: 33,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("persists tokenUsage from session.getSessionStats in single-session mode", async () => {
|
|
const store = createMockStore();
|
|
const taskState = createTaskWithSteps({
|
|
description: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
steps: [{ name: "Step 0", status: "done" }],
|
|
currentStep: 0,
|
|
});
|
|
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
runStepsInNewSessions: false,
|
|
});
|
|
store.getTask.mockImplementation(async () => ({ ...taskState }));
|
|
store.updateTask.mockImplementation(async (_taskId: string, updates: Record<string, unknown>) => {
|
|
if (updates.tokenUsage !== undefined) {
|
|
(taskState as Task).tokenUsage = updates.tokenUsage as Task["tokenUsage"];
|
|
}
|
|
if (updates.status !== undefined) {
|
|
(taskState as Task).status = updates.status as Task["status"];
|
|
}
|
|
return {};
|
|
});
|
|
|
|
const session = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
abortBash: vi.fn(),
|
|
state: {},
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
getSessionStats: vi.fn().mockReturnValue({
|
|
tokens: {
|
|
input: 31,
|
|
output: 17,
|
|
cacheRead: 5,
|
|
cacheWrite: 2,
|
|
total: 55,
|
|
},
|
|
}),
|
|
};
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({ session } as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
await executor.execute(taskState);
|
|
|
|
expect(store.updateTask).toHaveBeenCalledWith(
|
|
"FN-200",
|
|
expect.objectContaining({
|
|
tokenUsage: expect.objectContaining({
|
|
inputTokens: 31,
|
|
outputTokens: 17,
|
|
cachedTokens: 5,
|
|
cacheWriteTokens: 2,
|
|
totalTokens: 55,
|
|
}),
|
|
}),
|
|
);
|
|
expect(session.getSessionStats).toHaveBeenCalled();
|
|
});
|
|
|
|
it("persists single-session tokenUsage on failure so partial usage is visible", async () => {
|
|
const store = createMockStore();
|
|
const taskState = createTaskWithSteps({
|
|
description: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
steps: [{ name: "Step 0", status: "pending" }],
|
|
currentStep: 0,
|
|
});
|
|
|
|
store.getSettings.mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: false,
|
|
runStepsInNewSessions: false,
|
|
});
|
|
store.getTask.mockImplementation(async () => ({ ...taskState }));
|
|
store.updateTask.mockImplementation(async (_taskId: string, updates: Record<string, unknown>) => {
|
|
if (updates.tokenUsage !== undefined) {
|
|
(taskState as Task).tokenUsage = updates.tokenUsage as Task["tokenUsage"];
|
|
}
|
|
if (updates.status !== undefined) {
|
|
(taskState as Task).status = updates.status as Task["status"];
|
|
}
|
|
if (updates.error !== undefined) {
|
|
(taskState as Task).error = updates.error as Task["error"];
|
|
}
|
|
return {};
|
|
});
|
|
|
|
const session = {
|
|
prompt: vi.fn().mockRejectedValue(new Error("session failed")),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
abortBash: vi.fn(),
|
|
state: {},
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
getSessionStats: vi.fn().mockReturnValue({
|
|
tokens: {
|
|
input: 12,
|
|
output: 4,
|
|
cacheRead: 1,
|
|
cacheWrite: 0,
|
|
total: 17,
|
|
},
|
|
}),
|
|
};
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({ session } as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
await executor.execute(taskState);
|
|
|
|
const tokenUsageUpdates = store.updateTask.mock.calls
|
|
.filter(([, updates]: [string, Record<string, unknown>]) => updates.tokenUsage)
|
|
.map(([, updates]: [string, Record<string, unknown>]) => updates.tokenUsage as Record<string, unknown>);
|
|
|
|
expect(tokenUsageUpdates[tokenUsageUpdates.length - 1]).toEqual(
|
|
expect.objectContaining({
|
|
inputTokens: 12,
|
|
outputTokens: 4,
|
|
cachedTokens: 1,
|
|
totalTokens: 17,
|
|
}),
|
|
);
|
|
expect(session.getSessionStats).toHaveBeenCalled();
|
|
});
|
|
|
|
it("moves task to in-review when step-session execution fails", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
mockExecuteAll.mockRejectedValue(new Error("Infrastructure failure"));
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-200", expect.objectContaining({
|
|
status: "failed",
|
|
error: "Infrastructure failure",
|
|
}));
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-200", "in-review");
|
|
expect(onError).toHaveBeenCalled();
|
|
});
|
|
|
|
it("moves task to in-review when transient retries are exhausted (step-session)", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
mockExecuteAll.mockRejectedValue(new Error("socket hang up"));
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
|
|
await executor.execute(createTaskWithSteps({ recoveryRetryCount: 3 }));
|
|
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-200", {
|
|
status: "failed",
|
|
error: "socket hang up",
|
|
recoveryRetryCount: null,
|
|
nextRecoveryAt: null,
|
|
});
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-200", "in-review");
|
|
expect(store.moveTask).not.toHaveBeenCalledWith("FN-200", "todo");
|
|
expect(onError).toHaveBeenCalled();
|
|
});
|
|
|
|
it("pause terminates step sessions", async () => {
|
|
const store = createStepSessionStore();
|
|
const stuckDetector = {
|
|
trackTask: vi.fn(),
|
|
untrackTask: vi.fn(),
|
|
recordProgress: vi.fn(),
|
|
} as any;
|
|
|
|
// Make executeAll hang until we trigger pause
|
|
let resolveExecuteAll: () => void;
|
|
mockExecuteAll.mockReturnValue(new Promise<void>((resolve) => {
|
|
resolveExecuteAll = resolve;
|
|
}));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { stuckTaskDetector: stuckDetector });
|
|
|
|
const task = createTaskWithSteps();
|
|
|
|
// Start execution (don't await — it will hang)
|
|
const executePromise = executor.execute(task);
|
|
|
|
await waitForStepExecutorRegistration(executor, "FN-200");
|
|
|
|
// Trigger pause
|
|
store._trigger("task:updated", { ...task, paused: true });
|
|
|
|
// Resolve executeAll so the execution can complete
|
|
resolveExecuteAll!();
|
|
await executePromise;
|
|
|
|
expect(mockTerminateAllSessions).toHaveBeenCalled();
|
|
expect(stuckDetector.untrackTask).toHaveBeenCalledWith("FN-200");
|
|
});
|
|
|
|
it("stuck-kill terminates step sessions", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
// Make executeAll hang
|
|
let resolveExecuteAll: () => void;
|
|
mockExecuteAll.mockReturnValue(new Promise<void>((resolve) => {
|
|
resolveExecuteAll = resolve;
|
|
}));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
|
|
const task = createTaskWithSteps();
|
|
const executePromise = executor.execute(task);
|
|
|
|
await waitForStepExecutorRegistration(executor, "FN-200");
|
|
|
|
// Trigger stuck kill
|
|
executor.markStuckAborted("FN-200");
|
|
|
|
// Resolve executeAll so the execution can complete
|
|
resolveExecuteAll!();
|
|
await executePromise;
|
|
|
|
expect(mockTerminateAllSessions).toHaveBeenCalled();
|
|
});
|
|
|
|
// ── FN-1461: Step-session stuck retry regression tests ─────────────────────────────────────
|
|
|
|
it("REGRESSION: stuck-kill with bare task ID properly requeues step-session task to todo", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
// Make executeAll hang initially
|
|
let resolveExecuteAll: (() => void) | null = null;
|
|
mockExecuteAll.mockReturnValue(new Promise<void>((resolve) => {
|
|
resolveExecuteAll = resolve;
|
|
}));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
|
|
const task = createTaskWithSteps();
|
|
const executePromise = executor.execute(task);
|
|
|
|
await waitForStepExecutorRegistration(executor, "FN-200");
|
|
|
|
// Verify step executor is registered
|
|
expect((executor as any).activeStepExecutors.has("FN-200")).toBe(true);
|
|
|
|
// Trigger stuck kill with bare task ID (as StuckTaskDetector.onStuck would call)
|
|
executor.markStuckAborted("FN-200", true);
|
|
|
|
// Resolve executeAll to complete the execution
|
|
resolveExecuteAll!();
|
|
await executePromise;
|
|
|
|
// Verify: task should be requeued and moved to todo for retry.
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-200", expect.objectContaining({
|
|
status: "queued",
|
|
error: null,
|
|
worktree: null,
|
|
branch: null,
|
|
}));
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-200", "todo", { preserveProgress: true });
|
|
});
|
|
|
|
it("REGRESSION: stuck-kill with exhausted budget does not requeue step-session task", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
let resolveExecuteAll: (() => void) | null = null;
|
|
mockExecuteAll.mockReturnValue(new Promise<void>((resolve) => {
|
|
resolveExecuteAll = resolve;
|
|
}));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
|
|
const task = createTaskWithSteps();
|
|
const executePromise = executor.execute(task);
|
|
|
|
await waitForStepExecutorRegistration(executor, "FN-200");
|
|
|
|
// Budget exhausted — should NOT requeue
|
|
executor.markStuckAborted("FN-200", false);
|
|
|
|
resolveExecuteAll!();
|
|
await executePromise;
|
|
|
|
// Should NOT move to todo or mark as stuck-killed
|
|
expect(store.moveTask).not.toHaveBeenCalledWith("FN-200", "todo");
|
|
expect(store.moveTask).not.toHaveBeenCalledWith("FN-200", "todo", expect.anything());
|
|
expect(store.updateTask).not.toHaveBeenCalledWith("FN-200", expect.objectContaining({
|
|
status: "stuck-killed",
|
|
}));
|
|
});
|
|
|
|
it("REGRESSION: untrackTask called with bare task ID during pause in step-session mode", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
const stuckDetector = {
|
|
trackTask: vi.fn(),
|
|
untrackTask: vi.fn(),
|
|
recordProgress: vi.fn(),
|
|
} as any;
|
|
|
|
let resolveExecuteAll: (() => void) | null = null;
|
|
mockExecuteAll.mockReturnValue(new Promise<void>((resolve) => {
|
|
resolveExecuteAll = resolve;
|
|
}));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { stuckTaskDetector: stuckDetector });
|
|
|
|
const task = createTaskWithSteps();
|
|
const executePromise = executor.execute(task);
|
|
|
|
await waitForStepExecutorRegistration(executor, "FN-200");
|
|
|
|
// Trigger pause
|
|
store._trigger("task:updated", { ...task, paused: true });
|
|
|
|
resolveExecuteAll!();
|
|
await executePromise;
|
|
|
|
// In step-session mode, untrackTask is called with bare task ID "FN-200"
|
|
// But tracking was done with step-scoped keys like "FN-200-step-0"
|
|
// BUG: This test captures that the current implementation passes bare ID,
|
|
// which won't match the step-scoped tracking keys
|
|
expect(stuckDetector.untrackTask).toHaveBeenCalledWith("FN-200");
|
|
// After fix: untrackTask should also clean up any step-scoped entries
|
|
});
|
|
|
|
it("REGRESSION: StepSessionExecutor should pass bare task ID for recordProgress in onStepStart", async () => {
|
|
// Note: This test verifies the expected contract between StepSessionExecutor and StuckTaskDetector.
|
|
// In step-session mode:
|
|
// - StepSessionExecutor tracks with step-scoped keys (e.g., "FN-200-step-0")
|
|
// - But recordProgress should be called with the bare task ID for consistency
|
|
// - StuckTaskDetector.recordProgress should handle this by finding the canonical task ID
|
|
//
|
|
// Currently, StepSessionExecutor calls recordProgress(task.id) where task.id is "FN-200"
|
|
// But the entry was tracked with key "FN-200-step-0", so the lookup fails.
|
|
//
|
|
// After fix: recordProgress should handle both bare task IDs and step-scoped keys
|
|
// by extracting the canonical task ID from step-scoped keys.
|
|
//
|
|
// This test is informational - the actual fix will be in StuckTaskDetector.recordProgress()
|
|
// which should find entries by canonicalTaskId when looking up by bare task ID.
|
|
expect(true).toBe(true); // Placeholder - real test verifies StuckTaskDetector behavior
|
|
});
|
|
|
|
it("cleanup called in finally block even on error", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
mockExecuteAll.mockRejectedValue(new Error("Fatal error"));
|
|
mockCleanup.mockResolvedValue(undefined);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
// cleanup should still have been called despite the error
|
|
expect(mockCleanup).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("respects semaphore for concurrency control", async () => {
|
|
const store = createStepSessionStore();
|
|
const sem = new AgentSemaphore(2);
|
|
const runSpy = vi.spyOn(sem, "run");
|
|
|
|
mockExecuteAll.mockResolvedValue([
|
|
{ stepIndex: 0, success: true, retries: 0 },
|
|
]);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", { semaphore: sem });
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
expect(runSpy).toHaveBeenCalledOnce();
|
|
expect(sem.activeCount).toBe(0);
|
|
});
|
|
|
|
it("runs without semaphore when not provided", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
mockExecuteAll.mockResolvedValue([
|
|
{ stepIndex: 0, success: true, retries: 0 },
|
|
]);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
// Should still work fine without a semaphore
|
|
expect(mockExecuteAll).toHaveBeenCalledOnce();
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-200", "in-review");
|
|
});
|
|
|
|
it("dep-abort during step-session execution triggers cleanup", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
// Make executeAll hang so we can trigger dep-abort mid-execution
|
|
let resolveExecuteAll: () => void;
|
|
mockExecuteAll.mockReturnValue(new Promise<void>((resolve) => {
|
|
resolveExecuteAll = resolve;
|
|
}));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
|
|
const task = createTaskWithSteps();
|
|
const executePromise = executor.execute(task);
|
|
|
|
await waitForStepExecutorRegistration(executor, "FN-200");
|
|
|
|
// Simulate dep-abort by directly triggering the fn_task_add_dep cleanup logic
|
|
// The dep-abort flag should cause the step-session path to handle cleanup
|
|
// We can test this by checking that when depAborted is set, cleanup is called
|
|
// For now, just resolve and verify cleanup runs
|
|
resolveExecuteAll!();
|
|
await executePromise;
|
|
|
|
// Verify cleanup was called in finally
|
|
expect(mockCleanup).toHaveBeenCalled();
|
|
});
|
|
|
|
it("workflow steps run on success and block on failure", async () => {
|
|
const store = createStepSessionStore();
|
|
|
|
// Enable a workflow step
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-200",
|
|
title: "Step-session test task",
|
|
description: "Test task",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [
|
|
{ name: "Step 0", status: "pending" },
|
|
],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
baseCommitSha: "abc123",
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
});
|
|
|
|
store.getWorkflowStep.mockResolvedValue({
|
|
id: "WS-001",
|
|
name: "Test Workflow",
|
|
description: "Test",
|
|
mode: "script",
|
|
phase: "pre-merge",
|
|
scriptName: "test-script",
|
|
prompt: undefined,
|
|
enabled: true,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Steps succeed, but workflow step will fail
|
|
mockExecuteAll.mockResolvedValue([
|
|
{ stepIndex: 0, success: true, retries: 0 },
|
|
]);
|
|
|
|
const onError = vi.fn();
|
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
|
|
|
// Use fake timers to control the setTimeout in sendTaskBackForFix
|
|
vi.useFakeTimers();
|
|
|
|
// Exhaust retries so workflow step failure is immediate
|
|
await executor.execute(createTaskWithSteps({ steps: [{ name: "Step 0", status: "pending" }], workflowStepRetries: 3, enabledWorkflowSteps: ["WS-001"] }));
|
|
|
|
// Should have called getWorkflowStep to look up the workflow step
|
|
expect(store.getWorkflowStep).toHaveBeenCalledWith("WS-001");
|
|
// With script mode and no scripts configured, the step should fail (script not found)
|
|
// Task should be sent back to in-progress for remediation, NOT call onError
|
|
expect(store.addTaskComment).toHaveBeenCalledWith(
|
|
"FN-200",
|
|
expect.stringContaining("Workflow step failed"),
|
|
"agent",
|
|
);
|
|
// onError should NOT be called (task is being retried, not permanently failed)
|
|
expect(onError).not.toHaveBeenCalled();
|
|
|
|
// Advance timers to trigger the setTimeout that moves task to todo then in-progress
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
// Task should move to todo then in-progress (not in-review). The
|
|
// workflow-rerun bounce flags preserveResumeState so the worktree and
|
|
// accumulated step progress survive the transient todo state.
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-200", "todo", { preserveResumeState: true, preserveWorktree: true });
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-200", "in-progress");
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("onStepStart callback updates step status to in-progress", async () => {
|
|
const store = createStepSessionStore();
|
|
store.updateStep.mockResolvedValue({} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
// Capture the StepSessionExecutor constructor options
|
|
expect(mockedStepSessionExecutor).toHaveBeenCalled();
|
|
const ctorOptions = mockedStepSessionExecutor.mock.calls[mockedStepSessionExecutor.mock.calls.length - 1][0];
|
|
|
|
// Invoke the onStepStart callback
|
|
ctorOptions.onStepStart!(0);
|
|
|
|
// Should update step status in store
|
|
expect(store.updateStep).toHaveBeenCalledWith("FN-200", 0, "in-progress");
|
|
});
|
|
|
|
it("onStepComplete callback updates step status to done on success", async () => {
|
|
const store = createStepSessionStore();
|
|
store.updateStep.mockResolvedValue({} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
const ctorOptions = mockedStepSessionExecutor.mock.calls[mockedStepSessionExecutor.mock.calls.length - 1][0];
|
|
|
|
ctorOptions.onStepComplete!(0, { stepIndex: 0, success: true, retries: 0 });
|
|
|
|
expect(store.updateStep).toHaveBeenCalledWith("FN-200", 0, "done");
|
|
});
|
|
|
|
it("onStepComplete callback updates step status to skipped on failure", async () => {
|
|
const store = createStepSessionStore();
|
|
store.updateStep.mockResolvedValue({} as any);
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
const ctorOptions = mockedStepSessionExecutor.mock.calls[mockedStepSessionExecutor.mock.calls.length - 1][0];
|
|
|
|
ctorOptions.onStepComplete!(1, { stepIndex: 1, success: false, retries: 3 });
|
|
|
|
expect(store.updateStep).toHaveBeenCalledWith("FN-200", 1, "skipped");
|
|
});
|
|
|
|
it("step status update errors do not block execution", async () => {
|
|
const store = createStepSessionStore();
|
|
store.updateStep.mockRejectedValue(new Error("DB error"));
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
|
// Should not throw even when updateStep rejects
|
|
await executor.execute(createTaskWithSteps());
|
|
|
|
const ctorOptions = mockedStepSessionExecutor.mock.calls[mockedStepSessionExecutor.mock.calls.length - 1][0];
|
|
|
|
// Invoking callbacks should not throw
|
|
expect(() => ctorOptions.onStepStart!(0)).not.toThrow();
|
|
expect(() => ctorOptions.onStepComplete!(0, { stepIndex: 0, success: true, retries: 0 })).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("FN-5256 awaitAbortInFlightTaskWork pause synchronization", () => {
|
|
it("synchronously disposes activeSessions on pause via task:updated", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
let abortResolve: (() => void) | undefined;
|
|
const abortPromise = new Promise<void>((resolve) => {
|
|
abortResolve = resolve;
|
|
});
|
|
const abortSpy = vi.fn(() => abortPromise);
|
|
const disposeSpy = vi.fn();
|
|
(executor as any).activeSessions.set("FN-PAUSE-1", {
|
|
session: { abort: abortSpy, dispose: disposeSpy, steer: vi.fn() },
|
|
seenSteeringIds: new Set(),
|
|
});
|
|
|
|
const taskUpdatedHandler = (store.on as unknown as ReturnType<typeof vi.fn>).mock.calls
|
|
.find((call: any[]) => call[0] === "task:updated")?.[1] as (task: any) => Promise<void>;
|
|
expect(taskUpdatedHandler).toBeTypeOf("function");
|
|
|
|
const settled = taskUpdatedHandler({
|
|
id: "FN-PAUSE-1",
|
|
title: "pause",
|
|
description: "pause",
|
|
column: "in-progress",
|
|
paused: true,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} satisfies Task);
|
|
|
|
// Before abort resolves, dispose must NOT have been called — i.e. handler is
|
|
// truly awaiting the abort, not fire-and-forgetting it.
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
expect(disposeSpy).not.toHaveBeenCalled();
|
|
|
|
abortResolve?.();
|
|
await settled;
|
|
|
|
expect(abortSpy).toHaveBeenCalledTimes(1);
|
|
expect(disposeSpy).toHaveBeenCalledTimes(1);
|
|
expect((executor as any).activeSessions.has("FN-PAUSE-1")).toBe(false);
|
|
});
|
|
|
|
it("synchronously reaps workflow-step + step-session surfaces on pause", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
const stepTerminate = vi.fn().mockResolvedValue(undefined);
|
|
(executor as any).activeStepExecutors.set("FN-PAUSE-2", {
|
|
terminateAllSessions: stepTerminate,
|
|
});
|
|
|
|
const workflowAbort = vi.fn().mockResolvedValue(undefined);
|
|
const workflowDispose = vi.fn();
|
|
(executor as any).activeWorkflowStepSessions.set("FN-PAUSE-2", {
|
|
abort: workflowAbort,
|
|
dispose: workflowDispose,
|
|
});
|
|
|
|
const taskUpdatedHandler = (store.on as unknown as ReturnType<typeof vi.fn>).mock.calls
|
|
.find((call: any[]) => call[0] === "task:updated")?.[1] as (task: any) => Promise<void>;
|
|
|
|
await taskUpdatedHandler({
|
|
id: "FN-PAUSE-2",
|
|
title: "pause",
|
|
description: "pause",
|
|
column: "in-progress",
|
|
paused: true,
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} satisfies Task);
|
|
|
|
expect(stepTerminate).toHaveBeenCalledTimes(1);
|
|
expect(workflowAbort).toHaveBeenCalledTimes(1);
|
|
expect(workflowDispose).toHaveBeenCalledTimes(1);
|
|
expect((executor as any).activeStepExecutors.has("FN-PAUSE-2")).toBe(false);
|
|
expect((executor as any).activeWorkflowStepSessions.has("FN-PAUSE-2")).toBe(false);
|
|
});
|
|
|
|
it("awaitAbortInFlightTaskWork awaits abort before dispose for all surfaces", async () => {
|
|
const store = createMockStore();
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
|
|
const order: string[] = [];
|
|
const sessionAbort = vi.fn().mockImplementation(async () => {
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
order.push("session-abort");
|
|
});
|
|
const sessionDispose = vi.fn(() => order.push("session-dispose"));
|
|
(executor as any).activeSessions.set("FN-PAUSE-3", {
|
|
session: { abort: sessionAbort, dispose: sessionDispose, steer: vi.fn() },
|
|
seenSteeringIds: new Set(),
|
|
});
|
|
|
|
await (executor as any).awaitAbortInFlightTaskWork("FN-PAUSE-3", "test reason");
|
|
|
|
expect(order).toEqual(["session-abort", "session-dispose"]);
|
|
expect((executor as any).activeSessions.has("FN-PAUSE-3")).toBe(false);
|
|
});
|
|
});
|