diff --git a/packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts b/packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts index dc8da94af8..a72d667ae3 100644 --- a/packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts +++ b/packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts @@ -137,11 +137,11 @@ describe("fast mode workflow/runtime invariants", () => { // called. Fast mode is irrelevant to a bypassed group; the seam is simply gone. it("graph executor with builtin:coding selection bypasses the disabled browser-verification group", async () => { const { executor } = makeExecutorForTask(task({ executionMode: "fast", worktree: "/tmp/wt" })); - const runWorkflowSteps = vi.spyOn(executor as any, "runWorkflowSteps").mockResolvedValue(workflowResult()); + // U4 (KTD-2): runWorkflowSteps + the workflow-step seam were removed; workflow + // gates run as graph optional-group nodes only. const seams = { planning: vi.fn(async () => ({ outcome: "success", value: "planned" })), execute: vi.fn(async () => ({ outcome: "success", value: "implemented" })), - workflowStep: (executor as any).createAuthoritativeWorkflowSeams({}).workflowStep, review: vi.fn(async () => ({ outcome: "success", value: "approved" })), merge: vi.fn(async () => ({ outcome: "success", value: "merged" })), schedule: vi.fn(async () => ({ outcome: "success", value: "scheduled" })), @@ -161,7 +161,6 @@ describe("fast mode workflow/runtime invariants", () => { expect(result.visitedNodeIds).toContain("browser-verification"); expect(result.visitedNodeIds).not.toContain("browser-verification::browser-verification-step"); expect(result.visitedNodeIds).not.toContain("workflow-step"); - expect(runWorkflowSteps).not.toHaveBeenCalled(); expect(seams.review).toHaveBeenCalledTimes(1); expect(seams.merge).toHaveBeenCalledTimes(1); }); @@ -219,42 +218,11 @@ describe("fast mode workflow/runtime invariants", () => { expect(awaitInput).toHaveBeenCalledTimes(1); }); - it.each([ - ["legacy seam", (executor: TaskExecutor, settings: any) => (executor as any).createAuthoritativeWorkflowSeams(settings).workflowStep(task({ id: "FN-6226" }), {})], - ["graph primitive", (executor: TaskExecutor, settings: any) => (executor as any).createAuthoritativeWorkflowPrimitives(settings).runWorkflowStep( - { run: { taskId: "FN-6226" }, node: { node: { id: "workflow-step" }, context: {} } }, - task({ id: "FN-6226" }), - { phase: "pre-merge", worktreePath: "/tmp/wt" }, - )], - ])("%s skips pre-merge workflow steps in fast mode", async (_label, invoke) => { - const { executor } = makeExecutorForTask(task({ executionMode: "fast", worktree: "/tmp/wt" })); - const runWorkflowSteps = vi.spyOn(executor as any, "runWorkflowSteps").mockResolvedValue(workflowResult()); - - const result = await invoke(executor, { experimentalFeatures: { workflowGraphExecutor: true } }); - - expect(result.outcome).toBe("success"); - expect(result.value).toBe("workflow-step-skipped"); - expect(runWorkflowSteps).not.toHaveBeenCalled(); - }); - - it.each([ - ["legacy seam", (executor: TaskExecutor, settings: any) => (executor as any).createAuthoritativeWorkflowSeams(settings).workflowStep(task({ id: "FN-6226" }), {})], - ["graph primitive", (executor: TaskExecutor, settings: any) => (executor as any).createAuthoritativeWorkflowPrimitives(settings).runWorkflowStep( - { run: { taskId: "FN-6226" }, node: { node: { id: "workflow-step" }, context: {} } }, - task({ id: "FN-6226" }), - { phase: "pre-merge", worktreePath: "/tmp/wt" }, - )], - ])("%s runs pre-merge workflow steps for standard and default execution modes", async (_label, invoke) => { - for (const executionMode of ["standard", undefined]) { - const { executor } = makeExecutorForTask(task({ executionMode, worktree: "/tmp/wt" })); - const runWorkflowSteps = vi.spyOn(executor as any, "runWorkflowSteps").mockResolvedValue(workflowResult()); - - const result = await invoke(executor, { experimentalFeatures: { workflowGraphExecutor: true } }); - - expect(result.outcome).toBe("success"); - expect(runWorkflowSteps).toHaveBeenCalledTimes(1); - } - }); + // U4 (KTD-2): the legacy `workflow-step` seam and `runWorkflowStep` primitive + // were removed, so the two it.each blocks that drove them directly (fast-mode + // skip + standard-mode run) are gone. Fast-mode skip of workflow gates is now + // covered above by the custom-node tests ("skips custom %s nodes in fast mode") + // and by builtin-coding-workflow-step-results.test.ts (graph recording path). it("keeps fn_task_done mandatory while excluding fn_review_step in fast mode", async () => { mockedCreateFnAgent.mockImplementation(async (opts: any) => ({ diff --git a/packages/engine/src/__tests__/executor-pause.test.ts b/packages/engine/src/__tests__/executor-pause.test.ts index e3667b32d9..5f645d76cb 100644 --- a/packages/engine/src/__tests__/executor-pause.test.ts +++ b/packages/engine/src/__tests__/executor-pause.test.ts @@ -2774,78 +2774,11 @@ describe("StepSessionExecutor integration", () => { expect(mockCleanup).toHaveBeenCalled(); }); - it("workflow steps run on success and block on failure", async () => { - const store = createStepSessionStore(); - - // Enable a workflow step - store.getTask.mockResolvedValue({ - id: "FN-200", - title: "Step-session test task", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [ - { name: "Step 0", status: "pending" }, - ], - currentStep: 0, - log: [], - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - baseCommitSha: "abc123", - enabledWorkflowSteps: ["WS-001"], - }); - - store.getWorkflowStep.mockResolvedValue({ - id: "WS-001", - name: "Test Workflow", - description: "Test", - mode: "script", - phase: "pre-merge", - scriptName: "test-script", - prompt: undefined, - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Steps succeed, but workflow step will fail - mockExecuteAll.mockResolvedValue([ - { stepIndex: 0, success: true, retries: 0 }, - ]); - - const onError = vi.fn(); - const executor = new TaskExecutor(store, "/tmp/test", { onError }); - - // Use fake timers to control the setTimeout in sendTaskBackForFix - vi.useFakeTimers(); - - // Exhaust retries so workflow step failure is immediate - await executor.execute(createTaskWithSteps({ steps: [{ name: "Step 0", status: "pending" }], workflowStepRetries: 3, enabledWorkflowSteps: ["WS-001"] })); - - // Should have called getWorkflowStep to look up the workflow step - expect(store.getWorkflowStep).toHaveBeenCalledWith("WS-001"); - // With script mode and no scripts configured, the step should fail (script not found) - // Task should be sent back to in-progress for remediation, NOT call onError - expect(store.addTaskComment).toHaveBeenCalledWith( - "FN-200", - expect.stringContaining("Workflow step failed"), - "agent", - ); - // onError should NOT be called (task is being retried, not permanently failed) - expect(onError).not.toHaveBeenCalled(); - - // Advance timers to trigger the setTimeout that moves task to todo then in-progress - await vi.advanceTimersByTimeAsync(0); - - // Task should move to todo then in-progress (not in-review). The - // workflow-rerun bounce flags preserveResumeState so the worktree and - // accumulated step progress survive the transient todo state. - expect(store.moveTask).toHaveBeenCalledWith("FN-200", "todo", { preserveResumeState: true, preserveWorktree: true }); - expect(store.moveTask).toHaveBeenCalledWith("FN-200", "in-progress"); - - vi.useRealTimers(); - }); + // U4 (KTD-2): removed "workflow steps run on success and block on failure" — + // it drove the deleted legacy runWorkflowSteps loop via execute() on a minimal + // store (no getTaskWorkflowSelection). That path now fails closed (KTD-5), and + // the run-on-success / block-on-failure behavior is covered through the graph by + // builtin-coding-workflow-step-results.test.ts. it("onStepStart callback updates step status to in-progress", async () => { const store = createStepSessionStore(); diff --git a/packages/engine/src/__tests__/executor-step-session.test.ts b/packages/engine/src/__tests__/executor-step-session.test.ts index ced6e0d2d9..fd8c3f9d91 100644 --- a/packages/engine/src/__tests__/executor-step-session.test.ts +++ b/packages/engine/src/__tests__/executor-step-session.test.ts @@ -440,408 +440,6 @@ describe("Workflow Steps Execution", () => { }); }); - it("runs workflow steps after main task execution", async () => { - const store = createMockStore(); - - // Task has workflow steps enabled - 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"], - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - store.getWorkflowStep.mockResolvedValue({ - id: "WS-001", - name: "Docs Review", - description: "Check documentation", - prompt: "Review all docs and verify they are complete.", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // First call: main agent with fn_task_done, subsequent calls: simple mocks for workflow step agents - let callIdx = 0; - mockedCreateFnAgent.mockImplementation((async (opts: any) => { - callIdx++; - if (callIdx === 1) { - // Main execution — find and trigger fn_task_done - 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 }; - } else { - // Workflow step agent (no custom tools, uses readonly tools) - return { - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - subscribe: vi.fn(), - on: vi.fn(), - state: {}, - }, - }; - } - }) 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"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // createFnAgent called twice: main agent + workflow step agent - expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); - - // Second call should be the workflow step with readonly tools - const secondCall = mockedCreateFnAgent.mock.calls[1]; - expect(secondCall[0].tools).toBe("readonly"); - expect(secondCall[0].systemPrompt).toContain("Docs Review"); - expect(secondCall[0].systemPrompt).toContain("Review all docs and verify they are complete."); - const withoutWorkflowStep = (env: Record) => { - const { FUSION_WORKFLOW_STEP: _workflowStep, ...stableEnv } = env; - return stableEnv; - }; - expect(secondCall[0].taskEnv.FUSION_WORKFLOW_STEP).toBe("1"); - expect(withoutWorkflowStep(secondCall[0].taskEnv)).toEqual(withoutWorkflowStep(mockedCreateFnAgent.mock.calls[0][0].taskEnv)); - - // Task should move to in-review - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); - }); - - it("executes plugin-prefixed workflow steps", async () => { - const store = createMockStore(); - - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [{ name: "Preflight", status: "pending" }], - currentStep: 0, - log: [], - enabledWorkflowSteps: ["plugin:agent-browser:workflow-check"], - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - store.getWorkflowStep.mockResolvedValue({ - id: "plugin:agent-browser:workflow-check", - name: "Plugin Workflow Check", - description: "Plugin contributed step", - prompt: "Run plugin check", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - let callIdx = 0; - mockedCreateFnAgent.mockImplementation((async (opts: any) => { - callIdx++; - if (callIdx === 1) { - const customTools = opts.customTools || []; - return { - 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: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - subscribe: vi.fn(), - on: vi.fn(), - state: {}, - }, - }; - }) 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: ["plugin:agent-browser:workflow-check"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - expect(store.setPluginWorkflowStepTemplates).toHaveBeenCalledWith([]); - expect(store.logEntry).toHaveBeenCalledWith( - "FN-001", - "[pre-merge] Starting plugin workflow step: Plugin Workflow Check (plugin:agent-browser:workflow-check)", - ); - }); - - it("runs browser verification workflow steps with coding tools", async () => { - const store = createMockStore(); - - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Browser task", - description: "Verify browser behavior", - column: "in-progress", - dependencies: [], - steps: [{ name: "Preflight", status: "pending" }], - currentStep: 0, - log: [], - enabledWorkflowSteps: ["WS-001"], - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - store.getWorkflowStep.mockResolvedValue({ - id: "WS-001", - templateId: "browser-verification", - name: "Browser Verification", - description: "Verify with browser automation", - mode: "prompt", - toolMode: "coding", - prompt: "Use browser automation to verify the app.", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - let callIdx = 0; - mockedCreateFnAgent.mockImplementation((async (opts: any) => { - callIdx++; - if (callIdx === 1) { - 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 }; - } - - return { - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - subscribe: vi.fn(), - on: vi.fn(), - state: {}, - }, - }; - }) as any); - - const executor = new TaskExecutor(store, "/tmp/test"); - await executor.execute({ - id: "FN-001", - title: "Browser task", - description: "Verify browser behavior", - column: "in-progress", - dependencies: [], - steps: [{ name: "Preflight", status: "pending" }], - currentStep: 0, - log: [], - enabledWorkflowSteps: ["WS-001"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); - const secondCall = mockedCreateFnAgent.mock.calls[1]; - expect(secondCall[0].tools).toBe("coding"); - }); - - it("runs QA workflow steps with coding tools", async () => { - const store = createMockStore(); - - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "QA task", - description: "Verify tests pass", - column: "in-progress", - dependencies: [], - steps: [{ name: "Preflight", status: "pending" }], - currentStep: 0, - log: [], - enabledWorkflowSteps: ["WS-001"], - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - store.getWorkflowStep.mockResolvedValue({ - id: "WS-001", - templateId: "qa-check", - name: "QA Check", - description: "Run tests and verify they pass", - mode: "prompt", - toolMode: "coding", - prompt: "Run the test suite and report results.", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - let callIdx = 0; - mockedCreateFnAgent.mockImplementation((async (opts: any) => { - callIdx++; - if (callIdx === 1) { - 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 }; - } - - return { - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - subscribe: vi.fn(), - on: vi.fn(), - state: {}, - }, - }; - }) as any); - - const executor = new TaskExecutor(store, "/tmp/test"); - await executor.execute({ - id: "FN-001", - title: "QA task", - description: "Verify tests pass", - column: "in-progress", - dependencies: [], - steps: [{ name: "Preflight", status: "pending" }], - currentStep: 0, - log: [], - enabledWorkflowSteps: ["WS-001"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); - const secondCall = mockedCreateFnAgent.mock.calls[1]; - expect(secondCall[0].tools).toBe("coding"); - }); - - it("skips workflow steps with no prompt", async () => { - const store = createMockStore(); - - 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"], - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - store.getWorkflowStep.mockResolvedValue({ - id: "WS-001", - name: "Empty Step", - description: "No prompt", - prompt: "", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - createAgentWithTaskDone(); - - 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"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Should only call createFnAgent once (main execution), skip workflow step - expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1); - - // Should log that it was skipped - expect(store.logEntry).toHaveBeenCalledWith( - "FN-001", - expect.stringContaining("has no prompt"), - ); - - // Task should still move to in-review - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); - }); - it("handles tasks with no workflow steps", async () => { const store = createMockStore(); @@ -883,910 +481,6 @@ describe("Workflow Steps Execution", () => { expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); }); - it("uses workflow step model override when both provider and modelId are set", async () => { - const store = createMockStore(); - - 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"], - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - store.getWorkflowStep.mockResolvedValue({ - id: "WS-001", - name: "Security Audit", - description: "Check security", - prompt: "Scan for vulnerabilities.", - enabled: true, - modelProvider: "anthropic", - modelId: "claude-sonnet-4-5", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - let callIdx = 0; - mockedCreateFnAgent.mockImplementation((async (opts: any) => { - callIdx++; - if (callIdx === 1) { - // Main execution agent - 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 }; - } else { - // Workflow step agent - return { - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - subscribe: vi.fn(), - on: vi.fn(), - state: {}, - }, - }; - } - }) 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"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // createFnAgent called twice: main agent + workflow step agent - expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); - - // Second call should use the workflow step's model override - const secondCall = mockedCreateFnAgent.mock.calls[1]; - expect(secondCall[0].defaultProvider).toBe("anthropic"); - expect(secondCall[0].defaultModelId).toBe("claude-sonnet-4-5"); - - // Log should indicate the override - expect(store.logEntry).toHaveBeenCalledWith( - "FN-001", - expect.stringContaining("workflow step override"), - ); - }); - - it("uses global defaults when workflow step has no model override", async () => { - const store = createMockStore(); - - 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"], - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Workflow step without model override - store.getWorkflowStep.mockResolvedValue({ - id: "WS-001", - name: "Docs Review", - description: "Check documentation", - prompt: "Review all docs.", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - let callIdx = 0; - mockedCreateFnAgent.mockImplementation((async (opts: any) => { - callIdx++; - if (callIdx === 1) { - 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 }; - } else { - return { - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - subscribe: vi.fn(), - on: vi.fn(), - state: {}, - }, - }; - } - }) 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"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); - - // Second call should use settings defaults (no override indicator) - const secondCall = mockedCreateFnAgent.mock.calls[1]; - // defaults come from the mock store's getSettings - expect(secondCall[0].defaultProvider).toBeUndefined(); - expect(secondCall[0].defaultModelId).toBeUndefined(); - - // Log should NOT indicate override - expect(store.logEntry).not.toHaveBeenCalledWith( - "FN-001", - expect.stringContaining("workflow step override"), - ); - }); - - it("auto-skips built-in Frontend UX Design when diff scope has no frontend files", async () => { - const store = createMockStore(); - const task = { - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress" as const, - dependencies: [], - steps: [{ name: "Preflight", status: "done" as const }], - currentStep: 0, - log: [], - enabledWorkflowSteps: ["frontend-ux-design"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - store.getTask.mockResolvedValue(task as any); - store.getWorkflowStep.mockResolvedValue({ - id: "frontend-ux-design", - name: "Frontend UX Design", - description: "UI review", - prompt: "Review UI", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - mockedExecSync.mockImplementation((cmd: string | string[]) => { - if (typeof cmd === "string" && cmd.includes("git merge-base HEAD origin/main")) { - return Buffer.from("abc123\n"); - } - if (typeof cmd === "string" && cmd.includes("git diff --name-only abc123..HEAD")) { - return Buffer.from("packages/engine/src/foo.ts\n"); - } - return Buffer.from(""); - }); - - const executor = new TaskExecutor(store, "/tmp/test", {}); - vi.spyOn(executor as any, "captureModifiedFiles").mockResolvedValue(["packages/engine/src/foo.ts"]); - const executeStepSpy = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, output: "ok" }); - - const result = await (executor as any).runWorkflowSteps(task, "/tmp/test", {}); - - expect(result).toEqual({ allPassed: true }); - expect(executeStepSpy).not.toHaveBeenCalled(); - expect(store.updateTask).toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ - workflowStepResults: expect.arrayContaining([ - expect.objectContaining({ - workflowStepId: "frontend-ux-design", - status: "skipped", - output: expect.stringContaining("No frontend/UI files in diff scope"), - }), - ]), - }), - ); - expect(store.logEntry).toHaveBeenCalledWith( - "FN-001", - "[pre-merge] Auto-skipped Frontend UX Design — no frontend/UI files in diff scope", - ); - const logged = store.logEntry.mock.calls.map((call: any[]) => String(call[1] ?? "")); - expect(logged.some((line: string) => line.includes("Completion handoff deferred"))).toBe(false); - }); - - it("runs built-in Frontend UX Design normally when UI files are in scope", async () => { - const store = createMockStore(); - const task = { - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress" as const, - dependencies: [], - steps: [{ name: "Preflight", status: "done" as const }], - currentStep: 0, - log: [], - enabledWorkflowSteps: ["frontend-ux-design"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - store.getTask.mockResolvedValue(task as any); - store.getWorkflowStep.mockResolvedValue({ - id: "frontend-ux-design", - name: "Frontend UX Design", - description: "UI review", - prompt: "Review UI", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - mockedExecSync.mockImplementation((cmd: string | string[]) => { - if (typeof cmd === "string" && cmd.includes("git merge-base HEAD origin/main")) { - return Buffer.from("abc123\n"); - } - if (typeof cmd === "string" && cmd.includes("git diff --name-only abc123..HEAD")) { - return Buffer.from("packages/dashboard/app/components/Foo.tsx\n"); - } - return Buffer.from(""); - }); - - const executor = new TaskExecutor(store, "/tmp/test", {}); - const executeStepSpy = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, output: "approved" }); - - const result = await (executor as any).runWorkflowSteps(task, "/tmp/test", {}); - - expect(result).toEqual({ allPassed: true }); - expect(executeStepSpy).toHaveBeenCalledTimes(1); - expect(store.updateTask).toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ - workflowStepResults: expect.arrayContaining([ - expect.objectContaining({ workflowStepId: "frontend-ux-design", status: "passed" }), - ]), - }), - ); - const logged = store.logEntry.mock.calls.map((call: any[]) => String(call[1] ?? "")); - expect(logged.some((line: string) => line.includes("Auto-skipped Frontend UX Design"))).toBe(false); - }); - - it("avoids paused defer for built-in Frontend UX Design when no UI files are in scope", async () => { - const store = createMockStore(); - const pausedTask = { - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress" as const, - dependencies: [], - steps: [{ name: "Preflight", status: "done" as const }], - currentStep: 0, - paused: true, - log: [], - enabledWorkflowSteps: ["frontend-ux-design"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - store.getTask.mockResolvedValue(pausedTask as any); - store.getWorkflowStep.mockResolvedValue({ - id: "frontend-ux-design", - name: "Frontend UX Design", - description: "UI review", - prompt: "Review UI", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - mockedExecSync.mockImplementation((cmd: string | string[]) => { - if (typeof cmd === "string" && cmd.includes("git merge-base HEAD origin/main")) { - return Buffer.from("abc123\n"); - } - if (typeof cmd === "string" && cmd.includes("git diff --name-only abc123..HEAD")) { - return Buffer.from("packages/engine/src/foo.ts\n"); - } - return Buffer.from(""); - }); - - const executor = new TaskExecutor(store, "/tmp/test", {}); - vi.spyOn(executor as any, "captureModifiedFiles").mockResolvedValue(["packages/engine/src/foo.ts"]); - const executeStepSpy = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, output: "ok" }); - - const result = await (executor as any).runWorkflowSteps(pausedTask, "/tmp/test", {}); - - expect(result).toEqual({ allPassed: true }); - expect(executeStepSpy).not.toHaveBeenCalled(); - const logged = store.logEntry.mock.calls.map((call: any[]) => String(call[1] ?? "")); - expect(logged.some((line: string) => line.includes("Auto-skipped Frontend UX Design — no frontend/UI files in diff scope"))).toBe(true); - expect(logged.some((line: string) => line.includes("Completion handoff deferred — task paused (before workflow step 'Frontend UX Design')"))).toBe(false); - }); - - it("does not auto-skip custom step id even when named Frontend UX Design", async () => { - const store = createMockStore(); - const task = { - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress" as const, - dependencies: [], - steps: [{ name: "Preflight", status: "done" as const }], - currentStep: 0, - log: [], - enabledWorkflowSteps: ["ws-custom-1"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - store.getTask.mockResolvedValue(task as any); - store.getWorkflowStep.mockResolvedValue({ - id: "ws-custom-1", - name: "Frontend UX Design", - description: "Custom UI review", - prompt: "Review UI", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - const executor = new TaskExecutor(store, "/tmp/test", {}); - const executeStepSpy = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, output: "ok" }); - - const result = await (executor as any).runWorkflowSteps(task, "/tmp/test", {}); - - expect(result).toEqual({ allPassed: true }); - expect(executeStepSpy).toHaveBeenCalledTimes(1); - }); - - it("executes script-mode workflow step successfully", async () => { - const store = createMockStore(); - process.env.FN3968_SCRIPT_ENV = "workflow-script-env"; - - store.getSettings.mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - scripts: { test: `node -e "if (process.env.FN3968_SCRIPT_ENV !== 'workflow-script-env') process.exit(42)"` }, - }); - - 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"], - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - store.getWorkflowStep.mockResolvedValue({ - id: "WS-001", - name: "Run Tests", - description: "Execute test suite", - mode: "script", - prompt: "", - scriptName: "test", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Main agent with fn_task_done - createAgentWithTaskDone(); - - 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"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Should only call createFnAgent once (main execution — no agent for script mode) - expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1); - - // Should log script execution - expect(store.logEntry).toHaveBeenCalledWith( - "FN-001", - expect.stringContaining("executing script 'test'"), - ); - - // Task should move to in-review - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); - - // Should record a passed result - expect(store.updateTask).toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ - workflowStepResults: expect.arrayContaining([ - expect.objectContaining({ - workflowStepId: "WS-001", - workflowStepName: "Run Tests", - status: "passed", - output: "Script 'test' completed successfully", - }), - ]), - }), - ); - delete process.env.FN3968_SCRIPT_ENV; - }); - - it("executes plugin script-mode workflow step successfully", async () => { - const store = createMockStore(); - - store.getSettings.mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - scripts: { test: "echo 'all tests passed'" }, - }); - - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [{ name: "Preflight", status: "pending" }], - currentStep: 0, - log: [], - enabledWorkflowSteps: ["plugin:agent-browser:script-check"], - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - store.getWorkflowStep.mockResolvedValue({ - id: "plugin:agent-browser:script-check", - name: "Plugin Script Check", - description: "Execute plugin script", - mode: "script", - prompt: "", - scriptName: "test", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - mockedExecSync.mockImplementation((cmd: string | string[]) => { - if (typeof cmd === "string" && cmd.includes("echo")) return Buffer.from("all tests passed\n"); - return Buffer.from(""); - }); - - createAgentWithTaskDone(); - 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: ["plugin:agent-browser:script-check"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1); - expect(store.updateTask).toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ - workflowStepResults: expect.arrayContaining([ - expect.objectContaining({ - workflowStepId: "plugin:agent-browser:script-check", - status: "passed", - }), - ]), - }), - ); - }); - - it("executes mixed db and plugin workflow steps in sequence", async () => { - const store = createMockStore(); - - 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", "plugin:agent-browser:workflow-check"], - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - store.getWorkflowStep.mockImplementation(async (id: string) => id === "WS-001" - ? { - id: "WS-001", name: "DB Step", description: "DB", prompt: "Run DB step", enabled: true, - createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - } - : { - id: "plugin:agent-browser:workflow-check", name: "Plugin Step", description: "Plugin", prompt: "Run plugin step", enabled: true, - createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - }); - - let callIdx = 0; - mockedCreateFnAgent.mockImplementation((async (opts: any) => { - callIdx++; - if (callIdx === 1) { - const customTools = opts.customTools || []; - return { 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: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), state: {} } }; - }) 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", "plugin:agent-browser:workflow-check"], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - }); - - expect(store.getWorkflowStep).toHaveBeenNthCalledWith(1, "WS-001"); - expect(store.getWorkflowStep).toHaveBeenNthCalledWith(2, "plugin:agent-browser:workflow-check"); - expect(mockedCreateFnAgent).toHaveBeenCalledTimes(3); - }); - - it("skips missing plugin workflow step IDs with warning log", async () => { - const store = createMockStore(); - - store.getTask.mockResolvedValue({ - id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [], - steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], - enabledWorkflowSteps: ["plugin:missing:step"], prompt: "# test", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - }); - store.getWorkflowStep.mockResolvedValue(undefined); - createAgentWithTaskDone(); - - 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: ["plugin:missing:step"], - createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - }); - - expect(store.logEntry).toHaveBeenCalledWith("FN-001", "[pre-merge] Workflow step plugin:missing:step not found — skipping"); - }); - - it("sends task back to in-progress when script-mode workflow step fails with exhausted retries", async () => { - const store = createMockStore(); - - store.getSettings.mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - scripts: { lint: "pnpm lint" }, - }); - - // Mutable task object to track step changes - const mutableTask = { - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress" as const, - dependencies: [] as string[], - steps: [{ name: "Preflight", status: "pending" as const }], - currentStep: 0, - log: [] as any[], - enabledWorkflowSteps: ["WS-001"], - workflowStepRetries: 3, // Exhaust retries so task fails immediately - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - store.getTask.mockResolvedValue(mutableTask); - - // Make updateStep track changes in the mutable task - store.updateStep.mockImplementation(async (taskId: string, stepIndex: number, status: string) => { - if (mutableTask.steps[stepIndex]) { - mutableTask.steps[stepIndex].status = status as any; - } - return {}; - }); - - store.getWorkflowStep.mockResolvedValue({ - id: "WS-001", - name: "Lint Check", - description: "Run linter", - mode: "script", - prompt: "", - scriptName: "lint", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Mock execSync to throw for the lint command - const scriptErr = new Error("Command failed: pnpm lint"); - (scriptErr as any).status = 1; - (scriptErr as any).stderr = Buffer.from("syntax error on line 42\n"); - (scriptErr as any).stdout = Buffer.from(""); - mockedExecSync.mockImplementation((cmd: string | string[]) => { - if (typeof cmd === "string" && cmd.includes("lint")) { - throw scriptErr; - } - return Buffer.from(""); - }); - - // Use createAgentWithTaskDone to properly set up the agent mock - createAgentWithTaskDone(); - - const onComplete = vi.fn(); - const onError = vi.fn(); - const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError }); - - // Use fake timers to control the setTimeout in sendTaskBackForFix - vi.useFakeTimers(); - - 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"], - workflowStepRetries: 3, // Exhaust retries so task fails immediately - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Should record a failed result with exit code and stderr - // (This may not be the first call, so check if any call has workflowStepResults) - const updateTaskCalls = store.updateTask.mock.calls; - const hasWorkflowStepFailure = updateTaskCalls.some( - (call: any[]) => - call[0] === "FN-001" && - call[1]?.workflowStepResults?.some( - (r: any) => - r.workflowStepId === "WS-001" && - r.workflowStepName === "Lint Check" && - r.status === "failed" && - r.output?.includes("Exit code: 1") - ) - ); - expect(hasWorkflowStepFailure).toBe(true); - - // Task should be cleared and reset for retry (not failed + in-review) - expect(store.updateTask).toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: null, error: null, sessionFile: null, workflowStepRetries: 0 }), - ); - - // Should add a comment with failure feedback - // This will fail if sendTaskBackForFix is not called - expect(store.addTaskComment).toHaveBeenCalledWith( - "FN-001", - expect.stringContaining("Workflow step failed"), - "agent", - ); - - // Should reset all steps to pending - // Check that updateStep was called with "pending" for step 0 - // (There may be multiple calls - first from fn_task_done marking it done, second from sendTaskBackForFix resetting it) - const updateStepCalls = store.updateStep.mock.calls; - const hasResetToPending = updateStepCalls.some( - (call: any[]) => call[0] === "FN-001" && call[1] === 0 && call[2] === "pending" - ); - expect(hasResetToPending).toBe(true); - - // Advance timers to trigger the setTimeout that moves task to todo then in-progress - vi.advanceTimersByTime(0); - // Run any pending microtasks (the async code in setTimeout) - await vi.runAllTimersAsync(); - - // Task should move to todo then in-progress (not in-review). The hop to - // todo must flag preserveResumeState so the workflow-rerun bounce keeps - // the worktree and accumulated step progress through the transient - // todo state on its way back to in-progress. - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveResumeState: true, preserveWorktree: true }); - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress"); - - // onComplete should NOT be called (task is being retried, not completed) - expect(onComplete).not.toHaveBeenCalled(); - - // onError should NOT be called (task is being retried, not permanently failed) - expect(onError).not.toHaveBeenCalled(); - - vi.useRealTimers(); - }); - - it("sends task back to in-progress when script is missing from settings.scripts", async () => { - const store = createMockStore(); - - store.getSettings.mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - scripts: { other: "echo other" }, - }); - - // Mutable task object to track step changes - const mutableTask = { - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress" as const, - dependencies: [] as string[], - steps: [{ name: "Preflight", status: "pending" as const }], - currentStep: 0, - log: [] as any[], - enabledWorkflowSteps: ["WS-001"], - workflowStepRetries: 3, // Exhaust retries so task fails immediately - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - store.getTask.mockResolvedValue(mutableTask); - - // Make updateStep track changes in the mutable task - store.updateStep.mockImplementation(async (taskId: string, stepIndex: number, status: string) => { - if (mutableTask.steps[stepIndex]) { - mutableTask.steps[stepIndex].status = status as any; - } - return {}; - }); - - store.getWorkflowStep.mockResolvedValue({ - id: "WS-001", - name: "Missing Script", - description: "Uses nonexistent script", - mode: "script", - prompt: "", - scriptName: "nonexistent", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Use createAgentWithTaskDone to properly set up the agent mock - createAgentWithTaskDone(); - - const onComplete = vi.fn(); - const onError = vi.fn(); - const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError }); - - // Use fake timers to control the setTimeout in sendTaskBackForFix - vi.useFakeTimers(); - - 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"], - workflowStepRetries: 3, // Exhaust retries so task fails immediately - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Should log that the script was not found - expect(store.logEntry).toHaveBeenCalledWith( - "FN-001", - expect.stringContaining("not found in project settings"), - ); - - // Should record a failed result - expect(store.updateTask).toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ - workflowStepResults: expect.arrayContaining([ - expect.objectContaining({ - workflowStepId: "WS-001", - status: "failed", - output: expect.stringContaining("not found in project settings"), - }), - ]), - }), - ); - - // Task should be cleared and reset for retry (not failed + in-review) - expect(store.updateTask).toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: null, error: null, sessionFile: null, workflowStepRetries: 0 }), - ); - - // Should add a comment with failure feedback - expect(store.addTaskComment).toHaveBeenCalledWith( - "FN-001", - expect.stringContaining("Workflow step failed"), - "agent", - ); - - // Should reset all steps to pending - // Check that updateStep was called with "pending" for step 0 - // (There may be multiple calls - first from fn_task_done marking it done, second from sendTaskBackForFix resetting it) - const updateStepCalls = store.updateStep.mock.calls; - const hasResetToPending = updateStepCalls.some( - (call: any[]) => call[0] === "FN-001" && call[1] === 0 && call[2] === "pending" - ); - expect(hasResetToPending).toBe(true); - - // Advance timers to trigger the setTimeout that moves task to todo then in-progress - vi.advanceTimersByTime(0); - // Run any pending microtasks (the async code in setTimeout) - await vi.runAllTimersAsync(); - - // Task should move to todo then in-progress (not in-review). The hop to - // todo must flag preserveResumeState so the workflow-rerun bounce keeps - // the worktree and accumulated step progress through the transient - // todo state on its way back to in-progress. - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveResumeState: true, preserveWorktree: true }); - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress"); - - // onComplete should NOT be called (task is being retried, not completed) - expect(onComplete).not.toHaveBeenCalled(); - - // onError should NOT be called (task is being retried, not permanently failed) - expect(onError).not.toHaveBeenCalled(); - - vi.useRealTimers(); - }); - it("routes exhausted prompt-mode workflow hard failures back to remediation and only reopens the last step", async () => { // This test was previously written as an end-to-end run through // executor.execute(...) with vi.useFakeTimers(), but that path hung @@ -1939,1131 +633,6 @@ describe("Workflow Steps Execution", () => { injectSpy.mockRestore(); }); - it("skips script-mode step when scriptName is missing", async () => { - const store = createMockStore(); - - store.getSettings.mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - scripts: {}, - }); - - 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"], - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - store.getWorkflowStep.mockResolvedValue({ - id: "WS-001", - name: "No Script", - description: "Script step without scriptName", - mode: "script", - prompt: "", - scriptName: undefined, - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - createAgentWithTaskDone(); - - 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"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Should only call createFnAgent once (main execution) - expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1); - - // Should log that it was skipped - expect(store.logEntry).toHaveBeenCalledWith( - "FN-001", - expect.stringContaining("no scriptName"), - ); - - // Task should move to in-review (skipped step doesn't block) - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); - }); - - it("treats legacy steps without mode as prompt-mode", async () => { - const store = createMockStore(); - - 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"], - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Legacy step without mode field - store.getWorkflowStep.mockResolvedValue({ - id: "WS-001", - name: "Legacy Review", - description: "Old step without mode", - prompt: "Review the code changes.", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as any); // mode field intentionally omitted - - let callIdx = 0; - mockedCreateFnAgent.mockImplementation((async (opts: any) => { - callIdx++; - if (callIdx === 1) { - 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 }; - } else { - return { - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - subscribe: vi.fn(), - on: vi.fn(), - state: {}, - }, - }; - } - }) 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"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // createFnAgent called twice: main agent + workflow step agent (prompt mode) - expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); - - // Second call should use prompt mode (readonly tools, agent-based) - const secondCall = mockedCreateFnAgent.mock.calls[1]; - expect(secondCall[0].tools).toBe("readonly"); - expect(secondCall[0].systemPrompt).toContain("Legacy Review"); - - // Task should move to in-review - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); - }); - - // ── Workflow Step Phase Filtering ──────────────────────────────────── - - it("skips post-merge workflow steps during executor pre-merge execution", async () => { - const store = createMockStore(); - - 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", "WS-002"], - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - store.getWorkflowStep.mockImplementation(async (id: string) => { - if (id === "WS-001") { - return { - id: "WS-001", - name: "Pre-merge Check", - description: "Before merge", - prompt: "Run pre-merge checks", - phase: "pre-merge", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - } - if (id === "WS-002") { - return { - id: "WS-002", - name: "Post-merge Notify", - description: "After merge", - prompt: "Send notifications", - phase: "post-merge", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - } - return undefined; - }); - - // Main agent calls fn_task_done, then a workflow step agent for pre-merge only - let callIdx = 0; - mockedCreateFnAgent.mockImplementation((async (opts: any) => { - callIdx++; - if (callIdx === 1) { - 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 }; - } else { - return { - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - subscribe: vi.fn(), - on: vi.fn(), - state: {}, - }, - }; - } - }) 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", "WS-002"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // createFnAgent called twice: main agent + 1 pre-merge step (post-merge skipped) - expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); - - // Verify the workflow step results only contain pre-merge - const updateCalls = store.updateTask.mock.calls; - const resultsCall = updateCalls.find((c: any) => - c[1]?.workflowStepResults?.length > 0 - ); - expect(resultsCall).toBeDefined(); - const results = resultsCall![1].workflowStepResults; - expect(results).toHaveLength(1); - expect(results[0].workflowStepId).toBe("WS-001"); - expect(results[0].phase).toBe("pre-merge"); - }); - - it("normalizes legacy workflow steps without phase as pre-merge", async () => { - const store = createMockStore(); - - 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"], - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Legacy step without phase field - store.getWorkflowStep.mockResolvedValue({ - id: "WS-001", - name: "Legacy Check", - description: "No phase field", - prompt: "Run checks", - // phase is undefined — should be treated as pre-merge - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - let callIdx = 0; - mockedCreateFnAgent.mockImplementation((async (opts: any) => { - callIdx++; - if (callIdx === 1) { - 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 }; - } else { - return { - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - subscribe: vi.fn(), - on: vi.fn(), - state: {}, - }, - }; - } - }) 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"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Legacy step should have been executed (treated as pre-merge) - expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); - - // Verify result has phase: "pre-merge" - const updateCalls = store.updateTask.mock.calls; - const resultsCall = updateCalls.find((c: any) => - c[1]?.workflowStepResults?.some((r: any) => r.workflowStepId === "WS-001") - ); - expect(resultsCall).toBeDefined(); - const results = resultsCall![1].workflowStepResults; - expect(results[0].phase).toBe("pre-merge"); - }); - - it("only runs post-merge steps when all are post-merge (skips all in executor)", async () => { - const store = createMockStore(); - - 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"], - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - store.getWorkflowStep.mockResolvedValue({ - id: "WS-001", - name: "Post-merge Notify", - description: "After merge", - prompt: "Send notifications", - phase: "post-merge", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - createAgentWithTaskDone(); - - 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"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Only main agent called (no workflow step agent since all are post-merge) - expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1); - - // Task should still move to in-review - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); - }); - - // ── Workflow Step Revision Request ────────────────────────────────── - - it("workflow step agent returns revisionRequested when output starts with REQUEST REVISION", async () => { - const store = createMockStore(); - - 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"], - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - store.getWorkflowStep.mockResolvedValue({ - id: "WS-001", - name: "Security Audit", - description: "Check for vulnerabilities", - gateMode: "gate", - prompt: "Scan for security issues.", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - store.parseFileScopeFromPrompt.mockResolvedValue(["src/auth.ts"]); - - // First call: main agent with fn_task_done - // Second call: workflow step agent that returns REQUEST REVISION - let callIdx = 0; - let subscribeHandler: any; - mockedCreateFnAgent.mockImplementation((async (opts: any) => { - callIdx++; - if (callIdx === 1) { - // Main execution - 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 }; - } else { - // Workflow step agent that requests revision - const session = { - prompt: vi.fn().mockImplementation(async () => { - // Call the subscribe handler to simulate agent outputting REQUEST REVISION - if (subscribeHandler) { - subscribeHandler({ - type: "message_update", - assistantMessageEvent: { type: "text_delta", delta: "REQUEST REVISION\n\nFix the SQL injection vulnerability in src/auth.ts" }, - }); - } - }), - dispose: vi.fn(), - subscribe: vi.fn((handler: any) => { - subscribeHandler = handler; - }), - 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"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Both agents should be called - expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); - - // Log should show revision was requested - expect(store.logEntry).toHaveBeenCalledWith( - "FN-001", - expect.stringContaining("requested revision"), - expect.stringContaining("SQL injection"), - expect.objectContaining({ agentId: "executor" }), - ); - expect(store.createTask).not.toHaveBeenCalled(); - - // Workflow step result should be marked as failed - expect(store.updateTask).toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ - workflowStepResults: expect.arrayContaining([ - expect.objectContaining({ - workflowStepId: "WS-001", - status: "failed", - output: expect.stringContaining("SQL injection"), - }), - ]), - }), - ); - - // Task should NOT be marked as failed (revision requested, not hard failure) - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: "failed", error: "Workflow step failed" }), - ); - }); - - it("treats prompt advisory workflow revision requests as non-blocking findings", async () => { - const store = createMockStore(); - - 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"], - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - store.getWorkflowStep.mockResolvedValue({ - id: "WS-001", - name: "Frontend UX Design", - description: "Polish pass", - gateMode: "advisory", - mode: "prompt", - prompt: "Review polish quality.", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - store.parseFileScopeFromPrompt.mockResolvedValue(["src/auth.ts"]); - - let callIdx = 0; - let subscribeHandler: any; - mockedCreateFnAgent.mockImplementation((async (opts: any) => { - callIdx++; - if (callIdx === 1) { - 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 }; - } - - const session = { - prompt: vi.fn().mockImplementation(async () => { - subscribeHandler?.({ - type: "message_update", - assistantMessageEvent: { type: "text_delta", delta: "REQUEST REVISION\n\nPolish note: tighten auth error copy." }, - }); - }), - dispose: vi.fn(), - subscribe: vi.fn((handler: any) => { - subscribeHandler = handler; - }), - 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"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - expect(store.updateTask).toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ - workflowStepResults: expect.arrayContaining([ - expect.objectContaining({ - workflowStepId: "WS-001", - status: "advisory_failure", - notes: expect.stringContaining("Polish note"), - }), - ]), - }), - ); - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); - expect(store.logEntry).toHaveBeenCalledWith( - "FN-001", - expect.stringContaining("Advisory workflow step failed"), - ); - expect(store.updateTask).not.toHaveBeenCalledWith( - "FN-001", - expect.objectContaining({ status: "failed", error: "Workflow step failed" }), - ); - expect(onComplete).toHaveBeenCalled(); - }); - - it("forks out-of-scope workflow revision feedback into a follow-up task and leaves the original task untouched", async () => { - const store = createMockStore(); - - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [{ name: "Implement", status: "done" }], - currentStep: 0, - log: [], - enabledWorkflowSteps: ["WS-001"], - prompt: "# test\n## File Scope\n- `packages/engine/src/executor.ts`\n## Steps\n### Step 0: Implement\n- [x] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - store.parseFileScopeFromPrompt.mockResolvedValue(["packages/engine/src/executor.ts"]); - - store.getWorkflowStep.mockResolvedValue({ - id: "WS-001", - name: "Security Audit", - description: "Check for vulnerabilities", - gateMode: "gate", - prompt: "Scan for security issues.", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - let callIdx = 0; - let subscribeHandler: any; - mockedCreateFnAgent.mockImplementation((async (opts: any) => { - callIdx++; - if (callIdx === 1) { - const customTools = opts.customTools || []; - return { - 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: { - prompt: vi.fn().mockImplementation(async () => { - subscribeHandler?.({ - type: "message_update", - assistantMessageEvent: { type: "text_delta", delta: "REQUEST REVISION\n\nFix `packages/core/src/types.ts` before merge." }, - }); - }), - dispose: vi.fn(), - subscribe: vi.fn((handler: any) => { - subscribeHandler = handler; - }), - state: {}, - }, - }; - }) 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: "Implement", status: "done" }], - currentStep: 0, - log: [], - enabledWorkflowSteps: ["WS-001"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({ - title: expect.stringContaining("workflow follow-up"), - dependencies: ["FN-001"], - source: expect.objectContaining({ - sourceType: "workflow_step", - sourceParentTaskId: "FN-001", - }), - })); - expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", expect.objectContaining({ status: null, sessionFile: null })); - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); - expect(onComplete).toHaveBeenCalled(); - }); - - it("splits mixed workflow revision feedback between the original task and a follow-up task", async () => { - const store = createMockStore(); - - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [{ name: "Implement", status: "done" }], - currentStep: 0, - log: [], - enabledWorkflowSteps: ["WS-001"], - prompt: "# test\n## File Scope\n- `packages/engine/src/executor.ts`\n## Steps\n### Step 0: Implement\n- [x] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - store.parseFileScopeFromPrompt.mockResolvedValue(["packages/engine/src/executor.ts"]); - - store.getWorkflowStep.mockResolvedValue({ - id: "WS-001", - name: "Security Audit", - description: "Check for vulnerabilities", - gateMode: "gate", - prompt: "Scan for security issues.", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - let callIdx = 0; - let subscribeHandler: any; - mockedCreateFnAgent.mockImplementation((async (opts: any) => { - callIdx++; - if (callIdx === 1) { - const customTools = opts.customTools || []; - return { - 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: { - prompt: vi.fn().mockImplementation(async () => { - subscribeHandler?.({ - type: "message_update", - assistantMessageEvent: { - type: "text_delta", - delta: "REQUEST REVISION\n\nTighten `packages/engine/src/executor.ts`.\n\nAdd the setting in `packages/core/src/settings-schema.ts`.", - }, - }); - }), - dispose: vi.fn(), - subscribe: vi.fn((handler: any) => { - subscribeHandler = handler; - }), - state: {}, - }, - }; - }) 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: "Implement", status: "done" }], - currentStep: 0, - log: [], - enabledWorkflowSteps: ["WS-001"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - expect(store.createTask).toHaveBeenCalledTimes(1); - expect(store.logEntry).toHaveBeenCalledWith( - "FN-001", - expect.stringContaining("split feedback"), - expect.stringContaining("packages/engine/src/executor.ts"), - expect.objectContaining({ agentId: "executor" }), - ); - expect(store.updateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({ status: null, sessionFile: null })); - }); - - it("preserves legacy append-and-rerun behavior when workflow revision forking is disabled", async () => { - const store = createMockStore(); - store.getSettings.mockResolvedValue({ - maxConcurrent: 2, - maxWorktrees: 4, - pollIntervalMs: 15000, - groupOverlappingFiles: false, - autoMerge: false, - workflowRevisionForkOnScopeMismatch: false, - worktreeInitCommand: undefined, - }); - - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: [], - steps: [{ name: "Implement", status: "done" }], - currentStep: 0, - log: [], - enabledWorkflowSteps: ["WS-001"], - prompt: "# test\n## File Scope\n- `packages/engine/src/executor.ts`\n## Steps\n### Step 0: Implement\n- [x] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - store.parseFileScopeFromPrompt.mockResolvedValue(["packages/engine/src/executor.ts"]); - store.getWorkflowStep.mockResolvedValue({ - id: "WS-001", - name: "Security Audit", - description: "Check for vulnerabilities", - gateMode: "gate", - prompt: "Scan for security issues.", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - let callIdx = 0; - let subscribeHandler: any; - mockedCreateFnAgent.mockImplementation((async (opts: any) => { - callIdx++; - if (callIdx === 1) { - const customTools = opts.customTools || []; - return { - 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: { - prompt: vi.fn().mockImplementation(async () => { - subscribeHandler?.({ - type: "message_update", - assistantMessageEvent: { type: "text_delta", delta: "REQUEST REVISION\n\nFix `packages/core/src/types.ts` before merge." }, - }); - }), - dispose: vi.fn(), - subscribe: vi.fn((handler: any) => { - subscribeHandler = handler; - }), - state: {}, - }, - }; - }) 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: "Implement", status: "done" }], - currentStep: 0, - log: [], - enabledWorkflowSteps: ["WS-001"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - expect(store.createTask).not.toHaveBeenCalled(); - expect(store.updateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({ status: null, sessionFile: null })); - }); - - it("passing workflow step moves task to in-review normally", async () => { - const store = createMockStore(); - - 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"], - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - store.getWorkflowStep.mockResolvedValue({ - id: "WS-001", - name: "QA Check", - description: "Run tests", - prompt: "Run the test suite.", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - let callIdx = 0; - mockedCreateFnAgent.mockImplementation((async (opts: any) => { - callIdx++; - if (callIdx === 1) { - 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 }; - } else { - let stepSubscribeHandler: any; - // Workflow step agent that passes with the structured verdict required by the workflow-step parser. - return { - session: { - prompt: vi.fn().mockImplementation(async () => { - stepSubscribeHandler?.({ - type: "message_update", - assistantMessageEvent: { type: "text_delta", delta: '{"verdict":"APPROVE","notes":""}' }, - }); - }), - dispose: vi.fn(), - subscribe: vi.fn((handler: any) => { stepSubscribeHandler = handler; }), - state: {}, - }, - }; - } - }) as any); - - const onComplete = vi.fn(); - const onError = vi.fn(); - const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError }); - - 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"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Both agents called - expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); - - // Log should show workflow step passed - expect(store.logEntry).toHaveBeenCalledWith( - "FN-001", - expect.stringContaining("Workflow step completed"), - ); - - // Task should move to in-review (not revision loop) - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); - - // onComplete should be called - expect(onComplete).toHaveBeenCalled(); - expect(onError).not.toHaveBeenCalled(); - }); - - it("parks a task pause during a prompt-mode workflow step instead of routing through failure recovery", async () => { - const store = createMockStore(); - const mutableTask = { - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress" as const, - paused: false, - dependencies: [] as string[], - steps: [{ name: "Preflight", status: "pending" as const }], - currentStep: 0, - log: [] as any[], - enabledWorkflowSteps: ["WS-001"], - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - store.getTask.mockImplementation(async () => mutableTask as any); - store.updateTask.mockImplementation(async (_taskId: string, patch: Record) => { - Object.assign(mutableTask, patch); - return { ...mutableTask }; - }); - store.moveTask.mockImplementation(async (_taskId: string, column: string) => { - mutableTask.column = column as typeof mutableTask.column; - return { ...mutableTask }; - }); - - store.getWorkflowStep.mockResolvedValue({ - id: "WS-001", - name: "QA Check", - description: "Run tests", - mode: "prompt", - prompt: "Run the test suite.", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - const workflowAbort = vi.fn().mockResolvedValue(undefined); - const workflowDispose = vi.fn(); - let callIdx = 0; - mockedCreateFnAgent.mockImplementation((async (opts: any) => { - callIdx++; - if (callIdx === 1) { - const customTools = opts.customTools || []; - return { - session: { - prompt: vi.fn().mockImplementation(async () => { - const taskDoneTool = customTools.find((tool: any) => tool.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: { - prompt: vi.fn().mockImplementation(async () => { - mutableTask.paused = true; - store._trigger("task:updated", { ...mutableTask }); - throw new Error("workflow step aborted by pause"); - }), - abort: workflowAbort, - dispose: workflowDispose, - subscribe: vi.fn(), - on: vi.fn(), - state: {}, - }, - }; - }) as any); - - const executor = new TaskExecutor(store, "/tmp/test"); - await executor.execute({ ...mutableTask }); - - expect(workflowDispose).toHaveBeenCalled(); - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveResumeState: true }); - expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-review"); - expect(store.addTaskComment).not.toHaveBeenCalled(); - expect(store.logEntry).toHaveBeenCalledWith( - "FN-001", - "Execution paused during pre-merge workflow step — moved to todo", - undefined, - expect.objectContaining({ agentId: "executor" }), - ); - }); }); describe("Real-time steering injection", () => { diff --git a/packages/engine/src/__tests__/executor-workflow-step-scope.test.ts b/packages/engine/src/__tests__/executor-workflow-step-scope.test.ts deleted file mode 100644 index 1e4a93fade..0000000000 --- a/packages/engine/src/__tests__/executor-workflow-step-scope.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import "./executor-test-helpers.js"; -import { TaskExecutor } from "../executor.js"; -import { createMockStore, mockedExecSync, resetExecutorMocks } from "./executor-test-helpers.js"; - -function createTask() { - return { - id: "FN-001", - title: "Test", - description: "Test task", - column: "in-progress" as const, - dependencies: [], - steps: [{ name: "Preflight", status: "done" as const }], - currentStep: 0, - log: [], - enabledWorkflowSteps: ["frontend-ux-design"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; -} - -function createWorkflowStep(overrides: Record = {}) { - return { - id: "frontend-ux-design", - name: "Frontend UX Design", - description: "UI review", - mode: "prompt", - prompt: "Review UI", - gateMode: "gate", - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - ...overrides, - }; -} - -function mockDiffFiles(files: string[]) { - mockedExecSync.mockImplementation((cmd: string | string[]) => { - if (typeof cmd === "string" && cmd.includes("git merge-base HEAD origin/main")) { - return Buffer.from("abc123\n"); - } - if (typeof cmd === "string" && cmd.includes("git diff --name-only abc123..HEAD")) { - return Buffer.from(files.join("\n")); - } - return Buffer.from(""); - }); -} - -function mockDiffSequence(preStepFiles: string[], postStepFiles: string[]) { - let diffCallCount = 0; - mockedExecSync.mockImplementation((cmd: string | string[]) => { - if (typeof cmd === "string" && cmd.includes("git merge-base HEAD origin/main")) { - return Buffer.from("abc123\n"); - } - if (typeof cmd === "string" && cmd.includes("git diff --name-only abc123..HEAD")) { - diffCallCount += 1; - const files = diffCallCount === 1 ? preStepFiles : postStepFiles; - return Buffer.from(files.join("\n")); - } - return Buffer.from(""); - }); -} - -describe("executor workflow step scope gating", () => { - beforeEach(() => { - resetExecutorMocks(); - }); - - it.each([ - { name: "both signals empty", diffFiles: [] as string[], declaredFiles: [] as string[], expectedSkip: false }, - { name: "diff only non-frontend", diffFiles: ["packages/engine/src/executor.ts"], declaredFiles: [], expectedSkip: true }, - { - name: "declared only non-frontend", - diffFiles: [], - declaredFiles: [".github/workflows/ci.yml"], - expectedSkip: true, - expectedLog: "declared File Scope contains no frontend/UI files", - }, - { - name: "both present and both non-frontend", - diffFiles: ["packages/engine/src/executor.ts"], - declaredFiles: [".github/workflows/ci.yml"], - expectedSkip: true, - expectedLog: "declared File Scope contains no frontend/UI files", - }, - ])("FN-4343 auto-skip matrix: $name", async ({ diffFiles, declaredFiles, expectedSkip, expectedLog }) => { - const store = createMockStore(); - const task = createTask(); - store.getTask.mockResolvedValue(task as any); - store.getWorkflowStep.mockResolvedValue(createWorkflowStep() as any); - store.parseFileScopeFromPrompt.mockResolvedValue(declaredFiles); - mockDiffFiles(diffFiles); - - const executor = new TaskExecutor(store as any, "/tmp/test", {} as any); - vi.spyOn(executor as any, "captureModifiedFiles").mockResolvedValue(diffFiles); - const executeStepSpy = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, output: "ok" }); - - const result = await (executor as any).runWorkflowSteps(task as any, "/tmp/test", {} as any); - - expect(result).toEqual({ allPassed: true }); - if (expectedSkip) { - expect(executeStepSpy).not.toHaveBeenCalled(); - if (expectedLog) { - const logged = store.logEntry.mock.calls.map((call: any[]) => String(call[1] ?? "")); - expect(logged.some((line: string) => line.includes(expectedLog))).toBe(true); - } - } else { - expect(executeStepSpy).toHaveBeenCalledTimes(1); - } - }); - - it("passes when prompt-mode pre-merge step writes in-scope files", async () => { - const store = createMockStore(); - const task = { ...createTask(), enabledWorkflowSteps: ["WS-001"] }; - store.getTask.mockResolvedValue(task as any); - store.getWorkflowStep.mockResolvedValue(createWorkflowStep({ id: "WS-001", name: "Workflow Review" }) as any); - store.parseFileScopeFromPrompt.mockResolvedValue(["packages/engine/src/executor.ts"]); - mockDiffFiles(["packages/engine/src/executor.ts"]); - - const executor = new TaskExecutor(store as any, "/tmp/test", {} as any); - vi.spyOn(executor as any, "captureModifiedFiles").mockResolvedValue(["packages/engine/src/executor.ts"]); - vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, output: "ok" }); - - const result = await (executor as any).runWorkflowSteps(task as any, "/tmp/test", { workflowStepScopeEnforcement: "block" } as any); - - expect(result).toEqual({ allPassed: true }); - }); - - it("requests revision in block mode when step writes off-scope files", async () => { - const store = createMockStore(); - const task = { ...createTask(), enabledWorkflowSteps: ["WS-001"] }; - store.getTask.mockResolvedValue(task as any); - store.getWorkflowStep.mockResolvedValue(createWorkflowStep({ id: "WS-001", name: "Workflow Review" }) as any); - store.parseFileScopeFromPrompt.mockResolvedValue(["packages/engine/src/executor.ts"]); - mockDiffSequence([], ["packages/dashboard/app/components/TaskDetailModal.tsx"]); - - const executor = new TaskExecutor(store as any, "/tmp/test", {} as any); - vi.spyOn(executor as any, "captureModifiedFiles") - .mockResolvedValueOnce([]) - .mockResolvedValueOnce(["packages/dashboard/app/components/TaskDetailModal.tsx"]); - vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, output: "ok" }); - - const result = await (executor as any).runWorkflowSteps(task as any, "/tmp/test", { workflowStepScopeEnforcement: "block" } as any); - - expect(result).toEqual(expect.objectContaining({ allPassed: false, revisionRequested: true, stepName: "Workflow Review" })); - expect(String((result as any).feedback)).toContain("wrote files outside declared File Scope"); - }); - - it("detects off-scope delta even when pre-step diff has in-scope files", async () => { - const store = createMockStore(); - const task = { ...createTask(), enabledWorkflowSteps: ["WS-001"] }; - store.getTask.mockResolvedValue(task as any); - store.getWorkflowStep.mockResolvedValue(createWorkflowStep({ id: "WS-001", name: "Workflow Review" }) as any); - store.parseFileScopeFromPrompt.mockResolvedValue(["packages/engine/src/executor.ts"]); - - mockDiffSequence( - ["packages/engine/src/executor.ts"], - ["packages/engine/src/executor.ts", "packages/dashboard/app/components/TaskDetailModal.tsx"], - ); - - const executor = new TaskExecutor(store as any, "/tmp/test", {} as any); - vi.spyOn(executor as any, "captureModifiedFiles") - .mockResolvedValueOnce(["packages/engine/src/executor.ts"]) - .mockResolvedValueOnce(["packages/engine/src/executor.ts", "packages/dashboard/app/components/TaskDetailModal.tsx"]); - vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, output: "ok" }); - - const result = await (executor as any).runWorkflowSteps(task as any, "/tmp/test", { workflowStepScopeEnforcement: "block" } as any); - - expect(result).toEqual(expect.objectContaining({ allPassed: false, revisionRequested: true })); - expect(String((result as any).feedback)).toContain("TaskDetailModal.tsx"); - }); - - it("warn mode logs but passes on off-scope writes", async () => { - const store = createMockStore(); - const task = { ...createTask(), enabledWorkflowSteps: ["WS-001"] }; - store.getTask.mockResolvedValue(task as any); - store.getWorkflowStep.mockResolvedValue(createWorkflowStep({ id: "WS-001", name: "Workflow Review" }) as any); - store.parseFileScopeFromPrompt.mockResolvedValue(["packages/engine/src/executor.ts"]); - mockDiffSequence([], ["packages/dashboard/app/components/TaskDetailModal.tsx"]); - - const executor = new TaskExecutor(store as any, "/tmp/test", {} as any); - vi.spyOn(executor as any, "captureModifiedFiles") - .mockResolvedValueOnce([]) - .mockResolvedValueOnce(["packages/dashboard/app/components/TaskDetailModal.tsx"]); - vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, output: "ok" }); - - const result = await (executor as any).runWorkflowSteps(task as any, "/tmp/test", { workflowStepScopeEnforcement: "warn" } as any); - - expect(result).toEqual({ allPassed: true }); - const logged = store.logEntry.mock.calls.map((call: any[]) => String(call[1] ?? "")); - expect(logged.some((line: string) => line.includes("workflowStepScopeEnforcement=warn"))).toBe(true); - }); - - it("off mode bypasses enforcement", async () => { - const store = createMockStore(); - const task = { ...createTask(), enabledWorkflowSteps: ["WS-001"] }; - store.getTask.mockResolvedValue(task as any); - store.getWorkflowStep.mockResolvedValue(createWorkflowStep({ id: "WS-001", name: "Workflow Review" }) as any); - store.parseFileScopeFromPrompt.mockResolvedValue(["packages/engine/src/executor.ts"]); - mockDiffFiles(["packages/dashboard/app/components/TaskDetailModal.tsx"]); - - const executor = new TaskExecutor(store as any, "/tmp/test", {} as any); - vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, output: "ok" }); - - const result = await (executor as any).runWorkflowSteps(task as any, "/tmp/test", { workflowStepScopeEnforcement: "off" } as any); - - expect(result).toEqual({ allPassed: true }); - }); - - it("scopeOverride=true bypasses enforcement regardless of mode", async () => { - const store = createMockStore(); - const task = { ...createTask(), enabledWorkflowSteps: ["WS-001"], scopeOverride: true }; - store.getTask.mockResolvedValue(task as any); - store.getWorkflowStep.mockResolvedValue(createWorkflowStep({ id: "WS-001", name: "Workflow Review" }) as any); - store.parseFileScopeFromPrompt.mockResolvedValue(["packages/engine/src/executor.ts"]); - mockDiffSequence([], ["packages/dashboard/app/components/TaskDetailModal.tsx"]); - - const executor = new TaskExecutor(store as any, "/tmp/test", {} as any); - vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, output: "ok" }); - - const result = await (executor as any).runWorkflowSteps(task as any, "/tmp/test", { workflowStepScopeEnforcement: "block" } as any); - - expect(result).toEqual({ allPassed: true }); - }); - - it("FN-4280 regression: declared workflow-only scope skips Frontend UX without executing agent", async () => { - const store = createMockStore(); - const task = createTask(); - store.getTask.mockResolvedValue(task as any); - store.getWorkflowStep.mockResolvedValue(createWorkflowStep() as any); - store.parseFileScopeFromPrompt.mockResolvedValue([ - ".github/workflows/ci.yml", - ".github/workflows/mobile.yml", - ".github/workflows/test-release.yml", - ".github/workflows/release.yml", - ".github/workflows/version.yml", - ]); - mockDiffFiles([]); - - const executor = new TaskExecutor(store as any, "/tmp/test", {} as any); - const executeStepSpy = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, output: "ok" }); - - const result = await (executor as any).runWorkflowSteps(task as any, "/tmp/test", {} as any); - - expect(result).toEqual({ allPassed: true }); - expect(executeStepSpy).not.toHaveBeenCalled(); - const logged = store.logEntry.mock.calls.map((call: any[]) => String(call[1] ?? "")); - expect(logged.some((line: string) => line.includes("declared File Scope contains no frontend/UI files"))).toBe(true); - }); -}); diff --git a/packages/engine/src/__tests__/restart.integration.test.ts b/packages/engine/src/__tests__/restart.integration.test.ts index 60bf4b4444..4c00469425 100644 --- a/packages/engine/src/__tests__/restart.integration.test.ts +++ b/packages/engine/src/__tests__/restart.integration.test.ts @@ -791,71 +791,75 @@ describe("In-progress task resume after restart", () => { expect(store.logEntry).not.toHaveBeenCalledWith("FN-1473", "Resumed after engine restart"); }); - it("recoverCompletedTask() marks task failed then moves to in-review when workflow fails", async () => { + // U4 (KTD-2/KTD-5): the legacy `runWorkflowSteps` recovery path was deleted. + // recoverCompletedTask now RE-ENTERS the workflow graph (maybeExecuteWorkflowGraph), + // which records workflowStepResults and OWNS the in-review / back-for-fix + // transition. These two tests replace the old "runWorkflowSteps fails → bounce" + // test: one proves the graph re-entry seam, one proves the fail-closed guard. + it("recoverCompletedTask() re-enters the workflow graph (records results + owns the transition)", async () => { + const store = createMockStore(); + const task = makeTask("FN-963", "in-progress", { + worktree: "/tmp/wt/FN-963", + steps: makeSteps("done"), + }); + + const executor = new TaskExecutor(store, "/tmp/test"); + vi.spyOn(executor as any, "captureModifiedFiles").mockResolvedValue([]); + // The graph is the sole executor; recovery delegates to it. Spy the graph + // entry to assert the re-entry seam without standing up a full graph runner + // (the graph's own recording/transition behavior is covered by + // builtin-coding-workflow-step-results.test.ts and the cutover backstop). + const graphEntry = vi + .spyOn(executor as any, "maybeExecuteWorkflowGraph") + .mockResolvedValue(true); + + const recovered = await executor.recoverCompletedTask(task); + + expect(recovered).toBe(true); + expect(graphEntry).toHaveBeenCalledTimes(1); + expect(graphEntry).toHaveBeenCalledWith(task); + // The graph owns the transition — recovery must NOT itself bounce or hand off. + expect(store.moveTask).not.toHaveBeenCalledWith("FN-963", "in-review"); + }); + + it("recoverCompletedTask() fails closed (KTD-5) when the store lacks getTaskWorkflowSelection and the task has enabled workflow steps", async () => { + // createMockStore does NOT expose getTaskWorkflowSelection, so the workflow + // graph cannot resolve a selection — and the legacy runWorkflowSteps path was + // removed (U4). A task with an enabled pre-merge step MUST fail closed rather + // than silently hand off to review with no gate execution (the FN-7039 class). const store = createMockStore({ getTask: vi.fn().mockResolvedValue(makeTaskDetail("FN-963", "in-progress", { worktree: "/tmp/wt/FN-963", steps: makeSteps("done"), enabledWorkflowSteps: ["wf-1"], })), - getWorkflowStep: vi.fn().mockResolvedValue({ - id: "wf-1", - name: "Build", - mode: "script", - scriptName: "pnpm test", - phase: "pre-merge", - }), }); const task = makeTask("FN-963", "in-progress", { worktree: "/tmp/wt/FN-963", steps: makeSteps("done"), + enabledWorkflowSteps: ["wf-1"], }); - mockedExecSync.mockImplementation((command) => { - const cmd = String(command); - if (cmd === "pnpm test") { - throw new Error("tests failed"); - } - return "" as any; - }); - - // Use fake timers to control the setTimeout in sendTaskBackForFix - vi.useFakeTimers(); - const executor = new TaskExecutor(store, "/tmp/test"); + vi.spyOn(executor as any, "captureModifiedFiles").mockResolvedValue([]); + // Spy handleGraphFailure (the park-the-task seam) to assert the fail-closed + // branch fired with a clear reason — i.e. NOT a silent no-op. + const handleGraphFailure = vi + .spyOn(executor as any, "handleGraphFailure") + .mockResolvedValue(undefined); + const recovered = await executor.recoverCompletedTask(task); expect(recovered).toBe(true); - // Task should be cleared and reset for retry (not failed + in-review) - expect(store.updateTask).toHaveBeenCalledWith("FN-963", { - status: null, - error: null, - sessionFile: null, - workflowStepRetries: 0, - }); - // Should add a comment with failure feedback - expect(store.addTaskComment).toHaveBeenCalledWith( - "FN-963", - expect.stringContaining("Workflow step failed during recovery"), - "agent", - ); - // Should reset all steps to pending - expect(store.updateStep).toHaveBeenCalledWith("FN-963", 0, "pending"); - - // Advance timers to trigger the setTimeout that moves task to todo then in-progress - await vi.advanceTimersByTimeAsync(0); - - // Task should move to todo then in-progress (not in-review). The - // workflow-rerun bounce passes `preserveWorktree: true` so the - // checkout doesn't briefly disappear during the hop. - expect(store.moveTask).toHaveBeenCalledWith( - "FN-963", - "todo", - expect.objectContaining({ preserveWorktree: true }), - ); - expect(store.moveTask).toHaveBeenCalledWith("FN-963", "in-progress"); - - vi.useRealTimers(); + expect(handleGraphFailure).toHaveBeenCalledTimes(1); + const failureArg = (handleGraphFailure.mock.calls[0] as unknown[])[1] as { + disposition: string; + reason: string; + }; + expect(failureArg.disposition).toBe("failed"); + expect(String(failureArg.reason)).toContain("workflow-selection-api-unavailable"); + // Must NOT silently finalize to review as a success. + expect(store.moveTask).not.toHaveBeenCalledWith("FN-963", "in-review"); }); }); diff --git a/packages/engine/src/__tests__/workflow-step-execution-removed.test.ts b/packages/engine/src/__tests__/workflow-step-execution-removed.test.ts new file mode 100644 index 0000000000..4227f6c678 --- /dev/null +++ b/packages/engine/src/__tests__/workflow-step-execution-removed.test.ts @@ -0,0 +1,50 @@ +// FNXC:WorkflowExecution 2026-06-25-00:00: U4 (KTD-2) grep-guard. +// The legacy `runWorkflowSteps` execution path + the `workflow-step` seam / +// `runWorkflowStep` primitive were removed; the workflow graph is the sole +// workflow-step executor (results recorded into task.workflowStepResults, U2). +// This test fails loudly if a production caller of the deleted runner is +// reintroduced, or if the removed seam/primitive handlers come back. +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const srcDir = join(dirname(fileURLToPath(import.meta.url)), ".."); + +function listProductionTsFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === "__tests__") continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...listProductionTsFiles(full)); + else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) out.push(full); + } + return out; +} + +describe("U4: legacy workflow-step execution path removed", () => { + const files = listProductionTsFiles(srcDir); + + it("has no production caller of the deleted runWorkflowSteps runner", () => { + const offenders: string[] = []; + for (const file of files) { + const text = readFileSync(file, "utf8"); + // Match an actual call/seam-key, not the FNXC comments that reference the name. + if (/this\.runWorkflowSteps\s*\(|[^.\w]runWorkflowStep\s*:/.test(text)) { + offenders.push(file.replace(srcDir, "@fusion/engine/src")); + } + } + expect(offenders, `unexpected runWorkflowSteps/runWorkflowStep usage in ${offenders.join(", ")}`).toEqual([]); + }); + + it("no production code declares a workflow-step seam handler", () => { + const offenders: string[] = []; + for (const file of files) { + const text = readFileSync(file, "utf8"); + if (/workflowStep\s*:\s*async|workflowStep\?\s*:/.test(text)) { + offenders.push(file.replace(srcDir, "@fusion/engine/src")); + } + } + expect(offenders, `unexpected workflowStep seam handler in ${offenders.join(", ")}`).toEqual([]); + }); +}); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index bd177bdde8..00e3bf8bef 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -3714,22 +3714,42 @@ export class TaskExecutor { // Run workflow steps before transitioning — skip in fast mode if (task.executionMode !== "fast") { - if (await this.shouldDeferCompletionForGlobalPause(task.id, "before workflow steps during completed-task recovery")) { + if (await this.shouldDeferCompletionForGlobalPause(task.id, "before workflow-graph re-entry during completed-task recovery")) { return false; } - const workflowResult = await this.runWorkflowSteps(task, task.worktree, settings, undefined); - if (workflowResult === "deferred-paused") { - if (this.pausedAborted.has(task.id)) { - this.clearPausedAborted(task.id); - } - return false; - } - if (!workflowResult.allPassed) { - // For recovery path, treat any failure (including revision) as hard failure - // Send back to in-progress so executor can attempt to fix the issues - await this.sendTaskBackForFix(task, task.worktree!, workflowResult.feedback, workflowResult.stepName || "Unknown", "Workflow step failed during recovery", false); - return true; // Still transitioned out of in-progress + /* + FNXC:WorkflowExecution 2026-06-25-00:00: + U4 (KTD-2) watchdog re-entry. The legacy `runWorkflowSteps` recovery path + was deleted; the workflow graph is the sole executor. A stranded completed + task is recovered by RE-ENTERING the graph via `maybeExecuteWorkflowGraph` + (the same entry execute() uses), which: (1) re-runs any pending + optional-group / gate nodes, (2) records their outcomes into + `task.workflowStepResults` (U2) and emits the `[pre-merge]` logs, and + (3) OWNS the in-review vs back-for-fix transition. The graph's execute seam + registers the normal completion interceptor, so a task whose implementation + already completed resumes at the post-implementation nodes (it does not + re-run the agent from scratch). RECOVERY POLICY mapping (per plan U4): the + old "any failure including REVISE is hard" recovery rule now maps onto the + graph's gate semantics — a GATE node REVISE/failure routes the task back for + fix, while an ADVISORY REVISE is non-blocking and proceeds to review. KTD-5: + for a store lacking `getTaskWorkflowSelection` that has enabled steps, + `maybeExecuteWorkflowGraph` itself fails closed (parks) rather than letting + recovery silently skip the gates. + */ + const graphOwned = await this.maybeExecuteWorkflowGraph(task); + if (graphOwned) { + this.clearCompletedTaskWatchdog(task.id); + await this.store.logEntry( + task.id, + `Auto-recovered: stranded completed task re-dispatched through the workflow graph — the graph re-ran pending workflow steps (recording results) and owns the in-review / back-for-fix transition`, + ).catch(() => undefined); + executorLog.log(`✓ ${task.id} auto-recovered completed task via workflow-graph re-entry`); + return true; } + // Graph declined (minimal store WITHOUT the workflow-selection API and no + // enabled gates to run — a store WITH enabled steps would have been parked + // fail-closed above): there is nothing to gate, so fall through to the + // legacy in-review handoff below. } else { executorLog.log(`${task.id}: fast mode — skipping workflow steps on auto-recovery`); } @@ -4213,7 +4233,29 @@ export class TaskExecutor { /* FNXC:WorkflowExecution 2026-06-23-22:01: Graph execution is the default for production TaskStore implementations, which expose workflow-selection APIs. Minimal test stores and older embedded adapters can lack that API; fall back to the legacy executor instead of half-entering graph routing with no workflow persistence surface. + + FNXC:WorkflowExecution 2026-06-25-00:00: + U4 (KTD-2/KTD-5) FAIL-CLOSED. The legacy `runWorkflowSteps` execution path was deleted; the graph is now the sole workflow-step executor. A store without `getTaskWorkflowSelection` can no longer reach a legacy executor that runs the enabled pre-merge gates. If we returned `false` here for a task that has enabled workflow steps, execute() would proceed and SILENTLY SKIP every gate (the exact FN-7039 silent-skip class) before handing off to review. So when the task has enabled pre-merge workflow steps (and is not fast mode, which intentionally skips them), park the task as a workflow failure instead — loud, never silent. Tasks with NO enabled steps have nothing to gate, so they keep the legacy implementation path (no behavior change), which is what minimal test stores exercise. */ + let liveForGate: Task | null = null; + try { + liveForGate = await this.store.getTask(task.id); + } catch { + liveForGate = null; + } + const gateTask = liveForGate ?? task; + const hasEnabledSteps = (gateTask.enabledWorkflowSteps?.length ?? 0) > 0; + if (hasEnabledSteps && gateTask.executionMode !== "fast") { + await this.handleGraphFailure(task, { + disposition: "failed", + outcome: "failure", + reason: + "workflow-selection-api-unavailable: store lacks getTaskWorkflowSelection so the workflow graph cannot run " + + `${gateTask.enabledWorkflowSteps?.length ?? 0} enabled pre-merge workflow step(s); the legacy runWorkflowSteps path was removed (U4). Failing closed rather than skipping gates (KTD-5).`, + visitedNodeIds: [], + }); + return true; + } return false; } try { @@ -5412,69 +5454,12 @@ export class TaskExecutor { runVerification: async () => ({ outcome: "success", value: "verification-skipped", data: { verdict: "skipped", } }), - runWorkflowStep: async (_ctx, task, input) => { - if (input.phase !== "pre-merge") { - return { outcome: "success", value: "workflow-step-skipped", data: { allPassed: true } }; - } - const live = await this.store.getTask(task.id); - if (live.executionMode === "fast") { - executorLog.log(`${task.id}: fast mode — skipping pre-merge workflow steps`); - await this.store.logEntry(task.id, "Fast mode — pre-merge workflow steps skipped", undefined, this.getRunContextFor(task.id)); - return { outcome: "success", value: "workflow-step-skipped", data: { allPassed: true } }; - } - if (await this.shouldDeferCompletionForGlobalPause(task.id, "before workflow steps after task completion")) { - return { outcome: "success", value: "deferred-paused", data: { allPassed: false } }; - } - const worktreePath = input.worktreePath || live.worktree || this.rootDir; - const workflowResult = await this.runWorkflowSteps(live, worktreePath, settings, undefined); - if (workflowResult === "deferred-paused") { - if (await this.parkTaskAfterWorkflowStepPause(task.id)) { - this.clearPausedAborted(task.id); - } else if (this.pausedAborted.has(task.id)) { - this.clearPausedAborted(task.id); - } - return { outcome: "success", value: "deferred-paused", data: { allPassed: false } }; - } - if (!workflowResult.allPassed) { - const feedback = workflowResult.feedback || "Workflow step failed"; - const stepName = workflowResult.stepName || "Unknown"; - if (workflowResult.revisionRequested) { - const rerunScheduled = await this.handleWorkflowRevisionRequest( - live, - worktreePath, - feedback, - stepName, - settings, - ); - if (!rerunScheduled) { - return { - outcome: "failure", - value: "workflow-step-revision-unhandled", - data: workflowResult, - }; - } - } else { - const retried = await this.handleWorkflowStepFailure( - live, - worktreePath, - feedback, - stepName, - ); - if (!retried) { - await this.sendTaskBackForFix( - live, - worktreePath, - feedback, - stepName, - "Workflow step failed", - ); - } - } - return { outcome: "success", value: "remediation-scheduled", data: workflowResult }; - } - await this.store.updateTask(task.id, { workflowStepRetries: undefined, taskDoneRetryCount: null }); - return { outcome: "success", value: "workflow-steps-passed", data: workflowResult }; - }, + // FNXC:WorkflowExecution 2026-06-25-00:00: U4 (KTD-2) — the legacy + // `runWorkflowStep` primitive + the `workflow-step` seam it served were + // removed. Workflow quality gates run as the graph's own optional-group / + // gate nodes (builtin:coding already routes through them), which record + // results into `task.workflowStepResults` directly (U2). No `runWorkflowStep` + // primitive remains in `WorkflowRuntimePrimitives`. updateSteps: async (_ctx, task, steps) => { await this.store.updateTask(task.id, { steps }); return { outcome: "success", value: "steps-updated", data: { count: steps.length } }; @@ -5584,58 +5569,14 @@ export class TaskExecutor { value: paused ? "implementation-paused" : "implementation-incomplete", }; }, - workflowStep: async (seamTask) => { - const live = await this.store.getTask(seamTask.id); - if (live.executionMode === "fast") { - executorLog.log(`${seamTask.id}: fast mode — skipping pre-merge workflow steps`); - await this.store.logEntry(seamTask.id, "Fast mode — pre-merge workflow steps skipped", undefined, this.getRunContextFor(seamTask.id)); - return { outcome: "success", value: "workflow-step-skipped" }; - } - const worktreePath = live.worktree || this.rootDir; - const settings = await this.store.getSettings(); - const workflowResult = await this.runWorkflowSteps(live, worktreePath, settings, undefined); - if (workflowResult === "deferred-paused") { - if (await this.parkTaskAfterWorkflowStepPause(seamTask.id)) { - this.clearPausedAborted(seamTask.id); - } else if (this.pausedAborted.has(seamTask.id)) { - this.clearPausedAborted(seamTask.id); - } - return { outcome: "success", value: "deferred-paused" }; - } - if (!workflowResult.allPassed) { - const feedback = workflowResult.feedback || "Workflow step failed"; - const stepName = workflowResult.stepName || "Unknown"; - if (workflowResult.revisionRequested) { - const rerunScheduled = await this.handleWorkflowRevisionRequest( - live, - worktreePath, - feedback, - stepName, - settings, - ); - if (!rerunScheduled) return { outcome: "failure", value: "workflow-step-revision-unhandled" }; - } else { - const retried = await this.handleWorkflowStepFailure( - live, - worktreePath, - feedback, - stepName, - ); - if (!retried) { - await this.sendTaskBackForFix( - live, - worktreePath, - feedback, - stepName, - "Workflow step failed", - ); - } - } - return { outcome: "success", value: "remediation-scheduled" }; - } - await this.store.updateTask(seamTask.id, { workflowStepRetries: undefined, taskDoneRetryCount: null }); - return { outcome: "success", value: "workflow-steps-passed" }; - }, + // FNXC:WorkflowExecution 2026-06-25-00:00: U4 (KTD-2) — the legacy + // `workflowStep` seam was removed. Workflow quality gates run as the graph's + // own optional-group / gate nodes (builtin:coding replaced its `workflow-step` + // seam node with optional-group nodes) which record into + // `task.workflowStepResults` (U2). `WorkflowLegacySeams.workflowStep` no + // longer exists, and `resolveSeamName` no longer recognizes the + // `workflow-step` seam (an IR node still declaring it now fails loudly via + // WorkflowIrError rather than silently no-opping). review: async (seamTask) => { // The legacy "review" stage is the in-review handoff: per-step AI review // already ran during implementation (fn_review_step), and the in-review @@ -8242,38 +8183,25 @@ export class TaskExecutor { } } - // Run workflow steps before moving to in-review — skip in fast mode - if (executionMode !== "fast") { - const workflowResult = await this.runWorkflowSteps(task, worktreePath, settings, taskEnv); - if (workflowResult === "deferred-paused") { - if (await this.parkTaskAfterWorkflowStepPause(task.id)) { - this.clearPausedAborted(task.id); - return; - } - if (this.pausedAborted.has(task.id)) { - this.clearPausedAborted(task.id); - } - return; - } - if (!workflowResult.allPassed) { - // Check if revision was requested - if (workflowResult.revisionRequested) { - const rerunScheduled = await this.handleWorkflowRevisionRequest(task, worktreePath, workflowResult.feedback, workflowResult.stepName, settings); - if (rerunScheduled) { - return; - } - } else { - // Try to fix workflow step failures with retries - const retried = await this.handleWorkflowStepFailure(task, worktreePath, workflowResult.feedback, workflowResult.stepName || "Unknown"); - if (retried) { - return; // Retry scheduled - } - // Retries exhausted - send back to in-progress for remediation - await this.sendTaskBackForFix(task, worktreePath, workflowResult.feedback, workflowResult.stepName || "Unknown", "Workflow step failed"); - return; - } - } - } else { + // FNXC:WorkflowExecution 2026-06-25-00:00: U4 (KTD-2/KTD-5) — workflow + // steps are graph-owned. For a graph-driven run the execute seam + // registered a completion interceptor; stop at the + // implementation-complete boundary and hand the remaining lifecycle + // (workflow gates → review → merge) back to the graph runner, which + // records results into task.workflowStepResults (U2). The legacy + // runWorkflowSteps loop was deleted. A NON-graph run reaching here has no + // enabled workflow steps to run (a minimal store WITH enabled steps is + // parked fail-closed inside maybeExecuteWorkflowGraph, KTD-5), so there + // is nothing to gate before the in-review handoff. + const graphCompletion = this.graphCompletionInterceptors.get(task.id); + if (graphCompletion) { + this.clearCompletedTaskWatchdog(task.id); + executorLog.log(`✓ ${task.id} implementation complete — graph interpreter owns the remaining lifecycle`); + const liveModified = (await this.store.getTask(task.id).catch(() => task)).modifiedFiles ?? []; + graphCompletion({ modifiedFiles: liveModified }); + return; + } + if (executionMode === "fast") { executorLog.log(`${task.id}: fast mode — skipping pre-merge workflow steps`); await this.store.logEntry(task.id, "Fast mode — pre-merge workflow steps skipped", undefined, this.getRunContextFor(task.id)); } @@ -9068,51 +8996,25 @@ export class TaskExecutor { } this.scheduleCompletedTaskWatchdog(task.id, "task completion"); - if (await this.shouldDeferCompletionForGlobalPause(task.id, "before workflow steps after task completion")) { + if (await this.shouldDeferCompletionForGlobalPause(task.id, "before in-review transition after task completion")) { return; } - // Run workflow steps before moving to in-review — skip in fast mode - if (executionMode !== "fast") { - const workflowResult = await this.runWorkflowSteps(task, worktreePath, settings, taskEnv); - if (workflowResult === "deferred-paused") { - if (await this.parkTaskAfterWorkflowStepPause(task.id)) { - this.clearPausedAborted(task.id); - wasPaused = true; - return; - } - if (this.pausedAborted.has(task.id)) { - this.clearPausedAborted(task.id); - wasPaused = true; - } - return; - } - if (!workflowResult.allPassed) { - // Check if revision was requested - if (workflowResult.revisionRequested) { - const rerunScheduled = await this.handleWorkflowRevisionRequest(task, worktreePath, workflowResult.feedback, workflowResult.stepName, settings); - if (rerunScheduled) { - return; - } - } else { - // Try to fix workflow step failures with retries - const retried = await this.handleWorkflowStepFailure(task, worktreePath, workflowResult.feedback, workflowResult.stepName || "Unknown"); - if (retried) { - return; // Retry scheduled - } - // Retries exhausted - send back to in-progress for remediation - await this.sendTaskBackForFix(task, worktreePath, workflowResult.feedback, workflowResult.stepName || "Unknown", "Workflow step failed"); - return; - } - } - } else { + // FNXC:WorkflowExecution 2026-06-25-00:00: U4 (KTD-2/KTD-5) — the legacy + // runWorkflowSteps loop was deleted; workflow gates are graph-owned and + // record into task.workflowStepResults (U2). The graph-interceptor + // short-circuit above already returns for every graph-driven run, so a + // run reaching here is a non-graph fallback with NO enabled workflow + // steps (a minimal store WITH enabled steps is parked fail-closed in + // maybeExecuteWorkflowGraph, KTD-5) — nothing to gate before handoff. + if (executionMode === "fast") { executorLog.log(`${task.id}: fast mode — skipping pre-merge workflow steps`); await this.store.logEntry(task.id, "Fast mode — pre-merge workflow steps skipped", undefined, this.getRunContextFor(task.id)); } // Reset retry counters on success await this.store.updateTask(task.id, { workflowStepRetries: undefined, taskDoneRetryCount: null }); - if (await this.shouldDeferCompletionForGlobalPause(task.id, "before in-review transition after task completion")) { + if (await this.shouldDeferCompletionForGlobalPause(task.id, "before in-review transition after task completion (post-reset)")) { return; } @@ -9344,37 +9246,26 @@ export class TaskExecutor { } this.scheduleCompletedTaskWatchdog(task.id, "task completion retry"); - if (await this.shouldDeferCompletionForGlobalPause(task.id, "before workflow steps after task completion retry")) { + if (await this.shouldDeferCompletionForGlobalPause(task.id, "before in-review transition after task completion retry")) { return; } - // Run workflow steps before moving to in-review — skip in fast mode - if (executionMode !== "fast") { - const workflowResult = await this.runWorkflowSteps(task, worktreePath, settings, taskEnv); - if (workflowResult === "deferred-paused") { - if (await this.parkTaskAfterWorkflowStepPause(task.id)) { - this.clearPausedAborted(task.id); - wasPaused = true; - return; - } - if (this.pausedAborted.has(task.id)) { - this.clearPausedAborted(task.id); - wasPaused = true; - } - return; - } - if (!workflowResult.allPassed) { - if (workflowResult.revisionRequested) { - const rerunScheduled = await this.handleWorkflowRevisionRequest(task, worktreePath, workflowResult.feedback, workflowResult.stepName, settings); - if (rerunScheduled) { - return; - } - } else { - await this.sendTaskBackForFix(task, worktreePath, workflowResult.feedback, workflowResult.stepName || "Unknown", "Workflow step failed on retry"); - return; - } - } - } else { + // FNXC:WorkflowExecution 2026-06-25-00:00: U4 (KTD-2/KTD-5) — workflow + // gates are graph-owned (record into task.workflowStepResults, U2); the + // legacy runWorkflowSteps loop was deleted. For a graph-driven run the + // execute seam registered a completion interceptor, so stop at the + // implementation boundary and let the graph own the remaining + // lifecycle. A non-graph fallback reaching here has NO enabled workflow + // steps (a minimal store WITH enabled steps is parked fail-closed in + // maybeExecuteWorkflowGraph, KTD-5) — nothing to gate before handoff. + const graphCompletion = this.graphCompletionInterceptors.get(task.id); + if (graphCompletion) { + this.clearCompletedTaskWatchdog(task.id); + executorLog.log(`✓ ${task.id} implementation complete (retry) — graph interpreter owns the remaining lifecycle`); + graphCompletion({ modifiedFiles }); + return; + } + if (executionMode === "fast") { executorLog.log(`${task.id}: fast mode — skipping pre-merge workflow steps`); await this.store.logEntry(task.id, "Fast mode — pre-merge workflow steps skipped", undefined, this.getRunContextFor(task.id)); } @@ -12893,326 +12784,6 @@ ${failureFeedback} * @param startPoint — Optional git ref to branch from (e.g., `fusion/fn-041`). * When provided, the worktree starts from that ref instead of HEAD. */ - /** - * Run workflow step agents sequentially after main task execution completes. - * Each workflow step spawns a separate agent with the step's prompt. - * Returns structured result: all passed, all passed (true), failed (false), or revision requested. - */ - private async runWorkflowSteps( - task: Task, - worktreePath: string, - settings: Settings, - taskEnv?: NodeJS.ProcessEnv, - ): Promise { - await this.auditReadonlyWorkflowStepPromptsOnce(task.id); - // Check if task has enabled workflow steps - const currentTask = await this.store.getTask(task.id); - if (!currentTask.enabledWorkflowSteps?.length) return { allPassed: true }; - - const workflowStepIds = currentTask.enabledWorkflowSteps; - const results: import("@fusion/core").WorkflowStepResult[] = []; - - for (const wsId of workflowStepIds) { - const ws = await this.store.getWorkflowStep(wsId); - if (!ws) { - await this.store.logEntry(task.id, `[pre-merge] Workflow step ${wsId} not found — skipping`); - results.push({ - workflowStepId: wsId, - workflowStepName: "Unknown", - phase: "pre-merge", - status: "skipped", - output: "Workflow step definition not found", - }); - await this.store.updateTask(task.id, { workflowStepResults: results }); - continue; - } - - // Normalize legacy steps: undefined phase → "pre-merge" - const stepPhase = ws.phase || "pre-merge"; - - // readonly review steps always run pre-merge to reuse the coding worktree — see FN-2185 post-mortem. - // Skip non-readonly post-merge steps — those run in the merger after merge. - if (stepPhase === "post-merge" && ws.toolMode !== "readonly") continue; - - // Normalize legacy steps without mode to prompt-mode - const stepMode: "prompt" | "script" = ws.mode || "prompt"; - const gateMode: "gate" | "advisory" = ws.gateMode || (stepMode === "script" ? "gate" : "advisory"); - - // Skip validation per mode - if (stepMode === "prompt" && !ws.prompt?.trim()) { - await this.store.logEntry(task.id, `[pre-merge] Workflow step '${ws.name}' has no prompt — skipping`); - results.push({ - workflowStepId: ws.id, - workflowStepName: ws.name, - phase: stepPhase, - status: "skipped", - output: "No prompt configured for this workflow step", - }); - await this.store.updateTask(task.id, { workflowStepResults: results }); - continue; - } - - if (stepMode === "script" && !ws.scriptName?.trim()) { - await this.store.logEntry(task.id, `[pre-merge] Workflow step '${ws.name}' has no scriptName — skipping`); - results.push({ - workflowStepId: ws.id, - workflowStepName: ws.name, - phase: stepPhase, - status: "skipped", - output: "No scriptName configured for this workflow step", - }); - await this.store.updateTask(task.id, { workflowStepResults: results }); - continue; - } - - if (this.isFrontendUxStep(ws)) { - try { - const diffScopedFiles = await this.captureModifiedFiles(worktreePath, currentTask.baseCommitSha, task.id, undefined, "workflow-step-frontend-ux"); - const declaredScopedFiles = await this.store.parseFileScopeFromPrompt(task.id).catch(() => [] as string[]); - const diffHasSignal = diffScopedFiles.length > 0; - const declaredHasSignal = declaredScopedFiles.length > 0; - const diffHasFrontendFiles = diffHasSignal && this.hasFrontendFilesInScope(diffScopedFiles); - const declaredHasFrontendFiles = declaredHasSignal && this.hasFrontendFilesInScope(declaredScopedFiles); - - const shouldSkipForDiffOnly = diffHasSignal && !declaredHasSignal && !diffHasFrontendFiles; - const shouldSkipForDeclaredOnly = declaredHasSignal && !diffHasSignal && !declaredHasFrontendFiles; - const shouldSkipForBothSignals = diffHasSignal && declaredHasSignal && !diffHasFrontendFiles && !declaredHasFrontendFiles; - - if (shouldSkipForDiffOnly || shouldSkipForDeclaredOnly || shouldSkipForBothSignals) { - const skippedForDeclaredScope = shouldSkipForDeclaredOnly || shouldSkipForBothSignals; - results.push({ - workflowStepId: ws.id, - workflowStepName: ws.name, - phase: stepPhase, - status: "skipped", - output: skippedForDeclaredScope - ? "Declared File Scope contains no frontend/UI files — auto-skipped (FN-4343)" - : "No frontend/UI files in diff scope — auto-skipped (FN-3906)", - }); - await this.store.updateTask(task.id, { workflowStepResults: results }); - await this.store.logEntry( - task.id, - skippedForDeclaredScope - ? "[pre-merge] Auto-skipped Frontend UX Design — declared File Scope contains no frontend/UI files (FN-4343)" - : "[pre-merge] Auto-skipped Frontend UX Design — no frontend/UI files in diff scope", - ); - continue; - } - } catch { - // best-effort scope detection only; fall through to regular execution/defer flow - } - } - - if (await this.shouldDeferWorkflowStepCompletion(task.id, `before workflow step '${ws.name}'`)) { - return "deferred-paused"; - } - - if (ws.id.startsWith("plugin:")) { - await this.store.logEntry(task.id, `[pre-merge] Starting plugin workflow step: ${ws.name} (${ws.id})`); - } else { - await this.store.logEntry(task.id, `[pre-merge] Starting workflow step: ${ws.name} (${stepMode} mode)`); - } - executorLog.log(`${task.id} — [pre-merge] running workflow step: ${ws.name} (${stepMode} mode)`); - - const startedAt = new Date().toISOString(); - const stepStartedAtMs = Date.now(); - const workflowStepScopeEnforcement = settings.workflowStepScopeEnforcement ?? "block"; - const shouldCheckWorkflowStepScope = stepPhase === "pre-merge" - && stepMode === "prompt" - && workflowStepScopeEnforcement !== "off"; - const preStepModifiedFiles = shouldCheckWorkflowStepScope - ? await this.captureModifiedFiles(worktreePath, currentTask.baseCommitSha, task.id, undefined, "workflow-step-pre") - : []; - - // Push pending entry BEFORE execution so dashboard can show live status - results.push({ - workflowStepId: ws.id, - workflowStepName: ws.name, - phase: stepPhase, - status: "pending", - startedAt, - }); - await this.store.updateTask(task.id, { workflowStepResults: results }); - - try { - const result: WorkflowStepOutcome = stepMode === "script" - ? await this.executeScriptWorkflowStep(task, ws, worktreePath, settings, taskEnv) - : await this.executeWorkflowStep(task, ws, worktreePath, settings, taskEnv); - if (await this.shouldDeferWorkflowStepCompletion(task.id, `workflow step '${ws.name}'`)) { - return "deferred-paused"; - } - const completedAt = new Date().toISOString(); - - if (result.success) { - if (shouldCheckWorkflowStepScope) { - const declaredScope = await this.store.parseFileScopeFromPrompt(task.id).catch(() => [] as string[]); - const refreshedTask = await this.store.getTask(task.id); - if (declaredScope.length > 0 && refreshedTask?.scopeOverride !== true) { - const postStepModifiedFiles = await this.captureModifiedFiles(worktreePath, currentTask.baseCommitSha, task.id, undefined, "workflow-step-post"); - const preStepSet = new Set(preStepModifiedFiles); - const stepCommittedFiles = postStepModifiedFiles.filter((filePath) => !preStepSet.has(filePath)); - const stepUncommittedFiles = await this.captureUncommittedModifiedFiles(worktreePath); - const stepTouchedFiles = [...new Set([...stepCommittedFiles, ...stepUncommittedFiles])]; - const hasScopeOverlap = stepTouchedFiles.some((filePath) => workflowPathMatchesDeclaredScope(filePath, declaredScope)); - if (stepTouchedFiles.length > 0 && !hasScopeOverlap) { - const scopeLeakMessage = `Workflow step '${ws.name}' wrote files outside declared File Scope. Staged: [${stepTouchedFiles.join(", ")}]. Declared: [${declaredScope.join(", ")}]. (FN-4343)`; - await this.store.logEntry( - task.id, - `[pre-merge] Workflow step scope leak: ${ws.name} wrote off-scope files [${stepTouchedFiles.join(", ") || ""}]`, - ); - if (workflowStepScopeEnforcement === "warn") { - await this.store.logEntry(task.id, `[pre-merge] workflowStepScopeEnforcement=warn — ${scopeLeakMessage}`); - } else { - const existingIdx = results.findIndex(r => r.workflowStepId === ws.id); - if (existingIdx >= 0) { - results[existingIdx] = { - ...results[existingIdx], - status: gateMode === "advisory" ? "advisory_failure" : "failed", - output: scopeLeakMessage, - notes: scopeLeakMessage, - completedAt, - }; - } - await this.store.updateTask(task.id, { workflowStepResults: results }); - if (gateMode === "advisory") { - await this.store.updateTask(task.id, { status: "advisory_failure" }); - await this.store.logEntry(task.id, `[pre-merge] Advisory workflow step scope warning: ${ws.name}`); - continue; - } - return { - allPassed: false, - revisionRequested: true, - feedback: scopeLeakMessage, - stepName: ws.name, - }; - } - } - } - } - - await this.store.logEntry(task.id, `[timing] Workflow step '${ws.name}' completed in ${Date.now() - stepStartedAtMs}ms`); - await this.store.logEntry(task.id, `[pre-merge] Workflow step completed: ${ws.name}`); - executorLog.log(`${task.id} — [pre-merge] workflow step passed: ${ws.name}`); - // Update existing pending entry in place - const existingIdx = results.findIndex(r => r.workflowStepId === ws.id); - if (existingIdx >= 0) { - const malformed = result.malformed === true; - results[existingIdx] = { - ...results[existingIdx], - status: malformed ? "skipped" : "passed", - output: malformed ? "malformed output — no verdict extracted" : result.output, - verdict: result.verdict, - notes: result.notes ?? (malformed ? undefined : result.output), - completedAt, - }; - } - await this.store.updateTask(task.id, { workflowStepResults: results }); - } else if (result.revisionRequested) { - // Revision requested — this is a structured outcome that routes back to executor - await this.store.logEntry(task.id, `[timing] Workflow step '${ws.name}' requested revision after ${Date.now() - stepStartedAtMs}ms`); - await this.store.logEntry( - task.id, - `[pre-merge] Workflow step requested revision: ${ws.name}`, - result.output, - ); - executorLog.log(`${task.id} — [pre-merge] workflow step requested revision: ${ws.name}`); - // Update existing pending entry in place - const existingIdx = results.findIndex(r => r.workflowStepId === ws.id); - if (existingIdx >= 0) { - results[existingIdx] = { - ...results[existingIdx], - status: gateMode === "advisory" ? "advisory_failure" : "failed", - output: result.output || "Revision requested", - verdict: result.verdict, - notes: result.notes || result.output || "Revision requested", - completedAt, - }; - } - await this.store.updateTask(task.id, { workflowStepResults: results }); - if (gateMode === "advisory") { - await this.store.logEntry(task.id, `[pre-merge] Advisory workflow step failed: ${ws.name}`); - continue; - } - return { - allPassed: false, - revisionRequested: true, - feedback: result.output || "Workflow step requested revision", - stepName: ws.name, - }; - } else { - // Hard failure - await this.store.logEntry(task.id, `[timing] Workflow step '${ws.name}' failed after ${Date.now() - stepStartedAtMs}ms`); - await this.store.logEntry( - task.id, - `[pre-merge] Workflow step failed: ${ws.name}`, - result.error || "Unknown error", - ); - executorLog.error(`${task.id} — [pre-merge] workflow step failed: ${ws.name}; output captured in task log`); - // Update existing pending entry in place - const existingIdx = results.findIndex(r => r.workflowStepId === ws.id); - if (existingIdx >= 0) { - results[existingIdx] = { - ...results[existingIdx], - status: gateMode === "advisory" ? "advisory_failure" : "failed", - output: result.error || "Workflow step failed", - notes: result.error || "Workflow step failed", - completedAt, - }; - } - await this.store.updateTask(task.id, { workflowStepResults: results }); - if (gateMode === "advisory") { - await this.store.updateTask(task.id, { status: "advisory_failure" }); - await this.store.logEntry(task.id, `[pre-merge] Advisory workflow step failed: ${ws.name}`); - continue; - } - return { - allPassed: false, - revisionRequested: false, - feedback: result.error || "Workflow step failed", - stepName: ws.name, - }; - } - } catch (err: unknown) { - if (await this.shouldDeferWorkflowStepCompletion(task.id, `workflow step '${ws.name}'`)) { - return "deferred-paused"; - } - const { message: errorMessage, detail: errorDetail, stack: errorStack } = formatError(err); - const completedAt = new Date().toISOString(); - await this.store.logEntry( - task.id, - `[pre-merge] Workflow step failed: ${ws.name}`, - errorStack ?? errorDetail, - ); - executorLog.error(`${task.id} — [pre-merge] workflow step error: ${ws.name} — ${errorDetail}`); - // Update existing pending entry in place - const existingIdx = results.findIndex(r => r.workflowStepId === ws.id); - if (existingIdx >= 0) { - results[existingIdx] = { - ...results[existingIdx], - status: gateMode === "advisory" ? "advisory_failure" : "failed", - output: errorMessage || "Workflow step error", - notes: errorMessage || "Workflow step error", - completedAt, - }; - } - await this.store.updateTask(task.id, { workflowStepResults: results }); - if (gateMode === "advisory") { - await this.store.updateTask(task.id, { status: "advisory_failure" }); - await this.store.logEntry(task.id, `[pre-merge] Advisory workflow step error: ${ws.name}`); - continue; - } - return { - allPassed: false, - revisionRequested: false, - feedback: errorMessage || "Workflow step error", - stepName: ws.name, - }; - } - } - - return { allPassed: true }; - } - /** * Execute a script-mode workflow step by resolving the scriptName to a command * from project settings and running it in the task worktree. @@ -13280,42 +12851,6 @@ ${failureFeedback} } } - /** - * FN-3906: Only the built-in Frontend UX Design step gets orchestrator-level - * diff-scope auto-skip. Match by canonical template id only. - */ - private isFrontendUxStep(workflowStep: WorkflowStep): boolean { - return workflowStep.id === "frontend-ux-design"; - } - - /** - * FN-3906: Detect whether the task diff scope contains frontend/UI-related - * files so Frontend UX Design can be safely skipped when irrelevant. - */ - private hasFrontendFilesInScope(files: string[]): boolean { - const frontendExtensionPattern = /\.(tsx|jsx|vue|svelte|astro|html|css|scss|sass|less|styl)$/i; - const frontendPathMarkers = [ - "/components/", - "/app/components/", - "/dashboard/", - "/frontend/", - "/ui/", - "/styles/", - "/themes/", - "/design-system/", - "/design-tokens/", - ]; - const frontendTokenFilenamePattern = /(^|\/)(tokens|theme)\.(ts|js|json|css)$/i; - - return files.some((file) => { - const normalized = file.replace(/\\/g, "/"); - const lowered = normalized.toLowerCase(); - return frontendExtensionPattern.test(normalized) - || frontendPathMarkers.some((marker) => lowered.includes(marker)) - || frontendTokenFilenamePattern.test(lowered); - }); - } - /** Parse structured JSON verdict from workflow step output. */ private parseWorkflowStepOutput(rawOutput: string): { output: string; @@ -13754,28 +13289,6 @@ You have access to the file system to review changes.${verdictBlock}`; return runOnce(fallback.provider, fallback.modelId, "fallback"); } - private async auditReadonlyWorkflowStepPromptsOnce(taskId: string): Promise { - if (this.readonlyWorkflowStepAuditDone) return; - this.readonlyWorkflowStepAuditDone = true; - const tokens = ["edit", "write", "commit", "stage", "modify"]; - try { - const steps = await this.store.listWorkflowSteps(); - for (const step of steps) { - if ((step.mode || "prompt") !== "prompt" || (step.toolMode || "readonly") !== "readonly") continue; - const prompt = step.prompt || ""; - for (const token of tokens) { - const re = new RegExp(`\\b${token}\\b`, "i"); - if (re.test(prompt)) { - executorLog.warn(`[workflow-step-audit] readonly step "${step.name}" prompt contains write-implying token "${token}" — re-review intended scope (no auto-migration performed)`); - break; - } - } - } - } catch (error) { - executorLog.warn(`${taskId}: failed readonly workflow-step prompt audit: ${formatError(error)}`); - } - } - private MAX_WORKTREE_RETRIES = 3; private WORKTREE_RETRY_DELAYS = [100, 500, 1000]; // ms diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 18bd8716c4..0f5cb1e2d5 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -83,8 +83,6 @@ export { type CodingSessionResult, type ReviewPrimitiveResult, type VerificationPrimitiveResult, - type WorkflowStepPrimitiveInput, - type WorkflowStepPrimitiveResult, type TransitionPrimitiveInput, type MergePrimitiveInput, type MergePrimitiveResult, diff --git a/packages/engine/src/runtime-primitives.ts b/packages/engine/src/runtime-primitives.ts index 2136b05069..9010b6e5dd 100644 --- a/packages/engine/src/runtime-primitives.ts +++ b/packages/engine/src/runtime-primitives.ts @@ -14,7 +14,6 @@ export type RuntimePrimitiveName = | "reset-step" | "review" | "verification" - | "workflow-step" | "transition" | "merge" | "abort" @@ -80,18 +79,11 @@ export interface VerificationPrimitiveResult { stepName?: string; } -export interface WorkflowStepPrimitiveInput { - phase: "pre-merge" | "post-merge"; - stepId?: string; - worktreePath?: string; -} - -export interface WorkflowStepPrimitiveResult { - allPassed: boolean; - revisionRequested?: boolean; - feedback?: string; - stepName?: string; -} +// FNXC:WorkflowExecution 2026-06-25-00:00: U4 (KTD-2) — the `runWorkflowStep` +// primitive and its `WorkflowStepPrimitiveInput`/`WorkflowStepPrimitiveResult` +// shapes were removed. Workflow quality gates run as the graph's own +// optional-group / gate nodes which record into `task.workflowStepResults` (U2); +// there is no dedicated workflow-step runtime primitive. export interface TransitionPrimitiveInput { column?: string; @@ -179,12 +171,6 @@ export interface WorkflowRuntimePrimitives { prepared: PreparedWorktree, ): Promise>; - runWorkflowStep( - ctx: WorkflowPrimitiveContext, - task: TaskDetail, - input: WorkflowStepPrimitiveInput, - ): Promise>; - updateSteps( ctx: WorkflowPrimitiveContext, task: TaskDetail, diff --git a/packages/engine/src/workflow-authoritative-driver.ts b/packages/engine/src/workflow-authoritative-driver.ts index e22f6f7f49..2634289071 100644 --- a/packages/engine/src/workflow-authoritative-driver.ts +++ b/packages/engine/src/workflow-authoritative-driver.ts @@ -99,15 +99,10 @@ function primitivesFromLegacySeams(seams: WorkflowLegacySeams): WorkflowRuntimeP return { ...result, data: { verdict: result.outcome === "success" ? "APPROVE" : "REVISE" } }; }, runVerification: async () => ({ outcome: "success", data: { verdict: "skipped" } }), - runWorkflowStep: async (ctx, task) => { - const result = await seams.workflowStep?.(task, ctx.node.context ?? {}); - return { - outcome: result?.outcome ?? "success", - value: result?.value ?? "workflow-step-skipped", - contextPatch: result?.contextPatch, - data: { allPassed: result?.outcome !== "failure" }, - }; - }, + // FNXC:WorkflowExecution 2026-06-25-00:00: U4 (KTD-2) — the `runWorkflowStep` + // primitive + `workflow-step` seam were removed. Workflow gates run as graph + // optional-group / gate nodes that record into task.workflowStepResults (U2); + // this driver no longer adapts a workflow-step seam. updateSteps: async (_ctx, _task, steps) => ({ outcome: "success", data: { count: steps.length } }), transitionTask: async (ctx, task) => seams.schedule(task, ctx.node.context ?? {}), requestMerge: async (ctx, task) => { diff --git a/packages/engine/src/workflow-graph-task-runner.ts b/packages/engine/src/workflow-graph-task-runner.ts index ee11889f8f..844021041d 100644 --- a/packages/engine/src/workflow-graph-task-runner.ts +++ b/packages/engine/src/workflow-graph-task-runner.ts @@ -178,11 +178,9 @@ export class WorkflowGraphTaskRunner { const wrappedSeams: WorkflowLegacySeams = { planning: (t, c) => ((sideEffectsRan = true), invoked.push("planning"), seams.planning(t, c)), execute: (t, c) => ((sideEffectsRan = true), invoked.push("execute"), seams.execute(t, c)), - workflowStep: (t, c) => { - sideEffectsRan = true; - invoked.push("workflow-step"); - return seams.workflowStep?.(t, c) ?? Promise.resolve({ outcome: "success", value: "workflow-step-skipped" }); - }, + // FNXC:WorkflowExecution 2026-06-25-00:00: U4 (KTD-2) — the `workflow-step` + // seam wrapper was removed; workflow gates run as graph optional-group / gate + // nodes that record into task.workflowStepResults (U2). review: (t, c) => ((sideEffectsRan = true), invoked.push("review"), seams.review(t, c)), merge: (t, c) => ((sideEffectsRan = true), invoked.push("merge"), seams.merge(t, c)), schedule: (t, c) => ((sideEffectsRan = true), invoked.push("schedule"), seams.schedule(t, c)), diff --git a/packages/engine/src/workflow-node-handlers.ts b/packages/engine/src/workflow-node-handlers.ts index 71f1d03cd2..5464b3e4f6 100644 --- a/packages/engine/src/workflow-node-handlers.ts +++ b/packages/engine/src/workflow-node-handlers.ts @@ -11,10 +11,16 @@ import { } from "./runtime-primitives.js"; import { runWorkflowMergeAttemptNode } from "./workflow-merge-nodes.js"; +// FNXC:WorkflowExecution 2026-06-25-00:00: U4 (KTD-2) — the `workflow-step` seam +// was removed. Workflow quality gates run as the graph's own optional-group / +// gate nodes (builtin:coding replaced its `workflow-step` seam node with +// optional-group nodes) which record into `task.workflowStepResults` (U2). An IR +// node still declaring `config.seam: "workflow-step"` is no longer a recognized +// seam: `resolveSeamName` throws a WorkflowIrError for it (fails loud, never a +// silent no-op). export type WorkflowSeamName = | "planning" | "execute" - | "workflow-step" | "review" | "merge" | "schedule" @@ -26,7 +32,6 @@ export interface WorkflowLegacySeams { * custom planning behavior is expressed as a custom prompt node. */ planning: (task: TaskDetail, context: Record) => Promise; execute: (task: TaskDetail, context: Record) => Promise; - workflowStep?: (task: TaskDetail, context: Record) => Promise; review: (task: TaskDetail, context: Record) => Promise; merge: (task: TaskDetail, context: Record) => Promise; schedule: (task: TaskDetail, context: Record) => Promise; @@ -205,7 +210,6 @@ export function resolveSeamName(node: { config?: Record }): Wor if ( seam === "planning" || seam === "execute" || - seam === "workflow-step" || seam === "review" || seam === "merge" || seam === "schedule" || @@ -261,11 +265,6 @@ export function createPromptLikeHandler( // IS the seam node, so its declared column drives the binding. (Other seams // — planning/review/merge/schedule — stamp it too; only execute reads it.) context.context[SEAM_GOVERNING_NODE_CONTEXT_KEY] = node.id; - if (seam === "workflow-step") { - return seams.workflowStep - ? seams.workflowStep(context.task, context.context) - : { outcome: "success", value: "workflow-step-skipped" }; - } return seams[seam]!(context.task, context.context); } if (!runCustomNode) { @@ -352,16 +351,6 @@ export function createPrimitivePromptLikeHandler( }, }; } - if (seam === "workflow-step") { - const worktreePath = typeof context.context["workflow:worktree-path"] === "string" - ? context.context["workflow:worktree-path"] - : undefined; - const result = await primitives.runWorkflowStep(primitiveCtx, context.task, { - phase: "pre-merge", - worktreePath, - }); - return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch }; - } if (seam === "review") { const result = await primitives.runReview(primitiveCtx, context.task, { type: "code" }); return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch }; @@ -965,7 +954,7 @@ export function createNoopLegacySeams(): WorkflowLegacySeams { return { planning: success, execute: success, - workflowStep: success, + // U4 (KTD-2): no `workflow-step` seam — workflow gates run as graph nodes. review: success, merge: success, schedule: success,