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 () => {
|
||||
const task = createTriageTask({ id: "FN-WORKFLOW-TOOLS" });
|
||||
const detailedTask = { ...mockTaskDetail, id: task.id, attachments: [], comments: [] };
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
|
||||
@@ -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<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> {
|
||||
/*
|
||||
FNXC:PlanReview 2026-06-29-01:52:
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user