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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7228-plan-review-retry.md
Normal file
7
.changeset/fn-7228-plan-review-retry.md
Normal file
@@ -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.
|
||||||
140
packages/engine/src/__tests__/task-pipeline-smoke.test.ts
Normal file
140
packages/engine/src/__tests__/task-pipeline-smoke.test.ts
Normal file
@@ -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<Settings, "experimentalFeatures">;
|
||||||
|
|
||||||
|
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" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Task>);
|
||||||
|
const retryStore = createMockStore();
|
||||||
|
(retryStore.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(retryTask);
|
||||||
|
(retryStore.getSettings as ReturnType<typeof vi.fn>).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 () => {
|
it("includes workflow discovery and selection tools in the full triage toolset", async () => {
|
||||||
const task = createTriageTask({ id: "FN-WORKFLOW-TOOLS" });
|
const task = createTriageTask({ id: "FN-WORKFLOW-TOOLS" });
|
||||||
const detailedTask = { ...mockTaskDetail, id: task.id, attachments: [], comments: [] };
|
const detailedTask = { ...mockTaskDetail, id: task.id, attachments: [], comments: [] };
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ import {
|
|||||||
import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
|
import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
|
||||||
import type { WorkflowLegacySeams } from "../workflow-node-handlers.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";
|
type BaseSeam = "planning" | "execute" | "workflow-step" | "review" | "merge" | "schedule";
|
||||||
|
|
||||||
function runBaseSeam(seams: WorkflowLegacySeams, seam: BaseSeam, task: TaskDetail, context: Record<string, unknown>) {
|
function runBaseSeam(seams: WorkflowLegacySeams, seam: BaseSeam, task: TaskDetail, context: Record<string, unknown>) {
|
||||||
|
|||||||
@@ -866,6 +866,12 @@ export class TriageProcessor {
|
|||||||
// pick up workflow values. Behavior-inert when nothing is customized.
|
// pick up workflow values. Behavior-inert when nothing is customized.
|
||||||
const settings = await mergeEffectiveSettings(this.store, task, await this.store.getSettings());
|
const settings = await mergeEffectiveSettings(this.store, task, await this.store.getSettings());
|
||||||
const promptPath = `.fusion/tasks/${task.id}/PROMPT.md`;
|
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";
|
const isFast = task.executionMode === "fast";
|
||||||
// FN-6236: this is the only legacy executionMode="fast" bridge. Downstream
|
// FN-6236: this is the only legacy executionMode="fast" bridge. Downstream
|
||||||
// triage policy reads resolved workflow flags instead of the raw string.
|
// triage policy reads resolved workflow flags instead of the raw string.
|
||||||
@@ -1062,7 +1068,7 @@ export class TriageProcessor {
|
|||||||
defaultModelId: planningModel.modelId,
|
defaultModelId: planningModel.modelId,
|
||||||
};
|
};
|
||||||
|
|
||||||
let { session } = await createResolvedAgentSession({
|
const { session } = await createResolvedAgentSession({
|
||||||
sessionPurpose: "triage",
|
sessionPurpose: "triage",
|
||||||
runtimeHint: triageRuntimeHint,
|
runtimeHint: triageRuntimeHint,
|
||||||
pluginRunner: this.options.pluginRunner,
|
pluginRunner: this.options.pluginRunner,
|
||||||
@@ -1743,6 +1749,47 @@ export class TriageProcessor {
|
|||||||
return [taskList, taskSearch, taskShow, taskCreate];
|
return [taskList, taskSearch, taskShow, taskCreate];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async retryUnavailablePlanReview(task: Task, promptPath: string, settings: Settings): Promise<void> {
|
||||||
|
/*
|
||||||
|
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<string | null> {
|
private async validateGeneratedPrompt(taskId: string, promptContent: string): Promise<string | null> {
|
||||||
/*
|
/*
|
||||||
FNXC:PlanReview 2026-06-29-01:52:
|
FNXC:PlanReview 2026-06-29-01:52:
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ export default defineConfig({
|
|||||||
"src/__tests__/hold-release.test.ts",
|
"src/__tests__/hold-release.test.ts",
|
||||||
"src/__tests__/workflow-graph-task-runner.test.ts",
|
"src/__tests__/workflow-graph-task-runner.test.ts",
|
||||||
"src/__tests__/workflow-graph-executor-parity.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__/scheduler-workflow-cutover.test.ts",
|
||||||
"src/__tests__/executor-base-commit-capture.test.ts",
|
"src/__tests__/executor-base-commit-capture.test.ts",
|
||||||
"src/__tests__/executor-capture-modified-files-attribution.test.ts",
|
"src/__tests__/executor-capture-modified-files-attribution.test.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user