From bf3276295c700456d4586beb5df29fd64eadd404 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 20:09:00 -0700 Subject: [PATCH] fix(engine): complete workflow runtime cutover --- .../src/__tests__/executor-core.test.ts | 1709 ------ .../src/__tests__/executor-recovery.test.ts | 3600 ----------- .../engine/src/__tests__/hold-release.test.ts | 6 +- .../__tests__/scheduler-node-routing.test.ts | 674 -- .../scheduler-overlap-requeue.test.ts | 152 - .../engine/src/__tests__/scheduler.test.ts | 5412 ----------------- .../workflow-graph-task-runner.test.ts | 5 +- packages/engine/src/scheduler.ts | 47 +- packages/engine/vitest.config.ts | 12 +- 9 files changed, 51 insertions(+), 11566 deletions(-) delete mode 100644 packages/engine/src/__tests__/executor-core.test.ts delete mode 100644 packages/engine/src/__tests__/executor-recovery.test.ts delete mode 100644 packages/engine/src/__tests__/scheduler-node-routing.test.ts delete mode 100644 packages/engine/src/__tests__/scheduler-overlap-requeue.test.ts delete mode 100644 packages/engine/src/__tests__/scheduler.test.ts diff --git a/packages/engine/src/__tests__/executor-core.test.ts b/packages/engine/src/__tests__/executor-core.test.ts deleted file mode 100644 index 8f69411d2f..0000000000 --- a/packages/engine/src/__tests__/executor-core.test.ts +++ /dev/null @@ -1,1709 +0,0 @@ -// -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); - -describe("detectReviewHandoffIntent", () => { - it("returns true for 'send it back to me'", () => { - expect(detectReviewHandoffIntent("Please send it back to me for review")).toBe(true); - }); - - it("returns true for 'hand off to user'", () => { - expect(detectReviewHandoffIntent("I need to hand off to user")).toBe(true); - }); - - it("returns true for 'needs human review'", () => { - expect(detectReviewHandoffIntent("This needs human review")).toBe(true); - }); - - it("returns true for 'assign to user'", () => { - expect(detectReviewHandoffIntent("Please assign to user")).toBe(true); - }); - - it("returns true for 'return to user'", () => { - expect(detectReviewHandoffIntent("Return to user for final approval")).toBe(true); - }); - - it("returns true for 'user review needed'", () => { - expect(detectReviewHandoffIntent("User review needed")).toBe(true); - }); - - it("returns true for 'requesting user review'", () => { - expect(detectReviewHandoffIntent("I am requesting user review")).toBe(true); - }); - - it("is case-insensitive", () => { - expect(detectReviewHandoffIntent("SEND IT BACK TO ME")).toBe(true); - expect(detectReviewHandoffIntent("Send It Back To Me")).toBe(true); - }); - - it("returns false for regular comments without handoff intent", () => { - expect(detectReviewHandoffIntent("Good progress on the implementation")).toBe(false); - expect(detectReviewHandoffIntent("Please add more tests")).toBe(false); - expect(detectReviewHandoffIntent("The code looks great")).toBe(false); - }); - - it("returns false for empty strings", () => { - expect(detectReviewHandoffIntent("")).toBe(false); - }); -}); - -describe("buildExecutionPrompt", () => { - it("includes worktree boundary guidance in the execution prompt", () => { - const task: any = { - id: "FN-TEST", - title: "Test task", - dependencies: [], - prompt: "# Test task\n## Steps\n- Step 1", - steps: [], - currentStep: 0, - attachments: [], - }; - - const prompt = buildExecutionPrompt(task, "/project"); - - expect(prompt).toContain("## Worktree Boundaries"); - expect(prompt).toContain("isolated git worktree"); - expect(prompt).toContain("All code changes must be made inside the current worktree directory"); - }); - - it("mentions project memory exception in worktree boundary guidance", () => { - const task: any = { - id: "FN-TEST", - title: "Test task", - dependencies: [], - prompt: "# Test task\n## Steps\n- Step 1", - steps: [], - currentStep: 0, - attachments: [], - }; - - const prompt = buildExecutionPrompt(task, "/project"); - - expect(prompt).toContain(".fusion/memory/"); - expect(prompt).toContain("memory"); - expect(prompt).toContain("durable"); - }); - - it("mentions task attachments exception in worktree boundary guidance", () => { - const task: any = { - id: "FN-TEST", - title: "Test task", - dependencies: [], - prompt: "# Test task\n## Steps\n- Step 1", - steps: [], - currentStep: 0, - attachments: [], - }; - - const prompt = buildExecutionPrompt(task, "/project"); - - expect(prompt).toContain("attachments"); - expect(prompt).toContain("context"); - }); - - it("includes worktree boundary guidance regardless of review level", () => { - const task: any = { - id: "FN-TEST", - title: "Test task", - dependencies: [], - prompt: "# Test task\n## Review Level: 0\n## Steps\n- Step 1", - steps: [], - currentStep: 0, - attachments: [], - }; - - const prompt = buildExecutionPrompt(task, "/project"); - - expect(prompt).toContain("## Worktree Boundaries"); - }); -}); - -describe("TaskExecutor dependency dispatch gate", () => { - beforeEach(() => { - resetExecutorMocks(); - }); - - const task = (overrides: Partial = {}): Task => ({ - id: "FN-DP", - title: "Dependent task", - description: "Dependent task", - column: "in-progress", - dependencies: ["FN-DEP"], - steps: [], - currentStep: 0, - log: [], - prompt: "# Test", - createdAt: "2026-06-20T00:00:00.000Z", - updatedAt: "2026-06-20T00:00:00.000Z", - ...overrides, - } as Task); - - it("requeues workflow-authoritative dispatch when a live dependency is unmet", async () => { - const dependent = task(); - const dependency = task({ id: "FN-DEP", column: "todo", dependencies: [] }); - const store = createMockStore(); - store.listTasks.mockResolvedValue([dependent, dependency]); - store.getTask.mockResolvedValue(dependent); - const workflowAuthoritativeDispatch = vi.fn().mockResolvedValue(true); - const executor = new TaskExecutor(store, "/tmp/test", { workflowAuthoritativeDispatch }); - const graphDispatch = vi.spyOn(executor as any, "maybeExecuteWorkflowGraph").mockResolvedValue(true); - - await executor.execute(dependent); - - expect(graphDispatch).not.toHaveBeenCalled(); - expect(workflowAuthoritativeDispatch).not.toHaveBeenCalled(); - expect(store.moveTask).toHaveBeenCalledWith("FN-DP", "todo", expect.objectContaining({ - preserveProgress: true, - preserveWorktree: true, - preserveResumeState: true, - })); - expect(store.updateTask).toHaveBeenCalledWith("FN-DP", { status: "queued", blockedBy: "FN-DEP" }, undefined); - expect(store.logEntry).toHaveBeenCalledWith( - "FN-DP", - "queued — unmet dependencies: FN-DEP", - "Executor pre-dispatch dependency gate blocked workflow/authoritative execution.", - undefined, - ); - }); - - it("allows workflow-authoritative dispatch when dependencies are satisfied or absent", async () => { - const dependent = task({ dependencies: ["FN-DONE", "FN-REVIEW", "FN-ARCHIVED", "FN-MISSING"] }); - const store = createMockStore(); - store.listTasks.mockResolvedValue([ - dependent, - task({ id: "FN-DONE", column: "done", dependencies: [] }), - task({ id: "FN-REVIEW", column: "in-review", dependencies: [] }), - task({ id: "FN-ARCHIVED", column: "archived", dependencies: [] }), - ]); - store.getTask.mockResolvedValue(dependent); - const workflowAuthoritativeDispatch = vi.fn().mockResolvedValue(true); - const executor = new TaskExecutor(store, "/tmp/test", { workflowAuthoritativeDispatch }); - - await executor.execute(dependent); - - expect(workflowAuthoritativeDispatch).toHaveBeenCalledWith(dependent); - expect(store.updateTask).not.toHaveBeenCalledWith("FN-DP", expect.objectContaining({ status: "queued" }), expect.anything()); - }); -}); - -describe("TaskExecutor review addressing transitions", () => { - beforeEach(() => { - resetExecutorMocks(); - }); - - it("moves queued addressing records to in-progress", async () => { - const store = createMockStore(); - store.getTask.mockResolvedValue({ - id: "FN-001", - column: "in-progress", - status: null, - reviewState: { - source: "pull-request", - items: [], - addressing: [{ itemId: "ri-1", status: "queued", selectedAt: new Date().toISOString() }], - }, - }); - - const executor = new TaskExecutor(store, "/tmp/test"); - await (executor as any).transitionReviewAddressing("FN-001", ["queued"], "in-progress"); - - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { - reviewState: expect.objectContaining({ - addressing: [expect.objectContaining({ status: "in-progress", startedAt: expect.any(String) })], - }), - }); - }); - - it("marks in-progress addressing records as failed", async () => { - const store = createMockStore(); - store.getTask.mockResolvedValue({ - id: "FN-001", - column: "in-review", - status: "failed", - reviewState: { - source: "reviewer-agent", - items: [], - addressing: [{ itemId: "ri-1", status: "in-progress", selectedAt: new Date().toISOString() }], - }, - }); - - const executor = new TaskExecutor(store, "/tmp/test"); - await (executor as any).transitionReviewAddressing("FN-001", ["in-progress"], "failed"); - - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { - reviewState: expect.objectContaining({ - addressing: [expect.objectContaining({ status: "failed", completedAt: expect.any(String) })], - }), - }); - }); -}); - -describe("TaskExecutor action gate context", () => { - it("pauses task and agent for approval and marks completion", async () => { - const store = createMockStore(); - store.pauseTask = vi.fn().mockResolvedValue(undefined); - store.logEntry = vi.fn().mockResolvedValue(undefined); - const agentStore = { - updateAgentState: vi.fn().mockResolvedValue(undefined), - updateAgent: vi.fn().mockResolvedValue(undefined), - } as any; - - const executor = new TaskExecutor(store as any, "/tmp/project", { agentStore }); - (executor as any).currentRunContexts.set("FN-1", { runId: "run-1", agentId: "executor" }); - - const context = (executor as any).buildActionGateContext("FN-1", { id: "agent-1", name: "Agent One", permissionPolicy: undefined }); - - await context.pauseForApproval({ - approvalRequestId: "apr-1", - decision: { - disposition: "require-approval", - category: "command_execution", - toolName: "bash", - operation: "git commit", - summary: "bash: git commit", - resourceType: "git", - approvalDedupeKey: "dedupe-1", - metadata: {}, - }, - }); - - expect(store.pauseTask).toHaveBeenCalledWith( - "FN-1", - true, - expect.objectContaining({ runId: "run-1" }), - { pausedByAgentId: "agent-1" }, - ); - expect(agentStore.updateAgentState).toHaveBeenCalledWith("agent-1", "paused"); - expect(agentStore.updateAgent).toHaveBeenCalledWith("agent-1", { pauseReason: "awaiting-approval" }); - - }); -}); - -// ── Skill Selection Regression Tests (FN-1514) ────────────────────────── - -describe("TaskExecutor skillSelection regression (FN-1511)", () => { - const projectRoot = "/tmp/test-project"; - - beforeEach(() => { - resetExecutorMocks(); - mockedCreateFnAgent.mockResolvedValue({ - session: { - prompt: 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); - }); - - /** - * Helper: execute a task and capture createFnAgent call arguments. - */ - async function captureCreateFnAgentArgs(options?: { - assignedAgentId?: string; - assignedAgentSkills?: string[]; - settings?: Record; - }) { - const { assignedAgentId, assignedAgentSkills } = options || {}; - - const mockAgentStore = { - getAgent: vi.fn().mockImplementation(async (id: string) => { - if (id === assignedAgentId) { - return { - id, - name: "Test Agent", - role: "executor", - state: "idle", - metadata: { skills: assignedAgentSkills || [] }, - }; - } - return null; - }), - }; - - const store = createMockStore(); - store.getTask.mockResolvedValue({ - id: "FN-SKILL", - title: "Skill Test", - description: "Test skill selection", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - assignedAgentId, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - let capturedArgs: any = null; - mockedCreateFnAgent.mockImplementationOnce(async (opts: any) => { - capturedArgs = opts; - return { - session: { - prompt: 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; - }); - - const executor = new TaskExecutor(store, projectRoot, { agentStore: mockAgentStore as any }); - await executor.execute({ - id: "FN-SKILL", - title: "Skill Test", - description: "Test skill selection", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - assignedAgentId, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - return capturedArgs; - } - - describe("single-session mode (runStepsInNewSessions: false)", () => { - it("passes skillSelection to createFnAgent when assigned agent has skills", async () => { - const args = await captureCreateFnAgentArgs({ - assignedAgentId: "agent-001", - assignedAgentSkills: ["triage", "executor"], - }); - - expect(args).not.toBeNull(); - expect(args).toHaveProperty("skillSelection"); - // The agent's skills are passed directly; filtering happens at skill resolver level - expect(args.skillSelection).toMatchObject({ - projectRootDir: projectRoot, - requestedSkillNames: expect.arrayContaining(["triage", "executor"]), - sessionPurpose: "executor", - }); - }); - - it("normalizes whitespace in requestedSkillNames", async () => { - const args = await captureCreateFnAgentArgs({ - assignedAgentId: "agent-001", - assignedAgentSkills: [" triage ", " executor ", "reviewer"], - }); - - expect(args).not.toBeNull(); - expect(args.skillSelection).toMatchObject({ - projectRootDir: projectRoot, - requestedSkillNames: expect.arrayContaining(["triage", "executor", "reviewer"]), - }); - }); - - it("deduplicates requestedSkillNames while preserving first occurrence", async () => { - const args = await captureCreateFnAgentArgs({ - assignedAgentId: "agent-001", - assignedAgentSkills: ["triage", "executor", "triage", "reviewer", "executor"], - }); - - expect(args).not.toBeNull(); - // Should contain triage, executor, reviewer in that order (first occurrence) - expect(args.skillSelection).toMatchObject({ - projectRootDir: projectRoot, - requestedSkillNames: ["triage", "executor", "reviewer"], - }); - }); - - it("uses role fallback skillSelection when assigned agent has no skills", async () => { - const args = await captureCreateFnAgentArgs({ - assignedAgentId: "agent-001", - assignedAgentSkills: [], - }); - - expect(args).not.toBeNull(); - expect(args.skillSelection).toMatchObject({ - projectRootDir: projectRoot, - requestedSkillNames: expect.arrayContaining(["fusion"]), - sessionPurpose: "executor", - }); - }); - - it("uses role fallback skillSelection when no assigned agent", async () => { - const args = await captureCreateFnAgentArgs({}); - - expect(args).not.toBeNull(); - expect(args.skillSelection).toMatchObject({ - projectRootDir: projectRoot, - requestedSkillNames: expect.arrayContaining(["fusion"]), - sessionPurpose: "executor", - }); - }); - }); - - describe("step-session mode (runStepsInNewSessions: true)", () => { - // Ownership: executor tests verify skillSelection is wired into StepSessionExecutor - // constructor args. step-session-executor tests own downstream forwarding to createFnAgent. - - async function captureStepSessionCtorOptions(options?: { - assignedAgentId?: string; - assignedAgentSkills?: string[]; - }) { - const { assignedAgentId, assignedAgentSkills } = options || {}; - - const mockAgentStore = { - getAgent: vi.fn().mockImplementation(async (id: string) => { - if (id === assignedAgentId) { - return { - id, - name: "Test Agent", - role: "executor", - state: "idle", - metadata: { skills: assignedAgentSkills || [] }, - }; - } - return null; - }), - }; - - const store = createMockStore(); - store.getSettings.mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - pollIntervalMs: 15000, - groupOverlappingFiles: false, - autoMerge: false, - runStepsInNewSessions: true, - }); - store.getTask.mockResolvedValue({ - id: "FN-SKILL-SS", - title: "Skill Test Step-Session", - description: "Test skill selection in step-session mode", - column: "in-progress", - dependencies: [], - steps: [ - { name: "Step 0", status: "pending" }, - { name: "Step 1", status: "pending" }, - ], - currentStep: 0, - log: [], - assignedAgentId, - 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: [], - }); - - const executor = new TaskExecutor(store, projectRoot, { agentStore: mockAgentStore as any }); - await executor.execute({ - id: "FN-SKILL-SS", - title: "Skill Test Step-Session", - description: "Test skill selection in step-session mode", - column: "in-progress", - dependencies: [], - steps: [ - { name: "Step 0", status: "pending" }, - { name: "Step 1", status: "pending" }, - ], - currentStep: 0, - log: [], - assignedAgentId, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - expect(mockedStepSessionExecutor).toHaveBeenCalled(); - return mockedStepSessionExecutor.mock.calls[mockedStepSessionExecutor.mock.calls.length - 1][0]; - } - - it("passes skillSelection to StepSessionExecutor when assigned agent has skills", async () => { - const ctorOptions = await captureStepSessionCtorOptions({ - assignedAgentId: "agent-001", - assignedAgentSkills: ["triage", "executor"], - }); - - expect(ctorOptions).toHaveProperty("skillSelection"); - expect(ctorOptions.skillSelection).toMatchObject({ - projectRootDir: projectRoot, - requestedSkillNames: expect.arrayContaining(["triage", "executor"]), - sessionPurpose: "executor", - }); - }); - - it("uses role fallback skillSelection when assigned agent has no skills", async () => { - const ctorOptions = await captureStepSessionCtorOptions({ - assignedAgentId: "agent-001", - assignedAgentSkills: [], - }); - - // No explicit agent skills → executor falls back to built-in fusion skill context - expect(ctorOptions.skillSelection).toMatchObject({ - projectRootDir: projectRoot, - requestedSkillNames: expect.arrayContaining(["fusion"]), - sessionPurpose: "executor", - }); - }); - - it("uses role fallback skillSelection when no assigned agent", async () => { - const ctorOptions = await captureStepSessionCtorOptions({}); - - // No assigned agent → executor falls back to built-in fusion skill context - expect(ctorOptions.skillSelection).toMatchObject({ - projectRootDir: projectRoot, - requestedSkillNames: expect.arrayContaining(["fusion"]), - sessionPurpose: "executor", - }); - }); - }); -}); - -// ── Agent Messaging Tool Tests ──────────────────────────────────────── - -describe("TaskExecutor messaging tools", () => { - beforeEach(() => { - resetExecutorMocks(); - mockedCreateFnAgent.mockResolvedValue({ - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - sessionManager: { - getLeafId: vi.fn().mockReturnValue("leaf-id"), - branchWithSummary: vi.fn(), - navigateTree: vi.fn().mockResolvedValue({ cancelled: false }), - }, - navigateTree: vi.fn().mockResolvedValue({ cancelled: false }), - }, - } as any); - }); - - /** - * Helper: execute a task and capture the customTools array passed to createFnAgent. - */ - async function captureCustomTools(options?: { - messageStore?: unknown; - agentStore?: unknown; - assignedAgentId?: string; - executionMode?: "standard" | "fast"; - }): Promise { - const { messageStore, agentStore, assignedAgentId, executionMode } = options || {}; - let captured: any[] = []; - - mockedCreateFnAgent.mockImplementation(async (opts: any) => { - captured = opts.customTools || []; - return { - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - sessionManager: { - getLeafId: vi.fn().mockReturnValue("leaf-id"), - branchWithSummary: vi.fn(), - navigateTree: vi.fn().mockResolvedValue({ cancelled: false }), - }, - navigateTree: vi.fn().mockResolvedValue({ cancelled: false }), - }, - } as any; - }); - - const store = createMockStore(); - // Override getTask to return the correct assignedAgentId and executionMode - store.getTask.mockImplementation(async (id: string) => ({ - id, - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - assignedAgentId, - executionMode, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - })); - - const taskExecutor = new TaskExecutor(store, "/tmp/test", { - messageStore: messageStore as any, - agentStore: agentStore as any, - }); - - await taskExecutor.execute({ - id: "FN-MSG", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - assignedAgentId, - executionMode, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - return captured; - } - - it("includes fn_send_message when messageStore and assignedAgentId are available", async () => { - const mockMessageStore = { - sendMessage: vi.fn().mockReturnValue({ id: "msg-001" }), - }; - const tools = await captureCustomTools({ - messageStore: mockMessageStore, - assignedAgentId: "agent-001", - }); - - const toolNames = tools.map((t: any) => t.name); - expect(toolNames).toContain("fn_send_message"); - }); - - it("includes fn_read_messages when messageStore and assignedAgentId are available", async () => { - const mockMessageStore = { - sendMessage: vi.fn().mockReturnValue({ id: "msg-001" }), - getInbox: vi.fn().mockReturnValue([]), - }; - const tools = await captureCustomTools({ - messageStore: mockMessageStore, - assignedAgentId: "agent-001", - }); - - const toolNames = tools.map((t: any) => t.name); - expect(toolNames).toContain("fn_read_messages"); - }); - - it("excludes fn_read_messages when messageStore is not provided", async () => { - const tools = await captureCustomTools({ - assignedAgentId: "agent-001", - }); - - const toolNames = tools.map((t: any) => t.name); - expect(toolNames).not.toContain("fn_read_messages"); - }); - - it("excludes fn_read_messages when assignedAgentId is not provided", async () => { - const mockMessageStore = { - sendMessage: vi.fn().mockReturnValue({ id: "msg-001" }), - getInbox: vi.fn().mockReturnValue([]), - }; - const tools = await captureCustomTools({ - messageStore: mockMessageStore, - }); - - const toolNames = tools.map((t: any) => t.name); - expect(toolNames).not.toContain("fn_read_messages"); - }); - - it("excludes messaging tools when messageStore is not provided", async () => { - const tools = await captureCustomTools({ - assignedAgentId: "agent-001", - }); - - const toolNames = tools.map((t: any) => t.name); - expect(toolNames).not.toContain("fn_send_message"); - expect(toolNames).not.toContain("fn_read_messages"); - }); - - it("includes fn_list_agents and fn_delegate_task when agentStore is available", async () => { - const mockAgentStore = { - listAgents: vi.fn().mockResolvedValue([]), - getAgent: vi.fn().mockResolvedValue(null), - }; - const tools = await captureCustomTools({ - agentStore: mockAgentStore, - }); - - const toolNames = tools.map((t: any) => t.name); - expect(toolNames).toContain("fn_list_agents"); - expect(toolNames).toContain("fn_delegate_task"); - }); - - it("excludes delegation tools when agentStore is not provided", async () => { - const tools = await captureCustomTools({}); - - const toolNames = tools.map((t: any) => t.name); - expect(toolNames).not.toContain("fn_list_agents"); - expect(toolNames).not.toContain("fn_delegate_task"); - }); - - describe("fast mode", () => { - beforeEach(() => { - resetExecutorMocks(); - mockedExistsSync.mockReturnValue(true); - }); - - it("excludes fn_review_step tool when executionMode is 'fast'", async () => { - const tools = await captureCustomTools({ - executionMode: "fast", - }); - - const toolNames = tools.map((t: any) => t.name); - expect(toolNames).not.toContain("fn_review_step"); - }); - - it("includes fn_task_update and fn_task_done tools in fast mode", async () => { - const tools = await captureCustomTools({ - executionMode: "fast", - }); - - const toolNames = tools.map((t: any) => t.name); - expect(toolNames).toContain("fn_task_update"); - expect(toolNames).toContain("fn_task_done"); - }); - - it("includes fn_review_step tool when executionMode is 'standard'", async () => { - const tools = await captureCustomTools({ - executionMode: "standard", - }); - - const toolNames = tools.map((t: any) => t.name); - expect(toolNames).toContain("fn_review_step"); - }); - - it("includes fn_review_step tool when executionMode is undefined (defaults to standard)", async () => { - const tools = await captureCustomTools({}); - - const toolNames = tools.map((t: any) => t.name); - expect(toolNames).toContain("fn_review_step"); - }); - - it("logs executor model usage when execution starts", async () => { - const store = createMockStore(); - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - executionMode: "fast", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - mockedCreateFnAgent.mockResolvedValue({ - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, - }, - } as any); - - const executor = new TaskExecutor(store, "/tmp/test"); - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - executionMode: "fast", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Verify logEntry was called (indicates executor is running) - expect(store.logEntry).toHaveBeenCalled(); - }); - }); - - describe("Fast mode completion path", () => { - beforeEach(() => { - resetExecutorMocks(); - mockedExistsSync.mockReturnValue(false); - }); - - it("skips workflow steps in fast mode when task completes", async () => { - const store = createMockStore(); - - // Task with workflow steps enabled AND fast mode - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [{ name: "Preflight", status: "pending" }], - currentStep: 0, - log: [], - enabledWorkflowSteps: ["WS-001"], - executionMode: "fast", - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Mock workflow step exists - store.getWorkflowStep.mockResolvedValue({ - id: "WS-001", - name: "Docs Review", - description: "Check documentation", - prompt: "Review docs.", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Mock agent with fn_task_done - mockedCreateFnAgent.mockImplementation((async (opts: any) => { - const customTools = opts.customTools || []; - const session = { - prompt: vi.fn().mockImplementation(async () => { - const taskDoneTool = customTools.find((t: any) => t.name === "fn_task_done"); - if (taskDoneTool) await taskDoneTool.execute("tool-1", {}); - }), - dispose: vi.fn(), - subscribe: vi.fn(), - on: vi.fn(), - sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, - state: {}, - }; - return { session }; - }) as any); - - const onComplete = vi.fn(); - const executor = new TaskExecutor(store, "/tmp/test", { onComplete }); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [{ name: "Preflight", status: "pending" }], - currentStep: 0, - log: [], - enabledWorkflowSteps: ["WS-001"], - executionMode: "fast", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Verify task moved to in-review - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); - - // Verify onComplete was called - expect(onComplete).toHaveBeenCalled(); - - // Verify workflow step was NOT called (fast mode skips workflow steps) - // The agent should only be called once (main execution), not twice (main + workflow) - expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1); - }); - - it("still runs workflow steps in standard mode when task completes", async () => { - const store = createMockStore(); - - // Task with workflow steps enabled in standard mode - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [{ name: "Preflight", status: "pending" }], - currentStep: 0, - log: [], - enabledWorkflowSteps: ["WS-001"], - executionMode: "standard", - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Mock workflow step exists - store.getWorkflowStep.mockResolvedValue({ - id: "WS-001", - name: "Docs Review", - description: "Check documentation", - prompt: "Review docs.", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Track agent calls - let callIdx = 0; - mockedCreateFnAgent.mockImplementation((async (opts: any) => { - callIdx++; - const customTools = opts.customTools || []; - const session = { - prompt: vi.fn().mockImplementation(async () => { - if (callIdx === 1) { - // Main execution — find and trigger fn_task_done - const taskDoneTool = customTools.find((t: any) => t.name === "fn_task_done"); - if (taskDoneTool) await taskDoneTool.execute("tool-1", {}); - } else { - // Workflow step — no fn_task_done needed - } - }), - dispose: vi.fn(), - subscribe: vi.fn(), - on: vi.fn(), - sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, - state: {}, - }; - return { session }; - }) as any); - - const executor = new TaskExecutor(store, "/tmp/test"); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [{ name: "Preflight", status: "pending" }], - currentStep: 0, - log: [], - enabledWorkflowSteps: ["WS-001"], - executionMode: "standard", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Verify task moved to in-review - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); - - // Verify workflow step WAS called (standard mode runs workflow steps) - // Agent should be called twice: main execution + workflow step - expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); - }); - - it("still enforces fn_task_done requirement in fast mode", async () => { - const store = createMockStore(); - - // Task in fast mode - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [{ name: "Preflight", status: "pending" }], - currentStep: 0, - log: [], - executionMode: "fast", - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Mock agent that exits WITHOUT calling fn_task_done - 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 onError = vi.fn(); - const executor = new TaskExecutor(store, "/tmp/test", { onError }); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [{ name: "Preflight", status: "pending" }], - currentStep: 0, - log: [], - executionMode: "fast", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Fast mode should still enforce fn_task_done requirement. - // While retry budget remains, failures requeue instead of becoming terminal. - expect(onError).toHaveBeenCalled(); - expect(store.updateTask).toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ - status: "queued", - error: null, - taskDoneRetryCount: 1, - }), - ); - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true }); - }); - - it("still checks completion blockers in fast mode", async () => { - const store = createMockStore(); - - // Task in fast mode with no workflow steps - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - executionMode: "fast", - prompt: "# test\n## Steps\n", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Mock agent with fn_task_done - mockedCreateFnAgent.mockImplementation((async (opts: any) => { - const customTools = opts.customTools || []; - const session = { - prompt: vi.fn().mockImplementation(async () => { - const taskDoneTool = customTools.find((t: any) => t.name === "fn_task_done"); - if (taskDoneTool) await taskDoneTool.execute("tool-1", {}); - }), - dispose: vi.fn(), - subscribe: vi.fn(), - on: vi.fn(), - sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, - state: {}, - }; - return { session }; - }) as any); - - const onComplete = vi.fn(); - const executor = new TaskExecutor(store, "/tmp/test", { onComplete }); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - executionMode: "fast", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Verify task completed normally even without workflow steps - // Completion blockers (test/build/typecheck) are checked via getTaskCompletionBlocker - // which is called before finalizing - expect(onComplete).toHaveBeenCalled(); - }); - }); -}); - -describe("determineRevisionResetStart", () => { - const steps = [ - { name: "Preflight" }, - { name: "Reposition the agent badge in TaskCard.tsx" }, - { name: "Restyle `.card-agent-badge` and add `.card-agent-row` in styles.css" }, - { name: "Update tests" }, - { name: "Testing & Verification" }, - { name: "Documentation & Delivery" }, - ]; - - it("skips Preflight when feedback targets a later step", () => { - // Feedback is phrased to hit step 2's "restyle" without also mentioning - // step 1's distinctive tokens (reposition / badge / taskcard / agent). - const feedback = "The card styling needs more contrast — restyle the class tokens."; - expect(determineRevisionResetStart(steps, feedback)).toBe(2); - }); - - it("matches earliest step when multiple step names are mentioned", () => { - const feedback = "The reposition logic is off, and the restyle tokens also need a second pass."; - expect(determineRevisionResetStart(steps, feedback)).toBe(1); - }); - - it("falls back to first non-Preflight step when feedback matches nothing", () => { - const feedback = "Please improve overall polish and typography hierarchy."; - expect(determineRevisionResetStart(steps, feedback)).toBe(1); - }); - - it("never resets a Preflight step even if feedback somehow mentions preflight", () => { - const feedback = "Preflight context looks wrong; restyle the row as well."; - // Step 0 is Preflight → skipped. Earliest remaining match is step 2 (restyle). - expect(determineRevisionResetStart(steps, feedback)).toBe(2); - }); - - it("is case-insensitive across both feedback and step names", () => { - const feedback = "RESTYLE the component, please."; - expect(determineRevisionResetStart(steps, feedback)).toBe(2); - }); - - it("returns 0 when there is no Preflight and no match", () => { - const noPreflight = [{ name: "Apply Fix" }, { name: "Testing & Verification" }]; - expect(determineRevisionResetStart(noPreflight, "please improve polish")).toBe(0); - }); - - it("returns steps.length for an empty step list (nothing to reset)", () => { - expect(determineRevisionResetStart([], "anything")).toBe(0); - }); - - it("returns steps.length when only a Preflight step exists (nothing to reset)", () => { - expect(determineRevisionResetStart([{ name: "Preflight" }], "anything")).toBe(1); - }); - - it("ignores short tokens like 'test' to avoid matching 'Update tests' for generic feedback", () => { - // "test" is 4 chars — below the 5+ char threshold — so generic feedback - // mentioning "test" alone should not target the "Update tests" step. - const feedback = "Please test this by clicking."; - expect(determineRevisionResetStart(steps, feedback)).toBe(1); - }); -}); - -describe("Executor verification gate (FN-3345)", () => { - const mockedVerification = vi.mocked(mockedRunVerificationCommand); - - beforeEach(() => { - resetExecutorMocks(); - mockExecuteAll.mockResolvedValue([]); - mockTerminateAllSessions.mockResolvedValue(undefined); - mockCleanup.mockResolvedValue(undefined); - mockedVerification.mockReset(); - }); - - /** Helper to create a step-session store with default settings */ - function createVerificationStore(settingsOverrides: Record = {}) { - const store = createMockStore(); - store.getSettings.mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - pollIntervalMs: 15000, - groupOverlappingFiles: false, - autoMerge: false, - runStepsInNewSessions: true, - maxParallelSteps: 2, - ...settingsOverrides, - }); - store.getTask.mockResolvedValue({ - id: "FN-3345", - title: "Verification gate test task", - description: "Test", - 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; - } - - /** Helper to create a task for step-session mode */ - function createVerificationTask(overrides: Partial = {}): Task { - return { - id: "FN-3345", - title: "Verification gate test task", - description: "Test", - 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, - }; - } - - it("no testCommand/buildCommand configured → gate skipped → task moves to in-review", async () => { - const store = createVerificationStore({}); - mockExecuteAll.mockResolvedValue([ - { stepIndex: 0, success: true, retries: 0 }, - { stepIndex: 1, success: true, retries: 0 }, - ]); - - const executor = new TaskExecutor(store, "/tmp/test", {}); - await executor.execute(createVerificationTask()); - - // Verification command should NOT have been called - expect(mockedVerification).not.toHaveBeenCalled(); - // Task should move to in-review normally - expect(store.moveTask).toHaveBeenCalledWith("FN-3345", "in-review"); - }); - - it("testCommand configured, verification passes → task moves to in-review", async () => { - const store = createVerificationStore({ testCommand: "pnpm test" }); - mockExecuteAll.mockResolvedValue([ - { stepIndex: 0, success: true, retries: 0 }, - { stepIndex: 1, success: true, retries: 0 }, - ]); - mockedVerification.mockResolvedValue({ - command: "pnpm test", - exitCode: 0, - stdout: "all passed", - stderr: "", - success: true, - }); - - const executor = new TaskExecutor(store, "/tmp/test", {}); - await executor.execute(createVerificationTask()); - - // Verification command should have been called - expect(mockedVerification).toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining(".worktrees"), - "FN-3345", - "pnpm test", - "test", - undefined, - expect.anything(), - "executor", - expect.any(Object), - undefined, - ); - // Task should move to in-review - expect(store.moveTask).toHaveBeenCalledWith("FN-3345", "in-review"); - }); - - it("verification fails, fix agent succeeds on first attempt → task moves to in-review", async () => { - const store = createVerificationStore({ - testCommand: "pnpm test", - verificationFixRetries: 3, - }); - mockExecuteAll.mockResolvedValue([ - { stepIndex: 0, success: true, retries: 0 }, - { stepIndex: 1, success: true, retries: 0 }, - ]); - - // First verification fails, then re-verification passes after fix - mockedVerification - .mockResolvedValueOnce({ - command: "pnpm test", - exitCode: 1, - stdout: "", - stderr: "1 test failed", - success: false, - }) - // Re-verification after fix passes - .mockResolvedValue({ - command: "pnpm test", - exitCode: 0, - stdout: "all passed", - stderr: "", - success: true, - }); - - // Mock the fix agent session - mockedCreateFnAgent.mockResolvedValueOnce({ - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - subscribe: vi.fn(), - on: vi.fn(), - sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-fix-1") }, - state: {}, - }, - } as any); - - const executor = new TaskExecutor(store, "/tmp/test", {}); - await executor.execute(createVerificationTask()); - - // First call: initial verification (fails) - // Second call: re-verification after fix (passes) - expect(mockedVerification).toHaveBeenCalledTimes(2); - // Fix agent should have been created - expect(mockedCreateFnAgent).toHaveBeenCalled(); - // Task should move to in-review - expect(store.moveTask).toHaveBeenCalledWith("FN-3345", "in-review"); - }); - - it("verification fails, fix agent fails all attempts → task sent back to in-progress", async () => { - const store = createVerificationStore({ - testCommand: "pnpm test", - verificationFixRetries: 2, // 2 fix attempts - }); - mockExecuteAll.mockResolvedValue([ - { stepIndex: 0, success: true, retries: 0 }, - { stepIndex: 1, success: true, retries: 0 }, - ]); - - // All verification calls fail - mockedVerification.mockResolvedValue({ - command: "pnpm test", - exitCode: 1, - stdout: "", - stderr: "1 test failed", - success: false, - }); - - // Mock the fix agent session (2 attempts) - mockedCreateFnAgent.mockResolvedValue({ - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - subscribe: vi.fn(), - on: vi.fn(), - sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-fix") }, - state: {}, - }, - } as any); - - const executor = new TaskExecutor(store, "/tmp/test", {}); - await executor.execute(createVerificationTask()); - - // Fix agent should have been called twice (2 attempts) - expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); - // Task should NOT move to in-review - expect(store.moveTask).not.toHaveBeenCalledWith("FN-3345", "in-review"); - // Task should have been sent back for merge remediation with active merge status - expect(store.addTaskComment).toHaveBeenCalledWith( - "FN-3345", - expect.stringContaining("Deterministic verification failed"), - "agent", - ); - expect(store.updateTask).toHaveBeenCalledWith( - "FN-3345", - expect.objectContaining({ status: "merging-fix" }), - ); - }); - - it("test fails then fix succeeds → re-verification runs both test AND build", async () => { - const store = createVerificationStore({ - testCommand: "pnpm test", - buildCommand: "pnpm build", - verificationFixRetries: 3, - }); - mockExecuteAll.mockResolvedValue([ - { stepIndex: 0, success: true, retries: 0 }, - { stepIndex: 1, success: true, retries: 0 }, - ]); - - // Initial verification: test fails (build is never reached because test fails first) - // Re-verification after fix: both test and build pass - mockedVerification - .mockResolvedValueOnce({ - command: "pnpm test", - exitCode: 1, - stdout: "", - stderr: "1 test failed", - success: false, - }) - // Re-verification: test passes - .mockResolvedValueOnce({ - command: "pnpm test", - exitCode: 0, - stdout: "all passed", - stderr: "", - success: true, - }) - // Re-verification: build passes - .mockResolvedValueOnce({ - command: "pnpm build", - exitCode: 0, - stdout: "build ok", - stderr: "", - success: true, - }); - - // Mock the fix agent session - mockedCreateFnAgent.mockResolvedValueOnce({ - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - subscribe: vi.fn(), - on: vi.fn(), - sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-fix-1") }, - state: {}, - }, - } as any); - - const executor = new TaskExecutor(store, "/tmp/test", {}); - await executor.execute(createVerificationTask()); - - // Verification should have been called 3 times: - // 1. Initial test (fails) - // 2. Re-verification test (passes) - // 3. Re-verification build (passes) - expect(mockedVerification).toHaveBeenCalledTimes(3); - // Second call should be test - expect(mockedVerification).toHaveBeenNthCalledWith( - 2, - expect.anything(), - expect.stringContaining(".worktrees"), - "FN-3345", - "pnpm test", - "test", - undefined, - expect.anything(), - "executor", - expect.any(Object), - undefined, - ); - // Third call should be build - expect(mockedVerification).toHaveBeenNthCalledWith( - 3, - expect.anything(), - expect.stringContaining(".worktrees"), - "FN-3345", - "pnpm build", - "build", - undefined, - expect.anything(), - "executor", - expect.any(Object), - undefined, - ); - // Task should move to in-review - expect(store.moveTask).toHaveBeenCalledWith("FN-3345", "in-review"); - }); - - it("fast mode → verification gate is skipped", async () => { - const store = createVerificationStore({ - testCommand: "pnpm test", - buildCommand: "pnpm build", - }); - mockExecuteAll.mockResolvedValue([ - { stepIndex: 0, success: true, retries: 0 }, - { stepIndex: 1, success: true, retries: 0 }, - ]); - - const executor = new TaskExecutor(store, "/tmp/test", {}); - await executor.execute(createVerificationTask({ executionMode: "fast" })); - - // Verification command should NOT have been called (fast mode) - expect(mockedVerification).not.toHaveBeenCalled(); - // Task should move to in-review normally - expect(store.moveTask).toHaveBeenCalledWith("FN-3345", "in-review"); - }); - - it("verificationFixRetries is 0 → task sent back immediately without fix attempt", async () => { - const store = createVerificationStore({ - testCommand: "pnpm test", - verificationFixRetries: 0, - }); - mockExecuteAll.mockResolvedValue([ - { stepIndex: 0, success: true, retries: 0 }, - { stepIndex: 1, success: true, retries: 0 }, - ]); - - // Verification fails - mockedVerification.mockResolvedValue({ - command: "pnpm test", - exitCode: 1, - stdout: "", - stderr: "1 test failed", - success: false, - }); - - const executor = new TaskExecutor(store, "/tmp/test", {}); - await executor.execute(createVerificationTask()); - - // Fix agent should NOT have been created (0 retries) - expect(mockedCreateFnAgent).not.toHaveBeenCalled(); - // Task should NOT move to in-review - expect(store.moveTask).not.toHaveBeenCalledWith("FN-3345", "in-review"); - // Task should have been sent back for merge remediation with active merge status - expect(store.addTaskComment).toHaveBeenCalledWith( - "FN-3345", - expect.stringContaining("Deterministic verification failed"), - "agent", - ); - expect(store.updateTask).toHaveBeenCalledWith( - "FN-3345", - expect.objectContaining({ status: "merging-fix" }), - ); - }); -}); - -// --------------------------------------------------------------------------- -// allowParallelExecution gate -// --------------------------------------------------------------------------- - -describe("allowParallelExecution heartbeat gate", () => { - const TASK_BASE: Omit = { - title: "Gated task", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - function makeAgentStore(opts: { - ephemeral: boolean; - allowParallelExecution?: boolean; - hasActiveRun: boolean; - }) { - const agent = { - id: "agent-perm-1", - name: "Permanent Agent", - role: "executor", - state: "running", - metadata: opts.ephemeral ? { agentKind: "task-worker" } : {}, - runtimeConfig: opts.allowParallelExecution !== undefined - ? { allowParallelExecution: opts.allowParallelExecution } - : {}, - }; - return { - getAgent: vi.fn().mockResolvedValue(agent), - getActiveHeartbeatRun: vi.fn().mockResolvedValue( - opts.hasActiveRun ? { id: "run-1", status: "active" } : null, - ), - }; - } - - beforeEach(() => { - resetExecutorMocks(); - }); - - it.each([ - { - label: "permanent agent, allowParallelExecution=false, active heartbeat run → skipped", - ephemeral: false, - allowParallelExecution: false as boolean | undefined, - hasActiveRun: true, - expectExecute: false, - }, - { - label: "permanent agent, allowParallelExecution=true, active heartbeat run → proceeds", - ephemeral: false, - allowParallelExecution: true as boolean | undefined, - hasActiveRun: true, - expectExecute: true, - }, - { - label: "permanent agent, allowParallelExecution=false, no heartbeat run → proceeds", - ephemeral: false, - allowParallelExecution: false as boolean | undefined, - hasActiveRun: false, - expectExecute: true, - }, - { - label: "ephemeral agent, allowParallelExecution=false, active heartbeat run → proceeds (flag ignored)", - ephemeral: true, - allowParallelExecution: false as boolean | undefined, - hasActiveRun: true, - expectExecute: true, - }, - ])("$label", async ({ ephemeral, allowParallelExecution, hasActiveRun, expectExecute }) => { - const agentStore = makeAgentStore({ ephemeral, allowParallelExecution, hasActiveRun }); - const store = createMockStore(); - - mockedCreateFnAgent.mockResolvedValue({ - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - }, - } as any); - - const executor = new TaskExecutor(store, "/tmp/test", { agentStore: agentStore as any }); - - await executor.execute({ - ...TASK_BASE, - id: "FN-GATE-1", - assignedAgentId: "agent-perm-1", - }); - - if (expectExecute) { - expect(mockedCreateFnAgent).toHaveBeenCalled(); - } else { - expect(mockedCreateFnAgent).not.toHaveBeenCalled(); - } - }); - - it("builds permanent-agent gating context for durable assigned agents", () => { - const agentStore = makeAgentStore({ ephemeral: false, allowParallelExecution: true, hasActiveRun: false }); - const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test", { agentStore: agentStore as any }); - - const context = (executor as any).buildPermanentAgentGatingContext("FN-GATE-2", { - id: "agent-perm-1", - name: "Perm Agent", - type: "normal", - permissionPolicy: { - presetId: "approval-required", - rules: { - git_write: "require-approval", - file_write_delete: "require-approval", - command_execution: "require-approval", - network_api: "require-approval", - task_agent_mutation: "require-approval", - }, - }, - }); - - expect(context?.permissionPolicy?.presetId).toBe("approval-required"); - expect(context?.taskId).toBe("FN-GATE-2"); - expect(typeof context?.createApprovalRequest).toBe("function"); - expect(typeof context?.findPendingApprovalRequest).toBe("function"); - }); - - it("omits permanent-agent gating context when no agent is assigned", async () => { - const agentStore = makeAgentStore({ ephemeral: false, allowParallelExecution: true, hasActiveRun: false }); - const store = createMockStore(); - - mockedCreateFnAgent.mockResolvedValue({ - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - }, - } as any); - - const executor = new TaskExecutor(store, "/tmp/test", { agentStore: agentStore as any }); - - await executor.execute({ - ...TASK_BASE, - id: "FN-GATE-3", - assignedAgentId: undefined, - }); - - const hasPermanentGating = mockedCreateFnAgent.mock.calls - .map((call) => call[0] as { permanentAgentGating?: unknown }) - .some((args) => args.permanentAgentGating !== undefined); - expect(hasPermanentGating).toBe(false); - }); -}); diff --git a/packages/engine/src/__tests__/executor-recovery.test.ts b/packages/engine/src/__tests__/executor-recovery.test.ts deleted file mode 100644 index ae9e39343b..0000000000 --- a/packages/engine/src/__tests__/executor-recovery.test.ts +++ /dev/null @@ -1,3600 +0,0 @@ -// -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, removeWorktree } 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 { executingTaskLock } from "../active-session-registry.js"; -import { executorLog } from "../logger.js"; -import { withRateLimitRetry } from "../rate-limit-retry.js"; -import { runVerificationCommand as mockedRunVerificationCommand } from "../verification-utils.js"; -import { UsageLimitPauser } from "../usage-limit-detector.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 usage limit detection", () => { - beforeEach(() => { - resetExecutorMocks(); - mockedExistsSync.mockReturnValue(true); - }); - - it("triggers global pause when executor catches a usage-limit error", async () => { - const store = createMockStore(); - const pauser = new UsageLimitPauser(store); - const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit"); - - mockedCreateFnAgent.mockRejectedValue(new Error("rate_limit_error: Rate limit exceeded")); - - const onError = vi.fn(); - const executor = new TaskExecutor(store, "/tmp/test", { - onError, - usageLimitPauser: pauser, - }); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - expect(onUsageLimitHitSpy).toHaveBeenCalledWith( - "executor", - "FN-001", - "rate_limit_error: Rate limit exceeded", - ); - expect(store.updateSettings).toHaveBeenCalledWith({ - globalPause: true, - globalPauseReason: "rate-limit", - }); - // Task should still be marked as failed - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "failed", error: "rate_limit_error: Rate limit exceeded" }); - expect(onError).toHaveBeenCalled(); - }); - - it("does NOT trigger global pause for transient non-usage-limit errors", async () => { - const store = createMockStore(); - const pauser = new UsageLimitPauser(store); - const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit"); - const onError = vi.fn(); - - mockedCreateFnAgent.mockRejectedValue(new Error("connection refused")); - - const executor = new TaskExecutor(store, "/tmp/test", { - onError, - usageLimitPauser: pauser, - }); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - expect(onUsageLimitHitSpy).not.toHaveBeenCalled(); - // Recovery policy: first transient error → retry 1/3 with backoff - expect(store.logEntry).toHaveBeenCalledWith("FN-001", expect.stringContaining("Transient error (retry 1/3"), undefined, expect.objectContaining({ agentId: "executor" })); - expect(store.updateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({ - recoveryRetryCount: 1, - nextRecoveryAt: expect.any(String), - })); - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true }); - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: "failed" }), - ); - expect(onError).not.toHaveBeenCalled(); - }); - - it("works without usageLimitPauser (backward compatible)", async () => { - const store = createMockStore(); - - mockedCreateFnAgent.mockRejectedValue(new Error("rate_limit_error: Rate limit exceeded")); - - const onError = vi.fn(); - const executor = new TaskExecutor(store, "/tmp/test", { onError }); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Should not crash — just mark as failed - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "failed", error: "rate_limit_error: Rate limit exceeded" }); - expect(onError).toHaveBeenCalled(); - }); - - it("triggers global pause when session.prompt() resolves with exhausted-retry error on state.error", async () => { - const store = createMockStore(); - const pauser = new UsageLimitPauser(store); - const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit"); - - // session.prompt() resolves normally, but session.state.error is set - // (this is what happens when pi-coding-agent exhausts retries) - const mockSession = { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - state: { error: "rate_limit_error: Rate limit exceeded" }, - }; - mockedCreateFnAgent.mockResolvedValue({ session: mockSession } as any); - - const onError = vi.fn(); - const executor = new TaskExecutor(store, "/tmp/test", { - onError, - usageLimitPauser: pauser, - }); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // UsageLimitPauser should be called - expect(onUsageLimitHitSpy).toHaveBeenCalledWith( - "executor", - "FN-001", - "rate_limit_error: Rate limit exceeded", - ); - // Task should be marked as failed - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "failed", error: "rate_limit_error: Rate limit exceeded" }); - // onError callback should fire - expect(onError).toHaveBeenCalled(); - }); - - it("triggers global pause for overloaded error", async () => { - const store = createMockStore(); - const pauser = new UsageLimitPauser(store); - const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit"); - - mockedCreateFnAgent.mockRejectedValue(new Error("overloaded_error: Overloaded")); - - const executor = new TaskExecutor(store, "/tmp/test", { - usageLimitPauser: pauser, - }); - - await executor.execute({ - id: "FN-002", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - expect(onUsageLimitHitSpy).toHaveBeenCalledWith( - "executor", - "FN-002", - "overloaded_error: Overloaded", - ); - }); -}); - -describe("TaskExecutor bounded recovery retries", () => { - beforeEach(() => { - resetExecutorMocks(); - }); - - it("increments recoveryRetryCount on successive transient failures", async () => { - const store = createMockStore(); - const onError = vi.fn(); - - mockedCreateFnAgent.mockRejectedValue(new Error("upstream connect error")); - - const executor = new TaskExecutor(store, "/tmp/test", { onError }); - - // First failure: count goes from undefined to 1 - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - expect(store.updateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({ - recoveryRetryCount: 1, - nextRecoveryAt: expect.any(String), - })); - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true }); - expect(onError).not.toHaveBeenCalled(); - - // Second failure: count goes from 1 to 2 - resetExecutorMocks(); - mockedCreateFnAgent.mockRejectedValue(new Error("upstream connect error")); - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - recoveryRetryCount: 1, - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - expect(store.updateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({ - recoveryRetryCount: 2, - })); - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true }); - expect(onError).not.toHaveBeenCalled(); - }); - - it("moves task to in-review when transient retries are exhausted (single-session)", async () => { - const store = createMockStore(); - const onError = vi.fn(); - - mockedCreateFnAgent.mockRejectedValue(new Error("socket hang up")); - - const executor = new TaskExecutor(store, "/tmp/test", { onError }); - - // Task already has 3 retries (max) — next failure should escalate - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - recoveryRetryCount: 3, - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { - status: "failed", - error: "socket hang up", - recoveryRetryCount: null, - nextRecoveryAt: null, - }); - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); - expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo"); - expect(onError).toHaveBeenCalled(); - }); - - it("does NOT consume retry budget for paused tasks", async () => { - const store = createMockStore(); - - const executor = new TaskExecutor(store, "/tmp/test", {}); - - // Simulate a paused abort — the executor checks pausedAborted set - const task = { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress" as const, - recoveryRetryCount: 1, - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - // Simulate: task gets paused mid-execution → abort error - mockedCreateFnAgent.mockRejectedValue(new Error("Aborted")); - (executor as any).markPausedAborted("FN-001", "hard-cancel"); - - await executor.execute(task); - - // Should NOT update recoveryRetryCount - expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", expect.objectContaining({ - recoveryRetryCount: expect.any(Number), - })); - }); - - it("does not clobber self-healing parked incomplete-task pause metadata during abort cleanup", async () => { - const store = createMockStore(); - (store.getTask as ReturnType).mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test", - column: "todo", - status: "queued", - paused: true, - userPaused: false, - pausedReason: undefined, - branch: "fusion/fn-001", - worktree: null, - dependencies: [], - steps: [{ name: "Testing & Verification", status: "in-progress" }], - currentStep: 6, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - const executor = new TaskExecutor(store, "/tmp/test", {}); - mockedCreateFnAgent.mockRejectedValue(new Error("Aborted")); - (executor as any).markPausedAborted("FN-001", "hard-cancel"); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - recoveryRetryCount: 1, - branch: "fusion/fn-001", - dependencies: [], - steps: [{ name: "Testing & Verification", status: "in-progress" }], - currentStep: 6, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", expect.objectContaining({ - worktree: undefined, - branch: undefined, - })); - expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo"); - expect(store.logEntry).toHaveBeenCalledWith( - "FN-001", - "Execution abort cleanup skipped — incomplete stuck-loop task is already parked with progress preserved", - undefined, - expect.anything(), - ); - }); - - it("does NOT consume retry budget for stuck-task-detector kills", async () => { - const store = createMockStore(); - - const executor = new TaskExecutor(store, "/tmp/test", {}); - - mockedCreateFnAgent.mockRejectedValue(new Error("Aborted")); - (executor as any).stuckAborted.set("FN-001", true); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - recoveryRetryCount: 2, - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Should NOT update recoveryRetryCount - expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", expect.objectContaining({ - recoveryRetryCount: expect.any(Number), - })); - }); - - it("requeues to todo when a stuck-killed session resolves without throwing", async () => { - const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test", {}); - - mockedCreateFnAgent.mockImplementation(async () => ({ - session: { - prompt: vi.fn(async () => { - executor.markStuckAborted("FN-001", true); - }), - dispose: vi.fn(), - state: {}, - }, - }) as any); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1); - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: "failed" }), - ); - expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-review"); - // Executor now handles the requeue in its finally block - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { - status: "queued", - error: null, - worktree: null, - branch: null, - }); - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true }); - }); - - it("does not requeue when stuck-kill budget is exhausted", async () => { - const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test", {}); - - mockedCreateFnAgent.mockImplementation(async () => ({ - session: { - prompt: vi.fn(async () => { - // Budget exhausted — shouldRequeue=false - executor.markStuckAborted("FN-001", false); - }), - dispose: vi.fn(), - state: {}, - }, - }) as any); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Should NOT requeue or mark as failed (budget handler already did that) - expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo"); - expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", expect.objectContaining({ - status: "queued", - worktree: null, - branch: null, - })); - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: "failed" }), - ); - }); - - it("skips stuck-requeue cleanup when task was concurrently recovered to in-review", async () => { - const store = createMockStore(); - // Self-healing already moved the task to in-review while execute() was unwinding. - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-review", - dependencies: [], - steps: [{ name: "step", status: "done" }], - currentStep: 1, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - - mockedCreateFnAgent.mockImplementation(async () => ({ - session: { - prompt: vi.fn(async () => { - executor.markStuckAborted("FN-001", true); - throw new Error("Stuck task"); - }), - dispose: vi.fn(), - state: {}, - }, - }) as any); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [{ name: "step", status: "done" }], - currentStep: 1, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Must NOT undo the recovery: no move, no stuck-killed status, no worktree clearing. - expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo"); - expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo", expect.anything()); - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - { status: "stuck-killed", worktree: null, branch: null }, - ); - }); - - it("force-requeue timeout reaps hung in-flight surfaces and removes the worktree before clearing guards", async () => { - vi.useFakeTimers(); - try { - const store = createMockStore(); - const agentStore = { - updateAgentState: vi.fn().mockResolvedValue(undefined), - deleteAgent: vi.fn().mockResolvedValue(undefined), - }; - const executor = new TaskExecutor(store, "/tmp/test", { agentStore: agentStore as any }); - const taskId = "FN-001"; - const worktreePath = "/tmp/test/.worktrees/FN-001"; - const session = { abort: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: {} }; - const workflowSession = { abort: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: {} }; - const stepExecutor = { - abortAllSessionBash: vi.fn(), - terminateAllSessions: vi.fn().mockResolvedValue(undefined), - }; - const controller = new AbortController(); - const controllerAbort = vi.spyOn(controller, "abort"); - const subagent = { dispose: vi.fn(), state: {} }; - const cliSession = { kill: vi.fn().mockResolvedValue(undefined) }; - const childSession = { dispose: vi.fn(), state: {} }; - vi.mocked(removeWorktree).mockResolvedValue(undefined as any); - store.getTask.mockResolvedValue({ - id: taskId, - title: "Test", - description: "Test task", - column: "in-progress", - worktree: worktreePath, - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - (executor as any).executing.add(taskId); - executingTaskLock.tryClaim(taskId); - (executor as any).activeWorktrees.set(taskId, worktreePath); - (executor as any).activeSessions.set(taskId, { session }); - (executor as any).activeStepExecutors.set(taskId, stepExecutor); - (executor as any).activeWorkflowStepSessions.set(taskId, workflowSession); - (executor as any).activeConfiguredCommandControllers.set(taskId, new Set([controller])); - (executor as any).activeSubagentSessions.set(taskId, new Set([subagent])); - (executor as any).activeCliTaskSessions.set(taskId, cliSession); - (executor as any).spawnedAgents.set(taskId, new Set(["child-agent"])); - (executor as any).childSessions.set("child-agent", childSession); - (executor as any).loopRecoveryState.set(taskId, { attempts: 1, pending: true }); - - executor.markStuckAborted(taskId, true); - await vi.advanceTimersByTimeAsync(60_000); - - expect(agentStore.updateAgentState).toHaveBeenCalledWith("child-agent", "paused"); - expect(agentStore.deleteAgent).toHaveBeenCalledWith("child-agent"); - expect(childSession.dispose).toHaveBeenCalledTimes(1); - expect(session.abort).toHaveBeenCalledTimes(1); - expect(session.dispose).toHaveBeenCalledTimes(1); - expect(stepExecutor.abortAllSessionBash).toHaveBeenCalledTimes(1); - expect(stepExecutor.terminateAllSessions).toHaveBeenCalled(); - expect(workflowSession.abort).toHaveBeenCalledTimes(1); - expect(workflowSession.dispose).toHaveBeenCalledTimes(1); - expect(controllerAbort).toHaveBeenCalledTimes(1); - expect(subagent.dispose).toHaveBeenCalledTimes(1); - expect(cliSession.kill).toHaveBeenCalledWith("killed"); - expect(removeWorktree).toHaveBeenCalledWith(expect.objectContaining({ - worktreePath, - rootDir: "/tmp/test", - taskId, - expectedOwnerTaskId: taskId, - })); - expect(store.updateTask).toHaveBeenCalledWith(taskId, { - status: "queued", - error: null, - worktree: null, - branch: null, - }); - expect(store.moveTask).toHaveBeenCalledWith(taskId, "todo", { preserveProgress: true }); - expect(session.abort.mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(removeWorktree).mock.invocationCallOrder[0]); - expect(vi.mocked(removeWorktree).mock.invocationCallOrder[0]).toBeLessThan(store.moveTask.mock.invocationCallOrder[0]); - const cleanupCompleteLogIndex = store.logEntry.mock.calls.findIndex(([, message]: any[]) => String(message).includes("Force-kill cleanup completed")); - expect(cleanupCompleteLogIndex).toBeGreaterThanOrEqual(0); - expect(store.moveTask.mock.invocationCallOrder[0]).toBeLessThan(store.logEntry.mock.invocationCallOrder[cleanupCompleteLogIndex]); - expect((executor as any).activeWorktrees.has(taskId)).toBe(false); - expect((executor as any).executing.has(taskId)).toBe(false); - expect(executingTaskLock.has(taskId)).toBe(false); - expect((executor as any).stuckAborted.has(taskId)).toBe(false); - expect((executor as any).loopRecoveryState.has(taskId)).toBe(false); - expect((executor as any).pausedAborted.has(taskId)).toBe(false); - expect(store.logEntry).toHaveBeenCalledWith(taskId, expect.stringContaining("Force-kill cleanup starting")); - expect(store.logEntry).toHaveBeenCalledWith(taskId, expect.stringContaining("Force-requeued after stuck-kill")); - expect(store.logEntry).toHaveBeenCalledWith(taskId, expect.stringContaining("progress preserved")); - expect(store.logEntry).toHaveBeenCalledWith(taskId, expect.stringContaining("Force-kill cleanup completed")); - } finally { - vi.useRealTimers(); - executingTaskLock._clearForTest(); - } - }); - - it("force-requeue timeout preserves concurrent non-in-progress recovery without reaping surfaces", async () => { - vi.useFakeTimers(); - try { - const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test", {}); - const session = { abort: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: {} }; - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-review", - worktree: "/tmp/test/.worktrees/FN-001", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - (executor as any).executing.add("FN-001"); - executingTaskLock.tryClaim("FN-001"); - (executor as any).activeWorktrees.set("FN-001", "/tmp/test/.worktrees/FN-001"); - (executor as any).activeSessions.set("FN-001", { session }); - - executor.markStuckAborted("FN-001", true); - await vi.advanceTimersByTimeAsync(60_000); - - expect(session.abort).not.toHaveBeenCalled(); - expect(session.dispose).not.toHaveBeenCalled(); - expect(removeWorktree).not.toHaveBeenCalled(); - expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo", expect.anything()); - expect((executor as any).executing.has("FN-001")).toBe(false); - expect(executingTaskLock.has("FN-001")).toBe(false); - } finally { - vi.useRealTimers(); - executingTaskLock._clearForTest(); - } - }); - - it("force-requeue timeout no-ops when the executor unwound before the grace timer", async () => { - vi.useFakeTimers(); - try { - const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test", {}); - const session = { abort: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: {} }; - (executor as any).executing.add("FN-001"); - executingTaskLock.tryClaim("FN-001"); - (executor as any).activeSessions.set("FN-001", { session }); - - executor.markStuckAborted("FN-001", true); - (executor as any).executing.delete("FN-001"); - executingTaskLock.release("FN-001"); - await vi.advanceTimersByTimeAsync(60_000); - - expect(session.abort).not.toHaveBeenCalled(); - expect(removeWorktree).not.toHaveBeenCalled(); - expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo", expect.anything()); - } finally { - vi.useRealTimers(); - executingTaskLock._clearForTest(); - } - }); - - it("force-requeue timeout logs non-fatal worktree cleanup failures distinctly", async () => { - vi.useFakeTimers(); - try { - const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test", {}); - const session = { abort: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: {} }; - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - worktree: "/tmp/test/.worktrees/FN-001", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - vi.mocked(removeWorktree).mockRejectedValue(new Error("worktree busy")); - (executor as any).executing.add("FN-001"); - executingTaskLock.tryClaim("FN-001"); - (executor as any).activeWorktrees.set("FN-001", "/tmp/test/.worktrees/FN-001"); - (executor as any).activeSessions.set("FN-001", { session }); - - executor.markStuckAborted("FN-001", true); - await vi.advanceTimersByTimeAsync(60_000); - - expect(store.logEntry).toHaveBeenCalledWith("FN-001", expect.stringContaining("Force-kill cleanup failed to remove worktree")); - expect(store.logEntry).toHaveBeenCalledWith("FN-001", expect.stringContaining("Force-kill cleanup completed with non-fatal worktree removal failure")); - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true }); - } finally { - vi.useRealTimers(); - executingTaskLock._clearForTest(); - } - }); - - it("force-requeue timeout honors disabled preserveProgressOnStuckRequeue", async () => { - vi.useFakeTimers(); - try { - const store = createMockStore(); - store.getSettings.mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - pollIntervalMs: 15000, - groupOverlappingFiles: false, - autoMerge: false, - worktreeInitCommand: undefined, - preserveProgressOnStuckRequeue: false, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - const resetSpy = vi.spyOn(executor as any, "resetStepsIfWorkLost").mockResolvedValue(undefined); - const session = { abort: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: {} }; - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - worktree: "/tmp/test/.worktrees/FN-001", - dependencies: [], - steps: [{ name: "step", status: "in-progress" }], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - vi.mocked(removeWorktree).mockResolvedValue(undefined as any); - (executor as any).executing.add("FN-001"); - executingTaskLock.tryClaim("FN-001"); - (executor as any).activeWorktrees.set("FN-001", "/tmp/test/.worktrees/FN-001"); - (executor as any).activeSessions.set("FN-001", { session }); - - executor.markStuckAborted("FN-001", true); - await vi.advanceTimersByTimeAsync(60_000); - - expect(resetSpy).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-001" })); - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", undefined); - } finally { - vi.useRealTimers(); - executingTaskLock._clearForTest(); - } - }); - - it("does not let a late graph failure clobber a retryable requeue", async () => { - const store = createMockStore(); - const task = { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as Task; - store.getTask.mockResolvedValue({ - ...task, - column: "todo", - status: "queued", - error: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - - await (executor as any).handleGraphFailure(task, { - visitedNodeIds: ["execute"], - }); - - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: "failed" }), - expect.anything(), - ); - expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-review", expect.anything()); - expect(store.handoffToReview).not.toHaveBeenCalled(); - expect(store.logEntry).toHaveBeenCalledWith( - "FN-001", - "Workflow graph run ended after task already advanced to 'todo' — no further action needed", - undefined, - undefined, - ); - }); - - it.each(["in-review", "done"] as const)( - "treats a graph exit after task advanced to %s as benign", - async (column) => { - const store = createMockStore(); - const task = { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as Task; - store.getTask.mockResolvedValue({ - ...task, - column, - status: undefined, - error: null, - }); - const warnSpy = vi.spyOn(executorLog, "warn").mockImplementation(() => undefined); - const executor = new TaskExecutor(store, "/tmp/test", {}); - - await (executor as any).handleGraphFailure(task, { - visitedNodeIds: ["execute"], - }); - - const expectedMessage = `Workflow graph run ended after task already advanced to '${column}' — no further action needed`; - expect(store.logEntry).toHaveBeenCalledWith("FN-001", expectedMessage, undefined, undefined); - expect(store.logEntry.mock.calls.map((call) => call[1]).join("\n")).not.toContain("terminated with failure"); - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: "failed" }), - expect.anything(), - ); - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ error: expect.anything() }), - expect.anything(), - ); - expect(store.handoffToReview).not.toHaveBeenCalled(); - expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("terminated with failure")); - warnSpy.mockRestore(); - }, - ); - - it("treats a graph exit while task is paused as benign", async () => { - const store = createMockStore(); - const task = { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as Task; - store.getTask.mockResolvedValue({ - ...task, - column: "in-progress", - paused: true, - status: undefined, - error: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - - await (executor as any).handleGraphFailure(task, { - visitedNodeIds: ["execute"], - }); - - expect(store.logEntry).toHaveBeenCalledWith( - "FN-001", - "Workflow graph run ended while task is paused — pause state preserved", - undefined, - undefined, - ); - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: "failed" }), - expect.anything(), - ); - expect(store.handoffToReview).not.toHaveBeenCalled(); - }); - - it.each([ - ["plain execute", ["execute"], "awaiting-user-input", { "node:execute:value": "awaiting-user-input" }, "Workflow graph run ended awaiting user input at node 'execute' — awaiting state preserved"], - ["progress then execute", ["plan", "execute"], "awaiting-cli-approval", { "node:execute:value": "awaiting-cli-approval" }, "Workflow graph run ended awaiting CLI approval at node 'execute' — awaiting state preserved"], - ["step-execute foreach seam", ["foreach#0:step-execute"], "awaiting-user-input", { "node:foreach:value": "awaiting-user-input" }, "Workflow graph run ended awaiting user input at node 'foreach#0:step-execute' — awaiting state preserved"], - ] as const)( - "preserves awaiting graph failure values instead of terminal execute parking: %s", - async (_name, visitedNodeIds, value, context, message) => { - const store = createMockStore(); - const task = { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps: [{ name: "Step 1", status: "pending" }], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as Task; - store.getTask.mockResolvedValue({ - ...task, - column: "in-progress", - paused: false, - status: value, - error: null, - }); - const warnSpy = vi.spyOn(executorLog, "warn").mockImplementation(() => undefined); - const executor = new TaskExecutor(store, "/tmp/test", {}); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds, - context, - }); - - expect(store.logEntry).toHaveBeenCalledWith("FN-001", message, undefined, undefined); - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: value, paused: true }, undefined); - expect(store.logEntry.mock.calls.map((call) => call[1]).join("\n")).not.toContain("Workflow graph terminated with failure at node"); - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: "failed" }), - expect.anything(), - ); - expect(store.handoffToReview).not.toHaveBeenCalled(); - expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("Workflow graph terminated with failure at node")); - warnSpy.mockRestore(); - }, - ); - - it("preserves genuine step-execute-unwired failures as terminal graph failures", async () => { - const store = createMockStore(); - const task = { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps: [{ name: "Step 1", status: "pending" }], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as Task; - store.getTask.mockResolvedValue({ ...task, column: "in-progress", paused: false, status: undefined, error: null }); - const warnSpy = vi.spyOn(executorLog, "warn").mockImplementation(() => undefined); - const executor = new TaskExecutor(store, "/tmp/test", {}); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: ["foreach#0:step-execute"], - context: { "node:foreach#0:step-execute:value": "step-execute-unwired" }, - }); - - const message = "Workflow graph terminated with failure at node 'foreach#0:step-execute'"; - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { error: message, status: "failed" }, undefined); - expect(store.handoffToReview).toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ evidence: expect.objectContaining({ reason: "workflow-graph-failed" }) }), - ); - warnSpy.mockRestore(); - }); - - /* - FNXC:WorkflowLifecycle 2026-06-15-01:38: - FN-6478 established that a workflow graph exit while paused is benign only while the task remains in-progress. If the live row already advanced to in-review or another non-execution column, the executor must preserve explicit user pauses and autoMerge:false terminal review state while surfacing an operator-actionable workflow failure instead of the generic pause-preserved log. - */ - it("surfaces an operator-actionable failure for user-paused in-review graph exits", async () => { - const store = createMockStore(); - const steps = [ - { name: "Preflight", status: "pending" }, - { name: "Implement", status: "pending" }, - ]; - const task = { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps, - currentStep: 0, - log: [{ timestamp: new Date().toISOString(), action: "Resuming execution after unpause" }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as Task; - store.getTask.mockResolvedValue({ - ...task, - column: "in-review", - paused: true, - userPaused: true, - status: undefined, - error: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - - await (executor as any).handleGraphFailure(task, { - visitedNodeIds: ["execute"], - }); - - const messages = store.logEntry.mock.calls.map((call) => call[1]).join("\n"); - expect(store.logEntry.mock.calls.map((call) => call[1])).toEqual([ - "Workflow graph failure surfaced after paused explicit user pause in 'in-review' at node 'execute' — operator action required; retry or explicitly unpause/resume after inspecting the task", - ]); - expect(messages).toContain("Workflow graph failure surfaced"); - expect(messages).toContain("explicit user pause"); - expect(messages).toContain("operator action required"); - expect(messages).not.toContain("Workflow graph run ended while task is paused — pause state preserved"); - expect(store.updateTask).toHaveBeenCalledWith( - "FN-001", - { - error: "Workflow graph failure surfaced after paused explicit user pause in 'in-review' at node 'execute' — operator action required; retry or explicitly unpause/resume after inspecting the task", - status: "failed", - }, - undefined, - ); - expect(store.handoffToReview).not.toHaveBeenCalled(); - }); - - describe("completion-finalize abort classification (FN-6625)", () => { - /* - Surface Enumeration coverage: - - Classifier branch: completion-finalize provenance bypasses operator-action parking while hard-cancel, userPaused, and global-pause coverage remains in this suite. - - Abort provenance sources: the new completion-finalize value is asserted here; FN-6568 below covers merge-seam/global-pause and the hard-cancel test in this block preserves generic operator-cancel behavior. - - Completion-finalize paths: executor.ts marks both graceful-session-exit and finally-block handoffTaskToReview("paused-after-completion") sites; this direct classifier test reproduces the shared trailing graph failure. - - Failed-node identity: the symptom uses execute, but the production predicate keys on provenance/completion state, not a node-id allow-list. - - Column/progress states: in-review finalized-completion is benign; existing adjacent tests cover in-progress pause preservation and done/todo non-execution exits. - - Data states: userPaused true is covered above, paused true without userPaused is covered below, completion-finalize and hard-cancel are covered here, global-pause/merge-seam are covered in the FN-6568 block, and already-status/error-set guard is preserved here. - - Preserved semantics: genuine user/global pause parking, merge-seam retry routing, and genuine hard-cancel parking remain asserted without backward moveTask calls. - - No leftover shells: completion-finalize is stored in pausedAbortProvenance and cleared through the existing clearPausedAborted helper used by every cleanup site. - */ - it("treats completion-finalize pausedAborted in-review graph exits as benign", async () => { - const store = createMockStore(); - const steps = [ - { name: "Preflight", status: "done" }, - { name: "Implement", status: "done" }, - ]; - const task = { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps, - currentStep: 1, - log: [{ timestamp: new Date().toISOString(), action: "Execution paused after completion — finalizing to in-review" }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as Task; - store.getTask.mockResolvedValue({ - ...task, - column: "in-review", - paused: false, - userPaused: false, - status: undefined, - error: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - (executor as any).markPausedAborted("FN-001", "completion-finalize"); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: ["execute"], - }); - - const messages = store.logEntry.mock.calls.map((call) => call[1]).join("\n"); - expect(messages).toContain("Workflow graph run ended after task already advanced to 'in-review' — no further action needed"); - expect(messages).not.toContain("engine abort during pause/resume"); - expect(messages).not.toContain("operator action required"); - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: "failed" }), - expect.anything(), - ); - expect(store.moveTask).not.toHaveBeenCalled(); - expect(store.handoffToReview).not.toHaveBeenCalled(); - }); - - it("treats completion-finalize graph exits as benign after teardown re-marks hard-cancel (FN-6644)", async () => { - const store = createMockStore(); - const steps = [ - { name: "Preflight", status: "done" }, - { name: "Implement", status: "done" }, - ]; - const task = { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps, - currentStep: 1, - log: [{ timestamp: new Date().toISOString(), action: "Execution paused after completion — finalizing to in-review" }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as Task; - store.getTask.mockResolvedValue({ - ...task, - column: "in-review", - paused: false, - userPaused: false, - status: undefined, - error: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - (executor as any).markCompletionFinalized("FN-001"); - - await (executor as any).awaitAbortInFlightTaskWork("FN-001", "completion-finalize teardown after handoff"); - expect((executor as any).pausedAbortProvenance.get("FN-001")).toBe("hard-cancel"); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: ["execute"], - }); - - const messages = store.logEntry.mock.calls.map((call) => call[1]).join("\n"); - expect(messages).toContain("Workflow graph run ended after task already advanced to 'in-review' — no further action needed"); - expect(messages).not.toContain("engine abort during pause/resume"); - expect(messages).not.toContain("operator action required"); - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: "failed" }), - expect.anything(), - ); - expect(store.moveTask).not.toHaveBeenCalled(); - expect(store.handoffToReview).not.toHaveBeenCalled(); - }); - - it("preserves clean completed in-review rows after benign engine-restart hard-cancel provenance without finalize log", async () => { - const store = createMockStore(); - const steps = [ - { name: "Preflight", status: "done" }, - { name: "Implement", status: "done" }, - ]; - const task = { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps, - currentStep: 1, - // FNXC:WorkflowLifecycle 2026-06-20-00:00: - // FN-6796 symptom coverage must omit the paused-after-completion finalize log so this exercises the benign in-review pause-abort classifier, not the older alreadyFinalizedToReview suppression path. - log: [{ timestamp: new Date().toISOString(), action: "Normal review handoff without paused-completion marker" }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as Task; - store.getTask.mockResolvedValue({ - ...task, - column: "in-review", - paused: false, - userPaused: false, - status: null, - error: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - (executor as any).markPausedAborted("FN-001", "hard-cancel"); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: ["execute"], - }); - - const messages = store.logEntry.mock.calls.map((call) => call[1]).join("\n"); - expect(messages).toContain("Workflow graph run ended during engine pause/resume while already in-review — benign, in-review state preserved"); - expect(messages).not.toContain("Workflow graph failure surfaced after paused engine abort during pause/resume"); - expect(messages).not.toContain("operator action required"); - expect((executor as any).pausedAborted.has("FN-001")).toBe(false); - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: "failed" }), - expect.anything(), - ); - expect(store.moveTask).not.toHaveBeenCalled(); - expect(store.handoffToReview).not.toHaveBeenCalled(); - }); - - it("surfaces user hard-cancel in-review graph exits with completed steps as workflow failures", async () => { - const store = createMockStore(); - const steps = [ - { name: "Preflight", status: "done" }, - { name: "Implement", status: "done" }, - ]; - const task = { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps, - currentStep: 1, - log: [{ timestamp: new Date().toISOString(), action: "Normal review handoff without paused-completion marker" }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as Task; - store.getTask.mockResolvedValue({ - ...task, - column: "in-review", - paused: false, - userPaused: false, - status: null, - error: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - await (executor as any).awaitAbortInFlightTaskWork("FN-001", "user move in-progress to todo", { userCanceled: true }); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: ["execute"], - }); - - const expectedMessage = "Workflow graph failure surfaced after paused engine abort during pause/resume in 'in-review' at node 'execute' — operator action required; retry or explicitly unpause/resume after inspecting the task"; - expect((executor as any).userCanceledTaskIds.has("FN-001")).toBe(true); - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { error: expectedMessage, status: "failed" }, undefined); - expect(store.moveTask).not.toHaveBeenCalled(); - expect(store.handoffToReview).not.toHaveBeenCalled(); - }); - - it("surfaces incomplete hard-cancel pausedAborted in-review graph exits as workflow failures", async () => { - const store = createMockStore(); - const steps = [ - { name: "Preflight", status: "pending" }, - { name: "Implement", status: "pending" }, - ]; - const task = { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps, - currentStep: 0, - log: [{ timestamp: new Date().toISOString(), action: "Resuming execution after unpause" }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as Task; - store.getTask.mockResolvedValue({ - ...task, - column: "in-review", - paused: false, - status: undefined, - error: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - (executor as any).markPausedAborted("FN-001", "hard-cancel"); - - await (executor as any).handleGraphFailure(task, { - visitedNodeIds: ["execute"], - }); - - const messages = store.logEntry.mock.calls.map((call) => call[1]).join("\n"); - expect(store.logEntry.mock.calls.map((call) => call[1])).toEqual([ - "Workflow graph failure surfaced after paused engine abort during pause/resume in 'in-review' at node 'execute' — operator action required; retry or explicitly unpause/resume after inspecting the task", - ]); - expect(messages).toContain("Workflow graph failure surfaced"); - expect(messages).toContain("engine abort during pause/resume"); - expect(messages).toContain("operator action required"); - expect(messages).not.toContain("Workflow graph run ended while task is paused — pause state preserved"); - expect(store.updateTask).toHaveBeenCalledWith( - "FN-001", - { - error: "Workflow graph failure surfaced after paused engine abort during pause/resume in 'in-review' at node 'execute' — operator action required; retry or explicitly unpause/resume after inspecting the task", - status: "failed", - }, - undefined, - ); - expect(store.handoffToReview).not.toHaveBeenCalled(); - }); - - it("does not overwrite an already-surfaced in-review failure during paused abort cleanup", async () => { - const store = createMockStore(); - const task = { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps: [{ name: "Preflight", status: "pending" }], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as Task; - store.getTask.mockResolvedValue({ - ...task, - column: "in-review", - paused: false, - status: "failed", - error: "Task reached in-review without calling fn_task_done", - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - (executor as any).markPausedAborted("FN-001", "hard-cancel"); - - await (executor as any).handleGraphFailure(task, { - visitedNodeIds: ["execute"], - }); - - expect(store.logEntry).toHaveBeenCalledWith( - "FN-001", - "Workflow graph failure surfaced after paused engine abort during pause/resume in 'in-review' at node 'execute' — operator action required; retry or explicitly unpause/resume after inspecting the task", - undefined, - undefined, - ); - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: "failed" }), - expect.anything(), - ); - expect(store.moveTask).not.toHaveBeenCalled(); - expect(store.handoffToReview).not.toHaveBeenCalled(); - }); - - it("preserves global-pause provenance as operator-action parking for execute-node in-review graph exits", async () => { - const store = createMockStore(); - const task = { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps: [{ name: "Preflight", status: "done" }], - currentStep: 1, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as Task; - store.getTask.mockResolvedValue({ - ...task, - column: "in-review", - paused: false, - userPaused: false, - status: undefined, - error: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - (executor as any).markPausedAborted("FN-001", "global-pause"); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: ["execute"], - }); - - const expectedMessage = "Workflow graph failure surfaced after paused global pause in 'in-review' at node 'execute' — operator action required; retry or explicitly unpause/resume after inspecting the task"; - const messages = store.logEntry.mock.calls.map((call) => call[1]).join("\n"); - expect(messages).toContain("global pause"); - expect(messages).toContain("operator action required"); - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { error: expectedMessage, status: "failed" }, undefined); - expect(store.moveTask).not.toHaveBeenCalled(); - expect(store.handoffToReview).not.toHaveBeenCalled(); - }); - - it("keeps genuine in-progress hard-cancel aborts active and pause-preserved", async () => { - const store = createMockStore(); - const task = { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps: [{ name: "Preflight", status: "pending" }], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as Task; - store.getTask.mockResolvedValue({ - ...task, - column: "in-progress", - paused: false, - userPaused: false, - status: undefined, - error: null, - }); - const abort = vi.fn().mockResolvedValue(undefined); - const dispose = vi.fn(); - const executor = new TaskExecutor(store, "/tmp/test", {}); - (executor as any).activeSessions.set("FN-001", { session: { abort, dispose, state: {} } }); - - await (executor as any).awaitAbortInFlightTaskWork("FN-001", "user move in-progress to todo", { userCanceled: true }); - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: ["execute"], - }); - - expect(abort).toHaveBeenCalledTimes(1); - expect(dispose).toHaveBeenCalledTimes(1); - expect((executor as any).userCanceledTaskIds.has("FN-001")).toBe(true); - expect((executor as any).pausedAbortProvenance.get("FN-001")).toBe("hard-cancel"); - expect(store.logEntry).toHaveBeenCalledWith( - "FN-001", - "Workflow graph run ended while task is paused — pause state preserved", - undefined, - undefined, - ); - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: "failed" }), - expect.anything(), - ); - }); - - }); - - describe("completion-finalize hard-cancel overwrite classification (FN-6644)", () => { - /* - Surface Enumeration coverage (FN-6647): - - [x] Lifecycle paths that transition to `in-review`: both `handoffTaskToReview(task, "paused-after-completion")` call sites use `markCompletionFinalized(...)`; this block drives their shared classifier seam rather than duplicating executor finally/graceful-session-exit control flow. - - [x] No-commit / verification-only completion: the FN-6647 tests cover both a normal completed task (FN-6638 shape) and a zero-modified-files/no-commits completed task (FN-6641 shape). - - [x] Pause / resume / self-healing interactions: hard-cancel without a surviving `markCompletionFinalized(...)`, `clearPausedAborted(...)` then hard-cancel, and fresh `execute(...)` re-dispatch clearing stale suppression are all asserted. - - [x] Abort provenance sources: `global-pause`, `merge-seam`, `hard-cancel`, `completion-finalize`, and undefined provenance keep their existing routes; companion tests prove genuine pause/global-pause/merge-seam controls still win. - - [x] Failed-node identity: benign coverage uses `execute` and `verifySentinel`; merge coverage uses `requestMerge`, so the fix keys on durable completion state rather than node id and does not divert merge-seam retry routing. - - [x] Column / progress data states: finalized `in-review`, terminal `done`/`archived`, active `in-progress` hard-cancel, and pending-step `in-review` hard-cancel are covered; the pending-step control remains an operator-action failure. - - [x] Live-pause data states: `userPaused === true` and `global-pause` still park/preserve as operator-actionable even when stale finalized state exists. - - [x] Dashboard / board state rendering: benign finalized rows assert `store.updateTask` is not called with `status: "failed"`/operator-action `error`, leaving a normal `in-review` row with no failed badge. - - [x] Leftover shells: stale in-memory suppression is cleared on fresh dispatch; durable persisted-state suppression requires completed steps plus the finalize log so incomplete rows cannot inherit it. - */ - const makeCompletedTask = (overrides: Partial = {}) => ({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps: [ - { name: "Preflight", status: "done" }, - { name: "Verify", status: "done" }, - ], - currentStep: 1, - modifiedFiles: ["packages/engine/src/executor.ts"], - log: [{ timestamp: new Date().toISOString(), action: "Execution paused after completion — finalizing to in-review" }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - ...overrides, - }) as Task; - - const expectBenignAlreadyAdvanced = (store: ReturnType, column = "in-review") => { - const messages = store.logEntry.mock.calls.map((call) => call[1]).join("\n"); - expect(messages).toContain(`Workflow graph run ended after task already advanced to '${column}' — no further action needed`); - expect(messages).not.toContain("Workflow graph failure surfaced after paused engine abort during pause/resume"); - expect(messages).not.toContain("operator action required"); - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: "failed" }), - expect.anything(), - ); - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ error: expect.stringContaining("operator action required") }), - expect.anything(), - ); - expect(store.moveTask).not.toHaveBeenCalled(); - }; - - it("treats a normal completed in-review row as benign after the volatile finalize marker is lost", async () => { - const store = createMockStore(); - const task = makeCompletedTask(); - store.getTask.mockResolvedValue({ - ...task, - column: "in-review", - paused: false, - userPaused: false, - status: undefined, - error: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - (executor as any).markPausedAborted("FN-001", "hard-cancel"); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: ["execute"], - }); - - expectBenignAlreadyAdvanced(store); - }); - - it("treats a no-commits verification-only in-review row as benign after the volatile finalize marker is lost", async () => { - const store = createMockStore(); - const task = makeCompletedTask({ - noCommitsExpected: true, - modifiedFiles: [], - steps: [ - { name: "Preflight", status: "done" }, - { name: "Verify", status: "skipped" }, - ], - }); - store.getTask.mockResolvedValue({ - ...task, - column: "in-review", - paused: false, - userPaused: false, - status: undefined, - error: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - (executor as any).markPausedAborted("FN-001", "hard-cancel"); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: ["execute"], - }); - - expectBenignAlreadyAdvanced(store); - }); - - it("keeps finalized-completion rows benign after clearPausedAborted wipes the in-memory marker before hard-cancel", async () => { - const store = createMockStore(); - const task = makeCompletedTask(); - store.getTask.mockResolvedValue({ - ...task, - column: "in-review", - paused: false, - userPaused: false, - status: undefined, - error: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - (executor as any).markCompletionFinalized("FN-001"); - (executor as any).clearPausedAborted("FN-001"); - (executor as any).markPausedAborted("FN-001", "hard-cancel"); - - expect((executor as any).completionFinalizedTaskIds.has("FN-001")).toBe(false); - expect((executor as any).pausedAbortProvenance.get("FN-001")).toBe("hard-cancel"); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: ["execute"], - }); - - expectBenignAlreadyAdvanced(store); - }); - - it.each(["completion-finalize", undefined] as const)( - "keeps finalized-completion rows benign with %s abort provenance", - async (provenance) => { - const store = createMockStore(); - const task = makeCompletedTask(); - store.getTask.mockResolvedValue({ - ...task, - column: "in-review", - paused: false, - userPaused: false, - status: undefined, - error: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - if (provenance) { - (executor as any).markPausedAborted("FN-001", provenance); - } - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: ["verifySentinel"], - }); - - expectBenignAlreadyAdvanced(store); - }, - ); - - it.each(["execute", "verifySentinel"] as const)( - "treats finalized-completion graph exits as benign after hard-cancel overwrite at node %s", - async (nodeId) => { - const store = createMockStore(); - const task = makeCompletedTask(); - store.getTask.mockResolvedValue({ - ...task, - column: "in-review", - paused: false, - userPaused: false, - status: undefined, - error: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - (executor as any).markCompletionFinalized("FN-001"); - - await (executor as any).awaitAbortInFlightTaskWork("FN-001", "completion-finalize teardown after handoff"); - expect((executor as any).pausedAbortProvenance.get("FN-001")).toBe("hard-cancel"); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: [nodeId], - }); - - const messages = store.logEntry.mock.calls.map((call) => call[1]).join("\n"); - expect(messages).toContain("Workflow graph run ended after task already advanced to 'in-review' — no further action needed"); - expect(messages).not.toContain("engine abort during pause/resume"); - expect(messages).not.toContain("operator action required"); - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: "failed" }), - expect.anything(), - ); - expect(store.moveTask).not.toHaveBeenCalled(); - }, - ); - - it.each(["done", "archived"] as const)("keeps already-terminal %s finalized-completion rows benign after hard-cancel overwrite", async (column) => { - const store = createMockStore(); - const task = makeCompletedTask(); - store.getTask.mockResolvedValue({ - ...task, - column, - paused: false, - userPaused: false, - status: undefined, - error: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - (executor as any).markCompletionFinalized("FN-001"); - await (executor as any).awaitAbortInFlightTaskWork("FN-001", "completion-finalize teardown after terminal handoff"); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: ["execute"], - }); - - expectBenignAlreadyAdvanced(store, column); - }); - - it("treats a completed in-review row as benign even with a lingering NON-user paused flag (FN-6648)", async () => { - /* - FNXC:WorkflowLifecycle 2026-06-18-16:25: - FN-6648 (FN-6638 recurrence): the paused-after-completion graceful-exit - path finalizes a fully completed task to in-review while leaving a - NON-user `paused: true` flag set (handoffToReview/applyInReviewEnterEffects - clear status/blockedBy but never `paused`). Worst case: the volatile - completionFinalized marker is lost (execute re-entry) AND provenance is - overwritten to hard-cancel by teardown — only persisted evidence remains. - This must resolve benignly, NOT park the completed task as an - operator-action "engine abort during pause/resume" failure. - */ - const store = createMockStore(); - const task = makeCompletedTask(); - store.getTask.mockResolvedValue({ - ...task, - column: "in-review", - paused: true, - userPaused: false, - status: undefined, - error: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - (executor as any).markPausedAborted("FN-001", "hard-cancel"); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: ["execute"], - }); - - expectBenignAlreadyAdvanced(store); - }); - - it("preserves explicit user-pause parking even when durable completion state exists", async () => { - const store = createMockStore(); - const task = makeCompletedTask(); - store.getTask.mockResolvedValue({ - ...task, - column: "in-review", - paused: true, - userPaused: true, - status: undefined, - error: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - (executor as any).markCompletionFinalized("FN-001"); - await (executor as any).awaitAbortInFlightTaskWork("FN-001", "completion-finalize teardown after handoff"); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: ["execute"], - }); - - const expectedMessage = "Workflow graph failure surfaced after paused explicit user pause in 'in-review' at node 'execute' — operator action required; retry or explicitly unpause/resume after inspecting the task"; - expect(store.logEntry).toHaveBeenCalledWith("FN-001", expectedMessage, undefined, undefined); - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { error: expectedMessage, status: "failed" }, undefined); - expect(store.moveTask).not.toHaveBeenCalled(); - }); - - it("preserves global-pause parking even when durable completion state exists", async () => { - const store = createMockStore(); - const task = makeCompletedTask(); - store.getTask.mockResolvedValue({ - ...task, - column: "in-review", - paused: false, - userPaused: false, - status: undefined, - error: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - (executor as any).markCompletionFinalized("FN-001"); - (executor as any).markPausedAborted("FN-001", "global-pause"); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: ["execute"], - }); - - const expectedMessage = "Workflow graph failure surfaced after paused global pause in 'in-review' at node 'execute' — operator action required; retry or explicitly unpause/resume after inspecting the task"; - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { error: expectedMessage, status: "failed" }, undefined); - expect(store.moveTask).not.toHaveBeenCalled(); - }); - - it("preserves merge-seam retry routing when merge provenance coexists with stale durable completion state", async () => { - const store = createMockStore(); - const task = makeCompletedTask(); - store.getTask.mockResolvedValue({ - ...task, - column: "in-review", - paused: false, - userPaused: false, - status: undefined, - error: null, - mergeRetries: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - const mergeRequester = vi.fn(async () => ({ merged: false, noOp: false, reason: "merge-conflict" })); - executor.setMergeRequester(mergeRequester as any); - (executor as any).markCompletionFinalized("FN-001"); - (executor as any).markPausedAborted("FN-001", "merge-seam"); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: ["requestMerge"], - }); - - const messages = store.logEntry.mock.calls.map((call) => call[1]).join("\n"); - expect(messages).toContain("Workflow graph merge failure at node 'requestMerge' routed to bounded auto-merge retry after merge-seam abort"); - expect(messages).not.toContain("operator action required"); - expect(mergeRequester).toHaveBeenCalledWith("FN-001"); - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: "failed" }), - expect.anything(), - ); - }); - - it("preserves genuine pending-step hard-cancel parking when no completion finalize occurred", async () => { - const store = createMockStore(); - const task = makeCompletedTask({ - steps: [{ name: "Preflight", status: "pending" }], - currentStep: 0, - log: [{ timestamp: new Date().toISOString(), action: "Resuming execution after unpause" }], - }); - store.getTask.mockResolvedValue({ - ...task, - column: "in-review", - paused: false, - userPaused: false, - status: undefined, - error: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - (executor as any).markPausedAborted("FN-001", "hard-cancel"); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: ["execute"], - }); - - const expectedMessage = "Workflow graph failure surfaced after paused engine abort during pause/resume in 'in-review' at node 'execute' — operator action required; retry or explicitly unpause/resume after inspecting the task"; - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { error: expectedMessage, status: "failed" }, undefined); - expect(store.moveTask).not.toHaveBeenCalled(); - }); - - it("clears durable completion state on new execution dispatch so suppression cannot leak across runs", async () => { - const store = createMockStore(); - const task = makeCompletedTask({ steps: [{ name: "Preflight", status: "pending" }], currentStep: 0 }); - store.getTask.mockResolvedValue({ - ...task, - column: "in-review", - paused: false, - userPaused: false, - status: undefined, - error: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", { - workflowAuthoritativeDispatch: async () => true, - }); - (executor as any).markCompletionFinalized("FN-001"); - await executor.execute(task); - (executor as any).markPausedAborted("FN-001", "hard-cancel"); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: ["execute"], - }); - - const expectedMessage = "Workflow graph failure surfaced after paused engine abort during pause/resume in 'in-review' at node 'execute' — operator action required; retry or explicitly unpause/resume after inspecting the task"; - expect((executor as any).completionFinalizedTaskIds.has("FN-001")).toBe(false); - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { error: expectedMessage, status: "failed" }, undefined); - }); - }); - - it("keeps genuine in-progress user pauses benign even with partial step progress", async () => { - const store = createMockStore(); - const task = { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps: [ - { name: "Preflight", status: "done" }, - { name: "Implement", status: "pending" }, - ], - currentStep: 1, - log: [{ timestamp: new Date().toISOString(), action: "Started execution" }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as Task; - store.getTask.mockResolvedValue({ - ...task, - paused: true, - userPaused: true, - status: undefined, - error: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - - await (executor as any).handleGraphFailure(task, { - visitedNodeIds: ["execute"], - }); - - expect(store.logEntry).toHaveBeenCalledWith( - "FN-001", - "Workflow graph run ended while task is paused — pause state preserved", - undefined, - undefined, - ); - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: "failed" }), - expect.anything(), - ); - expect(store.handoffToReview).not.toHaveBeenCalled(); - expect(store.moveTask).not.toHaveBeenCalled(); - }); - - it("surfaces non-in-progress paused graph exits even after partial progress without requeueing autoMerge-off review", async () => { - const store = createMockStore(); - const task = { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps: [ - { name: "Preflight", status: "done" }, - { name: "Implement", status: "pending" }, - ], - currentStep: 1, - log: [{ timestamp: new Date().toISOString(), action: "Resuming execution after unpause" }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as Task; - store.getTask.mockResolvedValue({ - ...task, - column: "in-review", - paused: true, - userPaused: true, - status: undefined, - error: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - - await (executor as any).handleGraphFailure(task, { - visitedNodeIds: ["execute"], - }); - - const expectedMessage = "Workflow graph failure surfaced after paused explicit user pause in 'in-review' at node 'execute' — operator action required; retry or explicitly unpause/resume after inspecting the task"; - expect(store.logEntry).toHaveBeenCalledWith("FN-001", expectedMessage, undefined, undefined); - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { error: expectedMessage, status: "failed" }, undefined); - expect(store.moveTask).not.toHaveBeenCalled(); - expect(store.handoffToReview).not.toHaveBeenCalled(); - }); - - function advancedColumnTask(): Task { - return { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps: [{ name: "Preflight", status: "pending" }], - currentStep: 0, - log: [{ timestamp: new Date().toISOString(), action: "Resuming execution after unpause" }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as Task; - } - - // FN-6782: a paused graph exit that already landed back in `todo` is BENIGN — - // it must NOT be parked `failed` (that re-fail loop was the retry storm). It - // logs a benign line, clears the pause-abort marker, and leaves the task in - // todo for normal scheduling. (Previously this was surfaced as an - // operator-action failure; see the `done` case below for the still-surfaced path.) - it("treats a paused graph exit re-queued to todo as benign without parking failed", async () => { - const store = createMockStore(); - const task = advancedColumnTask(); - store.getTask.mockResolvedValue({ ...task, column: "todo", paused: true, status: undefined, error: null }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - - await (executor as any).handleGraphFailure(task, { visitedNodeIds: ["execute"] }); - - const benignMessage = "Workflow graph run ended during task pause with task re-queued to todo — benign, cleared for normal scheduling"; - expect(store.logEntry).toHaveBeenCalledWith("FN-001", benignMessage, undefined, undefined); - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: "failed" }), - expect.anything(), - ); - expect(store.moveTask).not.toHaveBeenCalled(); - expect(store.handoffToReview).not.toHaveBeenCalled(); - }); - - it("surfaces a paused graph exit in an already-advanced done column without parking failed", async () => { - const store = createMockStore(); - const task = advancedColumnTask(); - store.getTask.mockResolvedValue({ ...task, column: "done", paused: true, status: undefined, error: null }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - - await (executor as any).handleGraphFailure(task, { visitedNodeIds: ["execute"] }); - - const expectedMessage = "Workflow graph failure surfaced after paused task pause in 'done' at node 'execute' — operator action required; retry or explicitly unpause/resume after inspecting the task"; - expect(store.logEntry).toHaveBeenCalledWith("FN-001", expectedMessage, undefined, undefined); - // done/archived are terminal — surfaced via log only, never parked failed. - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: "failed" }), - expect.anything(), - ); - expect(store.moveTask).not.toHaveBeenCalled(); - expect(store.handoffToReview).not.toHaveBeenCalled(); - }); - - describe("merge-seam abort classification (FN-6568)", () => { - /* - Surface Enumeration coverage: - - Pause-branch classifier: merge-seam provenance bypasses operator-action pause parking; user/global pause provenance still parks. - - handleGraphFailure call surfaces: direct graph-failure handling for merge/requestMerge nodes plus existing execute-node hard-cancel tests. - - pausedAborted provenance: hard-cancel, global-pause, merge-seam, and no-provenance/clean merge failure behavior are explicit. - - Failed-node identity: legacy `merge` seam and graph primitive `requestMerge` are both treated as merge failures. - - Column/progress states: in-review merge failures retry; existing in-progress genuine pause tests preserve pause state. - - Data states: userPaused true, paused true, merge-seam provenance, global-pause provenance, and hard-cancel provenance are covered. - - autoMerge:false review parking: a genuinely paused in-review task remains parked without backward movement. - */ - const makeGraphTask = (overrides: Partial = {}) => ({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps: [{ name: "Preflight", status: "done" }], - currentStep: 1, - log: [{ timestamp: new Date().toISOString(), action: "Resuming execution after unpause" }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - ...overrides, - }) as Task; - - it.each(["merge", "requestMerge"] as const)( - "routes non-paused merge-seam abort at %s into bounded auto-merge retry instead of pause parking", - async (nodeId) => { - const store = createMockStore(); - const task = makeGraphTask(); - store.getTask.mockResolvedValue({ - ...task, - column: "in-review", - paused: false, - userPaused: false, - status: undefined, - error: null, - mergeRetries: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - const mergeRequester = vi.fn(async () => ({ merged: false, noOp: false, reason: "merge-conflict" })); - executor.setMergeRequester(mergeRequester as any); - (executor as any).markPausedAborted("FN-001", "merge-seam"); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: [nodeId], - }); - - const messages = store.logEntry.mock.calls.map((call) => call[1]).join("\n"); - expect(messages).toContain(`Workflow graph merge failure at node '${nodeId}' routed to bounded auto-merge retry after merge-seam abort`); - expect(messages).not.toContain("engine abort during pause/resume"); - expect(messages).not.toContain("operator action required"); - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: "failed" }), - expect.anything(), - ); - expect(store.handoffToReview).not.toHaveBeenCalled(); - expect(store.moveTask).not.toHaveBeenCalled(); - expect(mergeRequester).toHaveBeenCalledWith("FN-001"); - }, - ); - - it("preserves global-pause provenance as operator-action parking for in-review graph exits", async () => { - const store = createMockStore(); - const task = makeGraphTask(); - store.getTask.mockResolvedValue({ - ...task, - column: "in-review", - paused: false, - userPaused: false, - status: undefined, - error: null, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - (executor as any).markPausedAborted("FN-001", "global-pause"); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: ["merge"], - }); - - const expectedMessage = "Workflow graph failure surfaced after paused global pause in 'in-review' at node 'merge' — operator action required; retry or explicitly unpause/resume after inspecting the task"; - const messages = store.logEntry.mock.calls.map((call) => call[1]).join("\n"); - expect(messages).toContain("global pause"); - expect(messages).toContain("operator action required"); - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { error: expectedMessage, status: "failed" }, undefined); - expect(store.moveTask).not.toHaveBeenCalled(); - expect(store.handoffToReview).not.toHaveBeenCalled(); - }); - - it("keeps autoMerge:false genuinely paused in-review tasks parked without moving backward", async () => { - const store = createMockStore(); - const task = makeGraphTask({ autoMerge: false } as Partial); - store.getTask.mockResolvedValue({ - ...task, - column: "in-review", - paused: true, - userPaused: true, - status: undefined, - error: null, - autoMerge: false, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: ["merge"], - }); - - const expectedMessage = "Workflow graph failure surfaced after paused explicit user pause in 'in-review' at node 'merge' — operator action required; retry or explicitly unpause/resume after inspecting the task"; - expect(store.logEntry).toHaveBeenCalledWith("FN-001", expectedMessage, undefined, undefined); - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { error: expectedMessage, status: "failed" }, undefined); - expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo", expect.anything()); - expect(store.handoffToReview).not.toHaveBeenCalled(); - }); - }); - - it("auto-retries a bounded transient resume-after-restart graph failure instead of parking", async () => { - const store = createMockStore(); - const task = { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps: [{ name: "Step 1", status: "pending" }], - currentStep: 0, - log: [{ timestamp: new Date().toISOString(), action: "Resumed after engine restart" }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - graphResumeRetryCount: 0, - } as Task; - store.getTask.mockResolvedValue({ ...task, paused: false, error: null }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - const executeSpy = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: ["execute"], - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(store.updateTask).toHaveBeenCalledWith( - "FN-001", - { graphResumeRetryCount: 1, status: null, error: null }, - undefined, - ); - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: "failed" }), - expect.anything(), - ); - expect(store.handoffToReview).not.toHaveBeenCalled(); - expect(executeSpy).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-001" })); - }); - - it("auto-retries a bounded transient graph failure after unpause resume instead of parking", async () => { - const store = createMockStore(); - const task = { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps: [{ name: "Step 1", status: "pending" }], - currentStep: 0, - log: [{ timestamp: new Date().toISOString(), action: "Resuming execution after unpause" }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - graphResumeRetryCount: 0, - } as Task; - store.getTask.mockResolvedValue({ ...task, paused: false, error: null }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - const executeSpy = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: ["execute"], - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(store.updateTask).toHaveBeenCalledWith( - "FN-001", - { graphResumeRetryCount: 1, status: null, error: null }, - undefined, - ); - expect(store.handoffToReview).not.toHaveBeenCalled(); - expect(executeSpy).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-001" })); - }); - - it("parks a transient resume graph failure once the retry budget is exhausted", async () => { - const store = createMockStore(); - const task = { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps: [{ name: "Step 1", status: "pending" }], - currentStep: 0, - log: [{ timestamp: new Date().toISOString(), action: "Resumed after engine restart" }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - graphResumeRetryCount: 2, - } as Task; - store.getTask.mockResolvedValue({ ...task, paused: false, error: null }); - const warnSpy = vi.spyOn(executorLog, "warn").mockImplementation(() => undefined); - const executor = new TaskExecutor(store, "/tmp/test", {}); - const executeSpy = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - visitedNodeIds: ["execute"], - }); - - const message = "Workflow graph terminated with failure at node 'execute'"; - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { error: message, status: "failed" }, undefined); - expect(store.handoffToReview).toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ evidence: expect.objectContaining({ reason: "workflow-graph-failed" }) }), - ); - expect(executeSpy).not.toHaveBeenCalled(); - warnSpy.mockRestore(); - }); - - it.each([ - ["non-empty execute-seam reason", { result: { reason: "interpreter-error: boom", visitedNodeIds: ["execute"] } }], - ["settings/workflow-selection reason before node progress", { result: { reason: "settings-load-failed: boom", visitedNodeIds: [] } }], - ["completed step progress", { task: { steps: [{ name: "Step 1", status: "done" }] }, result: { visitedNodeIds: ["execute"] } }], - ["lastError", { task: { lastError: "boom" }, result: { visitedNodeIds: ["execute"] } }], - ["failureReason", { task: { failureReason: "boom" }, result: { visitedNodeIds: ["execute"] } }], - ])("preserves terminal failed handling for genuine graph failure: %s", async (_name, fixture) => { - const store = createMockStore(); - const task = { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps: [{ name: "Step 1", status: "pending" }], - currentStep: 0, - log: [{ timestamp: new Date().toISOString(), action: "Resumed after engine restart" }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - graphResumeRetryCount: 0, - ...(fixture.task ?? {}), - } as Task; - store.getTask.mockResolvedValue({ ...task, paused: false, error: null }); - const warnSpy = vi.spyOn(executorLog, "warn").mockImplementation(() => undefined); - const executor = new TaskExecutor(store, "/tmp/test", {}); - const executeSpy = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined); - - await (executor as any).handleGraphFailure(task, { - disposition: "failed", - outcome: "failure", - ...fixture.result, - }); - - const failedNode = fixture.result.visitedNodeIds.at(-1) ?? "unknown"; - const message = `Workflow graph terminated with failure at node '${failedNode}'`; - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { error: message, status: "failed" }, undefined); - expect(store.handoffToReview).toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ evidence: expect.objectContaining({ reason: "workflow-graph-failed" }) }), - ); - expect(executeSpy).not.toHaveBeenCalled(); - warnSpy.mockRestore(); - }); - - describe("transient resume-after-restart graph failure classifier", () => { - const makeClassifierTask = (overrides: Partial = {}) => ({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps: [ - { name: "Step 1", status: "pending" }, - { name: "Step 2", status: "pending" }, - ], - currentStep: 0, - log: [{ timestamp: new Date().toISOString(), action: "Resumed after engine restart" }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - ...overrides, - }) as Task; - - const isTransient = (task: Task, result: any) => { - const executor = new TaskExecutor(createMockStore(), "/tmp/test", {}); - return (executor as any).isTransientResumeAfterRestartGraphFailure(task, result); - }; - - it("accepts only the exact no-progress execute-seam post-resume signature", () => { - expect(isTransient(makeClassifierTask(), { visitedNodeIds: ["execute"] })).toBe(true); - expect(isTransient(makeClassifierTask({ log: [{ timestamp: new Date().toISOString(), action: "Resuming execution after unpause" }] }), { visitedNodeIds: ["execute"] })).toBe(true); - expect(isTransient(makeClassifierTask(), { visitedNodeIds: [] })).toBe(true); - }); - - it.each([ - ["non-empty reason", makeClassifierTask(), { visitedNodeIds: ["execute"], reason: "settings-load-failed: boom" }], - ["non-execute failed node", makeClassifierTask(), { visitedNodeIds: ["planning"] }], - ["completed step progress", makeClassifierTask({ steps: [{ name: "Step 1", status: "done" }] }), { visitedNodeIds: ["execute"] }], - ["lastError", makeClassifierTask({ lastError: "boom" } as any), { visitedNodeIds: ["execute"] }], - ["failureReason", makeClassifierTask({ failureReason: "boom" } as any), { visitedNodeIds: ["execute"] }], - ["missing resume log", makeClassifierTask({ log: [{ timestamp: new Date().toISOString(), action: "Started execution" }] }), { visitedNodeIds: ["execute"] }], - ])("rejects %s as genuine/non-transient", (_name, task, result) => { - expect(isTransient(task as Task, result)).toBe(false); - }); - }); - - it("preserves genuine in-progress graph failure handling", async () => { - const store = createMockStore(); - const task = { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - status: undefined, - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as Task; - store.getTask.mockResolvedValue({ - ...task, - column: "in-progress", - paused: false, - status: undefined, - error: null, - }); - const warnSpy = vi.spyOn(executorLog, "warn").mockImplementation(() => undefined); - const executor = new TaskExecutor(store, "/tmp/test", {}); - - await (executor as any).handleGraphFailure(task, { - visitedNodeIds: [], - }); - - const message = "Workflow graph terminated with failure at node 'unknown'"; - expect(warnSpy).toHaveBeenCalledWith(`FN-001: ${message}`); - expect(store.logEntry).toHaveBeenCalledWith("FN-001", message, undefined, undefined); - expect(store.updateTask).toHaveBeenCalledWith( - "FN-001", - { error: message, status: "failed" }, - undefined, - ); - expect(store.handoffToReview).toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ evidence: expect.objectContaining({ reason: "workflow-graph-failed" }) }), - ); - warnSpy.mockRestore(); - }); - - it("preserves step progress when requeuing stuck task by default", async () => { - const store = createMockStore(); - const executor = new TaskExecutor(store, "/tmp/test", {}); - const resetSpy = vi.spyOn(executor as any, "resetStepsIfWorkLost").mockResolvedValue(undefined); - - mockedCreateFnAgent.mockImplementation(async () => ({ - session: { - prompt: vi.fn(async () => { - executor.markStuckAborted("FN-001", true); - }), - dispose: vi.fn(), - state: {}, - }, - }) as any); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true }); - // resetStepsIfWorkLost MUST be skipped when preserveProgress is on, otherwise - // the requeue would silently drop committed step status before moveTask preserves it. - expect(resetSpy).not.toHaveBeenCalled(); - }); - - it("resets step progress when preserveProgressOnStuckRequeue is disabled", async () => { - const store = createMockStore(); - store.getSettings.mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - pollIntervalMs: 15000, - groupOverlappingFiles: false, - autoMerge: false, - worktreeInitCommand: undefined, - preserveProgressOnStuckRequeue: false, - }); - const executor = new TaskExecutor(store, "/tmp/test", {}); - const resetSpy = vi.spyOn(executor as any, "resetStepsIfWorkLost").mockResolvedValue(undefined); - - mockedCreateFnAgent.mockImplementation(async () => ({ - session: { - prompt: vi.fn(async () => { - executor.markStuckAborted("FN-001", true); - }), - dispose: vi.fn(), - state: {}, - }, - }) as any); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // No options arg → moveTask defaults to resetting steps - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", undefined); - expect(resetSpy).toHaveBeenCalledTimes(1); - }); - - it("clears recovery metadata after successful run completes", async () => { - const store = createMockStore(); - - // Mock successful agent session - const mockSession = { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - state: { error: undefined }, - }; - mockedCreateFnAgent.mockResolvedValue({ session: mockSession } as any); - - const executor = new TaskExecutor(store, "/tmp/test", {}); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - recoveryRetryCount: 2, - nextRecoveryAt: new Date().toISOString(), - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Exhausted no-fn_task_done retries now requeue immediately to todo. - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true }); - }); -}); - -describe("Per-task model overrides", () => { - beforeEach(() => { - resetExecutorMocks(); - mockedExistsSync.mockReturnValue(true); - }); - - it("uses per-task model overrides when both provider and modelId are set", async () => { - const store = createMockStore(); - const capturedOptions: any[] = []; - - mockedCreateFnAgent.mockImplementation(async (opts: any) => { - capturedOptions.push(opts); - return { - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - state: {}, - }, - } as any; - }); - - const executor = new TaskExecutor(store, "/tmp/test"); - - // Override getTask to return task with model overrides - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - prompt: "# test", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - modelProvider: "anthropic", - modelId: "claude-sonnet-4-5", - }); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - modelProvider: "anthropic", - modelId: "claude-sonnet-4-5", - }); - - // Should use per-task model overrides - expect(capturedOptions[0].defaultProvider).toBe("anthropic"); - expect(capturedOptions[0].defaultModelId).toBe("claude-sonnet-4-5"); - }); - - it("falls back to global settings when per-task model is not fully specified", async () => { - const store = createMockStore(); - const capturedOptions: any[] = []; - - mockedCreateFnAgent.mockImplementation(async (opts: any) => { - capturedOptions.push(opts); - return { - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - state: {}, - }, - } as any; - }); - - store.getSettings.mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - pollIntervalMs: 15000, - groupOverlappingFiles: false, - autoMerge: false, - worktreeInitCommand: undefined, - defaultProvider: "openai", - defaultModelId: "gpt-4o", - }); - - const executor = new TaskExecutor(store, "/tmp/test"); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - // No modelProvider/modelId set - }); - - // Should use global settings (not task overrides) - expect(capturedOptions[0].defaultProvider).toBe("openai"); - expect(capturedOptions[0].defaultModelId).toBe("gpt-4o"); - }); - - it("falls back to global settings when only modelProvider is set (missing modelId)", async () => { - const store = createMockStore(); - const capturedOptions: any[] = []; - - mockedCreateFnAgent.mockImplementation(async (opts: any) => { - capturedOptions.push(opts); - return { - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - state: {}, - }, - } as any; - }); - - store.getSettings.mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - pollIntervalMs: 15000, - groupOverlappingFiles: false, - autoMerge: false, - worktreeInitCommand: undefined, - defaultProvider: "openai", - defaultModelId: "gpt-4o", - }); - - const executor = new TaskExecutor(store, "/tmp/test"); - - // Override getTask to return task with only modelProvider set - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - prompt: "# test", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - modelProvider: "anthropic", - // modelId is missing - }); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - modelProvider: "anthropic", - // modelId is missing - }); - - // Should fall back to global settings since modelId is not set - expect(capturedOptions[0].defaultProvider).toBe("openai"); - expect(capturedOptions[0].defaultModelId).toBe("gpt-4o"); - }); -}); - -// ── Lane hierarchy model resolution tests ───────────────────────────────────── - -describe("Executor lane hierarchy model resolution", () => { - beforeEach(() => { - resetExecutorMocks(); - mockedExistsSync.mockReturnValue(true); - }); - - it("resolves task override when both provider and modelId are set", async () => { - const store = createMockStore(); - const capturedOptions: any[] = []; - - mockedCreateFnAgent.mockImplementation(async (opts: any) => { - capturedOptions.push(opts); - return { - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - state: {}, - }, - } as any; - }); - - store.getSettings.mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - pollIntervalMs: 15000, - defaultProvider: "openai", - defaultModelId: "gpt-4o", - executionGlobalProvider: "google", - executionGlobalModelId: "gemini-2.5", - executionProvider: undefined, - executionModelId: undefined, - }); - - const executor = new TaskExecutor(store, "/tmp/test"); - - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - prompt: "# test", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - modelProvider: "anthropic", - modelId: "claude-sonnet-4-5", - }); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - modelProvider: "anthropic", - modelId: "claude-sonnet-4-5", - }); - - // Task override takes precedence - expect(capturedOptions[0].defaultProvider).toBe("anthropic"); - expect(capturedOptions[0].defaultModelId).toBe("claude-sonnet-4-5"); - }); - - it("resolves project execution override when task override is not set", async () => { - const store = createMockStore(); - const capturedOptions: any[] = []; - - mockedCreateFnAgent.mockImplementation(async (opts: any) => { - capturedOptions.push(opts); - return { - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - state: {}, - }, - } as any; - }); - - store.getSettings.mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - pollIntervalMs: 15000, - defaultProvider: "openai", - defaultModelId: "gpt-4o", - executionGlobalProvider: "google", - executionGlobalModelId: "gemini-2.5", - executionProvider: "anthropic", - executionModelId: "claude-opus-4", - }); - - const executor = new TaskExecutor(store, "/tmp/test"); - - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - prompt: "# test", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - // No task-level model override - }); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - // No task-level model override - }); - - // Project execution override takes precedence over global lane - expect(capturedOptions[0].defaultProvider).toBe("anthropic"); - expect(capturedOptions[0].defaultModelId).toBe("claude-opus-4"); - }); - - it("resolves global execution lane when project override is not set", async () => { - const store = createMockStore(); - const capturedOptions: any[] = []; - - mockedCreateFnAgent.mockImplementation(async (opts: any) => { - capturedOptions.push(opts); - return { - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - state: {}, - }, - } as any; - }); - - store.getSettings.mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - pollIntervalMs: 15000, - defaultProvider: "openai", - defaultModelId: "gpt-4o", - executionGlobalProvider: "google", - executionGlobalModelId: "gemini-2.5", - executionProvider: undefined, - executionModelId: undefined, - }); - - const executor = new TaskExecutor(store, "/tmp/test"); - - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - prompt: "# test", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - // No task-level model override - }); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - // No task-level model override - }); - - // Global execution lane takes precedence over default - expect(capturedOptions[0].defaultProvider).toBe("google"); - expect(capturedOptions[0].defaultModelId).toBe("gemini-2.5"); - }); - - it("resolves project default override when execution lanes are not set", async () => { - const store = createMockStore(); - const capturedOptions: any[] = []; - - mockedCreateFnAgent.mockImplementation(async (opts: any) => { - capturedOptions.push(opts); - return { - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - state: {}, - }, - } as any; - }); - - store.getSettings.mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - pollIntervalMs: 15000, - defaultProvider: "anthropic", - defaultModelId: "claude-sonnet-4-5", - defaultProviderOverride: "openai", - defaultModelIdOverride: "gpt-4o", - executionGlobalProvider: undefined, - executionGlobalModelId: undefined, - executionProvider: undefined, - executionModelId: undefined, - }); - - const executor = new TaskExecutor(store, "/tmp/test"); - - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - prompt: "# test", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - // No task-level model override - }); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - // No task-level model override - }); - - expect(capturedOptions[0].defaultProvider).toBe("openai"); - expect(capturedOptions[0].defaultModelId).toBe("gpt-4o"); - }); - - it("falls back to default when no lane overrides are set", async () => { - const store = createMockStore(); - const capturedOptions: any[] = []; - - mockedCreateFnAgent.mockImplementation(async (opts: any) => { - capturedOptions.push(opts); - return { - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - state: {}, - }, - } as any; - }); - - store.getSettings.mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - pollIntervalMs: 15000, - defaultProvider: "openai", - defaultModelId: "gpt-4o", - executionGlobalProvider: undefined, - executionGlobalModelId: undefined, - executionProvider: undefined, - executionModelId: undefined, - }); - - const executor = new TaskExecutor(store, "/tmp/test"); - - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - prompt: "# test", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - // No task-level model override - }); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - // No task-level model override - }); - - // Default takes precedence when no lane overrides are set - expect(capturedOptions[0].defaultProvider).toBe("openai"); - expect(capturedOptions[0].defaultModelId).toBe("gpt-4o"); - }); -}); - -// ── Per-task thinkingLevel override tests ─────────────────────────── - -describe("Per-task thinkingLevel override", () => { - beforeEach(() => { - resetExecutorMocks(); - mockedExistsSync.mockReturnValue(true); - }); - - it("uses per-task thinkingLevel when set on the task", async () => { - const store = createMockStore(); - - mockedCreateFnAgent.mockResolvedValue({ - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - }, - } as any); - - // Override getTask to return task with thinkingLevel override - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - prompt: "# test", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - thinkingLevel: "high", - }); - - const executor = new TaskExecutor(store, "/tmp/test"); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - thinkingLevel: "high", - }); - - // Should use per-task thinkingLevel override - const callArgs = mockedCreateFnAgent.mock.calls[0]; - expect(callArgs).toBeDefined(); - expect(callArgs[0].defaultThinkingLevel).toBe("high"); - }); - - it("falls back to global defaultThinkingLevel when task has no thinkingLevel", async () => { - const store = createMockStore(); - - mockedCreateFnAgent.mockResolvedValue({ - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - }, - } as any); - - store.getSettings.mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - pollIntervalMs: 15000, - groupOverlappingFiles: false, - autoMerge: false, - worktreeInitCommand: undefined, - defaultThinkingLevel: "medium", - }); - - const executor = new TaskExecutor(store, "/tmp/test"); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - // No thinkingLevel set - }); - - // Should fall back to global defaultThinkingLevel - const callArgs = mockedCreateFnAgent.mock.calls[0]; - expect(callArgs).toBeDefined(); - expect(callArgs[0].defaultThinkingLevel).toBe("medium"); - }); - - it("uses explicit 'off' thinkingLevel from task over global setting", async () => { - const store = createMockStore(); - - mockedCreateFnAgent.mockResolvedValue({ - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - }, - } as any); - - store.getSettings.mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - pollIntervalMs: 15000, - groupOverlappingFiles: false, - autoMerge: false, - worktreeInitCommand: undefined, - defaultThinkingLevel: "high", - }); - - // Override getTask to return task with thinkingLevel: "off" - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - prompt: "# test", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - thinkingLevel: "off", - }); - - const executor = new TaskExecutor(store, "/tmp/test"); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - thinkingLevel: "off", - }); - - // Should use task's explicit "off" instead of global "high" - const callArgs = mockedCreateFnAgent.mock.calls[0]; - expect(callArgs).toBeDefined(); - expect(callArgs[0].defaultThinkingLevel).toBe("off"); - }); -}); - -describe("TaskExecutor no-fn_task_done reclaim retry handling", () => { - beforeEach(() => { - resetExecutorMocks(); - mockedExistsSync.mockReturnValue(true); - }); - - it("silently requeues to todo when worktree/branch is reclaimed mid-retry", async () => { - const store = createMockStore(); - const onError = vi.fn(); - const taskState: any = { - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - paused: false, - worktree: "/tmp/test/.worktrees/swift-falcon", - branch: "fusion/fn-001", - baseCommitSha: "abc123", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - prompt: "# test", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - taskDoneRetryCount: 0, - }; - - store.getTask.mockImplementation(async () => ({ ...taskState })); - store.updateTask.mockImplementation(async (_id: string, patch: Record) => { - Object.assign(taskState, patch); - return { ...taskState }; - }); - store.moveTask.mockImplementation(async (_id: string, column: string) => { - taskState.column = column; - return { ...taskState }; - }); - - let promptCalls = 0; - mockedCreateFnAgent.mockImplementation(async () => ({ - session: { - prompt: vi.fn(async () => { - promptCalls += 1; - if (promptCalls === 1) { - taskState.column = "todo"; - } - }), - dispose: vi.fn(), - sessionManager: { - getLeafId: vi.fn(), - branchWithSummary: vi.fn(), - }, - navigateTree: vi.fn(), - }, - }) as any); - - const executor = new TaskExecutor(store, "/tmp/test", { onError }); - - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - expect(taskState.column).toBe("todo"); - expect(taskState.status).not.toBe("failed"); - expect(taskState.error ?? null).toBeNull(); - expect(taskState.taskDoneRetryCount).toBe(0); - expect(onError).not.toHaveBeenCalled(); - expect(store.logEntry).toHaveBeenCalledWith( - "FN-001", - "Worktree/branch reclaimed mid-retry — requeued to todo (engine self-heal, no failure)", - undefined, - expect.objectContaining({ agentId: "executor" }), - ); - expect(taskState.worktree).toBeNull(); - expect(taskState.branch).toBeNull(); - expect(taskState.baseCommitSha).toBeNull(); - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: "failed" }), - ); - }); -}); - -// ── Invalid transition error handling tests ───────────────────────── - -describe("Invalid transition error handling", () => { - beforeEach(() => { - resetExecutorMocks(); - mockedExistsSync.mockReturnValue(true); - }); - - it("does not mark task as failed when invalid transition error occurs on completion", async () => { - const store = createMockStore(); - - // Mock moveTask to throw invalid transition error (task already moved to done) - store.moveTask.mockRejectedValue( - new Error("Invalid transition: 'done' → 'in-review'. Valid targets: none"), - ); - - // Mock agent that completes successfully - mockedCreateFnAgent.mockImplementation(async () => { - return { - session: { - prompt: vi.fn().mockImplementation(async () => { - // Agent completes work but moveTask will fail - }), - dispose: vi.fn(), - sessionManager: { - getLeafId: vi.fn(), - branchWithSummary: vi.fn(), - }, - navigateTree: vi.fn(), - }, - } as any; - }); - - const executor = new TaskExecutor(store, "/tmp/test"); - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // A missing fn_task_done triggers 3 retries. The final requeue-to-todo move - // then throws the Invalid transition error, - // which is caught by the outer handler. - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { - status: "queued", - error: null, - taskDoneRetryCount: 1, - }); - - // Should log informative message from the outer catch for Invalid transition - expect(store.logEntry).toHaveBeenCalledWith( - "FN-001", - "Task already moved from 'done' — skipping transition to 'in-review'", - expect.stringContaining("Invalid transition"), - expect.objectContaining({ agentId: "executor" }), - ); - }); - - it("calls onComplete when invalid transition occurs after successful execution", async () => { - const store = createMockStore(); - const onComplete = vi.fn(); - - // Mock moveTask to throw invalid transition error - store.moveTask.mockRejectedValue( - new Error("Invalid transition: 'in-progress' → 'in-review'. Valid targets: todo, triage"), - ); - - mockedCreateFnAgent.mockImplementation(async () => { - return { - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - sessionManager: { - getLeafId: vi.fn(), - branchWithSummary: vi.fn(), - }, - navigateTree: vi.fn(), - }, - } as any; - }); - - const executor = new TaskExecutor(store, "/tmp/test", { onComplete }); - await executor.execute({ - id: "FN-002", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // onComplete should be called even when invalid transition occurs - expect(onComplete).toHaveBeenCalled(); - expect(onComplete).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-002" })); - }); - - it("finalizes an already-reviewed task when it is ready to merge", async () => { - const store = createMockStore(); - store.getTask.mockResolvedValue({ - id: "FN-003", - title: "Test", - description: "Test", - column: "in-review", - paused: false, - status: null, - error: null, - worktree: "/tmp/test/.worktrees/fn-003", - dependencies: [], - steps: [{ name: "Done", status: "done" }], - workflowStepResults: [{ id: "ws-1", status: "passed", phase: "pre-merge" }], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - const executor = new TaskExecutor(store, "/tmp/test"); - const result = await (executor as any).finalizeAlreadyReviewedTask("FN-003"); - - expect(result).toBe("merged"); - expect(store.mergeTask).toHaveBeenCalledWith("FN-003"); - expect(store.logEntry).toHaveBeenCalledWith( - "FN-003", - "Task already in-review after completion — finalizing merge", - undefined, - undefined, - ); - }); -}); - -describe("TaskExecutor fn_task_done with summary", () => { - beforeEach(() => { - resetExecutorMocks(); - mockedExistsSync.mockReturnValue(true); - }); - - it("accepts and saves summary parameter when task is completed", async () => { - const store = createMockStore(); - let capturedTool: any = null; - - mockedCreateFnAgent.mockImplementation(async ({ customTools }: any) => { - // Capture the fn_task_done tool - capturedTool = customTools?.find((t: any) => t.name === "fn_task_done"); - return { - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - }, - } as any; - }); - - const executor = new TaskExecutor(store, "/tmp/test"); - - // Execute a task - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [{ name: "Step 1", status: "in-progress" }], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Verify fn_task_done tool was created - expect(capturedTool).toBeDefined(); - expect(capturedTool.name).toBe("fn_task_done"); - - // Verify the tool accepts summary parameter - expect(capturedTool.parameters).toBeDefined(); - - // Execute the tool with a summary - const result = await capturedTool.execute("tool-1", { summary: "Test summary of changes" }); - - // Verify the task was updated with the summary - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { summary: "Test summary of changes" }); - - // Verify success message includes summary mention - expect(result.content[0].text).toContain("summary"); - }); - - it("works without summary parameter (backward compatible)", async () => { - const store = createMockStore(); - let capturedTool: any = null; - - mockedCreateFnAgent.mockImplementation(async ({ customTools }: any) => { - capturedTool = customTools?.find((t: any) => t.name === "fn_task_done"); - return { - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - }, - } as any; - }); - - const executor = new TaskExecutor(store, "/tmp/test"); - - await executor.execute({ - id: "FN-002", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [{ name: "Step 1", status: "in-progress" }], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Execute the tool without summary - const result = await capturedTool.execute("tool-1", {}); - - // Verify summary was not updated - const summaryUpdateCalls = store.updateTask.mock.calls.filter( - (call: any[]) => call[1]?.summary !== undefined - ); - expect(summaryUpdateCalls).toHaveLength(0); - - // Verify standard success message - expect(result.content[0].text).toBe("Task marked complete. All steps done. Moving to in-review."); - }); -}); - -describe("TaskExecutor fn_task_done blockers", () => { - beforeEach(() => { - resetExecutorMocks(); - mockedExistsSync.mockReturnValue(true); - }); - - it("rejects fn_task_done when the task is explicitly blocked", async () => { - const store = createMockStore(); - let capturedTool: any = null; - - store.getTask.mockImplementation(async (taskId: string) => { - if (taskId === "FN-001") { - return { - id: "FN-001", - title: "Blocked task", - description: "Blocked task", - column: "in-progress", - blockedBy: "FN-DEP-1", - dependencies: [], - steps: [{ name: "Step 1", status: "in-progress" }], - currentStep: 0, - log: [], - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - } - return { - id: taskId, - column: taskId === "FN-DEP-1" ? "in-progress" : "done", - }; - }); - - mockedCreateFnAgent.mockImplementation(async ({ customTools }: any) => { - capturedTool = customTools?.find((t: any) => t.name === "fn_task_done"); - return { - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - }, - } as any; - }); - - const executor = new TaskExecutor(store, "/tmp/test"); - - await executor.execute({ - id: "FN-001", - title: "Blocked task", - description: "Blocked task", - column: "in-progress", - dependencies: [], - steps: [{ name: "Step 1", status: "in-progress" }], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - expect(capturedTool).toBeDefined(); - - store.updateStep.mockClear(); - store.updateTask.mockClear(); - - const result = await capturedTool.execute("tool-1", {}); - - expect(result.content[0].text).toContain("Cannot mark task done yet"); - expect(store.updateStep).not.toHaveBeenCalled(); - expect(store.updateTask).not.toHaveBeenCalled(); - }); -}); - - -describe("TaskExecutor recoverCompletedTask", () => { - beforeEach(() => { - resetExecutorMocks(); - }); - - it("uses todo -> in-progress -> in-review transitions for todo-origin recovery", async () => { - const store = createMockStore(); - const executor = new TaskExecutor(store as any, "/tmp/test"); - - const task = { - id: "FN-4086", - title: "Recover todo completed task", - description: "Recover todo completed task", - column: "todo", - dependencies: [], - steps: [{ name: "s1", status: "done" }], - currentStep: 1, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as any; - - const ok = await executor.recoverCompletedTask(task); - - expect(ok).toBe(true); - expect(store.moveTask).toHaveBeenNthCalledWith(1, "FN-4086", "in-progress"); - expect(store.moveTask).toHaveBeenNthCalledWith(2, "FN-4086", "in-review"); - }); -}); diff --git a/packages/engine/src/__tests__/hold-release.test.ts b/packages/engine/src/__tests__/hold-release.test.ts index dd4de0cdb1..5b43d40789 100644 --- a/packages/engine/src/__tests__/hold-release.test.ts +++ b/packages/engine/src/__tests__/hold-release.test.ts @@ -96,12 +96,12 @@ describe("hold-release sweep (U6)", () => { return task.id; } - it("flag OFF: sweep is a no-op (legacy scheduler path untouched)", async () => { + it("ignores stale workflowColumns=false and still releases held default-workflow cards", async () => { await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: false } }); const id = await seedTodoCard(); const result = await runHoldReleaseSweep(store, noReserveDeps); - expect(result.released).toEqual([]); - expect((await store.getTask(id))?.column).toBe("todo"); + expect(result.released).toEqual([id]); + expect((await store.getTask(id))?.column).toBe("in-progress"); }); it("two holds, one slot: exactly one releases; the other releases next sweep after the slot frees", async () => { diff --git a/packages/engine/src/__tests__/scheduler-node-routing.test.ts b/packages/engine/src/__tests__/scheduler-node-routing.test.ts deleted file mode 100644 index c4b3d68fd1..0000000000 --- a/packages/engine/src/__tests__/scheduler-node-routing.test.ts +++ /dev/null @@ -1,674 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { NodeStatus, Task, TaskStore } from "@fusion/core"; -import { Scheduler } from "../scheduler.js"; -import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { schedulerLog } from "../logger.js"; - -vi.mock("node:fs", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - existsSync: vi.fn(), - }; -}); - -vi.mock("node:fs/promises", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - readFile: vi.fn(), - }; -}); - -vi.mock("../logger.js", () => ({ - schedulerLog: { - log: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, - createLogger: () => ({ - log: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), -})); - -function createMockTask(overrides: Partial = {}): Task { - return { - id: "FN-100", - description: "Node routing task", - column: "todo", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - prompt: "", - ...overrides, - } as Task; -} - -const withLegacyWorkflowGraphDefault = (settings: Record) => ({ - ...settings, - experimentalFeatures: { - workflowColumns: false, - workflowGraphExecutor: false, - ...((settings.experimentalFeatures as Record | undefined) ?? {}), - }, -}); - -function createMockStore(task: Task, settings: Record = {}): TaskStore { - return { - listTasks: vi.fn().mockResolvedValue([task]), - getSettings: vi.fn().mockResolvedValue(withLegacyWorkflowGraphDefault(settings)), - getTask: vi.fn().mockResolvedValue(task), - updateTask: vi.fn().mockResolvedValue(undefined), - moveTask: vi.fn().mockResolvedValue(undefined), - parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), - logEntry: vi.fn().mockResolvedValue(undefined), - recordRunAuditEvent: vi.fn().mockResolvedValue(undefined), - getRootDir: vi.fn().mockReturnValue("/tmp/test"), - getTasksDir: vi.fn().mockReturnValue("/tmp/test/.fusion/tasks"), - on: vi.fn(), - off: vi.fn(), - } as unknown as TaskStore; -} - -function createMockHealthMonitor(statusMap: Record) { - return { - getNodeHealth: vi.fn((id: string) => statusMap[id]), - } as unknown as import("../node-health-monitor.js").NodeHealthMonitor; -} - -function allowDispatchValidator() { - return vi.fn().mockResolvedValue({ allowed: true } as const); -} - -describe("Scheduler node routing", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nNode routing"); - }); - - it("stores task override as effective node", async () => { - const task = createMockTask({ id: "FN-101", nodeId: "node-task" }); - const store = createMockStore(task, { maxConcurrent: 1, maxWorktrees: 1, defaultNodeId: "node-project" }); - const scheduler = new Scheduler(store); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - - expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({ - effectiveNodeId: "node-task", - effectiveNodeSource: "task-override", - })); - expect(store.logEntry).toHaveBeenCalledWith(task.id, "Node routing resolved: node-task (source: task-override)"); - expect(schedulerLog.log).toHaveBeenCalledWith("Task FN-101 routed to node=node-task (source=task-override)"); - }); - - it("uses project default when task nodeId is unset", async () => { - const task = createMockTask({ id: "FN-102", nodeId: undefined }); - const store = createMockStore(task, { maxConcurrent: 1, maxWorktrees: 1, defaultNodeId: "node-project" }); - const scheduler = new Scheduler(store); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - - expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({ - effectiveNodeId: "node-project", - effectiveNodeSource: "project-default", - })); - expect(store.logEntry).toHaveBeenCalledWith(task.id, "Node routing resolved: node-project (source: project-default)"); - }); - - it("uses local when neither task nor project default are set", async () => { - const task = createMockTask({ id: "FN-103", nodeId: undefined }); - const store = createMockStore(task, { maxConcurrent: 1, maxWorktrees: 1 }); - const scheduler = new Scheduler(store, { nodeHealthMonitor: undefined }); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - - expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({ - effectiveNodeId: null, - effectiveNodeSource: "local", - })); - expect(store.logEntry).toHaveBeenCalledWith(task.id, "Node routing resolved: local (source: local)"); - expect(schedulerLog.log).toHaveBeenCalledWith("Task FN-103 routed to node=local (source=local)"); - }); - - it("accepts nodeHealthMonitor option at construction", () => { - const task = createMockTask(); - const store = createMockStore(task); - - const scheduler = new Scheduler(store, { - nodeHealthMonitor: { - getNodeHealth: vi.fn(), - } as unknown as import("../node-health-monitor.js").NodeHealthMonitor, - }); - - expect(scheduler).toBeDefined(); - }); - - it("dispatches when node mapping validator allows execution", async () => { - const task = createMockTask({ id: "FN-112", nodeId: "node-mapped" }); - const store = createMockStore(task, { maxConcurrent: 1, maxWorktrees: 1 }); - const validateNodeDispatch = allowDispatchValidator(); - const scheduler = new Scheduler(store, { validateNodeDispatch }); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - - expect(validateNodeDispatch).toHaveBeenCalledWith("node-mapped"); - expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({ - effectiveNodeId: "node-mapped", - effectiveNodeSource: "task-override", - })); - }); - - it("blocks dispatch when node mapping validator fails", async () => { - const task = createMockTask({ id: "FN-113", nodeId: "node-unmapped" }); - const store = createMockStore(task, { maxConcurrent: 1, maxWorktrees: 1 }); - const validateNodeDispatch = vi.fn().mockResolvedValue({ - allowed: false, - code: "missing-project-mapping", - reason: "Execution blocked: project has no path mapping for node node-unmapped", - } as const); - const scheduler = new Scheduler(store, { validateNodeDispatch }); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - - expect(store.updateTask).not.toHaveBeenCalled(); - expect(store.moveTask).not.toHaveBeenCalled(); - expect(store.logEntry).toHaveBeenCalledWith( - task.id, - "Execution blocked: project has no path mapping for node node-unmapped", - ); - }); - - it("deduplicates missing-mapping block logs across schedule cycles", async () => { - const task = createMockTask({ id: "FN-114", nodeId: "node-unmapped" }); - const store = createMockStore(task, { maxConcurrent: 1, maxWorktrees: 1 }); - const validateNodeDispatch = vi.fn().mockResolvedValue({ - allowed: false, - code: "missing-project-mapping", - reason: "Execution blocked: project has no path mapping for node node-unmapped", - } as const); - const scheduler = new Scheduler(store, { validateNodeDispatch }); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - await scheduler.schedule(); - - const missingMappingLogs = vi.mocked(store.logEntry).mock.calls.filter(([, message]) => - String(message).includes("project has no path mapping for node node-unmapped"), - ); - expect(missingMappingLogs).toHaveLength(1); - }); - - it("clears missing-mapping dedup state after successful dispatch", async () => { - const task = createMockTask({ id: "FN-115", nodeId: "node-flappy" }); - const store = createMockStore(task, { maxConcurrent: 1, maxWorktrees: 1 }); - const validateNodeDispatch = vi - .fn() - .mockResolvedValueOnce({ - allowed: false, - code: "missing-project-mapping", - reason: "Execution blocked: project has no path mapping for node node-flappy", - } as const) - .mockResolvedValueOnce({ allowed: true } as const) - .mockResolvedValueOnce({ - allowed: false, - code: "missing-project-mapping", - reason: "Execution blocked: project has no path mapping for node node-flappy", - } as const); - const scheduler = new Scheduler(store, { validateNodeDispatch }); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - await scheduler.schedule(); - await scheduler.schedule(); - - const missingMappingLogs = vi.mocked(store.logEntry).mock.calls.filter(([, message]) => - String(message).includes("project has no path mapping for node node-flappy"), - ); - expect(missingMappingLogs).toHaveLength(2); - }); - - it("does not run unavailable-node fallback policy when mapping validation fails", async () => { - const task = createMockTask({ id: "FN-116", nodeId: "node-error" }); - const store = createMockStore(task, { - maxConcurrent: 1, - maxWorktrees: 1, - unavailableNodePolicy: "fallback-local", - }); - const healthMonitor = createMockHealthMonitor({ "node-error": "error" }); - const validateNodeDispatch = vi.fn().mockResolvedValue({ - allowed: false, - code: "missing-project-mapping", - reason: "Execution blocked: project has no path mapping for node node-error", - } as const); - const scheduler = new Scheduler(store, { nodeHealthMonitor: healthMonitor, validateNodeDispatch }); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - - expect((healthMonitor.getNodeHealth as ReturnType)).not.toHaveBeenCalled(); - expect(store.updateTask).not.toHaveBeenCalled(); - expect(store.logEntry).not.toHaveBeenCalledWith( - task.id, - "Node node-error is error; falling back to local per policy", - ); - }); - - it("preserves health-based fallback behavior for mapped nodes", async () => { - const task = createMockTask({ id: "FN-117", nodeId: "node-error" }); - const store = createMockStore(task, { - maxConcurrent: 1, - maxWorktrees: 1, - unavailableNodePolicy: "fallback-local", - }); - const healthMonitor = createMockHealthMonitor({ "node-error": "error" }); - const validateNodeDispatch = allowDispatchValidator(); - const scheduler = new Scheduler(store, { nodeHealthMonitor: healthMonitor, validateNodeDispatch }); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - - expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({ - effectiveNodeId: null, - effectiveNodeSource: "local", - })); - expect(store.logEntry).toHaveBeenCalledWith(task.id, "Node node-error is error; falling back to local per policy"); - }); - - it("blocks dispatch when node is unhealthy and policy is block", async () => { - const task = createMockTask({ id: "FN-104", nodeId: "node-offline" }); - const store = createMockStore(task, { maxConcurrent: 1, maxWorktrees: 1, unavailableNodePolicy: "block" }); - const healthMonitor = createMockHealthMonitor({ "node-offline": "offline" }); - const scheduler = new Scheduler(store, { nodeHealthMonitor: healthMonitor }); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - - expect(store.updateTask).not.toHaveBeenCalled(); - expect(store.moveTask).not.toHaveBeenCalled(); - expect(store.logEntry).toHaveBeenCalledWith(task.id, "Node node-offline is offline; policy is block"); - expect(schedulerLog.log).toHaveBeenCalledWith("Task FN-104 dispatch blocked — Node node-offline is offline; policy is block"); - }); - - it("deduplicates blocked log entries across polling cycles", async () => { - const task = createMockTask({ id: "FN-105", nodeId: "node-offline" }); - const store = createMockStore(task, { maxConcurrent: 1, maxWorktrees: 1, unavailableNodePolicy: "block" }); - const healthMonitor = createMockHealthMonitor({ "node-offline": "offline" }); - const scheduler = new Scheduler(store, { nodeHealthMonitor: healthMonitor }); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - await scheduler.schedule(); - - const blockLogs = vi.mocked(store.logEntry).mock.calls.filter(([, message]) => - String(message).includes("Node node-offline is offline; policy is block"), - ); - expect(blockLogs).toHaveLength(1); - }); - - it("falls back to local dispatch when node is unhealthy and policy is fallback-local", async () => { - const task = createMockTask({ id: "FN-106", nodeId: "node-error" }); - const store = createMockStore(task, { maxConcurrent: 1, maxWorktrees: 1, unavailableNodePolicy: "fallback-local" }); - const healthMonitor = createMockHealthMonitor({ "node-error": "error" }); - const scheduler = new Scheduler(store, { nodeHealthMonitor: healthMonitor }); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - - expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({ - effectiveNodeId: null, - effectiveNodeSource: "local", - })); - expect(store.logEntry).toHaveBeenCalledWith(task.id, "Node node-error is error; falling back to local per policy"); - }); - - it("dispatches normally when node is online with block policy", async () => { - const task = createMockTask({ id: "FN-107", nodeId: "node-online" }); - const store = createMockStore(task, { maxConcurrent: 1, maxWorktrees: 1, unavailableNodePolicy: "block" }); - const healthMonitor = createMockHealthMonitor({ "node-online": "online" }); - const scheduler = new Scheduler(store, { nodeHealthMonitor: healthMonitor }); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - - expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({ - effectiveNodeId: "node-online", - effectiveNodeSource: "task-override", - })); - }); - - it("dispatches normally when node health is unknown", async () => { - const task = createMockTask({ id: "FN-108", nodeId: "node-unknown" }); - const store = createMockStore(task, { maxConcurrent: 1, maxWorktrees: 1, unavailableNodePolicy: "block" }); - const healthMonitor = createMockHealthMonitor({ "node-unknown": undefined }); - const scheduler = new Scheduler(store, { nodeHealthMonitor: healthMonitor }); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - - expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({ - effectiveNodeId: "node-unknown", - effectiveNodeSource: "task-override", - })); - }); - - it("clears block dedup after successful dispatch", async () => { - const task = createMockTask({ id: "FN-109", nodeId: "node-flaky" }); - const store = createMockStore(task, { maxConcurrent: 1, maxWorktrees: 1, unavailableNodePolicy: "block" }); - const getNodeHealth = vi - .fn() - .mockReturnValueOnce("offline" satisfies NodeStatus) - .mockReturnValueOnce("online" satisfies NodeStatus) - .mockReturnValueOnce("offline" satisfies NodeStatus); - const scheduler = new Scheduler(store, { - nodeHealthMonitor: { getNodeHealth } as unknown as import("../node-health-monitor.js").NodeHealthMonitor, - }); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - await scheduler.schedule(); - await scheduler.schedule(); - - const blockLogs = vi.mocked(store.logEntry).mock.calls.filter(([, message]) => - String(message).includes("Node node-flaky is offline; policy is block"), - ); - expect(blockLogs).toHaveLength(2); - }); - - it("skips policy check when no health monitor is provided", async () => { - const task = createMockTask({ id: "FN-110", nodeId: "node-1" }); - const store = createMockStore(task, { maxConcurrent: 1, maxWorktrees: 1, unavailableNodePolicy: "block" }); - const scheduler = new Scheduler(store); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - - expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({ - effectiveNodeId: "node-1", - effectiveNodeSource: "task-override", - })); - }); - - it("never queries health for local tasks", async () => { - const task = createMockTask({ id: "FN-111", nodeId: undefined }); - const store = createMockStore(task, { maxConcurrent: 1, maxWorktrees: 1 }); - const healthMonitor = createMockHealthMonitor({}); - const scheduler = new Scheduler(store, { nodeHealthMonitor: healthMonitor }); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - - expect((healthMonitor.getNodeHealth as ReturnType)).not.toHaveBeenCalled(); - }); - - it("parks dispatch when owning-node handoff policy blocks peer-owner takeover", async () => { - const task = createMockTask({ id: "FN-118", nodeId: "node-online", checkoutNodeId: "node-owner", checkedOutBy: "agent-owner" }); - const store = createMockStore(task, { - maxConcurrent: 1, - maxWorktrees: 1, - unavailableNodePolicy: "block", - owningNodeHandoffPolicy: "block", - }); - const healthMonitor = createMockHealthMonitor({ "node-online": "online", "node-owner": "offline" }); - const scheduler = new Scheduler(store, { nodeHealthMonitor: healthMonitor }); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - - expect(store.updateTask).not.toHaveBeenCalled(); - expect(store.logEntry).toHaveBeenCalledWith( - task.id, - "Owning-node handoff parked dispatch: handoff_blocked_by_policy", - ); - const handoffEvent = vi.mocked(store.recordRunAuditEvent).mock.calls - .map(([event]) => event) - .find((event) => event.mutationType === "node:handoff:parked"); - expect(handoffEvent).toEqual(expect.objectContaining({ - domain: "database", - mutationType: "node:handoff:parked", - target: task.id, - metadata: { - taskId: task.id, - ownerNodeId: "node-owner", - ownerNodeHealth: "offline", - localNodeId: "local", - handoffPolicy: "block", - decisionReason: "handoff_blocked_by_policy", - source: "scheduler.dispatch", - }, - })); - }); - - it("forces local dispatch when owning-node handoff returns reassign-local", async () => { - const task = createMockTask({ id: "FN-119", nodeId: "node-online", checkoutNodeId: "node-owner", checkedOutBy: "agent-owner" }); - const store = createMockStore(task, { - maxConcurrent: 1, - maxWorktrees: 1, - unavailableNodePolicy: "block", - owningNodeHandoffPolicy: "reassign-to-local", - }); - const healthMonitor = createMockHealthMonitor({ "node-online": "online", "node-owner": "offline" }); - const scheduler = new Scheduler(store, { nodeHealthMonitor: healthMonitor }); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - - expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({ - effectiveNodeId: null, - effectiveNodeSource: "local", - })); - expect(store.logEntry).toHaveBeenCalledWith(task.id, "Owning-node handoff applied: owner_offline_local_takes_over"); - const handoffEvent = vi.mocked(store.recordRunAuditEvent).mock.calls - .map(([event]) => event) - .find((event) => event.mutationType === "node:handoff:reassign-local"); - expect(handoffEvent).toEqual(expect.objectContaining({ - domain: "database", - mutationType: "node:handoff:reassign-local", - target: task.id, - metadata: { - taskId: task.id, - ownerNodeId: "node-owner", - ownerNodeHealth: "offline", - localNodeId: "local", - handoffPolicy: "reassign-to-local", - decisionReason: "owner_offline_local_takes_over", - source: "scheduler.dispatch", - }, - })); - }); - - it("keeps non-local routing when owning-node handoff returns reassign-any", async () => { - const task = createMockTask({ id: "FN-120", nodeId: "node-online", checkoutNodeId: "node-owner", checkedOutBy: "agent-owner" }); - const store = createMockStore(task, { - maxConcurrent: 1, - maxWorktrees: 1, - unavailableNodePolicy: "block", - owningNodeHandoffPolicy: "reassign-any-healthy", - }); - const healthMonitor = createMockHealthMonitor({ "node-online": "online", "node-owner": "offline" }); - const scheduler = new Scheduler(store, { nodeHealthMonitor: healthMonitor }); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - - expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({ - effectiveNodeId: "node-online", - effectiveNodeSource: "task-override", - })); - expect(store.logEntry).toHaveBeenCalledWith(task.id, "Owning-node handoff applied: owner_offline_any_healthy_eligible"); - const handoffEvent = vi.mocked(store.recordRunAuditEvent).mock.calls - .map(([event]) => event) - .find((event) => event.mutationType === "node:handoff:reassign-any"); - expect(handoffEvent).toEqual(expect.objectContaining({ - domain: "database", - mutationType: "node:handoff:reassign-any", - target: task.id, - metadata: { - taskId: task.id, - ownerNodeId: "node-owner", - ownerNodeHealth: "offline", - localNodeId: "local", - handoffPolicy: "reassign-any-healthy", - decisionReason: "owner_offline_any_healthy_eligible", - source: "scheduler.dispatch", - }, - })); - }); -}); - -describe("owning-node lease guard (FN-4832)", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nNode routing"); - }); - - it("parks dispatch when owner is online on a foreign node", async () => { - // FN-4832: scheduler must not dispatch task with active lease on online foreign node. - const task = createMockTask({ id: "FN-121", nodeId: "node-a", checkoutNodeId: "node-a", checkedOutBy: "exec-a" }); - const store = createMockStore(task, { maxConcurrent: 1, maxWorktrees: 1, owningNodeHandoffPolicy: "reassign-to-local" }); - const scheduler = new Scheduler(store, { - validateNodeDispatch: allowDispatchValidator(), - nodeHealthMonitor: createMockHealthMonitor({ "node-a": "online" }), - }); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - - expect(store.moveTask).not.toHaveBeenCalledWith(task.id, "in-progress", expect.anything()); - expect(store.moveTask).not.toHaveBeenCalled(); - expect(store.logEntry).toHaveBeenCalledWith(task.id, "Owning-node handoff parked dispatch: owner_recovered"); - }); - - it("deduplicates owner_recovered park logs across schedule ticks", async () => { - // FN-4832: repeated scheduling should not flood parked-owner logs. - const task = createMockTask({ id: "FN-122", nodeId: "node-a", checkoutNodeId: "node-a", checkedOutBy: "exec-a" }); - const store = createMockStore(task, { maxConcurrent: 1, maxWorktrees: 1, owningNodeHandoffPolicy: "reassign-to-local" }); - const scheduler = new Scheduler(store, { - validateNodeDispatch: allowDispatchValidator(), - nodeHealthMonitor: createMockHealthMonitor({ "node-a": "online" }), - }); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - await scheduler.schedule(); - - const ownerRecoveredLogs = vi.mocked(store.logEntry).mock.calls.filter(([, message]) => - String(message).includes("owner_recovered"), - ); - expect(ownerRecoveredLogs).toHaveLength(1); - expect(store.moveTask).not.toHaveBeenCalledWith(task.id, "in-progress", expect.anything()); - }); - - it("skips guard when local node owns checkout lease", async () => { - // FN-4832: self-owned leases should dispatch normally. - const task = createMockTask({ id: "FN-123", nodeId: undefined, checkoutNodeId: "local", checkedOutBy: "exec-local" }); - const store = createMockStore(task, { maxConcurrent: 1, maxWorktrees: 1 }); - const scheduler = new Scheduler(store, { - localNodeId: "local", - validateNodeDispatch: allowDispatchValidator(), - nodeHealthMonitor: createMockHealthMonitor({ local: "online" }), - }); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - - expect(store.logEntry).not.toHaveBeenCalledWith(task.id, expect.stringContaining("owner_recovered")); - expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({ - effectiveNodeId: null, - effectiveNodeSource: "local", - })); - expect(store.moveTask).toHaveBeenCalledWith(task.id, "in-progress", expect.anything()); - }); - - it("preserves offline owner reassign-to-local behavior", async () => { - // FN-4832: offline owner still reassigns to local when policy requires. - const task = createMockTask({ id: "FN-124", nodeId: "node-a", checkoutNodeId: "node-a", checkedOutBy: "exec-a" }); - const store = createMockStore(task, { maxConcurrent: 1, maxWorktrees: 1, owningNodeHandoffPolicy: "reassign-to-local" }); - const scheduler = new Scheduler(store, { - validateNodeDispatch: allowDispatchValidator(), - nodeHealthMonitor: createMockHealthMonitor({ "node-a": "offline" }), - }); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - - expect(store.logEntry).toHaveBeenCalledWith(task.id, "Owning-node handoff applied: owner_offline_local_takes_over"); - expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({ - effectiveNodeId: null, - effectiveNodeSource: "local", - })); - expect(store.moveTask).toHaveBeenCalledWith(task.id, "in-progress", expect.anything()); - }); - - it("parks when owner is offline and policy blocks handoff", async () => { - // FN-4832: block policy must continue to park foreign leases. - const task = createMockTask({ id: "FN-125", nodeId: "node-a", checkoutNodeId: "node-a", checkedOutBy: "exec-a" }); - const store = createMockStore(task, { maxConcurrent: 1, maxWorktrees: 1, owningNodeHandoffPolicy: "block" }); - const scheduler = new Scheduler(store, { - validateNodeDispatch: allowDispatchValidator(), - nodeHealthMonitor: createMockHealthMonitor({ "node-a": "offline" }), - }); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - - expect(store.logEntry).toHaveBeenCalledWith(task.id, "Owning-node handoff parked dispatch: handoff_blocked_by_policy"); - expect(store.moveTask).not.toHaveBeenCalledWith(task.id, "in-progress", expect.anything()); - }); - - it("respects custom localNodeId for self-vs-foreign ownership", async () => { - // FN-4832: custom localNodeId must replace hardcoded "local" ownership checks. - const ownedTask = createMockTask({ - id: "FN-126", - nodeId: "node-prod-1", - checkoutNodeId: "node-prod-1", - checkedOutBy: "exec-prod", - }); - const ownedStore = createMockStore(ownedTask, { maxConcurrent: 1, maxWorktrees: 1 }); - const ownedScheduler = new Scheduler(ownedStore, { - localNodeId: "node-prod-1", - validateNodeDispatch: allowDispatchValidator(), - nodeHealthMonitor: createMockHealthMonitor({ "node-prod-1": "online" }), - }); - (ownedScheduler as unknown as { running: boolean }).running = true; - - await ownedScheduler.schedule(); - - expect(ownedStore.logEntry).not.toHaveBeenCalledWith(ownedTask.id, expect.stringContaining("owner_recovered")); - expect(ownedStore.moveTask).toHaveBeenCalledWith(ownedTask.id, "in-progress", expect.anything()); - - const foreignTask = createMockTask({ - id: "FN-127", - nodeId: "node-prod-1", - checkoutNodeId: "node-prod-1", - checkedOutBy: "exec-prod", - }); - const foreignStore = createMockStore(foreignTask, { maxConcurrent: 1, maxWorktrees: 1 }); - const foreignScheduler = new Scheduler(foreignStore, { - localNodeId: "node-prod-2", - validateNodeDispatch: allowDispatchValidator(), - nodeHealthMonitor: createMockHealthMonitor({ "node-prod-1": "online" }), - }); - (foreignScheduler as unknown as { running: boolean }).running = true; - - await foreignScheduler.schedule(); - - expect(foreignStore.logEntry).toHaveBeenCalledWith( - foreignTask.id, - "Owning-node handoff parked dispatch: owner_recovered", - ); - expect(foreignStore.moveTask).not.toHaveBeenCalledWith(foreignTask.id, "in-progress", expect.anything()); - }); -}); diff --git a/packages/engine/src/__tests__/scheduler-overlap-requeue.test.ts b/packages/engine/src/__tests__/scheduler-overlap-requeue.test.ts deleted file mode 100644 index 91f0af68d0..0000000000 --- a/packages/engine/src/__tests__/scheduler-overlap-requeue.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { Agent, AgentStore, Task, TaskStore } from "@fusion/core"; -import { Scheduler } from "../scheduler.js"; -import { EphemeralWorkerManager } from "../ephemeral-worker-manager.js"; -import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; - -/** - * FN-4249 call sequence under test: - * 1) EphemeralWorkerManager.onTaskStart(task) links assigned durable agent to the task and flips it active→running. - * 2) Scheduler.schedule() later sees file-scope overlap and requeues the todo task with status="queued". - * 3) Before the fix, overlap requeue never rolled back the durable agent row, leaving state="running" + executionTaskId. - * 4) This test enforces the invariant: durable agents must not remain running against todo/queued tasks. - */ - -vi.mock("node:fs", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - existsSync: vi.fn(), - }; -}); - -vi.mock("node:fs/promises", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - readFile: vi.fn(), - }; -}); - -type MutableAgent = Agent; - -function createMockTask(overrides: Partial = {}): Task { - return { - id: "FN-100", - description: "Test task", - column: "todo", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - prompt: "", - ...overrides, - } as Task; -} - -function createTaskStore(tasks: Task[]): TaskStore { - const byId = new Map(tasks.map((task) => [task.id, task])); - - return { - listTasks: vi.fn(async () => Array.from(byId.values())), - getTask: vi.fn(async (id: string) => byId.get(id) ?? null), - getSettings: vi.fn(async () => ({ maxConcurrent: 10, maxWorktrees: 10, groupOverlappingFiles: true })), - getRootDir: vi.fn(() => "/tmp/project"), - getTasksDir: vi.fn(() => "/tmp/project/.fusion/tasks"), - parseFileScopeFromPrompt: vi.fn(async (taskId: string) => { - if (taskId === "FN-001") return ["packages/engine/src/scheduler.ts"]; - if (taskId === "FN-100") return ["packages/engine/src/scheduler.ts"]; - return []; - }), - updateTask: vi.fn(async (id: string, patch: Partial) => { - const current = byId.get(id); - if (!current) return; - const updated = { ...current, ...patch } as Task; - byId.set(id, updated); - }), - moveTask: vi.fn(async () => undefined), - logEntry: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - } as unknown as TaskStore; -} - -function createAgentStore(agents: MutableAgent[]): AgentStore { - const byId = new Map(agents.map((agent) => [agent.id, { ...agent }])); - - return { - getAgent: vi.fn(async (id: string) => byId.get(id) ?? null), - listAgents: vi.fn(async (filters?: { state?: Agent["state"]; includeEphemeral?: boolean }) => { - const agents = Array.from(byId.values()); - if (filters?.state) return agents.filter((agent) => agent.state === filters.state); - return agents; - }), - updateAgentState: vi.fn(async (id: string, state: Agent["state"]) => { - const existing = byId.get(id); - if (!existing) return; - if (existing.state === state) return; - existing.state = state; - }), - syncExecutionTaskLink: vi.fn(async (id: string, taskId?: string) => { - const existing = byId.get(id); - if (!existing) return; - existing.taskId = taskId; - }), - } as unknown as AgentStore; -} - -describe("scheduler overlap requeue agent-state invariant (FN-4249)", () => { - beforeEach(() => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - }); - - it("rolls back assigned durable agent from running when overlap requeues todo task", async () => { - const blocker = createMockTask({ id: "FN-001", column: "in-progress" }); - const queuedCandidate = createMockTask({ - id: "FN-100", - column: "todo", - status: "todo", - assignedAgentId: "agent-assigned", - }); - - const taskStore = createTaskStore([blocker, queuedCandidate]); - const agentStore = createAgentStore([ - { - id: "agent-assigned", - name: "Assigned Agent", - role: "executor", - state: "active", - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - } as MutableAgent, - ]); - - const workerManager = new EphemeralWorkerManager({ - agentStore, - taskStore, - logger: { log: vi.fn(), warn: vi.fn() }, - }); - - await workerManager.onTaskStart(queuedCandidate); - - const scheduler = new Scheduler(taskStore, { agentStore }); - (scheduler as any).running = true; - await scheduler.schedule(); - await scheduler.schedule(); - - const agent = await agentStore.getAgent("agent-assigned") as MutableAgent; - const task = await taskStore.getTask("FN-100"); - - expect(task?.column).toBe("todo"); - expect(task?.status).toBe("queued"); - expect(agent.state).toBe("active"); - expect(agent.taskId).toBeUndefined(); - expect(agentStore.updateAgentState).toHaveBeenCalledWith("agent-assigned", "active"); - expect(agentStore.updateAgentState).toHaveBeenCalledWith("agent-assigned", "running"); - expect(agentStore.updateAgentState).toHaveBeenCalledTimes(2); - }); -}); diff --git a/packages/engine/src/__tests__/scheduler.test.ts b/packages/engine/src/__tests__/scheduler.test.ts deleted file mode 100644 index f892174918..0000000000 --- a/packages/engine/src/__tests__/scheduler.test.ts +++ /dev/null @@ -1,5412 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { PrMonitor } from "../pr-monitor.js"; -import { - Scheduler, - pathsOverlap, - filterPathsByIgnoreList, - formatConcurrencyLimitMemoKey, - findHigherPriorityQueuedOverlap, - isCoordinationOnlyTask, - isRunnableQueuedOverlapCandidate, - getUnmetSchedulingDependencies, -} from "../scheduler.js"; -import { AgentSemaphore } from "../concurrency.js"; -import { makeTransitionRejection, TransitionRejectionError, type TaskStore, type Task, type TaskDetail } from "@fusion/core"; -import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { schedulerLog } from "../logger.js"; -import { MissionExecutionLoop } from "../mission-execution-loop.js"; - -const staleReporterReportMock = vi.fn(); -const backlogPressureReporterReportMock = vi.fn(); -const unlinkedMissionsAdvisoryReporterReportMock = vi.fn(); - -// Mock fs modules -vi.mock("node:fs", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - existsSync: vi.fn(), - }; -}); - -vi.mock("node:fs/promises", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - readFile: vi.fn(), - }; -}); - -vi.mock("../logger.js", () => ({ - schedulerLog: { - log: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, - createLogger: () => ({ - log: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), -})); - -vi.mock("../stale-task-reporter.js", () => ({ - StaleTaskReporter: vi.fn().mockImplementation(function () { - return { - report: staleReporterReportMock, - }; - }), -})); - -vi.mock("../backlog-pressure-reporter.js", () => ({ - BacklogPressureReporter: vi.fn().mockImplementation(function () { - return { - report: backlogPressureReporterReportMock, - }; - }), -})); - -vi.mock("../unlinked-missions-advisory-reporter.js", () => ({ - UnlinkedMissionsAdvisoryReporter: vi.fn().mockImplementation(function () { - return { - report: unlinkedMissionsAdvisoryReporterReportMock, - }; - }), -})); - -// Helper to create mock tasks -function createMockTask(overrides: Partial = {}): Task { - return { - id: "FN-001", - description: "Test task", - column: "todo", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - prompt: "", - ...overrides, - } as Task; -} - -// Mock store factory -const withLegacyWorkflowGraphDefault = (settings: Record) => ({ - ...settings, - experimentalFeatures: { - workflowColumns: false, - workflowGraphExecutor: false, - ...((settings.experimentalFeatures as Record | undefined) ?? {}), - }, -}); - -function createMockStore(overrides: Partial = {}): TaskStore { - const overrideGetSettings = overrides.getSettings; - const store = { - listTasks: vi.fn().mockResolvedValue([]), - getSettings: vi.fn().mockResolvedValue({}), - getTask: vi.fn().mockResolvedValue(createMockTask()), - updateTask: vi.fn().mockResolvedValue(undefined), - moveTask: vi.fn().mockResolvedValue(undefined), - parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), - logEntry: vi.fn().mockResolvedValue(undefined), - recordRunAuditEvent: vi.fn().mockResolvedValue(undefined), - getRootDir: vi.fn().mockReturnValue("/test/project"), - getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"), - // U6: the hold/release sweep consults workflow selection + completion markers - // when the workflowColumns flag is ON; default mocks keep flag-OFF behavior - // (sweep early-returns before touching these). - getTaskWorkflowSelection: vi.fn().mockReturnValue(undefined), - getWorkflowDefinition: vi.fn().mockResolvedValue(undefined), - getCompletionHandoffAcceptedMarker: vi.fn().mockReturnValue(null), - on: vi.fn(), - off: vi.fn(), - ...overrides, - }; - store.getSettings = vi.fn(async (...args: unknown[]) => { - const settings = overrideGetSettings === undefined - ? {} - : await (overrideGetSettings as (...args: unknown[]) => Promise>)(...args); - return withLegacyWorkflowGraphDefault(settings); - }) as typeof store.getSettings; - return store as unknown as TaskStore; -} - -async function flushAsyncWork(): Promise { - await Promise.resolve(); - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); -} - -describe("pathsOverlap", () => { - it("returns false for empty arrays", () => { - expect(pathsOverlap([], [])).toBe(false); - expect(pathsOverlap(["src/index.ts"], [])).toBe(false); - expect(pathsOverlap([], ["src/index.ts"])).toBe(false); - }); - - it("detects exact file path matches", () => { - expect(pathsOverlap(["src/index.ts"], ["src/index.ts"])).toBe(true); - expect(pathsOverlap(["a.ts", "b.ts"], ["b.ts", "c.ts"])).toBe(true); - }); - - it("detects directory prefix overlaps with /* globs", () => { - // Directory glob overlaps with file in that directory - expect(pathsOverlap(["src/*"], ["src/index.ts"])).toBe(true); - expect(pathsOverlap(["src/*"], ["src/utils/helpers.ts"])).toBe(true); - - // File overlaps with directory glob containing it - expect(pathsOverlap(["src/index.ts"], ["src/*"])).toBe(true); - }); - - it("detects nested directory overlaps", () => { - expect(pathsOverlap(["src/components/*"], ["src/components/Button.tsx"])).toBe(true); - expect(pathsOverlap(["src/*"], ["src/components/Button.tsx"])).toBe(true); - }); - - it("returns false for non-overlapping paths", () => { - expect(pathsOverlap(["src/*"], ["test/*"])).toBe(false); - expect(pathsOverlap(["src/index.ts"], ["test/index.ts"])).toBe(false); - expect(pathsOverlap(["a.ts", "b.ts"], ["c.ts", "d.ts"])).toBe(false); - }); - - it("handles multiple paths in each array", () => { - const a = ["src/*", "test/*"]; - const b = ["src/components/Button.tsx"]; - expect(pathsOverlap(a, b)).toBe(true); - - const c = ["docs/*", "examples/*"]; - const d = ["src/index.ts"]; - expect(pathsOverlap(c, d)).toBe(false); - }); - - it("handles mixed globs and exact paths", () => { - expect(pathsOverlap(["src/*", "package.json"], ["package.json"])).toBe(true); - expect(pathsOverlap(["src/*", "package.json"], ["README.md"])).toBe(false); - }); - - it("handles both having globs with overlapping prefixes", () => { - expect(pathsOverlap(["src/*"], ["src/components/*"])).toBe(true); - expect(pathsOverlap(["src/components/*"], ["src/*"])).toBe(true); - }); -}); - -describe("filterPathsByIgnoreList", () => { - it("filters exact ignored file paths", () => { - expect(filterPathsByIgnoreList(["docs/README.md", "src/index.ts"], ["docs/README.md"])) - .toEqual(["src/index.ts"]); - }); - - it("filters ignored directories with and without trailing slash", () => { - expect(filterPathsByIgnoreList(["docs/guide.md", "docs/api/types.md", "src/index.ts"], ["docs"])) - .toEqual(["src/index.ts"]); - expect(filterPathsByIgnoreList(["docs/guide.md", "src/index.ts"], ["docs/"])) - .toEqual(["src/index.ts"]); - }); - - it("filters ignored glob-style directories", () => { - expect(filterPathsByIgnoreList(["generated/*", "generated/client.ts", "src/index.ts"], ["generated/*"])) - .toEqual(["src/index.ts"]); - }); -}); - -describe("findHigherPriorityQueuedOverlap", () => { - const overlap = (a: string[], b: string[]) => pathsOverlap(a, b); - - it("returns higher-priority queued overlap", () => { - const result = findHigherPriorityQueuedOverlap( - { id: "FN-2", priority: "normal", createdAt: "2026-01-02T00:00:00Z", scope: ["src/a.ts"] }, - [{ id: "FN-1", priority: "urgent", createdAt: "2026-01-03T00:00:00Z", scope: ["src/a.ts"] }], - overlap, - ); - expect(result?.id).toBe("FN-1"); - }); - - it("uses age tiebreaker at equal priority", () => { - const result = findHigherPriorityQueuedOverlap( - { id: "FN-2", priority: "normal", createdAt: "2026-01-02T00:00:00Z", scope: ["src/a.ts"] }, - [{ id: "FN-1", priority: "normal", createdAt: "2026-01-01T00:00:00Z", scope: ["src/a.ts"] }], - overlap, - ); - expect(result?.id).toBe("FN-1"); - }); - - it("uses task id tiebreaker when priority and age match", () => { - const result = findHigherPriorityQueuedOverlap( - { id: "FN-10", priority: "normal", createdAt: "2026-01-01T00:00:00Z", scope: ["src/a.ts"] }, - [{ id: "FN-2", priority: "normal", createdAt: "2026-01-01T00:00:00Z", scope: ["src/a.ts"] }], - overlap, - ); - expect(result?.id).toBe("FN-2"); - }); - - it("returns null when scopes do not overlap", () => { - const result = findHigherPriorityQueuedOverlap( - { id: "FN-2", priority: "normal", createdAt: "2026-01-02T00:00:00Z", scope: ["src/a.ts"] }, - [{ id: "FN-1", priority: "urgent", createdAt: "2026-01-01T00:00:00Z", scope: ["src/b.ts"] }], - overlap, - ); - expect(result).toBeNull(); - }); - - it("returns null when candidate or queued scopes are empty", () => { - expect( - findHigherPriorityQueuedOverlap( - { id: "FN-2", priority: "normal", createdAt: "2026-01-02T00:00:00Z", scope: [] }, - [{ id: "FN-1", priority: "urgent", createdAt: "2026-01-01T00:00:00Z", scope: ["src/a.ts"] }], - overlap, - ), - ).toBeNull(); - - expect( - findHigherPriorityQueuedOverlap( - { id: "FN-2", priority: "normal", createdAt: "2026-01-02T00:00:00Z", scope: ["src/a.ts"] }, - [{ id: "FN-1", priority: "urgent", createdAt: "2026-01-01T00:00:00Z", scope: [] }], - overlap, - ), - ).toBeNull(); - }); -}); - -describe("isCoordinationOnlyTask", () => { - it("treats explicit no-commit tasks with safe scopes as coordination-only", () => { - const task = createMockTask({ noCommitsExpected: true, description: "Analyze backlog docs" }); - expect(isCoordinationOnlyTask(task, ["docs/task-management.md", ".changeset/*.md"])).toBe(true); - }); - - it("does not let explicit no-commit metadata bypass implementation file scopes", () => { - const task = createMockTask({ noCommitsExpected: true, description: "Analyze src change" }); - expect(isCoordinationOnlyTask(task, ["packages/engine/src/scheduler.ts"])).toBe(false); - }); - - it("does not infer coordination-only behavior from writable-looking scope prefixes", () => { - const task = createMockTask({ - title: "Backlog flow audit", - description: "Audit backlog overlap and recommend next actions", - }); - expect(isCoordinationOnlyTask(task, ["docs/task-management.md", ".changeset/*.md", ".fusion/tasks/FN-158/task.json"])).toBe(false); - }); - - it("treats source metadata no-commit flag as explicit coordination signal", () => { - const task = createMockTask({ - noCommitsExpected: undefined, - sourceMetadata: { noCommitsExpected: true }, - }); - expect(isCoordinationOnlyTask(task, ["docs/task-management.md"])).toBe(true); - }); - - it("treats source metadata decision-only flag as explicit coordination signal", () => { - const task = createMockTask({ - noCommitsExpected: undefined, - sourceMetadata: { decisionOnly: true }, - }); - expect(isCoordinationOnlyTask(task, [".fusion/tasks/FN-158/task.json"])).toBe(true); - }); - - it("treats direct and source metadata no-commit flags equivalently", () => { - const direct = createMockTask({ noCommitsExpected: true }); - const fromMetadata = createMockTask({ noCommitsExpected: undefined, sourceMetadata: { noCommitsExpected: true } }); - - expect(isCoordinationOnlyTask(direct, ["docs/task-management.md"])).toBe(true); - expect(isCoordinationOnlyTask(fromMetadata, ["docs/task-management.md"])).toBe(true); - }); - - it("allows explicit no-commit tasks with empty scopes because there is no file lease to bypass", () => { - const task = createMockTask({ - title: "Backlog flow audit", - description: "Audit and recommend next actions", - noCommitsExpected: true, - }); - expect(isCoordinationOnlyTask(task, [])).toBe(true); - }); - - it("does not classify empty inferred scope as coordination-only", () => { - const task = createMockTask({ - title: "Backlog flow audit", - description: "Audit and recommend next actions", - }); - expect(isCoordinationOnlyTask(task, [])).toBe(false); - }); - - it("does not use legacy scope fallback when explicit no-commit metadata is false", () => { - const task = createMockTask({ - noCommitsExpected: undefined, - sourceMetadata: { noCommitsExpected: false }, - }); - expect(isCoordinationOnlyTask(task, ["docs/task-management.md", ".changeset/*.md"])).toBe(false); - }); - - it("does not classify implementation scope as coordination-only", () => { - const task = createMockTask({ - title: "Investigate and fix scheduler starvation", - description: "Investigate and fix if needed", - noCommitsExpected: false, - }); - expect(isCoordinationOnlyTask(task, ["packages/engine/src/scheduler.ts"])).toBe(false); - }); - - it("does not infer test-file scopes as coordination-only without explicit metadata", () => { - const task = createMockTask({ - title: "Fix scheduler tests", - description: "Update test implementation", - }); - expect(isCoordinationOnlyTask(task, ["tests/integration/scheduler.test.ts"])).toBe(false); - }); - - it("does not classify mixed coordination and implementation scope as coordination-only", () => { - const task = createMockTask({ - title: "Backlog flow audit", - description: "Audit and apply scheduler fix", - noCommitsExpected: true, - }); - expect(isCoordinationOnlyTask(task, ["docs/task-management.md", "packages/engine/src/scheduler.ts"])).toBe(false); - }); -}); - -describe("getUnmetSchedulingDependencies", () => { - it("keeps legacy in-review satisfaction authoritative while emitting parity diff", () => { - const task = createMockTask({ id: "FN-T", dependencies: ["FN-DEP"] }); - const dep = createMockTask({ id: "FN-DEP", column: "in-review" }); - const diffs: Array<{ dependencyId: string; legacySatisfied: boolean; markerSatisfied: boolean }> = []; - - const unmet = getUnmetSchedulingDependencies(task, [task, dep], { - markerAcceptedByTaskId: new Map([["FN-DEP", false]]), - onParityDiff: (diff) => { - diffs.push({ - dependencyId: diff.dependencyId, - legacySatisfied: diff.legacySatisfied, - markerSatisfied: diff.markerSatisfied, - }); - }, - }); - - expect(unmet).toEqual([]); - expect(diffs).toEqual([ - { - dependencyId: "FN-DEP", - legacySatisfied: true, - markerSatisfied: false, - }, - ]); - }); - - it("does not emit parity diff when legacy and marker paths agree dependency is satisfied", () => { - const task = createMockTask({ id: "FN-T", dependencies: ["FN-DEP"] }); - const dep = createMockTask({ id: "FN-DEP", column: "done" }); - const onParityDiff = vi.fn(); - - const unmet = getUnmetSchedulingDependencies(task, [task, dep], { - markerAcceptedByTaskId: new Map([["FN-DEP", false]]), - onParityDiff, - }); - - expect(unmet).toEqual([]); - expect(onParityDiff).not.toHaveBeenCalled(); - }); - - it("does not emit parity diff when legacy and marker paths agree dependency is unmet", () => { - const task = createMockTask({ id: "FN-T", dependencies: ["FN-DEP"] }); - const dep = createMockTask({ id: "FN-DEP", column: "in-progress" }); - const onParityDiff = vi.fn(); - - const unmet = getUnmetSchedulingDependencies(task, [task, dep], { - markerAcceptedByTaskId: new Map([["FN-DEP", false]]), - onParityDiff, - }); - - expect(unmet).toEqual(["FN-DEP"]); - expect(onParityDiff).not.toHaveBeenCalled(); - }); - - it("preserves legacy behavior when parity options are omitted", () => { - const task = createMockTask({ id: "FN-T", dependencies: ["FN-DEP"] }); - const dep = createMockTask({ id: "FN-DEP", column: "in-review" }); - - expect(getUnmetSchedulingDependencies(task, [task, dep])).toEqual([]); - }); - - it("blocks only live unsatisfied dependency columns across dispatch surfaces", () => { - const task = createMockTask({ - id: "FN-T", - dependencies: [ - "FN-TODO", - "FN-QUEUED", - "FN-INPROGRESS", - "FN-TRIAGE", - "FN-DONE", - "FN-REVIEW", - "FN-ARCHIVED", - "FN-SOFT-DELETED", - "FN-MISSING", - ], - }); - const tasks = [ - task, - createMockTask({ id: "FN-TODO", column: "todo" }), - createMockTask({ id: "FN-QUEUED", column: "todo", status: "queued" }), - createMockTask({ id: "FN-INPROGRESS", column: "in-progress" }), - createMockTask({ id: "FN-TRIAGE", column: "triage" }), - createMockTask({ id: "FN-DONE", column: "done" }), - createMockTask({ id: "FN-REVIEW", column: "in-review" }), - createMockTask({ id: "FN-ARCHIVED", column: "archived" }), - // Soft-deleted dependency records are absent from listTasks(), matching the - // executor/scheduler shared helper's missing-id-is-not-blocking contract. - ]; - - expect(getUnmetSchedulingDependencies(task, tasks)).toEqual([ - "FN-TODO", - "FN-QUEUED", - "FN-INPROGRESS", - "FN-TRIAGE", - ]); - }); -}); - -describe("isRunnableQueuedOverlapCandidate", () => { - const now = new Date("2026-01-01T00:00:00.000Z").getTime(); - - it("accepts queued todo tasks whose dependencies are complete or review-ready", () => { - const runnable = createMockTask({ id: "FN-R", status: "queued", dependencies: ["FN-DONE", "FN-REVIEW", "FN-ARCH"] }); - const tasks = [ - runnable, - createMockTask({ id: "FN-DONE", column: "done" }), - createMockTask({ id: "FN-REVIEW", column: "in-review" }), - createMockTask({ id: "FN-ARCH", column: "archived" }), - ]; - - expect(isRunnableQueuedOverlapCandidate(runnable, tasks, now)).toBe(true); - }); - - it("rejects queued todo overlap candidates that cannot dispatch statically", () => { - const unresolved = createMockTask({ id: "FN-BLOCKED", status: "queued", dependencies: ["FN-ACTIVE"] }); - const activeDep = createMockTask({ id: "FN-ACTIVE", column: "in-progress" }); - const futureBackoff = new Date(now + 60_000).toISOString(); - - expect(isRunnableQueuedOverlapCandidate(unresolved, [unresolved, activeDep], now)).toBe(false); - expect(isRunnableQueuedOverlapCandidate(createMockTask({ id: "FN-PAUSED", status: "queued", paused: true }), [], now)).toBe(false); - expect(isRunnableQueuedOverlapCandidate(createMockTask({ id: "FN-USER", status: "queued", userPaused: true }), [], now)).toBe(false); - expect(isRunnableQueuedOverlapCandidate(createMockTask({ id: "FN-BACKOFF", status: "queued", nextRecoveryAt: futureBackoff }), [], now)).toBe(false); - expect(isRunnableQueuedOverlapCandidate(createMockTask({ id: "FN-FRESH", status: "pending" }), [], now)).toBe(false); - }); - - it("rejects queued overlap candidates blocked by active file-scope leases", () => { - const activeScopes = new Map([["FN-039", ["packages/engine/src/scheduler.ts"]]]); - const blocked = createMockTask({ id: "FN-028", status: "queued" }); - const runnable = createMockTask({ id: "FN-030", status: "queued" }); - - expect(isRunnableQueuedOverlapCandidate(blocked, [blocked], now, activeScopes, ["packages/engine/src/scheduler.ts"])).toBe(false); - expect(isRunnableQueuedOverlapCandidate(runnable, [runnable], now, activeScopes, ["packages/core/src/store.ts"])).toBe(true); - }); - - it("accepts queued overlap candidates when no active scopes exist", () => { - const candidate = createMockTask({ id: "FN-050", status: "queued" }); - - expect(isRunnableQueuedOverlapCandidate(candidate, [candidate], now, undefined, ["packages/engine/src/scheduler.ts"])).toBe(true); - expect(isRunnableQueuedOverlapCandidate(candidate, [candidate], now, new Map(), ["packages/engine/src/scheduler.ts"])).toBe(true); - }); -}); - -describe("Scheduler", () => { - beforeEach(() => { - staleReporterReportMock.mockReset().mockResolvedValue({ surfaced: 0 }); - backlogPressureReporterReportMock.mockReset().mockResolvedValue({ alerted: false }); - unlinkedMissionsAdvisoryReporterReportMock.mockReset().mockResolvedValue({ alerted: false }); - }); - // Helper to create mock MissionStore (shared across mission-related test suites) - function createMockMissionStore(overrides = {}) { - return { - getFeatureByTaskId: vi.fn(), - updateFeatureStatus: vi.fn().mockResolvedValue(undefined), - getSlice: vi.fn(), - getMilestone: vi.fn(), - computeSliceStatus: vi.fn(), - getMission: vi.fn(), - getMissionWithHierarchy: vi.fn(), - findNextPendingSlice: vi.fn(), - activateSlice: vi.fn(), - listFeatures: vi.fn().mockReturnValue([]), - linkFeatureToTask: vi.fn((featureId: string, taskId: string) => ({ - id: featureId, - taskId, - sliceId: "SL-001", - title: "Linked feature", - status: "triaged", - })), - triageFeature: vi.fn(), - ...overrides, - }; - } - - describe("stale task reporter integration", () => { - it("invokes reporter from schedule", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([createMockTask({ id: "FN-STALE", column: "todo", dependencies: [] })]), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - staleInProgressWarningMs: 1000, - staleInReviewWarningMs: 2000, - }), - }); - const scheduler = new Scheduler(store); - (scheduler as unknown as { running: boolean }).running = true; - await scheduler.schedule(); - expect(staleReporterReportMock).toHaveBeenCalledTimes(1); - }); - - it("does not throw when reporter errors", async () => { - staleReporterReportMock.mockRejectedValueOnce(new Error("boom")); - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([createMockTask({ id: "FN-STALE", column: "todo", dependencies: [] })]), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - staleInProgressWarningMs: 1000, - staleInReviewWarningMs: 2000, - }), - }); - const scheduler = new Scheduler(store); - (scheduler as unknown as { running: boolean }).running = true; - await expect(scheduler.schedule()).resolves.toBeUndefined(); - expect(schedulerLog.warn).toHaveBeenCalledWith("Stale task reporter failed", expect.any(Error)); - }); - - it("rate-limits back-to-back reporter runs", async () => { - vi.useFakeTimers(); - try { - vi.setSystemTime(new Date("2026-05-14T12:00:00.000Z")); - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([createMockTask({ id: "FN-STALE", column: "todo", dependencies: [] })]), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - staleInProgressWarningMs: 5000, - staleInReviewWarningMs: 10000, - }), - }); - const scheduler = new Scheduler(store); - (scheduler as unknown as { running: boolean }).running = true; - await scheduler.schedule(); - await scheduler.schedule(); - expect(staleReporterReportMock).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } - }); - }); - - describe("U6 hold/release sweep integration (flag-gated)", () => { - function setupTodoStore(workflowColumns: boolean) { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - const todo = createMockTask({ id: "FN-1", column: "todo", dependencies: [] }); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([todo]), - getTask: vi.fn().mockResolvedValue(todo), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - experimentalFeatures: { workflowColumns }, - }), - }); - const scheduler = new Scheduler(store); - (scheduler as unknown as { running: boolean }).running = true; - return { store, scheduler }; - } - - it("flag-ON default-workflow pickup matches flag-OFF: same todo→in-progress dispatch", async () => { - // Flag-OFF baseline: the legacy pull-from-todo loop dispatches the card. - const off = setupTodoStore(false); - await off.scheduler.schedule(); - const offMoves = vi.mocked(off.store.moveTask).mock.calls.map((c) => [c[0], c[1]]); - expect(offMoves).toContainEqual(["FN-1", "in-progress"]); - - // Flag-ON: the sweep runs first (default-workflow todo is a capacity hold), - // then the legacy loop; the net dispatch is the SAME todo→in-progress move. - const on = setupTodoStore(true); - await on.scheduler.schedule(); - const onMoves = vi.mocked(on.store.moveTask).mock.calls.map((c) => [c[0], c[1]]); - expect(onMoves).toContainEqual(["FN-1", "in-progress"]); - }); - - it("re-reads tasks after flag-ON hold-release sweep before legacy dispatch", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - const tasks = new Map( - Array.from({ length: 6 }, (_, index) => { - const id = `FN-${String(index + 1).padStart(3, "0")}`; - return [id, createMockTask({ id, column: "todo", dependencies: [] })]; - }), - ); - const moveTask = vi.fn(async (taskId: string, column: Task["column"]) => { - const current = tasks.get(taskId); - if (!current) throw new Error(`missing task ${taskId}`); - if (column === "in-progress") { - const inProgressCount = [...tasks.values()].filter((task) => task.column === "in-progress").length; - if (inProgressCount >= 3) { - throw new TransitionRejectionError( - makeTransitionRejection("capacity-exhausted", "transition.rejected.capacityExhausted", true), - "queued — in-progress column at capacity", - ); - } - } - const updated = { ...current, column } as Task; - tasks.set(taskId, updated); - return updated; - }); - const store = createMockStore({ - listTasks: vi.fn(async () => [...tasks.values()]), - getTask: vi.fn(async (taskId: string) => tasks.get(taskId) ?? null), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 3, - maxWorktrees: 10, - experimentalFeatures: { workflowColumns: true }, - }), - moveTask, - }); - - const scheduler = new Scheduler(store); - (scheduler as unknown as { running: boolean }).running = true; - await scheduler.schedule(); - - expect([...tasks.values()].filter((task) => task.column === "in-progress")).toHaveLength(3); - expect(moveTask.mock.calls.filter((call) => call[1] === "in-progress")).toHaveLength(6); - expect(vi.mocked(store.listTasks).mock.calls.length).toBeGreaterThanOrEqual(2); - expect(vi.mocked(store.listTasks).mock.calls.some(([options]) => options?.startupMemo === false)).toBe(true); - }); - - it("holds workflow-column releases when maxWorktrees is exhausted", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = new Map([ - ["FN-001", createMockTask({ id: "FN-001", column: "in-progress", dependencies: [] })], - ["FN-002", createMockTask({ id: "FN-002", column: "in-progress", dependencies: [] })], - ["FN-003", createMockTask({ id: "FN-003", column: "todo", dependencies: [] })], - ["FN-004", createMockTask({ id: "FN-004", column: "todo", dependencies: [] })], - ["FN-005", createMockTask({ id: "FN-005", column: "todo", dependencies: [] })], - ]); - const movedListeners = new Set<(data: { task: object; to: string }) => void>(); - const moveTask = vi.fn(async (taskId: string, column: Task["column"]) => { - const current = tasks.get(taskId); - if (!current) throw new Error(`missing task ${taskId}`); - const updated = { ...current, column } as Task; - tasks.set(taskId, updated); - for (const listener of movedListeners) { - listener({ task: updated, to: column }); - } - return updated; - }); - const store = createMockStore({ - listTasks: vi.fn(async () => [...tasks.values()]), - getTask: vi.fn(async (taskId: string) => tasks.get(taskId) ?? null), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 15, - maxWorktrees: 3, - experimentalFeatures: { workflowColumns: true }, - }), - moveTask, - on: vi.fn((event: string, listener: (data: { task: object; to: string }) => void) => { - if (event === "task:moved") movedListeners.add(listener); - }), - off: vi.fn((event: string, listener: (data: { task: object; to: string }) => void) => { - if (event === "task:moved") movedListeners.delete(listener); - }), - }); - - const scheduler = new Scheduler(store); - (scheduler as unknown as { running: boolean }).running = true; - await scheduler.schedule(); - - const inProgress = [...tasks.values()].filter((task) => task.column === "in-progress"); - expect(inProgress.map((task) => task.id)).toEqual(["FN-001", "FN-002", "FN-003"]); - expect(moveTask.mock.calls.filter((call) => call[1] === "in-progress").map((call) => call[0])).toEqual(["FN-003"]); - expect(schedulerLog.log).toHaveBeenCalledWith(expect.stringContaining("no reservable slot")); - }); - - it("FN-6292: does not let an in-progress task with unmet deps block its own dependency by file-scope lease", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = new Map([ - ["FN-H", createMockTask({ id: "FN-H", column: "in-progress", dependencies: ["FN-D"] })], - ["FN-D", createMockTask({ id: "FN-D", column: "todo", dependencies: [] })], - ]); - const moveTask = vi.fn(async (taskId: string, column: Task["column"]) => { - const current = tasks.get(taskId); - if (!current) throw new Error(`missing task ${taskId}`); - const updated = { ...current, column } as Task; - tasks.set(taskId, updated); - return updated; - }); - const updateTask = vi.fn(async (taskId: string, updates: Partial) => { - const current = tasks.get(taskId); - if (!current) throw new Error(`missing task ${taskId}`); - const updated = { ...current, ...updates } as Task; - tasks.set(taskId, updated); - return updated; - }); - const store = createMockStore({ - listTasks: vi.fn(async () => [...tasks.values()]), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, maxWorktrees: 10, groupOverlappingFiles: true }), - parseFileScopeFromPrompt: vi.fn(async () => ["packages/engine/src/scheduler.ts"]), - updateTask, - moveTask, - }); - - const scheduler = new Scheduler(store); - (scheduler as unknown as { running: boolean }).running = true; - await scheduler.schedule(); - - expect(updateTask).not.toHaveBeenCalledWith("FN-D", { status: "queued", blockedBy: null, overlapBlockedBy: "FN-H" }); - expect(moveTask).toHaveBeenCalledWith("FN-D", "in-progress", expect.anything()); - }); - - it("FN-6292: workflow-column hold sweep does not lease unmet-dependency in-progress holders", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = new Map([ - ["FN-H", createMockTask({ id: "FN-H", column: "in-progress", dependencies: ["FN-D"] })], - ["FN-D", createMockTask({ id: "FN-D", column: "todo", dependencies: [] })], - ]); - const updateTask = vi.fn(async (taskId: string, updates: Partial) => { - const current = tasks.get(taskId); - if (!current) throw new Error(`missing task ${taskId}`); - const updated = { ...current, ...updates } as Task; - tasks.set(taskId, updated); - return updated; - }); - const moveTask = vi.fn(async (taskId: string, column: Task["column"]) => { - const current = tasks.get(taskId); - if (!current) throw new Error(`missing task ${taskId}`); - const updated = { ...current, column } as Task; - tasks.set(taskId, updated); - return updated; - }); - const store = createMockStore({ - listTasks: vi.fn(async () => [...tasks.values()]), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 10, - maxWorktrees: 10, - groupOverlappingFiles: true, - experimentalFeatures: { workflowColumns: true }, - }), - parseFileScopeFromPrompt: vi.fn(async () => ["packages/engine/src/scheduler.ts"]), - updateTask, - moveTask, - }); - - const scheduler = new Scheduler(store); - (scheduler as unknown as { running: boolean }).running = true; - await scheduler.schedule(); - - expect(updateTask).not.toHaveBeenCalledWith("FN-D", { status: "queued", blockedBy: null, overlapBlockedBy: "FN-H" }); - expect(moveTask).toHaveBeenCalledWith("FN-D", "in-progress", expect.anything()); - }); - - it("FN-6292: keeps file-scope leases for in-progress tasks whose dependencies are met", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-DEP", column: "done" }), - createMockTask({ id: "FN-H", column: "in-progress", dependencies: ["FN-DEP"] }), - createMockTask({ id: "FN-D", column: "todo", dependencies: [] }), - ]; - const updateTask = vi.fn().mockResolvedValue(undefined); - const moveTask = vi.fn().mockResolvedValue(undefined); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, maxWorktrees: 10, groupOverlappingFiles: true }), - parseFileScopeFromPrompt: vi.fn(async (taskId: string) => taskId === "FN-DEP" ? [] : ["packages/engine/src/scheduler.ts"]), - updateTask, - moveTask, - }); - - const scheduler = new Scheduler(store); - (scheduler as unknown as { running: boolean }).running = true; - await scheduler.schedule(); - - expect(updateTask).toHaveBeenCalledWith("FN-D", { status: "queued", blockedBy: null, overlapBlockedBy: "FN-H" }); - expect(moveTask).not.toHaveBeenCalledWith("FN-D", "in-progress", expect.anything()); - }); - - it("holds workflow-column releases when file scopes overlap active work", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = new Map([ - ["FN-001", createMockTask({ id: "FN-001", column: "in-progress", dependencies: [] })], - ["FN-002", createMockTask({ id: "FN-002", column: "todo", dependencies: [] })], - ["FN-003", createMockTask({ id: "FN-003", column: "todo", dependencies: [] })], - ]); - const scopes = new Map([ - ["FN-001", ["packages/engine/src/scheduler.ts"]], - ["FN-002", ["packages/engine/src/scheduler.ts"]], - ["FN-003", ["packages/core/src/store.ts"]], - ]); - const movedListeners = new Set<(data: { task: object; to: string }) => void>(); - const moveTask = vi.fn(async (taskId: string, column: Task["column"]) => { - const current = tasks.get(taskId); - if (!current) throw new Error(`missing task ${taskId}`); - const updated = { ...current, column } as Task; - tasks.set(taskId, updated); - for (const listener of movedListeners) { - listener({ task: updated, to: column }); - } - return updated; - }); - const updateTask = vi.fn(async (taskId: string, updates: Partial) => { - const current = tasks.get(taskId); - if (!current) throw new Error(`missing task ${taskId}`); - const updated = { ...current, ...updates } as Task; - if (updates.blockedBy === null) updated.blockedBy = undefined; - if (updates.overlapBlockedBy === null) updated.overlapBlockedBy = undefined; - tasks.set(taskId, updated); - return updated; - }); - const store = createMockStore({ - listTasks: vi.fn(async () => [...tasks.values()]), - getTask: vi.fn(async (taskId: string) => tasks.get(taskId) ?? null), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 15, - maxWorktrees: 10, - groupOverlappingFiles: true, - experimentalFeatures: { workflowColumns: true }, - }), - parseFileScopeFromPrompt: vi.fn(async (taskId: string) => scopes.get(taskId) ?? []), - updateTask, - moveTask, - on: vi.fn((event: string, listener: (data: { task: object; to: string }) => void) => { - if (event === "task:moved") movedListeners.add(listener); - }), - off: vi.fn((event: string, listener: (data: { task: object; to: string }) => void) => { - if (event === "task:moved") movedListeners.delete(listener); - }), - }); - - const scheduler = new Scheduler(store); - (scheduler as unknown as { running: boolean }).running = true; - await scheduler.schedule(); - - expect(tasks.get("FN-002")).toMatchObject({ - column: "todo", - status: "queued", - blockedBy: undefined, - overlapBlockedBy: "FN-001", - }); - expect(tasks.get("FN-003")?.column).toBe("in-progress"); - expect(moveTask.mock.calls.filter((call) => call[1] === "in-progress").map((call) => call[0])).toEqual(["FN-003"]); - expect(store.logEntry).toHaveBeenCalledWith( - "FN-002", - expect.stringContaining("queued — blocked by active file-scope lease FN-001"), - ); - }); - - it("flag-OFF: todo dispatch is tagged as scheduler-sourced for redispatch guards", async () => { - const off = setupTodoStore(false); - await off.scheduler.schedule(); - const schedulerSourcedMoves = vi - .mocked(off.store.moveTask) - .mock.calls.filter((c) => (c[2] as { moveSource?: string } | undefined)?.moveSource === "scheduler"); - expect(schedulerSourcedMoves.length).toBe(1); - expect(schedulerSourcedMoves[0]?.slice(0, 2)).toEqual(["FN-1", "in-progress"]); - }); - }); - - describe("backlog pressure reporter integration", () => { - it("invokes reporter from schedule when enabled", async () => { - vi.useFakeTimers(); - try { - vi.setSystemTime(new Date("2026-05-18T12:00:00.000Z")); - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([createMockTask({ id: "FN-BACKLOG", column: "todo", dependencies: [] })]), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - staleInProgressWarningMs: 0, - staleInReviewWarningMs: 0, - }), - }); - const scheduler = new Scheduler(store); - (scheduler as unknown as { running: boolean }).running = true; - await scheduler.schedule(); - expect(backlogPressureReporterReportMock).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } - }); - - it("does not invoke reporter when disabled", async () => { - vi.useFakeTimers(); - try { - vi.setSystemTime(new Date("2026-05-18T12:00:00.000Z")); - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([createMockTask({ id: "FN-BACKLOG", column: "todo", dependencies: [] })]), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - backlogPressureAlertEnabled: false, - staleInProgressWarningMs: 0, - staleInReviewWarningMs: 0, - }), - }); - const scheduler = new Scheduler(store); - (scheduler as unknown as { running: boolean }).running = true; - await scheduler.schedule(); - expect(backlogPressureReporterReportMock).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - }); - }); - - describe("constructor", () => { - it("initializes with default options", () => { - const store = createMockStore(); - const scheduler = new Scheduler(store); - expect(scheduler).toBeDefined(); - }); - - it("registers settings update handlers", () => { - const store = createMockStore(); - const scheduler = new Scheduler(store); - expect(store.on).toHaveBeenCalledWith("settings:updated", expect.any(Function)); - }); - - it("accepts custom options", () => { - const store = createMockStore(); - const onSchedule = vi.fn(); - const onBlocked = vi.fn(); - const scheduler = new Scheduler(store, { - maxConcurrent: 3, - maxWorktrees: 6, - pollIntervalMs: 5000, - onSchedule, - onBlocked, - }); - expect(scheduler).toBeDefined(); - }); - }); - - describe("event-driven scheduling", () => { - it("registers task:created event listener", () => { - const store = createMockStore(); - new Scheduler(store); - // Verify task:created listener is registered - expect(store.on).toHaveBeenCalledWith("task:created", expect.any(Function)); - }); - - it("triggers scheduling immediately when task:created event fires", async () => { - // Mock filesystem validation so schedule() can proceed - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - // First call (from start()) returns empty todo, second call (from event) returns the new task - const listTasksMock = vi.fn() - .mockResolvedValueOnce([]) // Initial schedule from start() sees no tasks - .mockResolvedValue([ - createMockTask({ id: "FN-001", column: "todo", dependencies: [] }), - ]); - - const store = createMockStore({ - listTasks: listTasksMock, - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - updateTask: vi.fn().mockResolvedValue(undefined), - moveTask: vi.fn().mockResolvedValue(undefined), - }); - - const scheduler = new Scheduler(store); - scheduler.start(); - - // Wait for initial schedule pass to complete - await flushAsyncWork(); - - // Find and call the task:created handler - const onCalls = (store.on as any).mock.calls; - const createdHandler = onCalls.find((call: any) => call[0] === "task:created")?.[1]; - expect(createdHandler).toBeDefined(); - - // Simulate task:created event — triggers schedule() which now sees FN-001 - const newTask = createMockTask({ id: "FN-001", column: "todo" }); - await createdHandler(newTask); - - // Wait for async schedule to complete - await flushAsyncWork(); - - // Verify schedule() was called (moveTask should be called since task can start) - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) })); - }); - - it("resets mergeRetries when dispatching a task to in-progress", async () => { - // Regression: a task whose previous run exhausted its merge budget - // (mergeRetries = MAX) would, after status was cleared, land back in - // in-review with the merger refusing it (canMergeTask false) and the - // ghost-review fallback bouncing it back every taskStuckTimeoutMs — - // infinite loop. Each fresh execution must get a fresh merge budget. - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const listTasksMock = vi.fn() - .mockResolvedValueOnce([]) - .mockResolvedValue([ - createMockTask({ id: "FN-001", column: "todo", dependencies: [], mergeRetries: 3 }), - ]); - - const store = createMockStore({ - listTasks: listTasksMock, - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - updateTask: vi.fn().mockResolvedValue(undefined), - moveTask: vi.fn().mockResolvedValue(undefined), - }); - - const scheduler = new Scheduler(store); - scheduler.start(); - await flushAsyncWork(); - - const onCalls = (store.on as any).mock.calls; - const createdHandler = onCalls.find((call: any) => call[0] === "task:created")?.[1]; - await createdHandler(createMockTask({ id: "FN-001", column: "todo", mergeRetries: 3 })); - await flushAsyncWork(); - - expect(store.updateTask).toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ mergeRetries: 0 }), - ); - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) })); - }); - - it("registers task:moved event listener", () => { - const store = createMockStore(); - new Scheduler(store); - // Verify task:moved listener is registered - expect(store.on).toHaveBeenCalledWith("task:moved", expect.any(Function)); - }); - - it("FN-5496: task:deleted immediately unblocks dependents in same tick", async () => { - const deleted = createMockTask({ id: "FN-DEL", column: "todo" }); - const dependent = createMockTask({ id: "FN-DEP", column: "todo", blockedBy: "FN-DEL", dependencies: ["FN-DEL"] }); - const tasks = [dependent]; - const listTasks = vi.fn(async (options?: { column?: string; includeArchived?: boolean }) => { - if (options?.column === "todo") return tasks.filter((task) => task.column === "todo"); - if (options?.column === "in-progress") return tasks.filter((task) => task.column === "in-progress"); - return tasks; - }); - - const store = createMockStore({ - listTasks, - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, globalPause: false, enginePaused: false }), - }); - - new Scheduler(store); - const deletedHandler = (store.on as any).mock.calls.find((call: any) => call[0] === "task:deleted")?.[1]; - deletedHandler(deleted); - await flushAsyncWork(); - - expect(store.updateTask).toHaveBeenCalledWith("FN-DEP", { blockedBy: null, status: null }); - expect(store.logEntry).toHaveBeenCalledWith("FN-DEP", "Auto-unblocked (FN-5496): blocker FN-DEL was soft-deleted"); - }); - - it("FN-5496: task:deleted clears blockedBy but preserves status for in-progress dependents", async () => { - const deleted = createMockTask({ id: "FN-DEL", column: "todo" }); - const dependent = createMockTask({ - id: "FN-DEP", - column: "in-progress", - blockedBy: "FN-DEL", - status: "running", - dependencies: ["FN-DEL"], - }); - const tasks = [dependent]; - const listTasks = vi.fn(async (options?: { column?: string; includeArchived?: boolean }) => { - if (options?.column === "todo") return tasks.filter((task) => task.column === "todo"); - if (options?.column === "in-progress") return tasks.filter((task) => task.column === "in-progress"); - return tasks; - }); - - const store = createMockStore({ - listTasks, - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, globalPause: false, enginePaused: false }), - }); - - new Scheduler(store); - const deletedHandler = (store.on as any).mock.calls.find((call: any) => call[0] === "task:deleted")?.[1]; - deletedHandler(deleted); - await flushAsyncWork(); - - expect(store.updateTask).toHaveBeenCalledWith("FN-DEP", { blockedBy: null }); - expect(store.updateTask).not.toHaveBeenCalledWith("FN-DEP", expect.objectContaining({ status: null })); - expect(store.logEntry).toHaveBeenCalledWith("FN-DEP", "Auto-unblocked (FN-5496): blocker FN-DEL was soft-deleted"); - }); - - it("FN-5496: task:deleted repoints blockedBy when another dependency remains unresolved", async () => { - const deleted = createMockTask({ id: "FN-DEL", column: "todo" }); - const live = createMockTask({ id: "FN-LIVE", column: "in-progress" }); - const dependent = createMockTask({ - id: "FN-DEP", - column: "todo", - status: "queued", - blockedBy: "FN-DEL", - dependencies: ["FN-DEL", "FN-LIVE"], - }); - const tasks = [dependent, live]; - const listTasks = vi.fn(async (options?: { column?: string; includeArchived?: boolean }) => { - if (options?.column === "todo") return tasks.filter((task) => task.column === "todo"); - if (options?.column === "in-progress") return tasks.filter((task) => task.column === "in-progress"); - return tasks; - }); - - const store = createMockStore({ - listTasks, - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, globalPause: false, enginePaused: false }), - }); - - new Scheduler(store); - const deletedHandler = (store.on as any).mock.calls.find((call: any) => call[0] === "task:deleted")?.[1]; - deletedHandler(deleted); - await flushAsyncWork(); - - expect(store.updateTask).toHaveBeenCalledWith("FN-DEP", { blockedBy: "FN-LIVE", status: "queued" }); - expect(store.logEntry).toHaveBeenCalledWith( - "FN-DEP", - "Auto-reblocked (FN-5496): unresolved dependency FN-LIVE remains after blocker FN-DEL was soft-deleted", - ); - }); - - it("FN-5496: task:deleted reconciliation is skipped when engine is paused", async () => { - const deleted = createMockTask({ id: "FN-DEL", column: "todo" }); - const dependent = createMockTask({ id: "FN-DEP", column: "todo", blockedBy: "FN-DEL", dependencies: ["FN-DEL"] }); - const tasks = [dependent]; - const listTasks = vi.fn(async (options?: { column?: string; includeArchived?: boolean }) => { - if (options?.column === "todo") return tasks.filter((task) => task.column === "todo"); - if (options?.column === "in-progress") return tasks.filter((task) => task.column === "in-progress"); - return tasks; - }); - - const store = createMockStore({ - listTasks, - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, globalPause: false, enginePaused: true }), - }); - - new Scheduler(store); - const deletedHandler = (store.on as any).mock.calls.find((call: any) => call[0] === "task:deleted")?.[1]; - deletedHandler(deleted); - await flushAsyncWork(); - - expect(store.updateTask).not.toHaveBeenCalled(); - expect(store.logEntry).not.toHaveBeenCalled(); - }); - - it("triggers scheduling immediately when task:moved to done event fires", async () => { - // Mock filesystem validation so schedule() can proceed - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - // Initially return only FN-001 in-progress so start() doesn't schedule FN-002 - const listTasksMock = vi.fn() - .mockResolvedValueOnce([ - createMockTask({ id: "FN-001", column: "in-progress", dependencies: [] }), - createMockTask({ id: "FN-002", column: "todo", dependencies: ["FN-001"] }), - ]) - // After event fires, FN-001 is done so FN-002's deps are satisfied - .mockResolvedValue([ - createMockTask({ id: "FN-001", column: "done", dependencies: [] }), - createMockTask({ id: "FN-002", column: "todo", dependencies: ["FN-001"] }), - ]); - - const store = createMockStore({ - listTasks: listTasksMock, - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - updateTask: vi.fn().mockResolvedValue(undefined), - moveTask: vi.fn().mockResolvedValue(undefined), - }); - - const scheduler = new Scheduler(store); - scheduler.start(); - - // Wait for initial schedule pass to complete - await flushAsyncWork(); - - // Find and call the task:moved handler - const onCalls = (store.on as any).mock.calls; - const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; - expect(movedHandler).toBeDefined(); - - // Simulate task:moved to done event - const doneTask = createMockTask({ id: "FN-001", column: "in-progress" }); - await movedHandler({ task: doneTask, from: "in-progress", to: "done" }); - - // Wait for async schedule to complete - await flushAsyncWork(); - - // Verify schedule() was called - FN-002 should now be able to start - expect(store.moveTask).toHaveBeenCalledWith("FN-002", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) })); - }); - - it.each(["done", "archived"] as const)("FN-3895: clears blockedBy when blocker moves to %s", async (to) => { - const dependent = createMockTask({ id: "FN-3799", column: "todo", blockedBy: "FN-3885" }); - const blocker = createMockTask({ id: "FN-3885", column: to }); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([dependent]), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - updateTask: vi.fn().mockResolvedValue(undefined), - }); - - new Scheduler(store); - const onCalls = (store.on as any).mock.calls; - const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; - - await movedHandler({ task: blocker, from: "in-review", to }); - - expect(store.updateTask).toHaveBeenCalledWith("FN-3799", { blockedBy: null, status: null }); - expect(store.logEntry).toHaveBeenCalledWith( - "FN-3799", - `Auto-unblocked: blocker FN-3885 reached ${to}`, - ); - }); - - it("FN-3895: does not clear blockedBy for non-terminal transitions", async () => { - const dependent = createMockTask({ id: "FN-3799", column: "todo", blockedBy: "FN-3885" }); - const blocker = createMockTask({ id: "FN-3885", column: "in-review" }); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([dependent]), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - }); - - new Scheduler(store); - const movedHandler = (store.on as any).mock.calls.find((call: any) => call[0] === "task:moved")?.[1]; - await movedHandler({ task: blocker, from: "in-progress", to: "in-review" }); - - expect(store.updateTask).not.toHaveBeenCalledWith("FN-3799", { blockedBy: null, status: null }); - }); - - it("FN-3895: does not clear blockedBy for tasks blocked by a different task", async () => { - const dependent = createMockTask({ id: "FN-3799", column: "todo", blockedBy: "FN-4000" }); - const blocker = createMockTask({ id: "FN-3885", column: "done" }); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([dependent]), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - }); - - new Scheduler(store); - const movedHandler = (store.on as any).mock.calls.find((call: any) => call[0] === "task:moved")?.[1]; - await movedHandler({ task: blocker, from: "in-review", to: "done" }); - - expect(store.updateTask).not.toHaveBeenCalledWith("FN-3799", { blockedBy: null, status: null }); - }); - - it("FN-3895: skips event-driven unblock when enginePaused is true", async () => { - const dependent = createMockTask({ id: "FN-3799", column: "todo", blockedBy: "FN-3885" }); - const blocker = createMockTask({ id: "FN-3885", column: "done" }); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([dependent]), - getSettings: vi.fn().mockResolvedValue({ enginePaused: true, globalPause: false }), - }); - - new Scheduler(store); - const movedHandler = (store.on as any).mock.calls.find((call: any) => call[0] === "task:moved")?.[1]; - await movedHandler({ task: blocker, from: "in-review", to: "done" }); - - expect(store.updateTask).not.toHaveBeenCalledWith("FN-3799", { blockedBy: null, status: null }); - }); - - it("FN-3895: unblocks FN-3799 and FN-3811 once FN-3885 reaches done", async () => { - const dependentA = createMockTask({ id: "FN-3799", column: "todo", blockedBy: "FN-3885" }); - const dependentB = createMockTask({ id: "FN-3811", column: "todo", blockedBy: "FN-3885" }); - const blocker = createMockTask({ id: "FN-3885", column: "done" }); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([dependentA, dependentB]), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - }); - - new Scheduler(store); - const movedHandler = (store.on as any).mock.calls.find((call: any) => call[0] === "task:moved")?.[1]; - await movedHandler({ task: blocker, from: "in-review", to: "done" }); - - expect(store.updateTask).toHaveBeenCalledWith("FN-3799", { blockedBy: null, status: null }); - expect(store.updateTask).toHaveBeenCalledWith("FN-3811", { blockedBy: null, status: null }); - }); - - it("FN-3908: unblocks queued multi-dependency task when moved blocker archives and remaining deps are satisfied", async () => { - const dependent = createMockTask({ - id: "FN-3170", - column: "todo", - status: "queued", - blockedBy: undefined, - dependencies: ["FN-3168", "FN-3169"], - }); - const blockerA = createMockTask({ id: "FN-3168", column: "archived" }); - const blockerB = createMockTask({ id: "FN-3169", column: "done" }); - const allTasks = [dependent, blockerA, blockerB]; - const store = createMockStore({ - listTasks: vi.fn(async (options?: { column?: string }) => - options?.column === "todo" ? [dependent] : allTasks, - ), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - }); - - new Scheduler(store); - const movedHandler = (store.on as any).mock.calls.find((call: any) => call[0] === "task:moved")?.[1]; - await movedHandler({ task: blockerA, from: "in-review", to: "archived" }); - - expect(store.updateTask).toHaveBeenCalledWith("FN-3170", { blockedBy: null, status: null }); - expect(store.logEntry).toHaveBeenCalledWith( - "FN-3170", - "Auto-unblocked: blocker FN-3168 reached archived — all dependencies satisfied", - ); - }); - - it("FN-3908: repoints blockedBy when moved blocker is done but another dependency remains unresolved", async () => { - const dependent = createMockTask({ - id: "FN-3170", - column: "todo", - status: "queued", - blockedBy: "FN-3168", - dependencies: ["FN-3168", "FN-3169"], - }); - const blockerA = createMockTask({ id: "FN-3168", column: "done" }); - const blockerB = createMockTask({ id: "FN-3169", column: "in-progress" }); - const allTasks = [dependent, blockerA, blockerB]; - const store = createMockStore({ - listTasks: vi.fn(async (options?: { column?: string }) => - options?.column === "todo" ? [dependent] : allTasks, - ), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - }); - - new Scheduler(store); - const movedHandler = (store.on as any).mock.calls.find((call: any) => call[0] === "task:moved")?.[1]; - await movedHandler({ task: blockerA, from: "in-progress", to: "done" }); - - expect(store.updateTask).toHaveBeenCalledWith("FN-3170", { status: "queued", blockedBy: "FN-3169" }); - expect(store.updateTask).not.toHaveBeenCalledWith("FN-3170", { blockedBy: null, status: null }); - }); - - it.each([ - { globalPause: true, enginePaused: false }, - { globalPause: false, enginePaused: true }, - ])("FN-3908: skips event-driven dependency reconciliation when pauses are active", async (settings) => { - const dependent = createMockTask({ - id: "FN-3170", - column: "todo", - status: "queued", - blockedBy: undefined, - dependencies: ["FN-3168"], - }); - const blocker = createMockTask({ id: "FN-3168", column: "archived" }); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([dependent, blocker]), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, ...settings }), - }); - - new Scheduler(store); - const movedHandler = (store.on as any).mock.calls.find((call: any) => call[0] === "task:moved")?.[1]; - await movedHandler({ task: blocker, from: "in-review", to: "archived" }); - - expect(store.updateTask).not.toHaveBeenCalledWith("FN-3170", { blockedBy: null, status: null }); - }); - - it("FN-3924: does not repoint cleared dependency blocker to unrelated overlap task", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const dep = createMockTask({ id: "FN-DEP", column: "done" }); - const unrelated = createMockTask({ - id: "FN-3170", - column: "in-review", - worktree: "/test/project/.worktrees/fn-3170", - }); - const dependent = createMockTask({ - id: "FN-3919", - column: "todo", - status: "queued", - blockedBy: "FN-DEP", - dependencies: ["FN-DEP"], - }); - const tasks = [dep, unrelated, dependent]; - - const listTasks = vi.fn(async (options?: { column?: string; includeArchived?: boolean }) => { - if (options?.column === "todo") { - return tasks.filter((task) => task.column === "todo"); - } - return tasks; - }); - - const updateTask = vi.fn(async (id: string, patch: Partial) => { - const task = tasks.find((candidate) => candidate.id === id); - if (task) Object.assign(task, patch); - return (task ?? createMockTask({ id })) as Task; - }); - - const store = createMockStore({ - listTasks, - getTask: vi.fn(async (id: string) => (tasks.find((task) => task.id === id) ?? createMockTask({ id })) as any), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - groupOverlappingFiles: true, - }), - parseFileScopeFromPrompt: vi.fn(async (taskId: string): Promise => { - if (taskId === "FN-3170") return ["packages/dashboard/app/App.tsx"]; - if (taskId === "FN-3919") return ["packages/dashboard/app/App.tsx"]; - return ["packages/core/src/index.ts"]; - }), - updateTask, - moveTask: vi.fn().mockResolvedValue(undefined), - }); - - const scheduler = new Scheduler(store); - const movedHandler = (store.on as any).mock.calls.find((call: any) => call[0] === "task:moved")?.[1]; - await movedHandler({ task: dep, from: "in-progress", to: "done" }); - - expect(updateTask).toHaveBeenCalledWith("FN-3919", { blockedBy: null, status: null }); - - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(updateTask).not.toHaveBeenCalledWith("FN-3919", { status: "queued", blockedBy: "FN-3170" }); - expect(tasks.find((task) => task.id === "FN-3919")?.blockedBy ?? null).toBeNull(); - }); - - it("does not trigger scheduling for non-done task:moved events", async () => { - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([ - createMockTask({ id: "FN-001", column: "todo", dependencies: [] }), - ]), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - updateTask: vi.fn().mockResolvedValue(undefined), - moveTask: vi.fn().mockResolvedValue(undefined), - }); - - const scheduler = new Scheduler(store); - scheduler.start(); - - // Clear previous calls - (store.moveTask as any).mockClear(); - - // Find and call the task:moved handler - const onCalls = (store.on as any).mock.calls; - const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; - - // Simulate task:moved to in-progress (not done) - const task = createMockTask({ id: "FN-001", column: "in-progress" }); - await movedHandler({ task, from: "todo", to: "in-progress" }); - - // Should NOT have triggered additional scheduling (no new task moved to in-progress) - // Note: The existing handler runs, but it doesn't call schedule() for non-done transitions - // So moveTask won't be called for a task already in in-progress - expect(store.moveTask).not.toHaveBeenCalled(); - }); - - it("triggers scheduling when task moves to todo (retry)", async () => { - // Mock filesystem validation so schedule() can proceed - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - // Return FN-001 in todo with satisfied deps - const listTasksMock = vi.fn() - .mockResolvedValueOnce([]) // Initial schedule from start() - .mockResolvedValueOnce([ - createMockTask({ id: "FN-001", column: "todo", dependencies: [] }), - ]); - - const store = createMockStore({ - listTasks: listTasksMock, - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - updateTask: vi.fn().mockResolvedValue(undefined), - moveTask: vi.fn().mockResolvedValue(undefined), - }); - - const scheduler = new Scheduler(store); - scheduler.start(); - - // Wait for initial schedule pass to complete - await flushAsyncWork(); - - // Find and call the task:moved handler - const onCalls = (store.on as any).mock.calls; - const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; - expect(movedHandler).toBeDefined(); - - // Simulate task:moved to todo (retry scenario) - const todoTask = createMockTask({ id: "FN-001", column: "in-progress" }); - await movedHandler({ task: todoTask, from: "in-progress", to: "todo" }); - - // Wait for async schedule to complete - await flushAsyncWork(); - - // Verify schedule() was called — task in todo should be scheduled - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) })); - }); - }); - - describe("task unpause scheduling", () => { - it("triggers scheduling immediately when a paused todo task is unpaused", async () => { - // Mock filesystem validation so schedule() can proceed - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const listTasksMock = vi.fn() - .mockResolvedValueOnce([]) // Initial schedule from start() - .mockResolvedValueOnce([ - createMockTask({ id: "FN-001", column: "todo", dependencies: [] }), - ]); - - const store = createMockStore({ - listTasks: listTasksMock, - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - updateTask: vi.fn().mockResolvedValue(undefined), - moveTask: vi.fn().mockResolvedValue(undefined), - }); - - const scheduler = new Scheduler(store); - scheduler.start(); - - // Wait for initial schedule pass to complete - await flushAsyncWork(); - - // Find the task:updated handler - const onCalls = (store.on as any).mock.calls; - const updatedHandler = onCalls.find((call: any) => call[0] === "task:updated")?.[1]; - expect(updatedHandler).toBeDefined(); - - // First, simulate pause event (to register the task as paused) - await updatedHandler(createMockTask({ id: "FN-001", column: "todo", paused: true })); - - // Now simulate unpause event - await updatedHandler(createMockTask({ id: "FN-001", column: "todo", paused: undefined })); - - // Wait for async scheduling to complete - await flushAsyncWork(); - - // Should have triggered scheduling and moved the task to in-progress - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) })); - }); - - it("does not trigger scheduling on unpause if scheduler is not running", async () => { - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([]), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - }); - - const scheduler = new Scheduler(store); - // Don't start the scheduler - - const onCalls = (store.on as any).mock.calls; - const updatedHandler = onCalls.find((call: any) => call[0] === "task:updated")?.[1]; - - // Pause then unpause - await updatedHandler(createMockTask({ id: "FN-001", column: "todo", paused: true })); - await updatedHandler(createMockTask({ id: "FN-001", column: "todo", paused: undefined })); - - // Should NOT have moved any tasks - expect(store.moveTask).not.toHaveBeenCalled(); - }); - - it("does not trigger scheduling for tasks that were never paused", async () => { - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([ - createMockTask({ id: "FN-001", column: "todo", dependencies: [] }), - ]), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - updateTask: vi.fn().mockResolvedValue(undefined), - moveTask: vi.fn().mockResolvedValue(undefined), - }); - - const scheduler = new Scheduler(store); - scheduler.start(); - await flushAsyncWork(); - - // Clear calls from initial schedule - (store.moveTask as any).mockClear(); - - const onCalls = (store.on as any).mock.calls; - const updatedHandler = onCalls.find((call: any) => call[0] === "task:updated")?.[1]; - - // Fire task:updated for a task that was never paused — should NOT trigger extra scheduling - await updatedHandler(createMockTask({ id: "FN-001", column: "todo", paused: undefined })); - - await flushAsyncWork(); - - // moveTask should not be called (no scheduling triggered) - expect(store.moveTask).not.toHaveBeenCalled(); - }); - - it("does not trigger scheduling on unpause for in-progress tasks", async () => { - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([]), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - moveTask: vi.fn().mockResolvedValue(undefined), - }); - - const scheduler = new Scheduler(store); - scheduler.start(); - await flushAsyncWork(); - - (store.moveTask as any).mockClear(); - - const onCalls = (store.on as any).mock.calls; - const updatedHandler = onCalls.find((call: any) => call[0] === "task:updated")?.[1]; - - // Pause then unpause an in-progress task — executor handles this, not scheduler - await updatedHandler(createMockTask({ id: "FN-001", column: "in-progress", paused: true })); - await updatedHandler(createMockTask({ id: "FN-001", column: "in-progress", paused: undefined })); - - await flushAsyncWork(); - - // Scheduler should NOT try to schedule an in-progress task - expect(store.moveTask).not.toHaveBeenCalled(); - }); - - it("triggers scheduling for unpaused triage tasks", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const scheduleSpy = vi.spyOn(Scheduler.prototype, "schedule"); - - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([]), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - }); - - const scheduler = new Scheduler(store); - scheduler.start(); - await flushAsyncWork(); - - // Clear calls from initial start() schedule - scheduleSpy.mockClear(); - - const onCalls = (store.on as any).mock.calls; - const updatedHandler = onCalls.find((call: any) => call[0] === "task:updated")?.[1]; - - // Pause then unpause a triage task - await updatedHandler(createMockTask({ id: "FN-001", column: "triage", paused: true })); - await updatedHandler(createMockTask({ id: "FN-001", column: "triage", paused: undefined })); - - // schedule() should have been triggered by the unpause - expect(scheduleSpy).toHaveBeenCalled(); - - scheduleSpy.mockRestore(); - }); - }); - - describe("start/stop", () => { - it("starts and stops the scheduler", () => { - const store = createMockStore(); - const scheduler = new Scheduler(store); - - scheduler.start(); - // Should set up polling interval - - scheduler.stop(); - // Should clear polling interval - }); - }); - - describe("schedule() concurrency limits", () => { - it("respects maxConcurrent limit", async () => { - const tasks = [ - createMockTask({ id: "FN-001", column: "in-progress" }), - createMockTask({ id: "FN-002", column: "in-progress" }), - createMockTask({ id: "FN-003", column: "todo" }), - createMockTask({ id: "FN-004", column: "todo" }), - ]; - - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - updateTask: vi.fn().mockResolvedValue(undefined), - moveTask: vi.fn().mockResolvedValue(undefined), - }); - - const scheduler = new Scheduler(store); - scheduler.start(); - await scheduler.schedule(); - - // With 2 already in-progress and maxConcurrent=2, no new tasks should start - expect(store.moveTask).not.toHaveBeenCalled(); - }); - - it("caps in-progress dispatch by the global semaphore limit even before executors acquire slots", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const semaphore = new AgentSemaphore(3); - const tasks = [ - createMockTask({ id: "FN-001", column: "in-progress" }), - createMockTask({ id: "FN-002", column: "in-progress" }), - createMockTask({ id: "FN-003", column: "in-progress" }), - createMockTask({ id: "FN-004", column: "todo", dependencies: [] }), - createMockTask({ id: "FN-005", column: "todo", dependencies: [] }), - ]; - - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getTask: vi.fn(async (taskId: string) => tasks.find((task) => task.id === taskId) ?? null), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 5, maxWorktrees: 10 }), - updateTask: vi.fn().mockResolvedValue(undefined), - moveTask: vi.fn().mockResolvedValue(undefined), - }); - - const scheduler = new Scheduler(store, { semaphore }); - scheduler.start(); - await scheduler.schedule(); - - expect(store.moveTask).not.toHaveBeenCalled(); - }); - - it("queues capacity-exhausted dispatches and continues to later candidates", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-001", column: "todo", dependencies: [] }), - createMockTask({ id: "FN-002", column: "todo", dependencies: [] }), - ]; - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getTask: vi.fn(async (taskId: string) => tasks.find((task) => task.id === taskId) ?? null), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 5, maxWorktrees: 10 }), - moveTask: vi.fn().mockRejectedValue( - new TransitionRejectionError( - makeTransitionRejection("capacity-exhausted", "transition.rejected.capacityExhausted", true), - "queued — in-progress column at capacity", - ), - ), - }); - - const scheduler = new Scheduler(store); - (scheduler as unknown as { running: boolean }).running = true; - await scheduler.schedule(); - - expect(store.moveTask).toHaveBeenCalledTimes(2); - expect(vi.mocked(store.updateTask).mock.calls.filter((call) => call[1]?.status === "queued").map((call) => call[0])).toEqual([ - "FN-001", - "FN-002", - ]); - expect(store.logEntry).toHaveBeenCalledWith("FN-001", expect.stringContaining("queued — in-progress column at capacity")); - }); - - it("respects maxWorktrees limit", async () => { - const tasks = [ - createMockTask({ id: "FN-001", column: "in-progress" }), - createMockTask({ id: "FN-002", column: "in-progress" }), - createMockTask({ id: "FN-003", column: "in-progress" }), - createMockTask({ id: "FN-004", column: "in-progress" }), - createMockTask({ id: "FN-005", column: "todo" }), - ]; - - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, maxWorktrees: 4 }), - }); - - const scheduler = new Scheduler(store); - scheduler.start(); - await scheduler.schedule(); - - // With 4 in-progress and maxWorktrees=4, no new tasks should start - expect(store.moveTask).not.toHaveBeenCalled(); - }); - - it("FN-3908: logs queued concurrency reason once per unchanged state", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-001", column: "todo", dependencies: [] }), - createMockTask({ id: "FN-002", column: "todo", dependencies: [] }), - ]; - - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 1, maxWorktrees: 4 }), - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - await scheduler.schedule(); - - const concurrencyReasonCalls = (store.logEntry as ReturnType).mock.calls.filter( - (call: unknown[]) => call[0] === "FN-002" && String(call[1]).includes("queued — concurrency limit reached"), - ); - expect(concurrencyReasonCalls).toHaveLength(1); - }); - }); - - describe("FN-5008: concurrency-gate attribution", () => { - it("logs maxConcurrent as the sole binding gate", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-A", column: "in-progress" }), - createMockTask({ id: "FN-B", column: "todo", dependencies: [] }), - createMockTask({ id: "FN-C", column: "todo", dependencies: [] }), - ]; - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - const call = (store.logEntry as ReturnType).mock.calls.find((c: unknown[]) => c[0] === "FN-C"); - expect(String(call?.[1])).toContain("gate=maxConcurrent"); - expect(String(call?.[1])).toContain("maxConcurrent used=2/2"); - expect(String(call?.[1])).toContain("holders: FN-A"); - }); - - it("logs maxWorktrees as the sole binding gate", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-A", column: "in-progress" }), - createMockTask({ id: "FN-B", column: "in-progress" }), - createMockTask({ id: "FN-C", column: "todo", dependencies: [] }), - createMockTask({ id: "FN-D", column: "todo", dependencies: [] }), - ]; - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, maxWorktrees: 3 }), - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - const call = (store.logEntry as ReturnType).mock.calls.find((c: unknown[]) => c[0] === "FN-D"); - expect(String(call?.[1])).toContain("gate=maxWorktrees"); - expect(String(call?.[1])).toContain("maxWorktrees used=3/3"); - expect(String(call?.[1])).toContain("holders: FN-A, FN-B"); - }); - - it("logs semaphore as the sole binding gate", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const semaphore = new AgentSemaphore(1); - const tasks = [ - createMockTask({ id: "FN-A", column: "todo", dependencies: [] }), - createMockTask({ id: "FN-B", column: "todo", dependencies: [] }), - ]; - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, maxWorktrees: 10 }), - }); - - const scheduler = new Scheduler(store, { semaphore }); - (scheduler as any).running = true; - await scheduler.schedule(); - - const call = (store.logEntry as ReturnType).mock.calls.find((c: unknown[]) => c[0] === "FN-B"); - expect(String(call?.[1])).toContain("gate=semaphore"); - expect(String(call?.[1])).toContain("semaphore used=1/1"); - expect(String(call?.[1])).toContain("semaphore slots may include triage/merge agents outside in-progress"); - }); - - it.each([false, true])("FN-6423: logs queue-point capacity without negative semaphore usage (workflowColumns=%s)", async (workflowColumns) => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const semaphore = new AgentSemaphore(3); - (semaphore as any)._active = -9; - const tasks = [ - createMockTask({ id: "FN-6412", column: "in-progress" }), - createMockTask({ id: "FN-B", column: "todo", dependencies: [] }), - createMockTask({ id: "FN-C", column: "todo", dependencies: [] }), - createMockTask({ id: "FN-D", column: "todo", dependencies: [] }), - ]; - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 15, - maxWorktrees: 3, - experimentalFeatures: { workflowColumns }, - }), - }); - - const scheduler = new Scheduler(store, { semaphore }); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(store.moveTask).toHaveBeenCalledWith( - "FN-B", - "in-progress", - expect.objectContaining({ moveSource: "scheduler" }), - ); - expect(store.moveTask).toHaveBeenCalledWith( - "FN-C", - "in-progress", - expect.objectContaining({ moveSource: "scheduler" }), - ); - const call = (store.logEntry as ReturnType).mock.calls.find((c: unknown[]) => c[0] === "FN-D"); - const reason = String(call?.[1]); - expect(reason).toContain("queued — concurrency limit reached"); - expect(reason).not.toMatch(/semaphore used=-/); - expect(reason).not.toContain("maxWorktrees used=1/3"); - expect(reason).toContain("maxWorktrees used=3/3"); - expect(reason).toContain("semaphore used=3/3"); - const gateLabel = reason.match(/gate=([^;]+)/)?.[1] ?? ""; - for (const gate of gateLabel.split(", ").filter(Boolean)) { - const usedLimit = reason.match(new RegExp(`${gate} used=(\\d+)/(\\d+)`)); - expect(usedLimit, `${gate} must have used/limit details`).not.toBeNull(); - expect(Number(usedLimit?.[1])).toBeGreaterThanOrEqual(Number(usedLimit?.[2])); - } - }); - - it("FN-6423: dispatches ready tasks while maxWorktrees still has slack", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-A", column: "in-progress" }), - createMockTask({ id: "FN-B", column: "todo", dependencies: [] }), - ]; - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 15, maxWorktrees: 3 }), - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(store.moveTask).toHaveBeenCalledWith( - "FN-B", - "in-progress", - expect.objectContaining({ moveSource: "scheduler" }), - ); - const concurrencyReasonCalls = (store.logEntry as ReturnType).mock.calls.filter( - (call: unknown[]) => call[0] === "FN-B" && String(call[1]).includes("queued — concurrency limit reached"), - ); - expect(concurrencyReasonCalls).toHaveLength(0); - }); - - it("FN-6423: preserves legitimate maxWorktrees queueing at the true limit", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-A", column: "in-progress" }), - createMockTask({ id: "FN-B", column: "todo", dependencies: [] }), - createMockTask({ id: "FN-C", column: "todo", dependencies: [] }), - ]; - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 15, maxWorktrees: 2 }), - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(store.moveTask).toHaveBeenCalledWith( - "FN-B", - "in-progress", - expect.objectContaining({ moveSource: "scheduler" }), - ); - const call = (store.logEntry as ReturnType).mock.calls.find((c: unknown[]) => c[0] === "FN-C"); - const reason = String(call?.[1]); - expect(reason).toContain("gate=maxWorktrees"); - expect(reason).toContain("maxWorktrees used=2/2"); - expect(formatConcurrencyLimitMemoKey({ - available: 0, - bindingGates: ["maxWorktrees"], - maxConcurrentGate: { used: 2, limit: 15, slack: 13 }, - maxWorktreesGate: { used: 2, limit: 2, slack: 0 }, - holders: { maxConcurrent: ["FN-A"], maxWorktrees: ["FN-A"] }, - })).toBe("queued-concurrency:maxWorktrees"); - }); - - it("recovers an idle leaked semaphore slot before dispatching", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const semaphore = new AgentSemaphore(1); - await semaphore.acquire(); - const task = createMockTask({ id: "FN-A", column: "todo", dependencies: [] }); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([task]), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, maxWorktrees: 10 }), - }); - - const scheduler = new Scheduler(store, { semaphore }); - (scheduler as any).running = true; - (scheduler as any).idleSemaphoreLeakCandidateSince = Date.now() - 6_000; - await scheduler.schedule(); - - expect(semaphore.activeCount).toBe(0); - expect(schedulerLog.warn).toHaveBeenCalledWith( - expect.stringContaining("scheduler: recovered stale semaphore active count 1 -> 0"), - ); - expect(store.moveTask).toHaveBeenCalledWith( - "FN-A", - "in-progress", - expect.objectContaining({ moveSource: "scheduler" }), - ); - }); - - it("lists tied binding gates in stable order", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const semaphore = new AgentSemaphore(2); - const tasks = [ - createMockTask({ id: "FN-A", column: "in-progress" }), - createMockTask({ id: "FN-B", column: "todo", dependencies: [] }), - createMockTask({ id: "FN-C", column: "todo", dependencies: [] }), - ]; - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 2 }), - }); - - const scheduler = new Scheduler(store, { semaphore }); - (scheduler as any).running = true; - await scheduler.schedule(); - - const call = (store.logEntry as ReturnType).mock.calls.find((c: unknown[]) => c[0] === "FN-C"); - expect(String(call?.[1])).toContain("gate=maxConcurrent, maxWorktrees"); - }); - - it("dedupes unchanged queued-concurrency logs and audit events", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-A", column: "in-progress" }), - createMockTask({ id: "FN-B", column: "todo", dependencies: [] }), - createMockTask({ id: "FN-C", column: "todo", dependencies: [] }), - ]; - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - await scheduler.schedule(); - - const concurrencyReasonCalls = (store.logEntry as ReturnType).mock.calls.filter( - (call: unknown[]) => call[0] === "FN-C" && String(call[1]).includes("queued — concurrency limit reached"), - ); - expect(concurrencyReasonCalls).toHaveLength(1); - }); - - it("dedupes queued-concurrency logs when only non-binding semaphore counts change", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const semaphore = new AgentSemaphore(40); - const tasks = [ - createMockTask({ id: "FN-A", column: "in-progress" }), - createMockTask({ id: "FN-B", column: "in-progress" }), - createMockTask({ id: "FN-C", column: "todo", dependencies: [] }), - createMockTask({ id: "FN-D", column: "todo", dependencies: [] }), - ]; - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 15, maxWorktrees: 3 }), - }); - - const scheduler = new Scheduler(store, { semaphore }); - (scheduler as any).running = true; - - await semaphore.acquire(); - await semaphore.acquire(); - await scheduler.schedule(); - semaphore.release(); - semaphore.release(); - await scheduler.schedule(); - - const concurrencyReasonCalls = (store.logEntry as ReturnType).mock.calls.filter( - (call: unknown[]) => call[0] === "FN-D" && String(call[1]).includes("queued — concurrency limit reached"), - ); - expect(concurrencyReasonCalls).toHaveLength(1); - }); - - it("dedupes queued-concurrency logs across used/limit churn on the same binding gate", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - let semaphoreLimit = 2; - const semaphore = new AgentSemaphore(() => semaphoreLimit); - const tasks = [ - createMockTask({ id: "FN-A", column: "in-progress" }), - createMockTask({ id: "FN-B", column: "todo", dependencies: [] }), - createMockTask({ id: "FN-C", column: "todo", dependencies: [] }), - ]; - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, maxWorktrees: 10 }), - }); - - const scheduler = new Scheduler(store, { semaphore }); - (scheduler as any).running = true; - - await semaphore.acquire(); - await scheduler.schedule(); - semaphoreLimit = 3; - await semaphore.acquire(); - await scheduler.schedule(); - - const concurrencyReasonCalls = (store.logEntry as ReturnType).mock.calls.filter( - (call: unknown[]) => call[0] === "FN-C" && String(call[1]).includes("queued — concurrency limit reached"), - ); - expect(concurrencyReasonCalls).toHaveLength(1); - expect(String(concurrencyReasonCalls[0]?.[1])).toContain("semaphore used=2/2"); - }); - - it("suppresses re-log and re-audit when only binding holder identity changes", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const firstPass = [ - createMockTask({ id: "FN-A", column: "in-progress" }), - createMockTask({ id: "FN-C", column: "todo", dependencies: [] }), - createMockTask({ id: "FN-D", column: "todo", dependencies: [] }), - ]; - const secondPass = [ - createMockTask({ id: "FN-B", column: "in-progress" }), - createMockTask({ id: "FN-C", column: "todo", dependencies: [] }), - createMockTask({ id: "FN-D", column: "todo", dependencies: [] }), - ]; - - let phase = 1; - const store = createMockStore({ - listTasks: vi.fn().mockImplementation(async () => (phase === 1 ? firstPass : secondPass)), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, maxWorktrees: 2 }), - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - phase = 2; - await scheduler.schedule(); - - const concurrencyReasonCalls = (store.logEntry as ReturnType).mock.calls.filter( - (call: unknown[]) => call[0] === "FN-D" && String(call[1]).includes("queued — concurrency limit reached"), - ); - expect(concurrencyReasonCalls).toHaveLength(1); - }); - - it("re-logs and re-audits when binding gate changes", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const firstPass = [ - createMockTask({ id: "FN-A", column: "in-progress" }), - createMockTask({ id: "FN-B", column: "todo", dependencies: [] }), - createMockTask({ id: "FN-C", column: "todo", dependencies: [] }), - ]; - const secondPass = [ - createMockTask({ id: "FN-A", column: "in-progress" }), - createMockTask({ id: "FN-B", column: "in-progress" }), - createMockTask({ id: "FN-C", column: "todo", dependencies: [] }), - createMockTask({ id: "FN-E", column: "todo", dependencies: [] }), - ]; - - let phase = 1; - const listTasks = vi.fn().mockImplementation(async () => (phase === 1 ? firstPass : secondPass)); - const getSettings = vi - .fn() - .mockImplementation(async () => (phase === 1 ? { maxConcurrent: 2, maxWorktrees: 4 } : { maxConcurrent: 10, maxWorktrees: 3 })); - - const store = createMockStore({ listTasks, getSettings }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - phase = 2; - await scheduler.schedule(); - - const concurrencyReasonCalls = (store.logEntry as ReturnType).mock.calls.filter( - (call: unknown[]) => String(call[1]).includes("queued — concurrency limit reached"), - ); - expect(concurrencyReasonCalls).toHaveLength(2); - expect(String(concurrencyReasonCalls[0]?.[1])).toContain("gate=maxConcurrent"); - expect(String(concurrencyReasonCalls[1]?.[1])).toContain("gate=maxWorktrees"); - }); - - it("formats queued-concurrency memo keys from binding gates only", () => { - const key = formatConcurrencyLimitMemoKey({ - available: 0, - bindingGates: ["maxConcurrent", "maxWorktrees"], - maxConcurrentGate: { used: 1, limit: 2, slack: 1 }, - maxWorktreesGate: { used: 9, limit: 10, slack: 1 }, - semaphoreGate: { used: 7, limit: 8, slack: 1 }, - holders: { - maxConcurrent: ["FN-B", "FN-A"], - maxWorktrees: ["FN-A", "FN-B", "FN-A"], - semaphore: ["FN-Z"], - }, - }); - - expect(key).toBe("queued-concurrency:maxConcurrent,maxWorktrees"); - expect(key).not.toMatch(/used=|limit=|available=|\d+\/\d+/); - expect(key).not.toContain("FN-A"); - expect(key).not.toContain("FN-Z"); - }); - }); - - describe("priority-aware todo dispatch", () => { - it("schedules eligible todo tasks by priority desc then createdAt asc", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-010", column: "todo", priority: "normal", createdAt: "2026-01-01T00:02:00.000Z" }), - createMockTask({ id: "FN-011", column: "todo", priority: "urgent", createdAt: "2026-01-01T00:10:00.000Z" }), - createMockTask({ id: "FN-012", column: "todo", priority: "high", createdAt: "2026-01-01T00:03:00.000Z" }), - createMockTask({ id: "FN-013", column: "todo", priority: "high", createdAt: "2026-01-01T00:01:00.000Z" }), - ]; - - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 10, - maxWorktrees: 10, - groupOverlappingFiles: false, - }), - updateTask: vi.fn().mockResolvedValue(undefined), - moveTask: vi.fn().mockResolvedValue(undefined), - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect((store.moveTask as ReturnType).mock.calls.map((call: unknown[]) => call[0])).toEqual([ - "FN-011", - "FN-013", - "FN-012", - "FN-010", - ]); - }); - - it("keeps blocked high-priority todo tasks unscheduled while scheduling ready lower-priority work", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const future = new Date(Date.now() + 60_000).toISOString(); - const tasks = [ - createMockTask({ id: "FN-001", column: "in-progress" }), - createMockTask({ id: "FN-100", column: "todo", priority: "urgent", dependencies: ["FN-900"] }), - createMockTask({ id: "FN-900", column: "todo", priority: "low" }), - createMockTask({ id: "FN-101", column: "todo", priority: "urgent", paused: true }), - createMockTask({ id: "FN-102", column: "todo", priority: "urgent", nextRecoveryAt: future }), - createMockTask({ id: "FN-103", column: "todo", priority: "urgent" }), - createMockTask({ id: "FN-104", column: "todo", priority: "normal" }), - ]; - - const parseScopeMock = vi.fn(async (taskId: string): Promise => { - if (taskId === "FN-001" || taskId === "FN-103") { - return ["packages/engine/src/scheduler.ts"]; - } - if (taskId === "FN-900") { - return ["packages/core/src/store.ts"]; - } - if (taskId === "FN-104") { - return ["packages/engine/src/triage.ts"]; - } - return ["packages/engine/src/logger.ts"]; - }); - - const updateTask = vi.fn().mockResolvedValue(undefined); - const moveTask = vi.fn().mockResolvedValue(undefined); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 10, - maxWorktrees: 10, - groupOverlappingFiles: true, - }), - parseFileScopeFromPrompt: parseScopeMock, - updateTask, - moveTask, - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - // Dependency-blocked urgent task should be queued, not started. - expect(updateTask).toHaveBeenCalledWith("FN-100", { status: "queued", blockedBy: "FN-900" }); - // Overlap-blocked urgent task should be queued with blocker id. - expect(updateTask).toHaveBeenCalledWith("FN-103", { status: "queued", blockedBy: null, overlapBlockedBy: "FN-001" }); - // Paused and recovery-gated urgent tasks never enter scheduling. - expect(moveTask).not.toHaveBeenCalledWith("FN-101", "in-progress"); - expect(moveTask).not.toHaveBeenCalledWith("FN-102", "in-progress"); - - // Lower-priority ready task still runs. - expect(moveTask).toHaveBeenCalledWith("FN-104", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) })); - // Overlap-blocked urgent task must not run. - expect(moveTask).not.toHaveBeenCalledWith("FN-103", "in-progress"); - }); - }); - - describe("FN-4969 dependency-unblock prioritization", () => { - it("promotes higher-fanout todo task over older same-priority tasks", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-010", column: "todo", priority: "normal", createdAt: "2026-01-01T00:02:00.000Z" }), - createMockTask({ id: "FN-011", column: "todo", priority: "normal", createdAt: "2026-01-01T00:01:00.000Z" }), - createMockTask({ id: "FN-012", column: "todo", priority: "normal", createdAt: "2026-01-01T00:03:00.000Z" }), - createMockTask({ id: "FN-101", column: "todo", dependencies: ["FN-010"] }), - createMockTask({ id: "FN-102", column: "todo", dependencies: ["FN-010"] }), - createMockTask({ id: "FN-103", column: "todo", dependencies: ["FN-010"] }), - ]; - - const moveTask = vi.fn().mockResolvedValue(undefined); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 1, maxWorktrees: 10, groupOverlappingFiles: false }), - moveTask, - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(moveTask.mock.calls[0][0]).toBe("FN-010"); - }); - - it("keeps urgent tasks ahead of lower-priority high-fanout tasks", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-020", column: "todo", priority: "urgent", createdAt: "2026-01-01T00:02:00.000Z" }), - createMockTask({ id: "FN-021", column: "todo", priority: "normal", createdAt: "2026-01-01T00:01:00.000Z" }), - createMockTask({ id: "FN-201", column: "todo", dependencies: ["FN-021"] }), - createMockTask({ id: "FN-202", column: "todo", dependencies: ["FN-021"] }), - createMockTask({ id: "FN-203", column: "todo", dependencies: ["FN-021"] }), - createMockTask({ id: "FN-204", column: "todo", dependencies: ["FN-021"] }), - createMockTask({ id: "FN-205", column: "todo", dependencies: ["FN-021"] }), - ]; - - const moveTask = vi.fn().mockResolvedValue(undefined); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 1, maxWorktrees: 10, groupOverlappingFiles: false }), - moveTask, - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(moveTask.mock.calls[0][0]).toBe("FN-020"); - }); - - it("falls back to age then numeric id when fanout ties", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-029", column: "todo", priority: "normal", createdAt: "2026-01-01T00:01:00.000Z" }), - createMockTask({ id: "FN-028", column: "todo", priority: "normal", createdAt: "2026-01-01T00:01:00.000Z" }), - createMockTask({ id: "FN-027", column: "todo", priority: "normal", createdAt: "2026-01-01T00:00:00.000Z" }), - createMockTask({ id: "FN-301", column: "todo", dependencies: ["FN-028"] }), - createMockTask({ id: "FN-302", column: "todo", dependencies: ["FN-029"] }), - createMockTask({ id: "FN-303", column: "todo", dependencies: ["FN-027"] }), - ]; - - const moveTask = vi.fn().mockResolvedValue(undefined); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 3, maxWorktrees: 10, groupOverlappingFiles: false }), - moveTask, - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(moveTask.mock.calls.slice(0, 3).map((call: unknown[]) => call[0])).toEqual(["FN-027", "FN-028", "FN-029"]); - }); - - it("does not count done/archived dependents toward unblock priority", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-040", column: "todo", priority: "normal", createdAt: "2026-01-01T00:00:00.000Z" }), - createMockTask({ id: "FN-041", column: "todo", priority: "normal", createdAt: "2026-01-01T00:01:00.000Z" }), - createMockTask({ id: "FN-401", column: "done", dependencies: ["FN-040"] }), - createMockTask({ id: "FN-402", column: "archived", dependencies: ["FN-040"] }), - ]; - - const moveTask = vi.fn().mockResolvedValue(undefined); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 10, groupOverlappingFiles: false }), - moveTask, - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(moveTask.mock.calls.slice(0, 2).map((call: unknown[]) => call[0])).toEqual(["FN-040", "FN-041"]); - }); - }); - - describe("overlap ignore paths", () => { - it("allows scheduling when overlap is only on ignored files", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-001", column: "in-progress" }), - createMockTask({ id: "FN-002", column: "todo" }), - ]; - - const parseScopeMock = vi.fn(async (taskId: string): Promise => { - if (taskId === "FN-001") return ["docs/README.md"]; - if (taskId === "FN-002") return ["docs/README.md"]; - return []; - }); - - const updateTask = vi.fn().mockResolvedValue(undefined); - const moveTask = vi.fn().mockResolvedValue(undefined); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - groupOverlappingFiles: true, - overlapIgnorePaths: ["docs/README.md"], - }), - parseFileScopeFromPrompt: parseScopeMock, - updateTask, - moveTask, - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(moveTask).toHaveBeenCalledWith("FN-002", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) })); - expect(updateTask).not.toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-001" }); - }); - - it("allows scheduling when overlap is only within ignored directories", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-001", column: "in-review", worktree: "/test/project/.worktrees/fn-001" }), - createMockTask({ id: "FN-002", column: "todo" }), - ]; - - const parseScopeMock = vi.fn(async (taskId: string): Promise => { - if (taskId === "FN-001") return ["docs/guide.md"]; - if (taskId === "FN-002") return ["docs/reference.md"]; - return []; - }); - - const updateTask = vi.fn().mockResolvedValue(undefined); - const moveTask = vi.fn().mockResolvedValue(undefined); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - groupOverlappingFiles: true, - overlapIgnorePaths: ["docs/"], - }), - parseFileScopeFromPrompt: parseScopeMock, - updateTask, - moveTask, - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(moveTask).toHaveBeenCalledWith("FN-002", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) })); - expect(updateTask).not.toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-001" }); - }); - - it("excludes paused in-review tasks from active scopes", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-001", column: "in-review", paused: true, worktree: "/test/project/.worktrees/fn-001" }), - createMockTask({ id: "FN-002", column: "todo" }), - ]; - - const parseScopeMock = vi.fn(async (taskId: string): Promise => { - if (taskId === "FN-001") return ["src/foo.ts"]; - if (taskId === "FN-002") return ["src/foo.ts"]; - return []; - }); - - const updateTask = vi.fn().mockResolvedValue(undefined); - const moveTask = vi.fn().mockResolvedValue(undefined); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - groupOverlappingFiles: true, - }), - parseFileScopeFromPrompt: parseScopeMock, - updateTask, - moveTask, - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(moveTask).toHaveBeenCalledWith("FN-002", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) })); - expect(updateTask).not.toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-001" }); - }); - - it("excludes permanently-failed in-review tasks from active scopes", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-001", column: "in-review", status: "failed", worktree: "/test/project/.worktrees/fn-001" }), - createMockTask({ id: "FN-002", column: "todo" }), - ]; - - const parseScopeMock = vi.fn(async (taskId: string): Promise => { - if (taskId === "FN-001") return ["src/foo.ts"]; - if (taskId === "FN-002") return ["src/foo.ts"]; - return []; - }); - - const updateTask = vi.fn().mockResolvedValue(undefined); - const moveTask = vi.fn().mockResolvedValue(undefined); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - groupOverlappingFiles: true, - }), - parseFileScopeFromPrompt: parseScopeMock, - updateTask, - moveTask, - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(moveTask).toHaveBeenCalledWith("FN-002", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) })); - expect(updateTask).not.toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-001" }); - }); - - it("clears stale blockedBy when prior overlap blocker is now permanently failed", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-FAIL", column: "in-review", status: "failed", worktree: "/test/project/.worktrees/fn-fail" }), - createMockTask({ id: "FN-002", column: "todo", status: "queued", blockedBy: "FN-FAIL" }), - ]; - - const parseScopeMock = vi.fn(async (taskId: string): Promise => { - if (taskId === "FN-FAIL") return ["src/foo.ts"]; - if (taskId === "FN-002") return ["src/foo.ts"]; - return []; - }); - - const updateTask = vi.fn().mockResolvedValue(undefined); - const moveTask = vi.fn().mockResolvedValue(undefined); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - groupOverlappingFiles: true, - }), - parseFileScopeFromPrompt: parseScopeMock, - updateTask, - moveTask, - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(moveTask).toHaveBeenCalledWith("FN-002", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) })); - expect(updateTask).not.toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-FAIL" }); - }); - - it("still blocks todo when overlapping in-review task is not paused and not failed", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-001", column: "in-review", worktree: "/test/project/.worktrees/fn-001" }), - createMockTask({ id: "FN-002", column: "todo" }), - ]; - - const parseScopeMock = vi.fn(async (taskId: string): Promise => { - if (taskId === "FN-001") return ["src/foo.ts"]; - if (taskId === "FN-002") return ["src/foo.ts"]; - return []; - }); - - const updateTask = vi.fn().mockResolvedValue(undefined); - const moveTask = vi.fn().mockResolvedValue(undefined); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - groupOverlappingFiles: true, - }), - parseFileScopeFromPrompt: parseScopeMock, - updateTask, - moveTask, - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(updateTask).toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: null, overlapBlockedBy: "FN-001" }); - expect(moveTask).not.toHaveBeenCalledWith("FN-002", "in-progress"); - }); - - it("still blocks overlap for non-ignored paths", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-001", column: "in-progress" }), - createMockTask({ id: "FN-002", column: "todo" }), - ]; - - const parseScopeMock = vi.fn(async (taskId: string): Promise => { - if (taskId === "FN-001") return ["src/scheduler.ts"]; - if (taskId === "FN-002") return ["src/scheduler.ts"]; - return []; - }); - - const updateTask = vi.fn().mockResolvedValue(undefined); - const moveTask = vi.fn().mockResolvedValue(undefined); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - groupOverlappingFiles: true, - overlapIgnorePaths: ["docs/"], - }), - parseFileScopeFromPrompt: parseScopeMock, - updateTask, - moveTask, - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(updateTask).toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: null, overlapBlockedBy: "FN-001" }); - expect(moveTask).not.toHaveBeenCalledWith("FN-002", "in-progress"); - }); - }); - - describe("FN-4538 overlap blocker persistence", () => { - it("FN-4538: overlap-blocked todo task with satisfied deps preserves overlapBlockedBy", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-DEP", column: "done" }), - createMockTask({ id: "FN-OVER", column: "in-progress" }), - createMockTask({ id: "FN-T", column: "todo", dependencies: ["FN-DEP"] }), - ]; - - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, groupOverlappingFiles: true }), - parseFileScopeFromPrompt: vi.fn(async (taskId: string) => { - if (taskId === "FN-OVER" || taskId === "FN-T") return ["packages/core/src/store.ts"]; - return ["packages/core/src/types.ts"]; - }), - updateTask: vi.fn().mockResolvedValue(undefined), - moveTask: vi.fn().mockResolvedValue(undefined), - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(store.updateTask).toHaveBeenCalledWith("FN-T", { - status: "queued", - blockedBy: null, - overlapBlockedBy: "FN-OVER", - }); - }); - - it("FN-4538: overlapBlockedBy cleared when overlap resolves", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-DEP", column: "done" }), - createMockTask({ id: "FN-T", column: "todo", dependencies: ["FN-DEP"], status: "queued", overlapBlockedBy: "FN-OVER" }), - ]; - - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, groupOverlappingFiles: true }), - parseFileScopeFromPrompt: vi.fn(async () => ["packages/core/src/types.ts"]), - updateTask: vi.fn().mockResolvedValue(undefined), - moveTask: vi.fn().mockResolvedValue(undefined), - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(store.updateTask).toHaveBeenCalledWith("FN-T", { overlapBlockedBy: null }); - }); - }); - - describe("blockedBy stability — FN-3899", () => { - it("preserves a still-valid queued blocker instead of repointing to another active task", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-A", column: "in-progress" }), - createMockTask({ id: "FN-B", column: "in-progress" }), - createMockTask({ id: "FN-T", column: "todo", status: "queued", blockedBy: "FN-B" }), - ]; - - const parseScopeMock = vi.fn(async (taskId: string): Promise => { - if (taskId === "FN-A") return ["packages/engine/src/merger.ts", "packages/dashboard/app/components/Header.tsx"]; - if (taskId === "FN-B") return ["packages/dashboard/app/App.tsx"]; - if (taskId === "FN-T") return ["packages/dashboard/app/App.tsx"]; - return []; - }); - - const updateTask = vi.fn().mockResolvedValue(undefined); - const moveTask = vi.fn().mockResolvedValue(undefined); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 10, - maxWorktrees: 10, - groupOverlappingFiles: true, - }), - parseFileScopeFromPrompt: parseScopeMock, - updateTask, - moveTask, - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(updateTask).not.toHaveBeenCalledWith("FN-T", { status: "queued", blockedBy: "FN-A" }); - expect(moveTask).not.toHaveBeenCalledWith("FN-T", "in-progress", expect.anything()); - }); - - it("recomputes stale queued blockers when the recorded blocker is no longer overlapping", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-A", column: "in-progress" }), - createMockTask({ id: "FN-B", column: "in-progress" }), - createMockTask({ id: "FN-T", column: "todo", status: "queued", blockedBy: "FN-A" }), - ]; - - const parseScopeMock = vi.fn(async (taskId: string): Promise => { - if (taskId === "FN-A") return ["packages/engine/src/merger.ts"]; - if (taskId === "FN-B") return ["packages/dashboard/app/App.tsx"]; - if (taskId === "FN-T") return ["packages/dashboard/app/App.tsx"]; - return []; - }); - - const updateTask = vi.fn().mockResolvedValue(undefined); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 10, - maxWorktrees: 10, - groupOverlappingFiles: true, - }), - parseFileScopeFromPrompt: parseScopeMock, - updateTask, - moveTask: vi.fn().mockResolvedValue(undefined), - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(updateTask).toHaveBeenCalledWith("FN-T", { status: "queued", blockedBy: null, overlapBlockedBy: "FN-B" }); - }); - - it("does not stamp blockedBy for todos without overlap, including empty scopes", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-A", column: "in-progress" }), - createMockTask({ id: "FN-T1", column: "todo" }), - createMockTask({ id: "FN-T2", column: "todo" }), - ]; - - const parseScopeMock = vi.fn(async (taskId: string): Promise => { - if (taskId === "FN-A") return ["packages/engine/src/merger.ts"]; - if (taskId === "FN-T1") return ["packages/dashboard/app/App.tsx"]; - if (taskId === "FN-T2") return []; - return []; - }); - - const updateTask = vi.fn().mockResolvedValue(undefined); - const moveTask = vi.fn().mockResolvedValue(undefined); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 10, - maxWorktrees: 10, - groupOverlappingFiles: true, - }), - parseFileScopeFromPrompt: parseScopeMock, - updateTask, - moveTask, - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(updateTask).not.toHaveBeenCalledWith("FN-T1", { status: "queued", blockedBy: "FN-A" }); - expect(updateTask).not.toHaveBeenCalledWith("FN-T2", { status: "queued", blockedBy: "FN-A" }); - expect(moveTask).toHaveBeenCalledWith("FN-T1", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) })); - expect(moveTask).toHaveBeenCalledWith("FN-T2", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) })); - }); - }); - - describe("worktree reservation", () => { - it("assigns a planned worktree path before moving a task to in-progress", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const task = createMockTask({ id: "FN-010", column: "todo" }); - const updateTask = vi.fn().mockResolvedValue(undefined); - const moveTask = vi.fn().mockResolvedValue(undefined); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([task]), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, worktreeNaming: "task-id" }), - updateTask, - moveTask, - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(updateTask).toHaveBeenCalledWith("FN-010", { - status: null, - blockedBy: null, - executionStartBranch: undefined, - effectiveNodeId: null, - effectiveNodeSource: "local", - mergeRetries: 0, - }); - expect(moveTask).toHaveBeenCalledWith("FN-010", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) })); - expect(updateTask.mock.invocationCallOrder[0]).toBeLessThan(moveTask.mock.invocationCallOrder[0]); - }); - - it("reserves unique random worktree names within the same scheduling pass", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const randomSpy = vi.spyOn(Math, "random") - .mockReturnValueOnce(0) - .mockReturnValueOnce(0) - .mockReturnValueOnce(0) - .mockReturnValueOnce(0); - - const updateTask = vi.fn().mockResolvedValue(undefined); - const moveTask = vi.fn().mockResolvedValue(undefined); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([ - createMockTask({ id: "FN-011", column: "todo" }), - createMockTask({ id: "FN-012", column: "todo" }), - ]), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 4, maxWorktrees: 4, worktreeNaming: "random" }), - updateTask, - moveTask, - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - const dispatchPrepCalls = updateTask.mock.calls.filter(([, patch]) => Object.prototype.hasOwnProperty.call(patch, "mergeRetries")); - expect(dispatchPrepCalls[0]).toEqual(["FN-011", { - status: null, - blockedBy: null, - executionStartBranch: undefined, - effectiveNodeId: null, - effectiveNodeSource: "local", - mergeRetries: 0, - }]); - expect(dispatchPrepCalls[1]).toEqual(["FN-012", { - status: null, - blockedBy: null, - executionStartBranch: undefined, - effectiveNodeId: null, - effectiveNodeSource: "local", - mergeRetries: 0, - }]); - - randomSpy.mockRestore(); - }); - }); - - describe("semaphore integration", () => { - it("respects semaphore available count", async () => { - const semaphore = { - availableCount: 0, - totalCount: 2, - acquire: vi.fn().mockResolvedValue(undefined), - release: vi.fn(), - } as unknown as AgentSemaphore; - - const tasks = [ - createMockTask({ id: "FN-001", column: "in-progress" }), - createMockTask({ id: "FN-002", column: "in-progress" }), - createMockTask({ id: "FN-003", column: "todo" }), - ]; - - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, maxWorktrees: 4 }), - }); - - const scheduler = new Scheduler(store, { semaphore }); - scheduler.start(); - await scheduler.schedule(); - - expect(store.moveTask).not.toHaveBeenCalled(); - }); - }); - - describe("global pause", () => { - it("halts scheduling when globalPause is active", async () => { - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([createMockTask({ id: "FN-001", column: "todo" })]), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - globalPause: true, - }), - }); - - const scheduler = new Scheduler(store); - scheduler.start(); - await scheduler.schedule(); - - expect(store.moveTask).not.toHaveBeenCalled(); - }); - - it("aborts dispatch when globalPause becomes active mid-pass", async () => { - const todoTask = createMockTask({ id: "FN-002", column: "todo" }); - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - const getSettings = vi.fn() - .mockResolvedValueOnce({ - maxConcurrent: 2, - maxWorktrees: 4, - globalPause: false, - enginePaused: false, - }) - .mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - globalPause: true, - enginePaused: false, - }); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([todoTask]), - getTask: vi.fn().mockResolvedValue(todoTask), - getSettings, - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(store.getTask).toHaveBeenCalledWith("FN-002"); - expect(store.updateTask).not.toHaveBeenCalled(); - expect(store.moveTask).not.toHaveBeenCalled(); - }); - }); - - describe("engine pause", () => { - it("halts new scheduling when enginePaused is active", async () => { - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([createMockTask({ id: "FN-001", column: "todo" })]), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - enginePaused: true, - }), - }); - - const scheduler = new Scheduler(store); - scheduler.start(); - await scheduler.schedule(); - - expect(store.moveTask).not.toHaveBeenCalled(); - }); - }); - - describe("filesystem validation", () => { - it("validates tasks using the .fusion task directory layout", async () => { - const todoTask = createMockTask({ id: "FN-010", column: "todo" }); - const moveTask = vi.fn().mockResolvedValue(undefined); - const updateTask = vi.fn().mockResolvedValue(undefined); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([todoTask]), - moveTask, - updateTask, - }); - - vi.mocked(existsSync).mockImplementation((path) => { - const value = String(path); - return value.includes(".fusion/tasks/FN-010") || value.includes("PROMPT.md"); - }); - vi.mocked(readFile).mockResolvedValue("# Prompt\n" as any); - - const scheduler = new Scheduler(store); - scheduler.start(); - await scheduler.schedule(); - - // Flush any remaining microtasks - await new Promise(resolve => setTimeout(resolve, 0)); - - expect(moveTask).toHaveBeenCalledWith("FN-010", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) })); - expect(moveTask).not.toHaveBeenCalledWith("FN-010", "triage"); - }); - - it("moves task to triage when task directory is missing", async () => { - const tasks = [ - createMockTask({ id: "FN-001", column: "todo", dependencies: [] }), - ]; - - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - updateTask: vi.fn().mockResolvedValue(undefined), - getRootDir: vi.fn().mockReturnValue("/test/project"), - getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"), - }); - - // Set up mocks directly on the store - const moveTask = vi.fn().mockResolvedValue(undefined); - const logEntry = vi.fn().mockResolvedValue(undefined); - store.moveTask = moveTask; - store.logEntry = logEntry; - - // Mock missing directory - vi.mocked(existsSync).mockReturnValue(false); - - const scheduler = new Scheduler(store); - scheduler.start(); - await scheduler.schedule(); - - // Flush any remaining microtasks - await new Promise(resolve => setTimeout(resolve, 0)); - - // Task should be moved to triage - expect(moveTask).toHaveBeenCalledWith("FN-001", "triage"); - // Log entry should be written with reason - expect(logEntry).toHaveBeenCalledWith( - "FN-001", - "Task moved to triage — filesystem validation failed", - "missing directory" - ); - // Task should not be moved to in-progress - expect(moveTask).not.toHaveBeenCalledWith("FN-001", "in-progress"); - }); - - it("moves task to triage when PROMPT.md is missing", async () => { - const tasks = [ - createMockTask({ id: "FN-002", column: "todo", dependencies: [] }), - ]; - - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - updateTask: vi.fn().mockResolvedValue(undefined), - getRootDir: vi.fn().mockReturnValue("/test/project"), - getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"), - }); - - const moveTask = vi.fn().mockResolvedValue(undefined); - const logEntry = vi.fn().mockResolvedValue(undefined); - store.moveTask = moveTask; - store.logEntry = logEntry; - - // Mock directory exists but PROMPT.md doesn't - vi.mocked(existsSync).mockImplementation((path) => { - if (typeof path === "string" && path.includes("FN-002") && !path.endsWith("PROMPT.md")) { - return true; // Directory exists - } - return false; // PROMPT.md missing - }); - - const scheduler = new Scheduler(store); - scheduler.start(); - await scheduler.schedule(); - - // Flush any remaining microtasks - await new Promise(resolve => setTimeout(resolve, 0)); - - expect(moveTask).toHaveBeenCalledWith("FN-002", "triage"); - expect(logEntry).toHaveBeenCalledWith( - "FN-002", - "Task moved to triage — filesystem validation failed", - "missing or empty PROMPT.md" - ); - }); - - it("moves task to triage when PROMPT.md is empty", async () => { - const tasks = [ - createMockTask({ id: "FN-003", column: "todo", dependencies: [] }), - ]; - - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - updateTask: vi.fn().mockResolvedValue(undefined), - getRootDir: vi.fn().mockReturnValue("/test/project"), - getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"), - }); - - const moveTask = vi.fn().mockResolvedValue(undefined); - const logEntry = vi.fn().mockResolvedValue(undefined); - store.moveTask = moveTask; - store.logEntry = logEntry; - - // Mock directory and PROMPT.md exist - vi.mocked(existsSync).mockReturnValue(true); - // Mock empty file content - vi.mocked(readFile).mockResolvedValue(" "); // whitespace only - - const scheduler = new Scheduler(store); - scheduler.start(); - await scheduler.schedule(); - - // Flush any remaining microtasks - await new Promise(resolve => setTimeout(resolve, 0)); - - expect(moveTask).toHaveBeenCalledWith("FN-003", "triage"); - expect(logEntry).toHaveBeenCalledWith( - "FN-003", - "Task moved to triage — filesystem validation failed", - "missing or empty PROMPT.md" - ); - }); - - it("logs warn when PROMPT.md read throws during validation", async () => { - const store = createMockStore({ - getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"), - }); - const scheduler = new Scheduler(store); - - vi.mocked(schedulerLog.warn).mockClear(); - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockRejectedValue(new Error("EACCES")); - - const validation = await (scheduler as any).validateTaskFilesystem("FN-READ"); - - expect(validation).toEqual({ valid: false, reason: "missing or empty PROMPT.md" }); - expect(schedulerLog.warn).toHaveBeenCalledWith( - expect.stringContaining( - "PROMPT.md read failed for task dispatch validation (FN-READ): EACCES", - ), - ); - }); - - it("proceeds with scheduling when filesystem is valid", async () => { - const tasks = [ - createMockTask({ id: "FN-004", column: "todo", dependencies: [] }), - ]; - - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - updateTask: vi.fn().mockResolvedValue(undefined), - getRootDir: vi.fn().mockReturnValue("/test/project"), - getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"), - }); - - const moveTask = vi.fn().mockResolvedValue(undefined); - const logEntry = vi.fn().mockResolvedValue(undefined); - store.moveTask = moveTask; - store.logEntry = logEntry; - - // Mock directory and PROMPT.md exist with valid content - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Valid PROMPT.md content\n\nThis task is valid."); - - const scheduler = new Scheduler(store); - scheduler.start(); - await scheduler.schedule(); - - // Flush any remaining microtasks - await new Promise(resolve => setTimeout(resolve, 0)); - - // Should NOT move to triage - expect(moveTask).not.toHaveBeenCalledWith("FN-004", "triage"); - // Should NOT log validation failure - expect(logEntry).not.toHaveBeenCalledWith( - "FN-004", - "Task moved to triage — filesystem validation failed", - expect.any(String) - ); - // Should move to in-progress (since deps are satisfied and concurrency allows) - expect(moveTask).toHaveBeenCalledWith("FN-004", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) })); - }); - - it("does not validate filesystem for tasks with unmet dependencies", async () => { - const tasks = [ - createMockTask({ id: "FN-005", column: "todo", dependencies: ["FN-006"] }), - createMockTask({ id: "FN-006", column: "todo", dependencies: [] }), // Unsatisfied dep - ]; - - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - updateTask: vi.fn().mockResolvedValue(undefined), - getRootDir: vi.fn().mockReturnValue("/test/project"), - getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"), - }); - - const moveTask = vi.fn().mockResolvedValue(undefined); - const updateTask = vi.fn().mockResolvedValue(undefined); - store.moveTask = moveTask; - store.updateTask = updateTask; - - // Mock that directory/PROMPT.md don't exist (would fail validation if checked) - vi.mocked(existsSync).mockReturnValue(false); - - const scheduler = new Scheduler(store); - scheduler.start(); - await scheduler.schedule(); - - // Flush any remaining microtasks - await new Promise(resolve => setTimeout(resolve, 0)); - - // Task with unmet deps should be queued, not validated - // Since KB-006 is not done, KB-005 should not be validated - expect(updateTask).toHaveBeenCalledWith("FN-005", { status: "queued", blockedBy: "FN-006" }); - // No filesystem validation should occur (no move to triage) - expect(moveTask).not.toHaveBeenCalledWith("FN-005", "triage"); - }); - }); - - describe("pr monitoring", () => { - it("hydrates PR monitoring with startup memoized slim reads", async () => { - const prMonitor = { - startMonitoring: vi.fn(), - stopMonitoring: vi.fn(), - updatePrInfo: vi.fn(), - getTrackedPrs: vi.fn().mockReturnValue(new Map()), - stopAll: vi.fn(), - } as unknown as PrMonitor; - - const store = createMockStore(); - const scheduler = new Scheduler(store, {}); - scheduler.configurePrMonitoring({ prMonitor }); - await flushAsyncWork(); - - expect(store.listTasks).toHaveBeenCalledWith({ slim: true, includeArchived: false, startupMemo: true }); - }); - - it("stops monitoring when task moves out of in-review based on from column", () => { - const prMonitor = { - startMonitoring: vi.fn(), - stopMonitoring: vi.fn(), - updatePrInfo: vi.fn(), - getTrackedPrs: vi.fn().mockReturnValue(new Map()), - stopAll: vi.fn(), - } as unknown as PrMonitor; - - const store = createMockStore(); - new Scheduler(store, { prMonitor }); - - const onCalls = (store.on as any).mock.calls; - const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; - const task = createMockTask({ id: "FN-001", column: "done", prInfo: { status: "open" } as any }); - - movedHandler({ task, from: "in-review", to: "done" }); - - expect(prMonitor.stopMonitoring).toHaveBeenCalledWith("FN-001"); - }); - - it("invokes onClosedPrFeedback with drained comments for closed/merged PR", async () => { - const mockComments = [ - { id: 1, body: "Fix this", user: { login: "reviewer" }, created_at: "2024-01-01", updated_at: "2024-01-01", html_url: "https://example.com" }, - { id: 2, body: "Update that", user: { login: "reviewer2" }, created_at: "2024-01-01", updated_at: "2024-01-01", html_url: "https://example.com" }, - ]; - - const prMonitor = { - startMonitoring: vi.fn(), - stopMonitoring: vi.fn(), - updatePrInfo: vi.fn(), - getTrackedPrs: vi.fn().mockReturnValue(new Map()), - stopAll: vi.fn(), - drainComments: vi.fn().mockReturnValue(mockComments), - } as unknown as PrMonitor; - - const onClosedPrFeedback = vi.fn().mockResolvedValue(undefined); - const store = createMockStore(); - new Scheduler(store, { prMonitor, onClosedPrFeedback }); - - const onCalls = (store.on as any).mock.calls; - const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; - - const task = createMockTask({ - id: "FN-001", - column: "done", - prInfo: { status: "merged", number: 42 } as any, - }); - - movedHandler({ task, from: "in-review", to: "done" }); - - // Wait for the void Promise.resolve chain to complete - await flushAsyncWork(); - - expect(prMonitor.drainComments).toHaveBeenCalledWith("FN-001"); - expect(onClosedPrFeedback).toHaveBeenCalledWith("FN-001", task.prInfo, mockComments); - expect(prMonitor.stopMonitoring).toHaveBeenCalledWith("FN-001"); - }); - - it("does not invoke onClosedPrFeedback when buffer is empty", async () => { - const prMonitor = { - startMonitoring: vi.fn(), - stopMonitoring: vi.fn(), - updatePrInfo: vi.fn(), - getTrackedPrs: vi.fn().mockReturnValue(new Map()), - stopAll: vi.fn(), - drainComments: vi.fn().mockReturnValue([]), - } as unknown as PrMonitor; - - const onClosedPrFeedback = vi.fn(); - const store = createMockStore(); - new Scheduler(store, { prMonitor, onClosedPrFeedback }); - - const onCalls = (store.on as any).mock.calls; - const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; - - const task = createMockTask({ - id: "FN-001", - column: "done", - prInfo: { status: "merged", number: 42 } as any, - }); - - movedHandler({ task, from: "in-review", to: "done" }); - - await flushAsyncWork(); - - expect(prMonitor.drainComments).toHaveBeenCalledWith("FN-001"); - expect(onClosedPrFeedback).not.toHaveBeenCalled(); - expect(prMonitor.stopMonitoring).toHaveBeenCalledWith("FN-001"); - }); - - it("does not invoke onClosedPrFeedback for open PR", async () => { - const prMonitor = { - startMonitoring: vi.fn(), - stopMonitoring: vi.fn(), - updatePrInfo: vi.fn(), - getTrackedPrs: vi.fn().mockReturnValue(new Map()), - stopAll: vi.fn(), - drainComments: vi.fn(), - } as unknown as PrMonitor; - - const onClosedPrFeedback = vi.fn(); - const store = createMockStore(); - new Scheduler(store, { prMonitor, onClosedPrFeedback }); - - const onCalls = (store.on as any).mock.calls; - const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; - - const task = createMockTask({ - id: "FN-001", - column: "done", - prInfo: { status: "open", number: 42 } as any, - }); - - movedHandler({ task, from: "in-review", to: "done" }); - - await flushAsyncWork(); - - expect(prMonitor.drainComments).not.toHaveBeenCalled(); - expect(onClosedPrFeedback).not.toHaveBeenCalled(); - expect(prMonitor.stopMonitoring).toHaveBeenCalledWith("FN-001"); - }); - - it("does not invoke onClosedPrFeedback when callback is not provided", async () => { - const prMonitor = { - startMonitoring: vi.fn(), - stopMonitoring: vi.fn(), - updatePrInfo: vi.fn(), - getTrackedPrs: vi.fn().mockReturnValue(new Map()), - stopAll: vi.fn(), - drainComments: vi.fn().mockReturnValue([ - { id: 1, body: "Fix", user: { login: "r" }, created_at: "2024-01-01", updated_at: "2024-01-01", html_url: "" }, - ]), - } as unknown as PrMonitor; - - // No onClosedPrFeedback provided - const store = createMockStore(); - new Scheduler(store, { prMonitor }); - - const onCalls = (store.on as any).mock.calls; - const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; - - const task = createMockTask({ - id: "FN-001", - column: "done", - prInfo: { status: "closed", number: 42 } as any, - }); - - // Should not throw - movedHandler({ task, from: "in-review", to: "done" }); - - await flushAsyncWork(); - - expect(prMonitor.drainComments).toHaveBeenCalledWith("FN-001"); - expect(prMonitor.stopMonitoring).toHaveBeenCalledWith("FN-001"); - }); - - it("drains comments before stopping monitoring (order matters)", async () => { - const callOrder: string[] = []; - const mockComments = [ - { id: 1, body: "Fix this", user: { login: "reviewer" }, created_at: "2024-01-01", updated_at: "2024-01-01", html_url: "" }, - ]; - - const prMonitor = { - startMonitoring: vi.fn(), - stopMonitoring: vi.fn(() => { callOrder.push("stopMonitoring"); }), - updatePrInfo: vi.fn(), - getTrackedPrs: vi.fn().mockReturnValue(new Map()), - stopAll: vi.fn(), - drainComments: vi.fn(() => { callOrder.push("drainComments"); return mockComments; }), - } as unknown as PrMonitor; - - const onClosedPrFeedback = vi.fn().mockResolvedValue(undefined); - const store = createMockStore(); - new Scheduler(store, { prMonitor, onClosedPrFeedback }); - - const onCalls = (store.on as any).mock.calls; - const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; - - const task = createMockTask({ - id: "FN-001", - column: "done", - prInfo: { status: "merged", number: 42 } as any, - }); - - movedHandler({ task, from: "in-review", to: "done" }); - - await flushAsyncWork(); - - // drainComments should be called before stopMonitoring - expect(callOrder).toEqual(["drainComments", "stopMonitoring"]); - }); - - it("second move event with empty drain does not create duplicate follow-up", async () => { - const prMonitor = { - startMonitoring: vi.fn(), - stopMonitoring: vi.fn(), - updatePrInfo: vi.fn(), - getTrackedPrs: vi.fn().mockReturnValue(new Map()), - stopAll: vi.fn(), - drainComments: vi.fn().mockReturnValue([]), - } as unknown as PrMonitor; - - const onClosedPrFeedback = vi.fn(); - const store = createMockStore(); - new Scheduler(store, { prMonitor, onClosedPrFeedback }); - - const onCalls = (store.on as any).mock.calls; - const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; - - const task = createMockTask({ - id: "FN-001", - column: "done", - prInfo: { status: "merged", number: 42 } as any, - }); - - // First move — comments were already drained, buffer is empty - movedHandler({ task, from: "in-review", to: "done" }); - await flushAsyncWork(); - - // Second move — still empty - movedHandler({ task, from: "in-review", to: "done" }); - await flushAsyncWork(); - - // onClosedPrFeedback should never be called since buffer is empty - expect(onClosedPrFeedback).not.toHaveBeenCalled(); - }); - }); - - describe("mission integration", () => { - it("activateNextPendingSlice returns null when no missionStore", async () => { - const store = createMockStore(); - const scheduler = new Scheduler(store); - const result = await scheduler.activateNextPendingSlice("M-001"); - expect(result).toBeNull(); - }); - - it("triggers feature in-progress update when task with sliceId moves to in-progress", async () => { - const mockMissionStore = createMockMissionStore({ - getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001", status: "triaged" }), - updateFeatureStatus: vi.fn().mockReturnValue({ id: "F-001", status: "in-progress" }), - }); - - const store = createMockStore({ - getTask: vi.fn().mockResolvedValue(createMockTask({ - id: "FN-001", - column: "in-progress", - sliceId: "SL-001", - })), - }); - const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); - - // Trigger task:moved event by calling the registered handler - const onCalls = (store.on as any).mock.calls; - const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; - expect(movedHandler).toBeDefined(); - - // Simulate task moving to in-progress with sliceId - const task = createMockTask({ id: "FN-001", column: "in-progress", sliceId: "SL-001" }); - movedHandler({ task, to: "in-progress" }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(mockMissionStore.getFeatureByTaskId).toHaveBeenCalledWith("FN-001"); - expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "in-progress"); - }); - - it("does not update feature status when already past triaged", async () => { - const mockMissionStore = createMockMissionStore({ - getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001", status: "in-progress" }), - updateFeatureStatus: vi.fn(), - }); - - const store = createMockStore({ - getTask: vi.fn().mockResolvedValue(createMockTask({ - id: "FN-001", - column: "in-progress", - sliceId: "SL-001", - })), - }); - const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); - - const onCalls = (store.on as any).mock.calls; - const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; - - const task = createMockTask({ id: "FN-001", column: "in-progress", sliceId: "SL-001" }); - movedHandler({ task, to: "in-progress" }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(mockMissionStore.getFeatureByTaskId).toHaveBeenCalledWith("FN-001"); - expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled(); - }); - - it("onSliceComplete auto-advances when autoAdvance is enabled", async () => { - const missionHierarchy = { - id: "M-001", - status: "active", - milestones: [ - { - id: "MS-001", - dependencies: [], - slices: [ - { id: "SL-001", status: "complete" }, - { id: "SL-002", status: "pending" }, - ], - }, - ], - }; - const mockMissionStore = createMockMissionStore({ - getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), - getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active", autoAdvance: true }), - getMissionWithHierarchy: vi.fn().mockReturnValue(missionHierarchy), - activateSlice: vi.fn().mockReturnValue({ id: "SL-002", status: "active" }), - }); - - const store = createMockStore({ - getTask: vi.fn().mockResolvedValue(createMockTask({ - id: "FN-001", - column: "done", - sliceId: "SL-001", - })), - }); - const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); - - const slice = { id: "SL-001", milestoneId: "MS-001", status: "complete" } as any; - await scheduler.onSliceComplete(slice); - - expect(mockMissionStore.getMilestone).toHaveBeenCalledWith("MS-001"); - expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001"); - expect(mockMissionStore.getMissionWithHierarchy).toHaveBeenCalledWith("M-001"); - expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002"); - }); - - it("onSliceComplete does not auto-advance when autoAdvance is disabled", async () => { - const mockMissionStore = createMockMissionStore({ - getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), - getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active", autoAdvance: false }), - }); - - const store = createMockStore({ - getTask: vi.fn().mockResolvedValue(createMockTask({ - id: "FN-001", - column: "done", - sliceId: "SL-001", - })), - }); - const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); - - const slice = { id: "SL-001", milestoneId: "MS-001", status: "complete" } as any; - await scheduler.onSliceComplete(slice); - - expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001"); - expect(mockMissionStore.activateSlice).not.toHaveBeenCalled(); - }); - - // ── autopilotEnabled as primary control for onSliceComplete fallback ───────── - - it("onSliceComplete auto-advances when autopilotEnabled is true (autoAdvance false)", async () => { - const missionHierarchy = { - id: "M-001", - status: "active", - milestones: [ - { - id: "MS-001", - dependencies: [], - slices: [ - { id: "SL-001", status: "complete" }, - { id: "SL-002", status: "pending" }, - ], - }, - ], - }; - const mockMissionStore = createMockMissionStore({ - getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), - getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active", autopilotEnabled: true, autoAdvance: false }), - getMissionWithHierarchy: vi.fn().mockReturnValue(missionHierarchy), - activateSlice: vi.fn().mockReturnValue({ id: "SL-002", status: "active" }), - }); - - const store = createMockStore(); - const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); - - const slice = { id: "SL-001", milestoneId: "MS-001", status: "complete" } as any; - await scheduler.onSliceComplete(slice); - - expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001"); - expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002"); - }); - - it("onSliceComplete auto-advances when autopilotEnabled is true (autoAdvance unset)", async () => { - const missionHierarchy = { - id: "M-001", - status: "active", - milestones: [ - { - id: "MS-001", - dependencies: [], - slices: [ - { id: "SL-001", status: "complete" }, - { id: "SL-002", status: "pending" }, - ], - }, - ], - }; - const mockMissionStore = createMockMissionStore({ - getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), - getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active", autopilotEnabled: true }), - getMissionWithHierarchy: vi.fn().mockReturnValue(missionHierarchy), - activateSlice: vi.fn().mockReturnValue({ id: "SL-002", status: "active" }), - }); - - const store = createMockStore(); - const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); - - const slice = { id: "SL-001", milestoneId: "MS-001", status: "complete" } as any; - await scheduler.onSliceComplete(slice); - - expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001"); - expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002"); - }); - - it("onSliceComplete does not auto-advance when both autopilotEnabled and autoAdvance are false", async () => { - const mockMissionStore = createMockMissionStore({ - getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), - getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active", autopilotEnabled: false, autoAdvance: false }), - }); - - const store = createMockStore(); - const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); - - const slice = { id: "SL-001", milestoneId: "MS-001", status: "complete" } as any; - await scheduler.onSliceComplete(slice); - - expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001"); - expect(mockMissionStore.activateSlice).not.toHaveBeenCalled(); - }); - - it("onSliceComplete auto-advances when autopilotEnabled is false but autoAdvance is true (legacy compat)", async () => { - const missionHierarchy = { - id: "M-001", - status: "active", - milestones: [ - { - id: "MS-001", - dependencies: [], - slices: [ - { id: "SL-001", status: "complete" }, - { id: "SL-002", status: "pending" }, - ], - }, - ], - }; - const mockMissionStore = createMockMissionStore({ - getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), - getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active", autoAdvance: true }), - getMissionWithHierarchy: vi.fn().mockReturnValue(missionHierarchy), - activateSlice: vi.fn().mockReturnValue({ id: "SL-002", status: "active" }), - }); - - const store = createMockStore(); - const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); - - const slice = { id: "SL-001", milestoneId: "MS-001", status: "complete" } as any; - await scheduler.onSliceComplete(slice); - - expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001"); - expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002"); - }); - - it("skips mission progression when task sliceId mismatches linked feature sliceId", async () => { - const mockMissionStore = createMockMissionStore({ - getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-OTHER" }), - updateFeatureStatus: vi.fn(), - }); - - const store = createMockStore({ - getTask: vi.fn().mockResolvedValue(createMockTask({ - id: "FN-001", - column: "done", - sliceId: "SL-001", - })), - }); - const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); - - const onCalls = (store.on as any).mock.calls; - const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1]; - - const task = createMockTask({ id: "FN-001", column: "done", sliceId: "SL-001" }); - movedHandler({ task, from: "in-progress", to: "done" }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled(); - expect(mockMissionStore.getSlice).not.toHaveBeenCalled(); - }); - - it("onSliceComplete does not auto-advance when mission is not active", async () => { - const mockMissionStore = createMockMissionStore({ - getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), - getMission: vi.fn().mockReturnValue({ id: "M-001", status: "planning", autoAdvance: true }), - }); - - const store = createMockStore(); - const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); - - const slice = { id: "SL-001", milestoneId: "MS-001", status: "complete" } as any; - await scheduler.onSliceComplete(slice); - - expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001"); - expect(mockMissionStore.activateSlice).not.toHaveBeenCalled(); - }); - - it("handles task with sliceId but no linked feature gracefully", async () => { - const mockMissionStore = createMockMissionStore({ - getFeatureByTaskId: vi.fn().mockReturnValue(undefined), - }); - - const store = createMockStore(); - const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); - - const slice = { id: "SL-001", milestoneId: "MS-001", status: "complete" } as any; - await scheduler.onSliceComplete(slice); - - // onSliceComplete does not call getFeatureByTaskId; it checks milestone/mission/missionHierarchy - // This test verifies no errors are thrown when slice has no linked feature - expect(mockMissionStore.activateSlice).not.toHaveBeenCalled(); - }); - - it("activateNextPendingSlice finds and activates correct slice", async () => { - const nextSlice = { id: "SL-002", status: "pending", orderIndex: 1 }; - const mockMissionStore = createMockMissionStore({ - getMissionWithHierarchy: vi.fn().mockReturnValue({ - id: "M-001", - status: "active", - milestones: [ - { - id: "MS-001", - orderIndex: 0, - dependencies: [], - slices: [ - nextSlice, - { id: "SL-003", status: "pending", orderIndex: 2 }, - { id: "SL-001", status: "complete", orderIndex: 0 }, - ], - }, - ], - }), - activateSlice: vi.fn().mockReturnValue({ ...nextSlice, status: "active" }), - }); - - const store = createMockStore(); - const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); - - const result = await scheduler.activateNextPendingSlice("M-001"); - - expect(mockMissionStore.getMissionWithHierarchy).toHaveBeenCalledWith("M-001"); - expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002"); - expect(result).toEqual({ id: "SL-002", status: "active", orderIndex: 1 }); - }); - - it("activateNextPendingSlice skips milestones with incomplete dependencies", async () => { - const mockMissionStore = createMockMissionStore({ - getMissionWithHierarchy: vi.fn().mockReturnValue({ - id: "M-001", - status: "active", - milestones: [ - { - id: "MS-001", - orderIndex: 0, - status: "planning", - dependencies: ["MS-999"], - slices: [{ id: "SL-001", status: "pending", orderIndex: 0 }], - }, - { - id: "MS-002", - orderIndex: 1, - status: "planning", - dependencies: [], - slices: [{ id: "SL-002", status: "pending", orderIndex: 0 }], - }, - ], - }), - activateSlice: vi.fn().mockReturnValue({ id: "SL-002", status: "active" }), - }); - - const store = createMockStore(); - const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); - - const result = await scheduler.activateNextPendingSlice("M-001"); - - expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002"); - expect(result).toEqual({ id: "SL-002", status: "active" }); - }); - - it("activateNextPendingSlice returns null when mission is not active", async () => { - const mockMissionStore = createMockMissionStore({ - getMissionWithHierarchy: vi.fn().mockReturnValue({ - id: "M-001", - status: "planning", - milestones: [], - }), - }); - - const store = createMockStore(); - const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); - - const result = await scheduler.activateNextPendingSlice("M-001"); - - expect(result).toBeNull(); - expect(mockMissionStore.activateSlice).not.toHaveBeenCalled(); - }); - - it("activateNextPendingSlice returns null when no pending slices", async () => { - const mockMissionStore = createMockMissionStore({ - getMissionWithHierarchy: vi.fn().mockReturnValue({ - id: "M-001", - status: "active", - milestones: [ - { - id: "MS-001", - orderIndex: 0, - dependencies: [], - slices: [{ id: "SL-001", status: "complete", orderIndex: 0 }], - }, - ], - }), - }); - - const store = createMockStore(); - const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); - - const result = await scheduler.activateNextPendingSlice("M-001"); - - expect(result).toBeNull(); - expect(mockMissionStore.activateSlice).not.toHaveBeenCalled(); - }); - }); - - describe("blocked mission scheduling", () => { - it("skips tasks belonging to a blocked mission", async () => { - const task = createMockTask({ - id: "FN-100", - column: "todo", - sliceId: "SL-001", - }); - - const mockMissionStore = createMockMissionStore({ - getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001" }), - getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), - getMission: vi.fn().mockReturnValue({ id: "M-001", status: "blocked" }), - }); - - (existsSync as any).mockReturnValue(true); - (readFile as any).mockResolvedValue("# Task\n\nSome content\n"); - - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([task]), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), - getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"), - }); - - const onSchedule = vi.fn(); - const scheduler = new Scheduler(store, { onSchedule, missionStore: mockMissionStore as any }); - (scheduler as any).running = true; - await scheduler.schedule(); - - // Task should NOT be scheduled because its mission is blocked - expect(store.moveTask).not.toHaveBeenCalled(); - expect(onSchedule).not.toHaveBeenCalled(); - }); - - it("schedules tasks when mission is active", async () => { - const task = createMockTask({ - id: "FN-100", - column: "todo", - sliceId: "SL-001", - }); - - const mockMissionStore = createMockMissionStore({ - getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001" }), - getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), - getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active" }), - }); - - (existsSync as any).mockReturnValue(true); - (readFile as any).mockResolvedValue("# Task\n\nSome content\n"); - - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([task]), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), - getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"), - }); - - const onSchedule = vi.fn(); - const scheduler = new Scheduler(store, { onSchedule, missionStore: mockMissionStore as any }); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(store.moveTask).toHaveBeenCalledWith("FN-100", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) })); - }); - - it("schedules tasks without sliceId regardless of mission state", async () => { - const task = createMockTask({ - id: "FN-100", - column: "todo", - // No sliceId — not associated with any mission - }); - - (existsSync as any).mockReturnValue(true); - (readFile as any).mockResolvedValue("# Task\n\nSome content\n"); - - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([task]), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), - getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"), - }); - - const onSchedule = vi.fn(); - const scheduler = new Scheduler(store, { onSchedule, missionStore: createMockMissionStore() as any }); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(store.moveTask).toHaveBeenCalledWith("FN-100", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) })); - }); - }); - - describe("recovery due-time gating (nextRecoveryAt)", () => { - it("skips todo tasks whose nextRecoveryAt is in the future", async () => { - const future = new Date(Date.now() + 60_000).toISOString(); - const task = createMockTask({ - id: "FN-010", - column: "todo", - nextRecoveryAt: future, - recoveryRetryCount: 1, - }); - - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([task]), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - }); - - const onSchedule = vi.fn(); - const scheduler = new Scheduler(store, { onSchedule }); - scheduler.start(); - await scheduler.schedule(); - scheduler.stop(); - - // Should NOT have been started - expect(onSchedule).not.toHaveBeenCalled(); - expect(store.moveTask).not.toHaveBeenCalled(); - }); - - it("picks up todo tasks whose nextRecoveryAt has elapsed", async () => { - const past = new Date(Date.now() - 1000).toISOString(); - const task = createMockTask({ - id: "FN-011", - column: "todo", - nextRecoveryAt: past, - recoveryRetryCount: 1, - }); - - // Mock filesystem validation: task dir exists, PROMPT.md exists and non-empty - (existsSync as any).mockReturnValue(true); - (readFile as any).mockResolvedValue("# Task\n\nSome content\n## File Scope\n- foo.ts\n"); - - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([task]), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), - getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"), - }); - - const onSchedule = vi.fn(); - const scheduler = new Scheduler(store, { onSchedule }); - // Call schedule() directly without start() to avoid scheduling guard race - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(store.moveTask).toHaveBeenCalledWith("FN-011", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) })); - }); - - it("picks up todo tasks without nextRecoveryAt normally", async () => { - const task = createMockTask({ - id: "FN-012", - column: "todo", - // No nextRecoveryAt — should be picked up normally - }); - - (existsSync as any).mockReturnValue(true); - (readFile as any).mockResolvedValue("# Task\n\nSome content\n## File Scope\n- foo.ts\n"); - - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([task]), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }), - parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), - getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"), - }); - - const onSchedule = vi.fn(); - const scheduler = new Scheduler(store, { onSchedule }); - // Call schedule() directly without start() to avoid scheduling guard race - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(store.moveTask).toHaveBeenCalledWith("FN-012", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) })); - }); - }); - - describe("userPaused dispatch behavior", () => { - it("skips dispatch for todo task marked userPaused and re-enables after clearing", async () => { - const pausedTask = createMockTask({ - id: "FN-UP-1", - column: "todo", - dependencies: [], - userPaused: true, - }); - const activeTask = createMockTask({ - id: "FN-UP-1", - column: "todo", - dependencies: [], - }); - const listTasks = vi - .fn() - .mockResolvedValueOnce([pausedTask]) - .mockResolvedValueOnce([activeTask]); - const getTask = vi - .fn() - .mockResolvedValueOnce(pausedTask) - .mockResolvedValueOnce(activeTask); - const updateTask = vi.fn().mockResolvedValue(undefined); - const moveTask = vi.fn().mockResolvedValue(undefined); - const store = createMockStore({ - listTasks, - getTask, - updateTask, - moveTask, - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 1, groupOverlappingFiles: true }), - parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\n\n## File Scope\n- src/example.ts\n"); - - await scheduler.schedule(); - expect(moveTask).not.toHaveBeenCalled(); - expect(updateTask).toHaveBeenCalledWith("FN-UP-1", { status: "queued" }); - expect(store.logEntry).toHaveBeenCalledWith("FN-UP-1", "queued — user paused (manual move to todo)"); - - await scheduler.schedule(); - expect(moveTask).toHaveBeenCalledWith( - "FN-UP-1", - "in-progress", - expect.objectContaining({ allocateWorktree: expect.any(Function) }), - ); - }); - }); - - describe("autopilot integration", () => { - it("watches missions with autopilotEnabled on start", () => { - const store = createMockStore(); - const mockMissionStore = createMockMissionStore({ - listMissions: vi.fn().mockReturnValue([ - { id: "M-001", autopilotEnabled: true, status: "active" }, - { id: "M-002", autopilotEnabled: false, status: "active" }, - { id: "M-003", autopilotEnabled: true, status: "complete" }, - ]), - getMission: vi.fn((id: string) => { - const missions: Record = { - "M-001": { id: "M-001", autopilotEnabled: true, autopilotState: "inactive" }, - "M-002": { id: "M-002", autopilotEnabled: false, autopilotState: "inactive" }, - }; - return missions[id]; - }), - }); - const mockAutopilot = { - setScheduler: vi.fn(), - watchMission: vi.fn(), - start: vi.fn(), - stop: vi.fn(), - }; - - const scheduler = new Scheduler(store, { - missionStore: mockMissionStore as any, - missionAutopilot: mockAutopilot as any, - }); - scheduler.start(); - - // setScheduler should be called with the scheduler instance - expect(mockAutopilot.setScheduler).toHaveBeenCalledWith(scheduler); - // Only M-001 should be watched (autopilotEnabled, not complete/archived) - expect(mockAutopilot.watchMission).toHaveBeenCalledWith("M-001"); - expect(mockAutopilot.watchMission).not.toHaveBeenCalledWith("M-002"); - expect(mockAutopilot.watchMission).not.toHaveBeenCalledWith("M-003"); - // Autopilot should be started - expect(mockAutopilot.start).toHaveBeenCalled(); - - scheduler.stop(); - // Autopilot should be stopped - expect(mockAutopilot.stop).toHaveBeenCalled(); - }); - - it("does not start autopilot when no missionAutopilot option", () => { - const store = createMockStore(); - const scheduler = new Scheduler(store); - scheduler.start(); - // Should not throw - scheduler.stop(); - }); - - it("delegates to autopilot.handleTaskCompletion when autopilot is available", async () => { - const store = createMockStore(); - const mockAutopilot = { - setScheduler: vi.fn(), - watchMission: vi.fn(), - start: vi.fn(), - stop: vi.fn(), - handleTaskCompletion: vi.fn().mockResolvedValue(undefined), - isWatching: vi.fn(() => true), // autopilot IS watching - }; - const completeSlice = { - id: "SL-001", - milestoneId: "MS-001", - status: "complete", - orderIndex: 0, - }; - - const mockMissionStore = createMockMissionStore({ - getFeatureByTaskId: vi.fn().mockReturnValue({ - id: "F-001", - sliceId: "SL-001", - status: "active", - }), - updateFeatureStatus: vi.fn(), - getSlice: vi.fn().mockReturnValue(completeSlice), - getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), - }); - - const scheduler = new Scheduler(store, { - missionStore: mockMissionStore as any, - missionAutopilot: mockAutopilot as any, - }); - - // Simulate task:moved event: task moves to "done" - await (scheduler as any).handleMissionTaskMove("FN-001", "done"); - - // Feature status should be updated to "done" - expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done"); - // Should delegate to autopilot (not call onSliceComplete) - expect(mockAutopilot.handleTaskCompletion).toHaveBeenCalledWith("FN-001"); - }); - - it("marks a linked feature in-progress when a task reaches in-review without task slice metadata", async () => { - const store = createMockStore({ - getTask: vi.fn().mockResolvedValue(createMockTask({ - id: "FN-1702", - column: "in-review", - sliceId: undefined, - })), - }); - const mockMissionStore = createMockMissionStore({ - getFeatureByTaskId: vi.fn().mockReturnValue({ - id: "F-1702", - sliceId: "SL-001", - status: "triaged", - }), - updateFeatureStatus: vi.fn(), - }); - - const scheduler = new Scheduler(store, { - missionStore: mockMissionStore as any, - }); - - await (scheduler as any).handleMissionTaskMove("FN-1702", "in-review"); - - expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-1702", "in-progress"); - }); - - it("links a one-way mission task to a matching unlinked feature before marking it done", async () => { - const store = createMockStore({ - getTask: vi.fn().mockResolvedValue(createMockTask({ - id: "FN-1702", - title: "First-run onboarding trigger", - column: "done", - missionId: "M-001", - sliceId: "SL-001", - } as Partial)), - }); - const matchedFeature = { - id: "F-1702", - sliceId: "SL-001", - title: "First-run onboarding trigger", - status: "triaged", - }; - const linkedFeature = { - ...matchedFeature, - taskId: "FN-1702", - }; - const mockMissionStore = createMockMissionStore({ - getFeatureByTaskId: vi.fn() - .mockReturnValueOnce(undefined) - .mockReturnValue(linkedFeature), - listFeatures: vi.fn().mockReturnValue([matchedFeature]), - linkFeatureToTask: vi.fn().mockReturnValue(linkedFeature), - updateFeatureStatus: vi.fn(), - getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001", status: "active" }), - }); - - const scheduler = new Scheduler(store, { - missionStore: mockMissionStore as any, - }); - - await (scheduler as any).handleMissionTaskMove("FN-1702", "done"); - - expect(mockMissionStore.linkFeatureToTask).toHaveBeenCalledWith("F-1702", "FN-1702"); - expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-1702", "done"); - }); - - it("falls back to onSliceComplete when no autopilot", async () => { - const store = createMockStore(); - const completeSlice = { - id: "SL-001", - milestoneId: "MS-001", - status: "complete", - orderIndex: 0, - }; - - const missionHierarchy = { - id: "M-001", - status: "active", - autoAdvance: true, - milestones: [ - { - id: "MS-001", - missionId: "M-001", - status: "active", - dependencies: [], - slices: [ - { id: "SL-001", status: "complete", orderIndex: 0 }, - { id: "SL-002", status: "pending", orderIndex: 1 }, - ], - }, - ], - }; - - const mockMissionStore = createMockMissionStore({ - getFeatureByTaskId: vi.fn().mockReturnValue({ - id: "F-001", - sliceId: "SL-001", - status: "active", - }), - updateFeatureStatus: vi.fn(), - getSlice: vi.fn().mockReturnValue(completeSlice), - getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), - getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active", autoAdvance: true }), - getMissionWithHierarchy: vi.fn().mockReturnValue(missionHierarchy), - activateSlice: vi.fn().mockResolvedValue({ id: "SL-002" }), - }); - - const scheduler = new Scheduler(store, { - missionStore: mockMissionStore as any, - }); - - await (scheduler as any).handleMissionTaskMove("FN-001", "done"); - - // Feature status should be updated - expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done"); - // Legacy path: activateSlice should be called via onSliceComplete - expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002"); - }); - - it("autopilot does not advance when autoAdvance is false", async () => { - const store = createMockStore(); - const mockAutopilot = { - setScheduler: vi.fn(), - watchMission: vi.fn(), - start: vi.fn(), - stop: vi.fn(), - handleTaskCompletion: vi.fn().mockResolvedValue(undefined), - isWatching: vi.fn(() => true), // autopilot IS watching - }; - - const mockMissionStore = createMockMissionStore({ - getFeatureByTaskId: vi.fn().mockReturnValue({ - id: "F-001", - sliceId: "SL-001", - status: "done", - }), - updateFeatureStatus: vi.fn(), - getSlice: vi.fn().mockReturnValue({ - id: "SL-001", - milestoneId: "MS-001", - status: "complete", - orderIndex: 0, - }), - getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), - }); - - const scheduler = new Scheduler(store, { - missionStore: mockMissionStore as any, - missionAutopilot: mockAutopilot as any, - }); - - await (scheduler as any).handleMissionTaskMove("FN-001", "done"); - - // Delegates to autopilot, which internally checks autoAdvance - expect(mockAutopilot.handleTaskCompletion).toHaveBeenCalledWith("FN-001"); - }); - - it("keeps assertion-linked features non-done until validator pass", async () => { - const feature = { - id: "F-001", - sliceId: "SL-001", - status: "triaged", - loopState: "implementing", - taskId: "FN-001", - }; - const missionStore = { - getFeatureByTaskId: vi.fn().mockReturnValue(feature), - updateFeatureStatus: vi.fn(), - getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001", status: "active" }), - getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), - listAssertionsForFeature: vi.fn().mockReturnValue([ - { - id: "CA-1", - milestoneId: "MS-001", - title: "Must pass", - assertion: "Should pass", - status: "pending", - orderIndex: 0, - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - }, - ]), - }; - const missionExecutionLoop = { - isRunning: vi.fn().mockReturnValue(true), - processTaskOutcome: vi.fn().mockResolvedValue(undefined), - start: vi.fn(), - }; - const taskStore = createMockStore({ - getTask: vi.fn().mockResolvedValue(createMockTask({ - id: "FN-001", - title: "Mission task", - description: "done", - column: "done", - sliceId: "SL-001", - log: [], - })), - }); - - const scheduler = new Scheduler(taskStore, { - missionStore: missionStore as any, - missionExecutionLoop: missionExecutionLoop as any, - }); - - await (scheduler as any).handleMissionTaskMove("FN-001", "done"); - - expect(missionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "in-progress"); - expect(missionExecutionLoop.processTaskOutcome).toHaveBeenCalledWith("FN-001"); - expect(missionStore.updateFeatureStatus).not.toHaveBeenCalledWith("F-001", "done"); - }); - - it("starts validator run through missionExecutionLoop when a linked task moves to done", async () => { - const feature = { - id: "F-001", - sliceId: "SL-001", - status: "in-progress", - loopState: "implementing", - taskId: "FN-001", - }; - const missionStore = { - getFeatureByTaskId: vi.fn().mockReturnValue(feature), - updateFeatureStatus: vi.fn(), - getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001", status: "active" }), - getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }), - listAssertionsForFeature: vi.fn().mockReturnValue([ - { - id: "CA-1", - milestoneId: "MS-001", - title: "Must pass", - assertion: "Should pass", - status: "pending", - orderIndex: 0, - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - }, - ]), - startValidatorRun: vi.fn().mockReturnValue({ - id: "VR-001", - featureId: "F-001", - milestoneId: "MS-001", - sliceId: "SL-001", - status: "running", - triggerType: "task_completion", - implementationAttempt: 1, - validatorAttempt: 1, - startedAt: "2026-01-01T00:00:00Z", - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - }), - completeValidatorRun: vi.fn(), - recordValidatorFailures: vi.fn(), - createGeneratedFixFeature: vi.fn(), - triageFeature: vi.fn(), - }; - const taskStore = createMockStore({ - getTask: vi.fn().mockResolvedValue(createMockTask({ - id: "FN-001", - title: "Mission task", - description: "done", - column: "done", - sliceId: "SL-001", - log: [], - })), - }); - const missionExecutionLoop = new MissionExecutionLoop({ - taskStore: taskStore as any, - missionStore: missionStore as any, - rootDir: "/tmp", - }); - - const scheduler = new Scheduler(taskStore, { - missionStore: missionStore as any, - missionExecutionLoop, - }); - const startSpy = vi.spyOn(missionExecutionLoop, "start"); - - await (scheduler as any).handleMissionTaskMove("FN-001", "done"); - await Promise.resolve(); - - expect(startSpy).toHaveBeenCalledTimes(1); - expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion"); - expect(startSpy.mock.invocationCallOrder[0]).toBeLessThan( - missionStore.startValidatorRun.mock.invocationCallOrder[0], - ); - }); - - it("keeps processTaskOutcome guarded when loop is not started", async () => { - const feature = { - id: "F-001", - sliceId: "SL-001", - status: "in-progress", - loopState: "implementing", - taskId: "FN-001", - }; - const missionStore = { - getFeatureByTaskId: vi.fn().mockReturnValue(feature), - listAssertionsForFeature: vi.fn().mockReturnValue([ - { - id: "CA-1", - milestoneId: "MS-001", - title: "Must pass", - assertion: "Should pass", - status: "pending", - orderIndex: 0, - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - }, - ]), - startValidatorRun: vi.fn(), - }; - const loop = new MissionExecutionLoop({ - taskStore: createMockStore() as any, - missionStore: missionStore as any, - rootDir: "/tmp", - }); - - await loop.processTaskOutcome("FN-001"); - - expect(missionStore.startValidatorRun).not.toHaveBeenCalled(); - }); - - it("does not mark a feature done when the completed task is blocked", async () => { - const store = createMockStore({ - getTask: vi.fn(async (taskId: string) => { - if (taskId === "FN-001") { - return createMockTask({ - id: "FN-001", - blockedBy: "FN-000", - column: "done", - }) as TaskDetail; - } - return createMockTask({ id: taskId, column: "in-progress" }) as TaskDetail; - }) as TaskStore["getTask"], - }); - const mockAutopilot = { - setScheduler: vi.fn(), - watchMission: vi.fn(), - start: vi.fn(), - stop: vi.fn(), - handleTaskCompletion: vi.fn().mockResolvedValue(undefined), - isWatching: vi.fn(() => true), - }; - const mockMissionStore = createMockMissionStore({ - getFeatureByTaskId: vi.fn().mockReturnValue({ - id: "F-001", - sliceId: "SL-001", - status: "in-progress", - }), - updateFeatureStatus: vi.fn(), - }); - - const scheduler = new Scheduler(store, { - missionStore: mockMissionStore as any, - missionAutopilot: mockAutopilot as any, - }); - - await (scheduler as any).handleMissionTaskMove("FN-001", "done"); - - expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled(); - expect(mockAutopilot.handleTaskCompletion).not.toHaveBeenCalled(); - }); - }); - - describe("overlap bottleneck warnings", () => { - it("logs scheduler warning and blocker task log for high overlap fan-out", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-B", column: "in-progress", blockedBy: undefined }), - createMockTask({ id: "FN-1", column: "todo", blockedBy: "FN-B", dependencies: [] }), - createMockTask({ id: "FN-2", column: "todo", blockedBy: "FN-B", dependencies: [] }), - createMockTask({ id: "FN-3", column: "todo", blockedBy: "FN-B", dependencies: [] }), - createMockTask({ id: "FN-4", column: "todo", blockedBy: "FN-B", dependencies: [] }), - createMockTask({ id: "FN-5", column: "todo", blockedBy: "FN-B", dependencies: [] }), - ]; - - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, maxWorktrees: 10, groupOverlappingFiles: false }), - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect(schedulerLog.warn).toHaveBeenCalledWith(expect.stringContaining("Overlap bottleneck: FN-B is currently blocking 5 todo task(s) via blockedBy")); - expect(store.logEntry).toHaveBeenCalledWith( - "FN-B", - expect.stringContaining("Overlap bottleneck: FN-B is currently blocking 5 todo task(s) via blockedBy"), - ); - }); - - it("does not warn for small or dependency-only fan-out", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-B", column: "in-progress" }), - createMockTask({ id: "FN-1", column: "todo", dependencies: ["FN-B"] }), - createMockTask({ id: "FN-2", column: "todo", dependencies: ["FN-B"] }), - createMockTask({ id: "FN-3", column: "todo", blockedBy: "FN-B" }), - ]; - - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, maxWorktrees: 10, groupOverlappingFiles: false }), - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - - expect((store.logEntry as ReturnType).mock.calls.some((call) => - call[0] === "FN-B" && String(call[1]).includes("Overlap bottleneck:"), - )).toBe(false); - }); - - it("dedupes unchanged overlap bottleneck warnings across passes", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); - - const tasks = [ - createMockTask({ id: "FN-B", column: "in-progress" }), - createMockTask({ id: "FN-1", column: "todo", blockedBy: "FN-B" }), - createMockTask({ id: "FN-2", column: "todo", blockedBy: "FN-B" }), - createMockTask({ id: "FN-3", column: "todo", blockedBy: "FN-B" }), - createMockTask({ id: "FN-4", column: "todo", blockedBy: "FN-B" }), - createMockTask({ id: "FN-5", column: "todo", blockedBy: "FN-B" }), - ]; - - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue(tasks), - getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, maxWorktrees: 10, groupOverlappingFiles: false }), - }); - - const scheduler = new Scheduler(store); - (scheduler as any).running = true; - await scheduler.schedule(); - await scheduler.schedule(); - - const overlapLogs = (store.logEntry as ReturnType).mock.calls.filter((call) => - call[0] === "FN-B" && String(call[1]).includes("Overlap bottleneck:"), - ); - expect(overlapLogs).toHaveLength(1); - }); - }); - - describe("reconcileAllMissionFeatures", () => { - it("returns early when missionStore is not provided", async () => { - const store = createMockStore(); - const scheduler = new Scheduler(store); - - const result = await scheduler.reconcileAllMissionFeatures(); - - expect(result).toBe(0); - }); - - it("skips non-active missions", async () => { - const store = createMockStore(); - const mockMissionStore = createMockMissionStore({ - listMissions: vi.fn().mockReturnValue([ - { id: "M-001", status: "complete" }, - { id: "M-002", status: "archived" }, - ]), - getMissionWithHierarchy: vi.fn(), - }); - - const scheduler = new Scheduler(store, { - missionStore: mockMissionStore as any, - }); - - await scheduler.reconcileAllMissionFeatures(); - - expect(mockMissionStore.getMissionWithHierarchy).not.toHaveBeenCalled(); - }); - - it("updates feature to in-progress when task is in-progress and feature is triaged", async () => { - const store = createMockStore({ - getTask: vi.fn().mockReturnValue(createMockTask({ id: "FN-001", column: "in-progress" })), - }); - const mockMissionStore = createMockMissionStore({ - listMissions: vi.fn().mockReturnValue([ - { id: "M-001", status: "active" }, - ]), - getMissionWithHierarchy: vi.fn().mockReturnValue({ - id: "M-001", - status: "active", - milestones: [{ - id: "MS-001", - slices: [{ - id: "SL-001", - status: "active", - features: [{ - id: "F-001", - taskId: "FN-001", - status: "triaged", - }], - }], - }], - }), - updateFeatureStatus: vi.fn(), - }); - - const scheduler = new Scheduler(store, { - missionStore: mockMissionStore as any, - }); - - const result = await scheduler.reconcileAllMissionFeatures(); - - expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "in-progress"); - expect(result).toBe(1); - }); - - it("updates feature to in-progress when task is in-review and feature is triaged", async () => { - const store = createMockStore({ - getTask: vi.fn().mockReturnValue(createMockTask({ id: "FN-1702", column: "in-review" })), - }); - const mockMissionStore = createMockMissionStore({ - listMissions: vi.fn().mockReturnValue([ - { id: "M-001", status: "active" }, - ]), - getMissionWithHierarchy: vi.fn().mockReturnValue({ - id: "M-001", - status: "active", - milestones: [{ - id: "MS-001", - slices: [{ - id: "SL-001", - status: "active", - features: [{ - id: "F-1702", - taskId: "FN-1702", - status: "triaged", - }], - }], - }], - }), - updateFeatureStatus: vi.fn(), - }); - - const scheduler = new Scheduler(store, { - missionStore: mockMissionStore as any, - }); - - const result = await scheduler.reconcileAllMissionFeatures(); - - expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-1702", "in-progress"); - expect(result).toBe(1); - }); - - it("updates feature to done when task is done and feature is not done", async () => { - const store = createMockStore({ - getTask: vi.fn().mockReturnValue(createMockTask({ id: "FN-001", column: "done" })), - }); - const mockMissionStore = createMockMissionStore({ - listMissions: vi.fn().mockReturnValue([ - { id: "M-001", status: "active" }, - ]), - getMissionWithHierarchy: vi.fn().mockReturnValue({ - id: "M-001", - status: "active", - milestones: [{ - id: "MS-001", - slices: [{ - id: "SL-001", - status: "active", - features: [{ - id: "F-001", - taskId: "FN-001", - status: "in-progress", - }], - }], - }], - }), - updateFeatureStatus: vi.fn(), - }); - - const scheduler = new Scheduler(store, { - missionStore: mockMissionStore as any, - }); - - const result = await scheduler.reconcileAllMissionFeatures(); - - expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done"); - expect(result).toBe(1); - }); - - it("does not reconcile feature to done when the linked task has unresolved dependencies", async () => { - const getTask = vi.fn(async (id: string) => { - if (id === "FN-001") { - return createMockTask({ - id: "FN-001", - column: "done", - dependencies: ["FN-000"], - }); - } - return createMockTask({ id, column: "in-progress" }); - }); - const store = createMockStore({ getTask: getTask as any }); - const mockMissionStore = createMockMissionStore({ - listMissions: vi.fn().mockReturnValue([ - { id: "M-001", status: "active" }, - ]), - getMissionWithHierarchy: vi.fn().mockReturnValue({ - id: "M-001", - status: "active", - milestones: [{ - id: "MS-001", - slices: [{ - id: "SL-001", - status: "active", - features: [{ - id: "F-001", - taskId: "FN-001", - status: "in-progress", - }], - }], - }], - }), - updateFeatureStatus: vi.fn(), - }); - - const scheduler = new Scheduler(store, { - missionStore: mockMissionStore as any, - }); - - const result = await scheduler.reconcileAllMissionFeatures(); - - expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled(); - expect(result).toBe(0); - }); - - it("routes failed linked tasks through onTaskFailed during reconciliation", async () => { - const store = createMockStore({ - getTask: vi.fn().mockReturnValue(createMockTask({ - id: "FN-001", - column: "in-progress", - status: "failed", - })), - }); - const onTaskFailed = vi.fn().mockResolvedValue(undefined); - const mockMissionStore = createMockMissionStore({ - listMissions: vi.fn().mockReturnValue([ - { id: "M-001", status: "active" }, - ]), - getMissionWithHierarchy: vi.fn().mockReturnValue({ - id: "M-001", - status: "active", - milestones: [{ - id: "MS-001", - slices: [{ - id: "SL-001", - status: "active", - features: [{ - id: "F-001", - taskId: "FN-001", - status: "in-progress", - }], - }], - }], - }), - updateFeatureStatus: vi.fn(), - }); - - const scheduler = new Scheduler(store, { - missionStore: mockMissionStore as any, - onTaskFailed, - }); - - const result = await scheduler.reconcileAllMissionFeatures(); - - expect(onTaskFailed).toHaveBeenCalledWith("FN-001"); - expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled(); - expect(result).toBe(1); - }); - - it("updates feature to triaged when task moves back to todo and feature is in-progress", async () => { - const store = createMockStore({ - getTask: vi.fn().mockReturnValue(createMockTask({ id: "FN-001", column: "todo" })), - }); - const mockMissionStore = createMockMissionStore({ - listMissions: vi.fn().mockReturnValue([ - { id: "M-001", status: "active" }, - ]), - getMissionWithHierarchy: vi.fn().mockReturnValue({ - id: "M-001", - status: "active", - milestones: [{ - id: "MS-001", - slices: [{ - id: "SL-001", - status: "active", - features: [{ - id: "F-001", - taskId: "FN-001", - status: "in-progress", - }], - }], - }], - }), - updateFeatureStatus: vi.fn(), - }); - - const scheduler = new Scheduler(store, { - missionStore: mockMissionStore as any, - }); - - const result = await scheduler.reconcileAllMissionFeatures(); - - expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "triaged"); - expect(result).toBe(1); - }); - - it("does not update correctly synced features", async () => { - const store = createMockStore({ - getTask: vi.fn().mockReturnValue(createMockTask({ id: "FN-001", column: "in-progress" })), - }); - const mockMissionStore = createMockMissionStore({ - listMissions: vi.fn().mockReturnValue([ - { id: "M-001", status: "active" }, - ]), - getMissionWithHierarchy: vi.fn().mockReturnValue({ - id: "M-001", - status: "active", - milestones: [{ - id: "MS-001", - slices: [{ - id: "SL-001", - status: "active", - features: [{ - id: "F-001", - taskId: "FN-001", - status: "in-progress", // Already synced - }], - }], - }], - }), - updateFeatureStatus: vi.fn(), - }); - - const scheduler = new Scheduler(store, { - missionStore: mockMissionStore as any, - }); - - const result = await scheduler.reconcileAllMissionFeatures(); - - expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled(); - expect(result).toBe(0); - }); - - it("repairs one-way mission task links by exact feature title during reconciliation", async () => { - const matchedTask = createMockTask({ - id: "FN-1702", - title: "First-run onboarding trigger", - column: "done", - missionId: "M-001", - sliceId: "SL-001", - } as Partial); - const matchedFeature = { - id: "F-1702", - sliceId: "SL-001", - title: "First-run onboarding trigger", - taskId: undefined, - status: "triaged", - }; - const linkedFeature = { - ...matchedFeature, - taskId: "FN-1702", - }; - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([matchedTask]), - getTask: vi.fn(), - }); - const mockMissionStore = createMockMissionStore({ - listMissions: vi.fn().mockReturnValue([ - { id: "M-001", status: "active" }, - ]), - getMissionWithHierarchy: vi.fn().mockReturnValue({ - id: "M-001", - status: "active", - milestones: [{ - id: "MS-001", - slices: [{ - id: "SL-001", - status: "active", - features: [matchedFeature], - }], - }], - }), - linkFeatureToTask: vi.fn().mockReturnValue(linkedFeature), - updateFeatureStatus: vi.fn(), - }); - - const scheduler = new Scheduler(store, { - missionStore: mockMissionStore as any, - }); - - const result = await scheduler.reconcileAllMissionFeatures(); - - expect(mockMissionStore.linkFeatureToTask).toHaveBeenCalledWith("F-1702", "FN-1702"); - expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-1702", "done"); - expect(result).toBe(2); - }); - - it("triages defined stranded features for active autopilot slices", async () => { - const triagedFeature = { - id: "F-001", - sliceId: "SL-001", - title: "Feature one", - taskId: "FN-TRIAGED", - status: "triaged", - }; - const store = createMockStore({ - getTask: vi.fn().mockResolvedValue(createMockTask({ id: "FN-TRIAGED", column: "todo" })), - }); - const mockMissionStore = createMockMissionStore({ - listMissions: vi.fn().mockReturnValue([{ id: "M-001", status: "active", autopilotEnabled: true }]), - getMissionWithHierarchy: vi.fn().mockReturnValue({ - id: "M-001", - status: "active", - milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features: [{ id: "F-001", sliceId: "SL-001", title: "Feature one", taskId: undefined, status: "defined" }] }] }], - }), - triageFeature: vi.fn().mockResolvedValue(triagedFeature), - updateFeatureStatus: vi.fn(), - }); - - const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); - const result = await scheduler.reconcileAllMissionFeatures(); - - expect(mockMissionStore.triageFeature).toHaveBeenCalledWith("F-001"); - expect(result).toBe(1); - }); - - it("does not triage stranded features for non-autopilot missions", async () => { - const store = createMockStore({ getTask: vi.fn() }); - const mockMissionStore = createMockMissionStore({ - listMissions: vi.fn().mockReturnValue([{ id: "M-001", status: "active", autopilotEnabled: false, autoAdvance: false }]), - getMissionWithHierarchy: vi.fn().mockReturnValue({ - id: "M-001", - status: "active", - milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features: [{ id: "F-001", sliceId: "SL-001", title: "Feature one", taskId: undefined, status: "defined" }] }] }], - }), - triageFeature: vi.fn(), - updateFeatureStatus: vi.fn(), - }); - - const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); - const result = await scheduler.reconcileAllMissionFeatures(); - - expect(mockMissionStore.triageFeature).not.toHaveBeenCalled(); - expect(result).toBe(0); - }); - - it("links title-matched stranded features instead of re-triaging", async () => { - const matchedTask = createMockTask({ - id: "FN-1703", - title: "Feature one", - missionId: "M-001", - sliceId: "SL-001", - column: "todo", - } as Partial); - const store = createMockStore({ - listTasks: vi.fn().mockResolvedValue([matchedTask]), - getTask: vi.fn().mockResolvedValue(matchedTask), - }); - const mockMissionStore = createMockMissionStore({ - listMissions: vi.fn().mockReturnValue([{ id: "M-001", status: "active", autopilotEnabled: true }]), - getMissionWithHierarchy: vi.fn().mockReturnValue({ - id: "M-001", - status: "active", - milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features: [{ id: "F-001", sliceId: "SL-001", title: "Feature one", taskId: undefined, status: "triaged" }] }] }], - }), - triageFeature: vi.fn(), - linkFeatureToTask: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001", title: "Feature one", taskId: "FN-1703", status: "triaged" }), - updateFeatureStatus: vi.fn(), - }); - - const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); - const result = await scheduler.reconcileAllMissionFeatures(); - - expect(mockMissionStore.linkFeatureToTask).toHaveBeenCalledWith("F-001", "FN-1703"); - expect(mockMissionStore.triageFeature).not.toHaveBeenCalled(); - expect(result).toBe(1); - }); - - it("skips inconsistent non-defined stranded features with no title match", async () => { - const store = createMockStore({ getTask: vi.fn() }); - const mockMissionStore = createMockMissionStore({ - listMissions: vi.fn().mockReturnValue([{ id: "M-001", status: "active", autopilotEnabled: true }]), - getMissionWithHierarchy: vi.fn().mockReturnValue({ - id: "M-001", - status: "active", - milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features: [{ id: "F-001", sliceId: "SL-001", title: "Feature one", taskId: undefined, status: "triaged" }] }] }], - }), - triageFeature: vi.fn(), - updateFeatureStatus: vi.fn(), - }); - - const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); - const result = await scheduler.reconcileAllMissionFeatures(); - - expect(mockMissionStore.triageFeature).not.toHaveBeenCalled(); - expect(result).toBe(0); - }); - - it("skips blocked stranded features", async () => { - const store = createMockStore({ getTask: vi.fn() }); - const mockMissionStore = createMockMissionStore({ - listMissions: vi.fn().mockReturnValue([{ id: "M-001", status: "active", autopilotEnabled: true }]), - getMissionWithHierarchy: vi.fn().mockReturnValue({ - id: "M-001", - status: "active", - milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features: [{ id: "F-001", sliceId: "SL-001", title: "Feature one", taskId: undefined, status: "blocked" }] }] }], - }), - triageFeature: vi.fn(), - }); - - const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); - const result = await scheduler.reconcileAllMissionFeatures(); - - expect(mockMissionStore.triageFeature).not.toHaveBeenCalled(); - expect(result).toBe(0); - }); - - it("emits stranded-feature audit events for triaged features", async () => { - const triagedFeature = { - id: "F-001", - sliceId: "SL-001", - title: "Feature one", - taskId: "FN-TRIAGED", - status: "triaged", - }; - const recordRunAuditEvent = vi.fn().mockResolvedValue(undefined); - const store = createMockStore({ - recordRunAuditEvent, - getTask: vi.fn().mockResolvedValue(createMockTask({ id: "FN-TRIAGED", column: "todo" })), - }); - const mockMissionStore = createMockMissionStore({ - listMissions: vi.fn().mockReturnValue([{ id: "M-001", status: "active", autopilotEnabled: true }]), - getMissionWithHierarchy: vi.fn().mockReturnValue({ - id: "M-001", - status: "active", - milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features: [{ id: "F-001", sliceId: "SL-001", title: "Feature one", taskId: undefined, status: "defined" }] }] }], - }), - triageFeature: vi.fn().mockResolvedValue(triagedFeature), - }); - - const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any }); - await scheduler.reconcileAllMissionFeatures(); - - expect(recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ - domain: "database", - mutationType: "mission:stranded-feature-triaged", - target: "F-001", - metadata: expect.objectContaining({ missionId: "M-001", sliceId: "SL-001", featureId: "F-001", taskId: "FN-TRIAGED" }), - })); - }); - - it("skips features without taskId", async () => { - const store = createMockStore({ - getTask: vi.fn(), - }); - const mockMissionStore = createMockMissionStore({ - listMissions: vi.fn().mockReturnValue([ - { id: "M-001", status: "active" }, - ]), - getMissionWithHierarchy: vi.fn().mockReturnValue({ - id: "M-001", - status: "active", - milestones: [{ - id: "MS-001", - slices: [{ - id: "SL-001", - status: "active", - features: [{ - id: "F-001", - taskId: undefined, // No linked task - status: "defined", - }], - }], - }], - }), - updateFeatureStatus: vi.fn(), - }); - - const scheduler = new Scheduler(store, { - missionStore: mockMissionStore as any, - }); - - const result = await scheduler.reconcileAllMissionFeatures(); - - expect(store.getTask).not.toHaveBeenCalled(); - expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled(); - expect(result).toBe(0); - }); - - it("skips inactive slices", async () => { - const store = createMockStore({ - getTask: vi.fn(), - }); - const mockMissionStore = createMockMissionStore({ - listMissions: vi.fn().mockReturnValue([ - { id: "M-001", status: "active" }, - ]), - getMissionWithHierarchy: vi.fn().mockReturnValue({ - id: "M-001", - status: "active", - milestones: [{ - id: "MS-001", - slices: [{ - id: "SL-001", - status: "pending", // Not active - features: [{ - id: "F-001", - taskId: "FN-001", - status: "triaged", - }], - }], - }], - }), - updateFeatureStatus: vi.fn(), - }); - - const scheduler = new Scheduler(store, { - missionStore: mockMissionStore as any, - }); - - const result = await scheduler.reconcileAllMissionFeatures(); - - expect(store.getTask).not.toHaveBeenCalled(); - expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled(); - expect(result).toBe(0); - }); - - it("handles multiple features across multiple missions and slices", async () => { - const store = createMockStore({ - getTask: vi.fn(async (id: string) => { - const columns: Record = { - "FN-001": "in-progress", - "FN-002": "done", - "FN-003": "triage", - }; - return createMockTask({ id, column: columns[id] as "todo" }); - }) as unknown as (id: string) => Promise, - }); - const mockMissionStore = createMockMissionStore({ - listMissions: vi.fn().mockReturnValue([ - { id: "M-001", status: "active" }, - { id: "M-002", status: "active" }, - ]), - getMissionWithHierarchy: vi.fn((id: string) => { - if (id === "M-001") { - return { - id: "M-001", - status: "active", - milestones: [{ - id: "MS-001", - slices: [{ - id: "SL-001", - status: "active", - features: [ - { id: "F-001", taskId: "FN-001", status: "triaged" }, // Should update to in-progress - { id: "F-002", taskId: "FN-002", status: "in-progress" }, // Should update to done - ], - }], - }], - }; - } - return { - id: "M-002", - status: "active", - milestones: [{ - id: "MS-002", - slices: [{ - id: "SL-002", - status: "active", - features: [ - { id: "F-003", taskId: "FN-003", status: "in-progress" }, // Should update to triaged - ], - }], - }], - }; - }), - updateFeatureStatus: vi.fn(), - }); - - const scheduler = new Scheduler(store, { - missionStore: mockMissionStore as any, - }); - - const result = await scheduler.reconcileAllMissionFeatures(); - - expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledTimes(3); - expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "in-progress"); - expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-002", "done"); - expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-003", "triaged"); - expect(result).toBe(3); - }); - }); -}); diff --git a/packages/engine/src/__tests__/workflow-graph-task-runner.test.ts b/packages/engine/src/__tests__/workflow-graph-task-runner.test.ts index 3e7b8d937f..fc92ddd265 100644 --- a/packages/engine/src/__tests__/workflow-graph-task-runner.test.ts +++ b/packages/engine/src/__tests__/workflow-graph-task-runner.test.ts @@ -185,15 +185,14 @@ describe("WorkflowGraphTaskRunner (CU-U2)", () => { expect(calls).toEqual(["custom:lint"]); }); - it("falls back when the flag is off", async () => { + it("ignores stale workflowGraphExecutor=false and still runs the graph", async () => { const runner = new WorkflowGraphTaskRunner({ store: storeWith(definition(fullLifecycleIr())), seams: recordingSeams([]), runCustomNode: async () => ({ outcome: "success" }), }); const result = await runner.run(task, flagOff); - expect(result.disposition).toBe("fell-back"); - expect(result.reason).toBe("flag-off"); + expect(result.disposition).toBe("completed"); }); it("falls back when the task has no workflow selection", async () => { diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index 0169070601..74f3de1776 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -1212,18 +1212,49 @@ export class Scheduler { } this.wasEnginePaused = false; - // ── U6: hold/release sweep (flag-ON only) ────────────────────────────── - // Flag OFF: this is skipped entirely — the legacy pull-from-todo loop - // below is byte-identical. Flag ON: the sweep evaluates hold-column - // release conditions (manual/timer/capacity/dependency/external-event) and - // releases eligible cards via moveSource:"scheduler", serializing through - // the in-txn capacity check. For the DEFAULT workflow the legacy loop below - // still drives todo→in-progress pickup (parity); the sweep adds custom- - // workflow hold handling and the generalized capacity-release path. + // ── U6: hold/release sweep ───────────────────────────────────────────── + /* + FNXC:WorkflowScheduling 2026-06-23-10:32: + Workflow columns graduated from Experimental and are now the scheduler's only dispatch model. The hold/release sweep owns todo→in-progress pickup, so do not fall through into the legacy pull-from-todo dispatcher after the sweep runs. + */ if (isWorkflowColumnsEnabled(settings)) { await this.runHoldReleaseSweepPass(tasks, settings); tasks = await this.store.listTasks({ slim: true, includeArchived: false, startupMemo: false }); settings = await this.store.getSettings(); + await this.emitHighOverlapFanoutWarnings(tasks); + + const staleWarningWindows = [settings.staleInProgressWarningMs, settings.staleInReviewWarningMs] + .filter((value): value is number => typeof value === "number" && Number.isFinite(value) && value > 0); + const minWarningMs = staleWarningWindows.length > 0 ? Math.min(...staleWarningWindows) : 0; + if (minWarningMs > 0 && Date.now() - this.lastStaleTaskReportAt >= minWarningMs) { + try { + await this.staleTaskReporter.report(); + this.lastStaleTaskReportAt = Date.now(); + } catch (error) { + schedulerLog.warn("Stale task reporter failed", error); + } + } + + if (settings.backlogPressureAlertEnabled !== false && Date.now() - this.lastBacklogPressureReportAt >= 60_000) { + try { + await this.backlogPressureReporter.report(); + } catch (error) { + schedulerLog.warn("Backlog pressure reporter failed", error); + } finally { + this.lastBacklogPressureReportAt = Date.now(); + } + } + + if (Date.now() - this.lastUnlinkedMissionsAdvisoryReportAt >= 60_000) { + try { + await this.unlinkedMissionsAdvisoryReporter.report(); + } catch (error) { + schedulerLog.warn("Unlinked missions advisory reporter failed", error); + } finally { + this.lastUnlinkedMissionsAdvisoryReportAt = Date.now(); + } + } + return; } const maxConcurrent = settings.maxConcurrent ?? this.options.maxConcurrent ?? 2; diff --git a/packages/engine/vitest.config.ts b/packages/engine/vitest.config.ts index 50317d2eef..973fb3f974 100644 --- a/packages/engine/vitest.config.ts +++ b/packages/engine/vitest.config.ts @@ -70,14 +70,16 @@ export default defineConfig({ "src/__tests__/merger-diff-scope.test.ts", "src/__tests__/merger-landed-files-capture.test.ts", "src/__tests__/branch-attribution.test.ts", - "src/__tests__/executor-core.test.ts", - "src/__tests__/executor-recovery.test.ts", + /* + FNXC:EngineTests 2026-06-23-10:48: + Workflow columns and workflow graph execution are now the default runtime. Retire the legacy direct-dispatch executor/scheduler gate files and gate the new hold-release plus graph interpreter seams instead. + */ + "src/__tests__/hold-release.test.ts", + "src/__tests__/workflow-graph-task-runner.test.ts", + "src/__tests__/workflow-graph-executor-parity.test.ts", "src/__tests__/executor-base-commit-capture.test.ts", "src/__tests__/executor-capture-modified-files-attribution.test.ts", "src/__tests__/triage-preflight.test.ts", - "src/__tests__/scheduler.test.ts", - "src/__tests__/scheduler-node-routing.test.ts", - "src/__tests__/scheduler-overlap-requeue.test.ts", "src/__tests__/mission-scheduler.test.ts", "src/__tests__/heartbeat-monitor.test.ts", "src/__tests__/workflow-node-handlers.test.ts",