From 2b73a0a238f4b87716c15cb7dfcb8399536f503c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 29 Jun 2026 10:21:29 -0700 Subject: [PATCH] FN-7228: add task pipeline smoke and plan review retry Add engine coverage for the default task pipeline while preserving accepted plans during Plan Review retries. - Add a deterministic engine-core smoke test for the minimal builtin:coding task pipeline. - Reuse existing PROMPT.md when retrying plan-review-unavailable tasks instead of replanning them. - Cover missing-PROMPT retry failure handling and workflow parity ordering. - Add a patch changeset for the Plan Review retry behavior. Files changed: .changeset/fn-7228-plan-review-retry.md | 7 ++ .../src/__tests__/task-pipeline-smoke.test.ts | 140 +++++++++++++++++++++ packages/engine/src/__tests__/triage.test.ts | 47 +++++++ .../workflow-graph-executor-parity.test.ts | 6 +- packages/engine/src/triage.ts | 49 +++++++- packages/engine/vitest.config.ts | 5 + 6 files changed, 252 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-7228 Fusion-Task-Lineage: e61ba4aa-6d96-4413-9048-9eeaf9ea95b5 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7228-plan-review-retry.md | 7 + .../src/__tests__/task-pipeline-smoke.test.ts | 140 ++++++++++++++++++ packages/engine/src/__tests__/triage.test.ts | 47 ++++++ .../workflow-graph-executor-parity.test.ts | 6 +- packages/engine/src/triage.ts | 49 +++++- packages/engine/vitest.config.ts | 5 + 6 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 .changeset/fn-7228-plan-review-retry.md create mode 100644 packages/engine/src/__tests__/task-pipeline-smoke.test.ts diff --git a/.changeset/fn-7228-plan-review-retry.md b/.changeset/fn-7228-plan-review-retry.md new file mode 100644 index 0000000000..38054a8c07 --- /dev/null +++ b/.changeset/fn-7228-plan-review-retry.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Retry unavailable Plan Review without rewriting an accepted task plan. +category: fix +dev: Adds a triage retry path for plan-review-unavailable tasks that reuses PROMPT.md. diff --git a/packages/engine/src/__tests__/task-pipeline-smoke.test.ts b/packages/engine/src/__tests__/task-pipeline-smoke.test.ts new file mode 100644 index 0000000000..5dcd665832 --- /dev/null +++ b/packages/engine/src/__tests__/task-pipeline-smoke.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import type { Settings, TaskDetail, TaskStep } from "@fusion/core"; + +import { WorkflowTaskRuntime } from "../workflow-task-runtime.js"; +import type { WorkflowRuntimePrimitives } from "../runtime-primitives.js"; +import { + WORKFLOW_ID_CONTEXT_KEY, + WORKFLOW_RUN_ID_CONTEXT_KEY, +} from "../workflow-node-handlers.js"; + +const promptWithOneStep = `# Task: FN-7228 Pipeline smoke + +## Steps + +### Step 1: Add the minimal pipeline smoke test +- Prove the default task pipeline reaches merge. +`; + +const task = { + id: "FN-7228-SMOKE", + title: "Pipeline smoke", + description: "Exercise the default task pipeline without external side effects.", + column: "todo", + dependencies: [], + steps: [], + currentStep: 0, + prompt: promptWithOneStep, + createdAt: "2026-06-29T00:00:00.000Z", + updatedAt: "2026-06-29T00:00:00.000Z", +} as TaskDetail; + +const settings = { experimentalFeatures: {} } as Pick; + +describe("task pipeline smoke", () => { + it("runs an unselected task through the default built-in coding pipeline", async () => { + const calls: string[] = []; + const mergeContexts: Array<{ workflowId: string; runId: string }> = []; + let selectionReads = 0; + + /* + * FNXC:WorkflowSmoke 2026-06-29-00:00: + * This smoke intentionally drives WorkflowTaskRuntime with in-memory primitives only. The invariant is that an unselected task resolves to the default `builtin:coding` pipeline, parses the required PROMPT.md step source, executes one planned step, reaches review gates, and calls merge exactly once without git, network, subprocess, timer, or database dependencies. + */ + const primitives: WorkflowRuntimePrimitives = { + prepareWorktree: async () => { + calls.push("prepare-worktree"); + return { outcome: "success", data: { worktreePath: "/memory/worktree" } }; + }, + readArtifact: async (_ctx, _task, key) => key === "PROMPT.md" ? promptWithOneStep : undefined, + writeArtifact: async (_ctx, _task, key) => ({ outcome: "success", data: { key } }), + runPlanningSession: async () => { + calls.push("plan"); + return { outcome: "success", data: { approved: true, artifactKeys: ["PROMPT.md"] } }; + }, + runCodingSession: async () => { + calls.push("coding-session"); + return { outcome: "success", data: { taskDone: true, modifiedFiles: [] } }; + }, + runTaskStep: async (_ctx, _task, stepIndex) => { + calls.push(`step-execute:${stepIndex}`); + return { outcome: "success", baselineSha: "baseline", checkpointId: "checkpoint" }; + }, + resetTaskStep: async () => ({ ok: true }), + runReview: async (_ctx, _task, input) => { + calls.push(input.type === "plan" ? "plan-review" : "code-review"); + return { outcome: "success", data: { verdict: "APPROVE" } }; + }, + runVerification: async () => ({ outcome: "success", data: { verdict: "skipped" } }), + updateSteps: async (_ctx, target, steps: TaskStep[]) => { + calls.push("parse"); + target.steps = steps; + return { outcome: "success", data: { count: steps.length } }; + }, + transitionTask: async () => ({ outcome: "success" }), + requestMerge: async (ctx) => { + calls.push("merge"); + mergeContexts.push({ workflowId: ctx.run.workflowId, runId: ctx.run.runId }); + return { outcome: "success", value: "merged", data: { status: "merged" } }; + }, + abortRun: async () => ({ outcome: "success" }), + audit: () => undefined, + }; + + const runtime = new WorkflowTaskRuntime({ + store: { + getTaskWorkflowSelection: () => { + selectionReads += 1; + return undefined; + }, + getWorkflowDefinition: async () => undefined, + getTaskDocument: async (_taskId, key) => key === "PROMPT.md" ? { key, content: promptWithOneStep } : null, + }, + primitives, + runCustomNode: async (node) => { + calls.push(`custom:${node.id}`); + return { outcome: "success" }; + }, + parseStepsDeps: { + readArtifact: async (_target, key) => key === "PROMPT.md" ? promptWithOneStep : undefined, + writeSteps: async (target, steps) => { + calls.push("parse"); + target.steps = steps; + }, + }, + }); + + const result = await runtime.run({ ...task, steps: [] }, settings); + + expect(result.disposition).toBe("completed"); + expect(result.outcome).toBe("success"); + expect(selectionReads).toBe(1); + expect(result.context[WORKFLOW_RUN_ID_CONTEXT_KEY]).toBe("FN-7228-SMOKE:builtin:coding"); + expect(result.context[WORKFLOW_ID_CONTEXT_KEY]).toBe("builtin-stepwise-final-review-coding"); + expect(result.visitedNodeIds).toEqual([ + "start", + "plan", + "plan-review", + "plan-review::plan-review-step", + "parse", + "steps", + "steps#0:step-execute", + "steps#0:step-done", + "browser-verification", + "code-review", + "code-review::code-review-step", + "merge", + ]); + expect(calls).toEqual([ + "plan", + "custom:plan-review-step", + "parse", + "step-execute:0", + "custom:code-review-step", + "merge", + ]); + expect(mergeContexts).toEqual([ + { workflowId: "builtin-stepwise-final-review-coding", runId: "FN-7228-SMOKE:builtin:coding" }, + ]); + }); +}); diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index b27229259e..cc8de4ac8f 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -1388,6 +1388,53 @@ describe("TriageProcessor", () => { })); }); + it("retries unavailable Plan Review without launching the planning agent", async () => { + const tempRoot = await createTriageFixtureRoot("fusion-triage-plan-review-retry-"); + const taskId = "FN-PLAN-RETRY"; + const promptPath = join(tempRoot, ".fusion", "tasks", taskId, "PROMPT.md"); + const prompt = "# Task: FN-PLAN-RETRY - Retry review\n\n## Mission\n\nReuse this existing plan.\n"; + + try { + await mkdir(join(tempRoot, ".fusion", "tasks", taskId), { recursive: true }); + await writeFile(promptPath, prompt, "utf-8"); + + const retryTask = createTriageTask({ + id: taskId, + title: "Retry review", + status: "plan-review-unavailable", + enabledWorkflowSteps: ["plan-review", "code-review"], + } as Partial); + const retryStore = createMockStore(); + (retryStore.getTask as ReturnType).mockResolvedValue(retryTask); + (retryStore.getSettings as ReturnType).mockResolvedValue({ requirePlanApproval: false } as Settings); + const retryProcessor = new TriageProcessor(retryStore, tempRoot); + + mockCreateFnAgent.mockClear(); + mockReviewStep.mockResolvedValue({ + verdict: "APPROVE", + review: "### Verdict: APPROVE\n\n### Summary\nReady.", + summary: "Ready.", + }); + + await retryProcessor.specifyTask(retryTask); + + expect(mockCreateFnAgent).not.toHaveBeenCalled(); + expect(mockReviewStep).toHaveBeenCalledWith( + tempRoot, + taskId, + 0, + "PROMPT.md", + "plan", + prompt, + undefined, + expect.objectContaining({ taskId }), + ); + expect(retryStore.moveTask).toHaveBeenCalledWith(taskId, "todo"); + } finally { + await cleanupTriageFixtureRoot(tempRoot); + } + }); + it("includes workflow discovery and selection tools in the full triage toolset", async () => { const task = createTriageTask({ id: "FN-WORKFLOW-TOOLS" }); const detailedTask = { ...mockTaskDetail, id: task.id, attachments: [], comments: [] }; diff --git a/packages/engine/src/__tests__/workflow-graph-executor-parity.test.ts b/packages/engine/src/__tests__/workflow-graph-executor-parity.test.ts index 62802fa85f..93d70551d9 100644 --- a/packages/engine/src/__tests__/workflow-graph-executor-parity.test.ts +++ b/packages/engine/src/__tests__/workflow-graph-executor-parity.test.ts @@ -27,7 +27,11 @@ import { import { WorkflowGraphExecutor } from "../workflow-graph-executor.js"; import type { WorkflowLegacySeams } from "../workflow-node-handlers.js"; -const task = { id: "FN-5767" } as TaskDetail; +/* + * FNXC:WorkflowParity 2026-06-29-07:45: + * These legacy byte-parity checks intentionally disable default-on optional groups so the observed seam sequence remains the historical planning → execute → review → merge oracle. Separate tests own default-on optional step execution and the stepwise builtin pipeline smoke. + */ +const task = { id: "FN-5767", enabledWorkflowSteps: [] } as TaskDetail; type BaseSeam = "planning" | "execute" | "workflow-step" | "review" | "merge" | "schedule"; function runBaseSeam(seams: WorkflowLegacySeams, seam: BaseSeam, task: TaskDetail, context: Record) { diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 4f95dc69b5..ca945d52ca 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -866,6 +866,12 @@ export class TriageProcessor { // pick up workflow values. Behavior-inert when nothing is customized. const settings = await mergeEffectiveSettings(this.store, task, await this.store.getSettings()); const promptPath = `.fusion/tasks/${task.id}/PROMPT.md`; + + if (task.status === "plan-review-unavailable") { + await this.retryUnavailablePlanReview(task, promptPath, settings); + return; + } + const isFast = task.executionMode === "fast"; // FN-6236: this is the only legacy executionMode="fast" bridge. Downstream // triage policy reads resolved workflow flags instead of the raw string. @@ -1062,7 +1068,7 @@ export class TriageProcessor { defaultModelId: planningModel.modelId, }; - let { session } = await createResolvedAgentSession({ + const { session } = await createResolvedAgentSession({ sessionPurpose: "triage", runtimeHint: triageRuntimeHint, pluginRunner: this.options.pluginRunner, @@ -1743,6 +1749,47 @@ export class TriageProcessor { return [taskList, taskSearch, taskShow, taskCreate]; } + private async retryUnavailablePlanReview(task: Task, promptPath: string, settings: Settings): Promise { + /* + FNXC:PlanReview 2026-06-29-12:35: + A reviewer outage parks tasks as plan-review-unavailable after PROMPT.md is already accepted. Backoff retry must reuse that exact PROMPT.md and rerun only the Plan Review gate; sending the task through the planner again would rewrite an approved draft without reviewer feedback. + */ + const written = await readFile(join(this.rootDir, promptPath), "utf-8").catch(async (error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + const failure = `Plan Review retry could not read existing PROMPT.md (${promptPath}): ${message}`; + planLog.warn(`${task.id}: ${failure}`); + await this.store.logEntry(task.id, failure).catch((logError: unknown) => { + const logMessage = logError instanceof Error ? logError.message : String(logError); + planLog.warn(`${task.id}: failed to log missing PROMPT.md during Plan Review retry: ${logMessage}`); + }); + await this.store.updateTask(task.id, { + status: "failed", + error: failure, + nextRecoveryAt: null, + }).catch((updateError: unknown) => { + const updateMessage = updateError instanceof Error ? updateError.message : String(updateError); + planLog.warn(`${task.id}: failed to persist missing PROMPT.md Plan Review retry failure: ${updateMessage}`); + }); + return ""; + }); + + if (!written.trim()) { + return; + } + + await this.store.updateTask(task.id, { status: "planning", error: null }).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + planLog.warn(`${task.id}: failed to mark Plan Review retry as planning: ${message}`); + }); + + await this.finalizeApprovedTask( + { ...task, status: "planning" }, + written, + settings, + { recoveryLogAction: "Plan Review retry approved existing PROMPT.md — moved to execution" }, + ); + } + private async validateGeneratedPrompt(taskId: string, promptContent: string): Promise { /* FNXC:PlanReview 2026-06-29-01:52: diff --git a/packages/engine/vitest.config.ts b/packages/engine/vitest.config.ts index 6921772ecf..c8ace669b9 100644 --- a/packages/engine/vitest.config.ts +++ b/packages/engine/vitest.config.ts @@ -88,6 +88,11 @@ export default defineConfig({ "src/__tests__/hold-release.test.ts", "src/__tests__/workflow-graph-task-runner.test.ts", "src/__tests__/workflow-graph-executor-parity.test.ts", + /* + FNXC:EngineTests 2026-06-29-00:00: + The minimal task-pipeline smoke belongs in engine-core because the default builtin:coding path is now a merge-gate canary: it proves the unselected-task runtime reaches merge with deterministic in-memory seams only, without real git, network, subprocesses, timers, or broad e2e scope. + */ + "src/__tests__/task-pipeline-smoke.test.ts", "src/__tests__/scheduler-workflow-cutover.test.ts", "src/__tests__/executor-base-commit-capture.test.ts", "src/__tests__/executor-capture-modified-files-attribution.test.ts",