Files
fusion/packages/engine/src/__tests__/executor-pause.test.ts
Fusion 979cf115d9 feat(FN-3720): split executor.test.ts into eight focused suites and add she
The merge splits the large monolithic `executor.test.ts` into seven focused test suites covering core logic, pause behavior, prompt handling, recovery, review verdicts, step sessions, and worktree operations, and adds shell context hook plumbing across the dashboard, desktop, and mobile packages to

Fusion-Task-Id: FN-3720
2026-05-07 23:21:05 -07:00

2595 lines
88 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 "@mariozechner/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);
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);
// Find the child session call
const childCall = mockedCreateFnAgent.mock.calls.find(
(call: any) => call[0].systemPrompt?.includes("child agent spawned")
);
expect(childCall).toBeDefined();
expect(childCall![0].tools).toBe("coding");
expect(childCall![0].systemPrompt).toContain("FN-SPAWN");
});
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" });
// Wait for async execution to complete
await new Promise((resolve) => setTimeout(resolve, 50));
// 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" });
// Wait for async
await new Promise((resolve) => setTimeout(resolve, 50));
// 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 new Promise((resolve) => setTimeout(resolve, 20));
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");
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 new Promise((resolve) => setTimeout(resolve, 20));
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");
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 new Promise((resolve) => setTimeout(resolve, 20));
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 new Promise((resolve) => setTimeout(resolve, 20));
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" });
// Allow async handlers to complete
await new Promise((resolve) => setTimeout(resolve, 50));
// 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" });
// Allow async handlers to complete
await new Promise((resolve) => setTimeout(resolve, 50));
// 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 new Promise((resolve) => setTimeout(resolve, 50));
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 new Promise((resolve) => setTimeout(resolve, 50));
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("prevents duplicate execution when task:moved fires twice for same task", 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 event twice quickly
store._trigger("task:moved", { task, from: "todo", to: "in-progress" });
store._trigger("task:moved", { task, from: "todo", to: "in-progress" });
// Wait for completion
await new Promise((resolve) => setTimeout(resolve, 200));
// The executing guard prevents duplicate execution from the event handler.
// Note: createFnAgent may be called a second time if the agent finishes
// without calling fn_task_done (retry path), but the initial trigger should
// only cause one execution, not two.
// Verify that store.on was called with task:moved (listener registered)
expect(store.on).toHaveBeenCalledWith("task:moved", expect.any(Function));
// Verify the event handler initiated execute() (not twice from events)
// The executing set guard works — both triggers don't cause double execution
});
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" });
// Wait for async
await new Promise((resolve) => setTimeout(resolve, 50));
// 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(((options: any) => ({
executeAll: vi.fn(async () => {
options.onStepComplete(0, {
stepIndex: 0,
success: true,
retries: 0,
tokenUsage: { inputTokens: 20, outputTokens: 10, cachedTokens: 2, totalTokens: 32 },
});
await new Promise((resolve) => setTimeout(resolve, 0));
options.onStepComplete(1, {
stepIndex: 1,
success: true,
retries: 0,
tokenUsage: { inputTokens: 30, outputTokens: 5, cachedTokens: 1, totalTokens: 36 },
});
await new Promise((resolve) => setTimeout(resolve, 0));
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: 7,
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);
// Give it time to set up the step executor
await new Promise((r) => setTimeout(r, 50));
// 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);
// Give it time to set up the step executor
await new Promise((r) => setTimeout(r, 50));
// 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);
// Give it time to set up the step executor
await new Promise((r) => setTimeout(r, 50));
// 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 marked stuck-killed and moved to todo for retry
expect(store.updateTask).toHaveBeenCalledWith("FN-200", expect.objectContaining({
status: "stuck-killed",
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 new Promise((r) => setTimeout(r, 50));
// 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 new Promise((r) => setTimeout(r, 50));
// 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);
// Give it time to set up the step executor
await new Promise((r) => setTimeout(r, 50));
// 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
vi.advanceTimersByTime(0);
// Run any pending microtasks (the async code in setTimeout)
await vi.runAllTimersAsync();
// 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();
});
});