FN-7244: retry unavailable Plan Review without replanning

Preserve existing task specs when Plan Review retries after reviewer outages.

- Route plan-review-unavailable triage tasks through the Plan Review/finalization path instead of the planner.
- Validate existing PROMPT.md content and fail clearly when it is missing, empty, or invalid.
- Preserve retry status across interrupted triage work and cover retry behavior with engine tests.
- Document the retry lifecycle and add a patch changeset.

Files changed:
 .../fn-7244-plan-review-unavailable-retry.md       |   7 +
 docs/architecture.md                               |   2 +
 docs/workflow-steps.md                             |   2 +
 .../triage-plan-review-unavailable-retry.test.ts   | 251 +++++++++
 packages/engine/src/__tests__/triage.test.ts       | 604 +++++++++++++++++++++
 packages/engine/src/triage.ts                      | 108 +++-
 6 files changed, 945 insertions(+), 29 deletions(-)

Fusion-Task-Id: FN-7244

Fusion-Task-Lineage: 80e790e3-d2b9-4309-beec-3dbf6a03db39

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-29 17:40:00 -07:00
parent 72e50ac8c6
commit 6f2b8ab6e5
6 changed files with 945 additions and 29 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Retry unavailable Plan Review without rewriting existing task specs.
category: fix
dev: plan-review-unavailable triage tasks now rerun Plan Review/finalization from the existing PROMPT.md under global agent concurrency instead of launching the planner.

View File

@@ -693,6 +693,8 @@ When stuck-kill retries are exhausted, `checkStuckBudget()` marks executor-phase
Planning-phase stuck kills use the same `stuckKillCount` / `settings.maxStuckKills` budget before execution starts. While under budget, a stuck triage requeue resumes from a non-empty on-disk `PROMPT.md` draft in revision mode and logs resume feedback; if `PROMPT.md` is absent, it falls back to a non-empty `plan` task document. Absent or whitespace-only drafts preserve cold-start behavior, and recoverable written drafts continue through prompt-based planning recovery. At budget exhaustion, triage parks the task as `status: "failed"`, `paused: true` with a `STUCK_LOOP_EXHAUSTED:` error so a reasoning-looping planner cannot restart indefinitely.
Plan Review reviewer outages use a narrower retry state: triage tasks parked as `status: "plan-review-unavailable"` already have an existing `PROMPT.md`, so polling routes them around the full planning agent. Retry rereads the prompt, requires non-empty deterministic-valid content, preserves the file unchanged, and reruns only the Plan Review/finalization path while holding the same global agent semaphore slot as planning/review AI work. APPROVE (or a previously passed Plan Review result) releases the task normally; REVISE/RETHINK moves it to `needs-replan`; another UNAVAILABLE/error refreshes the backoff. Missing, empty, or deterministically invalid prompts fail with a task log instead of cold-starting planning.
Active `fn_run_verification` subprocesses are a bounded progress signal (FN-6598). `createRunVerificationTool()` brackets each command with `StuckTaskDetector.beginVerification()` / `endVerification()`; while the command is active and still inside its own timeout plus cleanup grace, the detector suppresses `loop` and `no-progress-churn` classification so healthy marathon verification output cannot consume stuck-kill budget. `inactivity` is not suppressed: the verification runner must continue emitting line output or synthetic heartbeats, and if the process overruns its recorded deadline or never sends an end signal, normal detection resumes.
If loop recovery times out during compact-and-resume and the executor does not unwind within the bounded force-requeue grace window, `TaskExecutor.markStuckAborted()` now hard-cancels the hung task before clearing execution guards: spawned child agents are terminated, `awaitAbortInFlightTaskWork()` reaps API/step/workflow/configured-command/subagent/CLI surfaces, completed/in-progress steps are reconciled against committed branch state before any checkout deletion, the task worktree is removed with `RemovalReason.ExecutorStuckKilled`, stale in-memory worktree/loop/paused/stuck state is cleared, and then the task is moved back to `todo` with the configured `preserveProgressOnStuckRequeue` semantics. With preserve-progress enabled, committed step progress is retained; when the branch has no unique commits, affected steps are reset to `pending` before the worktree/branch are cleared so a retry cannot skip deleted uncommitted-only work. The path preserves the concurrent-recovery guard: if the latest task column is no longer `in-progress`, it only clears the execution guard and does not reap/remove resources that a self-healing recovery now owns. Task logs distinguish loop detection, compaction timeout, force-kill cleanup start, force-requeue, and cleanup completion/failure.

View File

@@ -185,6 +185,8 @@ The default built-in catalog entry `builtin:coding` is backed by a Stepwise-deri
- `triage` → `plan` → `plan-review` (default-on optional plan review) → `parse-steps` → `foreach(step-execute)` → `browser-verification` (optional) → `code-review` (default-on optional final review) → `merge-gate` / branch-group integration / `merge-attempt` / retry or manual hold → `end`
If the Plan Review reviewer is unavailable before producing a verdict, the task stays in triage as `status: "plan-review-unavailable"` with a short backoff. That retry state is not a replan: Fusion rereads the existing non-empty `PROMPT.md`, preserves it unchanged, and reruns only Plan Review/finalization while holding a global agent concurrency slot for the reviewer lane. A reviewer revision verdict moves the task to `needs-replan`; missing/empty/invalid prompt content fails clearly instead of restarting the planner.
`builtin:legacy-coding` is backed by the original monolithic `BUILTIN_CODING_WORKFLOW_IR`: `planning` → `execute` → optional quality gates → `review` → merge region.
`builtin:stepwise-coding` displays as Coding (per-step review). It is backed by `BUILTIN_STEPWISE_CODING_WORKFLOW_IR`; it keeps the same lifecycle columns/traits while adding the default-on optional Plan Review before `parse-steps`, modeling per-step parse/execute/review/rework as authored graph structure, and retaining the post-foreach optional Code Review gate before its final review/merge region.

View File

@@ -0,0 +1,251 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import type { Settings, Task, TaskStore } from "@fusion/core";
import { join } from "node:path";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { TriageProcessor } from "../triage.js";
const { mockReviewStep, mockCreateFnAgent } = vi.hoisted(() => ({
mockReviewStep: vi.fn(),
mockCreateFnAgent: vi.fn(),
}));
vi.mock("../reviewer.js", () => ({
reviewStep: mockReviewStep,
}));
vi.mock("../pi.js", () => ({
createFnAgent: mockCreateFnAgent,
describeModel: vi.fn().mockReturnValue("mock-model"),
promptWithFallback: vi.fn().mockReturnValue("mock-prompt"),
}));
vi.mock("@fusion/core", async (importOriginal) => {
const { createEngineCoreMock } = await import("../test/mockCore.js");
const original = await importOriginal<typeof import("@fusion/core")>();
return createEngineCoreMock(() => Promise.resolve(original));
});
async function createFixtureRoot(): Promise<string> {
return mkdtemp(join(tmpdir(), "fusion-triage-plan-review-unavailable-retry-"));
}
async function cleanupFixtureRoot(rootDir: string): Promise<void> {
await rm(rootDir, { recursive: true, force: true });
}
function createRetryTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-PLAN-RETRY-FOCUSED",
description: "Retry existing Plan Review draft",
title: "Retry existing Plan Review draft",
column: "triage",
status: "plan-review-unavailable",
nextRecoveryAt: "2026-01-01T00:00:00.000Z",
enabledWorkflowSteps: ["plan-review", "code-review"],
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
} as Task;
}
function createStore(task: Task): TaskStore {
return {
getTask: vi.fn().mockResolvedValue(task),
listTasks: vi.fn().mockResolvedValue([task]),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10_000,
groupOverlappingFiles: false,
autoMerge: true,
requirePlanApproval: false,
} as Settings),
updateTask: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
createTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
updateSettings: vi.fn(),
addSteeringComment: vi.fn(),
on: vi.fn(),
emit: vi.fn(),
} as unknown as TaskStore;
}
async function writePrompt(rootDir: string, taskId: string, prompt: string): Promise<string> {
const taskDir = join(rootDir, ".fusion", "tasks", taskId);
await mkdir(taskDir, { recursive: true });
const promptPath = join(taskDir, "PROMPT.md");
await writeFile(promptPath, prompt, "utf-8");
return promptPath;
}
async function retryTask(rootDir: string, task: Task, store = createStore(task)): Promise<TaskStore> {
const processor = new TriageProcessor(store, rootDir);
await processor.specifyTask(task);
return store;
}
describe("Plan Review unavailable retry", () => {
let roots: string[] = [];
afterEach(async () => {
mockReviewStep.mockReset();
mockCreateFnAgent.mockReset();
await Promise.all(roots.map(cleanupFixtureRoot));
roots = [];
});
/**
* FNXC:PlanReview 2026-06-29-16:45:
* Reviewer-outage retry is a recovery path for an already-written PROMPT.md. These focused regressions pin that retry to the reviewer/finalizer seam so outages do not silently relaunch planning or rewrite the accepted draft.
*/
it("approves an elapsed retry using the exact existing PROMPT.md without launching the planner", async () => {
const rootDir = await createFixtureRoot();
roots.push(rootDir);
const task = createRetryTask({ id: "FN-PLAN-RETRY-APPROVE" });
const prompt = `# Task: ${task.id} - Existing draft\n\n## Mission\n\nKeep this exact text.\n`;
const promptPath = await writePrompt(rootDir, task.id, prompt);
const store = createStore(task);
mockReviewStep.mockResolvedValue({ verdict: "APPROVE", review: "Approved.", summary: "Ready." });
await retryTask(rootDir, task, store);
expect(mockCreateFnAgent).not.toHaveBeenCalled();
expect(mockReviewStep).toHaveBeenCalledWith(
rootDir,
task.id,
0,
"PROMPT.md",
"plan",
prompt,
undefined,
expect.objectContaining({ taskId: task.id }),
);
expect(readFileSync(promptPath, "utf-8")).toBe(prompt);
expect(store.moveTask).toHaveBeenCalledWith(task.id, "todo");
});
it.each([
{
name: "unavailable verdict",
setup: () => mockReviewStep.mockResolvedValue({ verdict: "UNAVAILABLE", review: "Reviewer capacity outage.", summary: "Unavailable." }),
expectedOutput: "Reviewer capacity outage",
},
{
name: "thrown reviewer error",
setup: () => mockReviewStep.mockRejectedValue(new Error("review process crashed")),
expectedOutput: "review process crashed",
},
])("keeps PROMPT.md and refreshes backoff on $name", async ({ setup, expectedOutput }) => {
const rootDir = await createFixtureRoot();
roots.push(rootDir);
const staleRecoveryAt = "2026-01-01T00:00:00.000Z";
const task = createRetryTask({ id: "FN-PLAN-RETRY-OUTAGE", nextRecoveryAt: staleRecoveryAt });
const prompt = `# Task: ${task.id} - Existing draft\n\n## Mission\n\nDo not rewrite me.\n`;
const promptPath = await writePrompt(rootDir, task.id, prompt);
const store = createStore(task);
setup();
await retryTask(rootDir, task, store);
expect(mockCreateFnAgent).not.toHaveBeenCalled();
expect(readFileSync(promptPath, "utf-8")).toBe(prompt);
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({
status: "plan-review-unavailable",
nextRecoveryAt: expect.any(String),
}));
const outageUpdate = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls.find(
([id, update]) => id === task.id && update?.status === "plan-review-unavailable",
);
expect(outageUpdate?.[1].nextRecoveryAt).not.toBe(staleRecoveryAt);
expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({
workflowStepResults: expect.arrayContaining([
expect.objectContaining({ workflowStepId: "plan-review", status: "failed", output: expect.stringContaining(expectedOutput) }),
]),
}));
expect(store.logEntry).toHaveBeenCalledWith(
task.id,
"[pre-merge] Workflow step unavailable: Plan Review",
expect.stringContaining(expectedOutput),
);
});
it.each([
{ name: "missing", contents: null, expectedError: /could not read existing PROMPT\.md/i },
{ name: "whitespace-only", contents: " \n\t\n", expectedError: /PROMPT\.md.*(empty|whitespace)/i },
])("parks invalid $name PROMPT.md with a clear error and no planner launch", async ({ contents, expectedError }) => {
const rootDir = await createFixtureRoot();
roots.push(rootDir);
const task = createRetryTask({ id: `FN-PLAN-RETRY-${contents === null ? "MISSING" : "BLANK"}` });
let promptPath: string | null = null;
if (contents !== null) {
promptPath = await writePrompt(rootDir, task.id, contents);
}
const store = createStore(task);
await retryTask(rootDir, task, store);
expect(mockCreateFnAgent).not.toHaveBeenCalled();
expect(mockReviewStep).not.toHaveBeenCalled();
expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({
status: "failed",
error: expect.stringMatching(expectedError),
nextRecoveryAt: null,
}));
expect(store.logEntry).toHaveBeenCalledWith(task.id, expect.stringMatching(expectedError));
if (promptPath) {
expect(readFileSync(promptPath, "utf-8")).toBe(contents);
}
});
it.each(["REVISE", "RETHINK"] as const)("moves retry to needs-replan with feedback when reviewer returns %s", async (verdict) => {
const rootDir = await createFixtureRoot();
roots.push(rootDir);
const task = createRetryTask({ id: `FN-PLAN-RETRY-${verdict}` });
const prompt = `# Task: ${task.id} - Existing draft\n\n## Mission\n\nOnly rewrite after reviewer feedback.\n`;
const promptPath = await writePrompt(rootDir, task.id, prompt);
const store = createStore(task);
const feedback = `${verdict} feedback from reviewer.`;
mockReviewStep.mockResolvedValue({ verdict, review: feedback, summary: "Needs revision." });
await retryTask(rootDir, task, store);
expect(mockCreateFnAgent).not.toHaveBeenCalled();
expect(mockReviewStep).toHaveBeenCalledWith(
rootDir,
task.id,
0,
"PROMPT.md",
"plan",
prompt,
undefined,
expect.objectContaining({ taskId: task.id }),
);
expect(readFileSync(promptPath, "utf-8")).toBe(prompt);
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({
status: "needs-replan",
error: null,
nextRecoveryAt: null,
}));
expect(store.logEntry).toHaveBeenCalledWith(
task.id,
"AI spec revision requested",
expect.stringContaining(feedback),
);
});
});

View File

@@ -1402,6 +1402,7 @@ describe("TriageProcessor", () => {
id: taskId,
title: "Retry review",
status: "plan-review-unavailable",
nextRecoveryAt: "2026-01-01T00:00:00.000Z",
enabledWorkflowSteps: ["plan-review", "code-review"],
} as Partial<Task>);
const retryStore = createMockStore();
@@ -1429,12 +1430,481 @@ describe("TriageProcessor", () => {
undefined,
expect.objectContaining({ taskId }),
);
expect(readFileSync(promptPath, "utf-8")).toBe(prompt);
expect(retryStore.updateTask).toHaveBeenCalledWith(taskId, expect.objectContaining({
workflowStepResults: expect.arrayContaining([
expect.objectContaining({ workflowStepId: "plan-review", status: "passed", verdict: "APPROVE" }),
]),
}));
expect(retryStore.moveTask).toHaveBeenCalledWith(taskId, "todo");
} finally {
await cleanupTriageFixtureRoot(tempRoot);
}
});
/*
FNXC:PlanReview 2026-06-29-23:02:
Reviewer-outage retry runs the reviewer lane without the planner, but it still consumes global agent concurrency so outage loops cannot bypass capacity limits.
*/
it("runs unavailable Plan Review retry inside the global agent semaphore", async () => {
const tempRoot = await createTriageFixtureRoot("fusion-triage-plan-review-retry-semaphore-");
const taskId = "FN-PLAN-RETRY-SEMAPHORE";
const promptPath = join(tempRoot, ".fusion", "tasks", taskId, "PROMPT.md");
const prompt = `# Task: ${taskId} - Retry review\n\n## Mission\n\nReview while holding capacity.\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 with semaphore",
status: "plan-review-unavailable",
nextRecoveryAt: "2026-01-01T00:00:00.000Z",
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);
let inSemaphoreSlot = false;
const semaphore = {
availableCount: 1,
snapshot: vi.fn(() => ({ activeCount: 0, waitingCount: 0, availableCount: 1, limit: 1 })),
run: vi.fn(async (work: () => Promise<void>, priority?: number) => {
expect(priority).toEqual(expect.any(Number));
inSemaphoreSlot = true;
try {
return await work();
} finally {
inSemaphoreSlot = false;
}
}),
};
const retryProcessor = new TriageProcessor(retryStore, tempRoot, { semaphore: semaphore as any });
mockCreateFnAgent.mockClear();
mockReviewStep.mockImplementation(async () => {
expect(inSemaphoreSlot).toBe(true);
return {
verdict: "APPROVE",
review: "### Verdict: APPROVE\n\n### Summary\nReady.",
summary: "Ready.",
};
});
await retryProcessor.specifyTask(retryTask);
expect(semaphore.run).toHaveBeenCalledTimes(1);
expect(semaphore.run).toHaveBeenCalledWith(expect.any(Function), expect.any(Number));
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);
}
});
/*
FNXC:PlanReview 2026-06-29-16:00:
Reviewer-outage retry must reuse finalizeApprovedTask, including duplicate-marker closure, so the retry path cannot fork lifecycle behavior while avoiding a planner rewrite.
*/
it("uses shared duplicate finalization during unavailable Plan Review retry", async () => {
const tempRoot = await createTriageFixtureRoot("fusion-triage-plan-review-retry-duplicate-");
const taskId = "FN-PLAN-RETRY-DUPLICATE";
const canonicalId = "FN-999";
const promptPath = join(tempRoot, ".fusion", "tasks", taskId, "PROMPT.md");
const prompt = `DUPLICATE: ${canonicalId}\n`;
try {
await mkdir(join(tempRoot, ".fusion", "tasks", taskId), { recursive: true });
await writeFile(promptPath, prompt, "utf-8");
const retryTask = createTriageTask({
id: taskId,
title: "Retry duplicate review",
status: "plan-review-unavailable",
nextRecoveryAt: "2026-01-01T00:00:00.000Z",
enabledWorkflowSteps: ["plan-review", "code-review"],
} as Partial<Task>);
const canonicalTask = createTriageTask({ id: canonicalId, column: "todo" } as Partial<Task>);
const retryStore = createMockStore();
(retryStore.getTask as ReturnType<typeof vi.fn>).mockImplementation(async (id: string) => {
if (id === taskId) return retryTask;
if (id === canonicalId) return canonicalTask;
return null;
});
(retryStore.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ requirePlanApproval: false } as Settings);
const retryProcessor = new TriageProcessor(retryStore, tempRoot);
mockCreateFnAgent.mockClear();
mockReviewStep.mockClear();
await retryProcessor.specifyTask(retryTask);
expect(mockCreateFnAgent).not.toHaveBeenCalled();
expect(mockReviewStep).not.toHaveBeenCalled();
expect(readFileSync(promptPath, "utf-8")).toBe(prompt);
expect(retryStore.deleteTask).toHaveBeenCalledWith(taskId, expect.objectContaining({
removeLineageReferences: true,
}));
expect(retryStore.moveTask).not.toHaveBeenCalled();
} finally {
await cleanupTriageFixtureRoot(tempRoot);
}
});
it("does not rerun Plan Review when retry already has a passed result", async () => {
const tempRoot = await createTriageFixtureRoot("fusion-triage-plan-review-retry-passed-");
const taskId = "FN-PLAN-RETRY-PASSED";
const promptPath = join(tempRoot, ".fusion", "tasks", taskId, "PROMPT.md");
const prompt = `# Task: ${taskId} - Retry review\n\n## Mission\n\nAlready reviewed.\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 passed",
status: "plan-review-unavailable",
nextRecoveryAt: "2026-01-01T00:00:00.000Z",
enabledWorkflowSteps: ["plan-review", "code-review"],
workflowStepResults: [
{
workflowStepId: "plan-review",
workflowStepName: "Plan Review",
phase: "pre-merge",
status: "passed",
verdict: "APPROVE",
},
],
} 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.mockClear();
await retryProcessor.specifyTask(retryTask);
expect(mockCreateFnAgent).not.toHaveBeenCalled();
expect(mockReviewStep).not.toHaveBeenCalled();
expect(readFileSync(promptPath, "utf-8")).toBe(prompt);
expect(retryStore.moveTask).toHaveBeenCalledWith(taskId, "todo");
} finally {
await cleanupTriageFixtureRoot(tempRoot);
}
});
it("restores plan-review-unavailable instead of clearing status when retry persistence fails", async () => {
const tempRoot = await createTriageFixtureRoot("fusion-triage-plan-review-retry-store-failure-");
const taskId = "FN-PLAN-RETRY-STORE-FAILURE";
const promptPath = join(tempRoot, ".fusion", "tasks", taskId, "PROMPT.md");
const prompt = `# Task: ${taskId} - Retry review\n\n## Mission\n\nPreserve retry status on store failures.\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 store failure",
status: "plan-review-unavailable",
nextRecoveryAt: "2026-01-01T00:00:00.000Z",
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);
(retryStore.updateTask as ReturnType<typeof vi.fn>).mockImplementation(async (_id: string, update: Partial<Task>) => {
if (Array.isArray(update.workflowStepResults)) {
throw new Error("workflow result write failed");
}
});
const onSpecifyError = vi.fn();
const retryProcessor = new TriageProcessor(retryStore, tempRoot, { onSpecifyError });
mockCreateFnAgent.mockClear();
mockReviewStep.mockResolvedValue({
verdict: "APPROVE",
review: "Approved after retry.",
summary: "Ready.",
});
await retryProcessor.specifyTask(retryTask);
expect(mockCreateFnAgent).not.toHaveBeenCalled();
expect(readFileSync(promptPath, "utf-8")).toBe(prompt);
expect(onSpecifyError).toHaveBeenCalledWith(retryTask, expect.any(Error));
const statusUpdates = (retryStore.updateTask as ReturnType<typeof vi.fn>).mock.calls
.filter(([id, update]) => id === taskId && Object.prototype.hasOwnProperty.call(update ?? {}, "status"))
.map(([, update]) => update.status);
expect(statusUpdates.at(-1)).toBe("plan-review-unavailable");
} finally {
await cleanupTriageFixtureRoot(tempRoot);
}
});
it.each([
{
name: "unavailable verdict",
setupReview: () => mockReviewStep.mockResolvedValue({
verdict: "UNAVAILABLE",
review: "Reviewer capacity exhausted.",
summary: "Reviewer unavailable.",
}),
expectedOutput: "Reviewer capacity exhausted",
},
{
name: "reviewer throw",
setupReview: () => mockReviewStep.mockRejectedValue(new Error("reviewer transport unavailable")),
expectedOutput: "reviewer transport unavailable",
},
])("keeps PROMPT.md and refreshes backoff when Plan Review retry hits $name", async ({ setupReview, expectedOutput }) => {
const tempRoot = await createTriageFixtureRoot("fusion-triage-plan-review-retry-unavailable-");
const taskId = "FN-PLAN-RETRY-UNAVAILABLE";
const promptPath = join(tempRoot, ".fusion", "tasks", taskId, "PROMPT.md");
const prompt = "# Task: FN-PLAN-RETRY-UNAVAILABLE - Retry review\n\n## Mission\n\nKeep this plan intact.\n";
const staleRecoveryAt = "2026-01-01T00:00:00.000Z";
try {
await mkdir(join(tempRoot, ".fusion", "tasks", taskId), { recursive: true });
await writeFile(promptPath, prompt, "utf-8");
const retryTask = createTriageTask({
id: taskId,
title: "Retry review outage",
status: "plan-review-unavailable",
nextRecoveryAt: staleRecoveryAt,
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();
setupReview();
await retryProcessor.specifyTask(retryTask);
expect(mockCreateFnAgent).not.toHaveBeenCalled();
expect(mockReviewStep).toHaveBeenCalledWith(
tempRoot,
taskId,
0,
"PROMPT.md",
"plan",
prompt,
undefined,
expect.objectContaining({ taskId }),
);
expect(readFileSync(promptPath, "utf-8")).toBe(prompt);
expect(retryStore.moveTask).not.toHaveBeenCalled();
expect(retryStore.updateTask).toHaveBeenCalledWith(taskId, expect.objectContaining({
status: "plan-review-unavailable",
error: "Plan Review did not produce a verdict; retrying from triage.",
nextRecoveryAt: expect.any(String),
}));
const finalStatusUpdate = (retryStore.updateTask as ReturnType<typeof vi.fn>).mock.calls.find(
([id, update]) => id === taskId && update?.status === "plan-review-unavailable",
);
expect(finalStatusUpdate?.[1].nextRecoveryAt).not.toBe(staleRecoveryAt);
expect(retryStore.updateTask).toHaveBeenCalledWith(taskId, expect.objectContaining({
workflowStepResults: expect.arrayContaining([
expect.objectContaining({
workflowStepId: "plan-review",
status: "failed",
output: expect.stringContaining(expectedOutput),
}),
]),
}));
expect(retryStore.logEntry).toHaveBeenCalledWith(
taskId,
"[pre-merge] Workflow step unavailable: Plan Review",
expect.stringContaining(expectedOutput),
);
} finally {
await cleanupTriageFixtureRoot(tempRoot);
}
});
it.each([
{
name: "missing",
contents: null,
errorPattern: /could not read existing PROMPT\.md/i,
},
{
name: "whitespace-only",
contents: " \n\t\n",
errorPattern: /PROMPT\.md.*(empty|whitespace)/i,
},
])("fails Plan Review retry for $name PROMPT.md without launching the planner", async ({ contents, errorPattern }) => {
const tempRoot = await createTriageFixtureRoot("fusion-triage-plan-review-retry-invalid-");
const taskId = `FN-PLAN-RETRY-${contents === null ? "MISSING" : "BLANK"}`;
const taskDir = join(tempRoot, ".fusion", "tasks", taskId);
const promptPath = join(taskDir, "PROMPT.md");
try {
if (contents !== null) {
await mkdir(taskDir, { recursive: true });
await writeFile(promptPath, contents, "utf-8");
}
const retryTask = createTriageTask({
id: taskId,
title: "Invalid retry review",
status: "plan-review-unavailable",
nextRecoveryAt: "2026-01-01T00:00:00.000Z",
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.mockClear();
await retryProcessor.specifyTask(retryTask);
expect(mockCreateFnAgent).not.toHaveBeenCalled();
expect(mockReviewStep).not.toHaveBeenCalled();
expect(retryStore.moveTask).not.toHaveBeenCalled();
expect(retryStore.updateTask).toHaveBeenCalledWith(taskId, expect.objectContaining({
status: "failed",
error: expect.stringMatching(errorPattern),
nextRecoveryAt: null,
}));
expect(retryStore.logEntry).toHaveBeenCalledWith(taskId, expect.stringMatching(errorPattern));
if (contents !== null) {
expect(readFileSync(promptPath, "utf-8")).toBe(contents);
}
} finally {
await cleanupTriageFixtureRoot(tempRoot);
}
});
it("fails Plan Review retry on deterministic PROMPT.md validation errors without launching the planner", async () => {
const tempRoot = await createTriageFixtureRoot("fusion-triage-plan-review-retry-validation-");
const taskId = "FN-PLAN-RETRY-DANGLING";
const taskDir = join(tempRoot, ".fusion", "tasks", taskId);
const promptPath = join(taskDir, "PROMPT.md");
const prompt = `# Task: ${taskId} - Dangling retry\n\n## Context to Read First\n\n- .fusion/tasks/${taskId}/missing-notes.md\n`;
try {
await mkdir(taskDir, { recursive: true });
await writeFile(promptPath, prompt, "utf-8");
const retryTask = createTriageTask({
id: taskId,
title: "Invalid retry review",
status: "plan-review-unavailable",
nextRecoveryAt: "2026-01-01T00:00:00.000Z",
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.mockClear();
await retryProcessor.specifyTask(retryTask);
expect(mockCreateFnAgent).not.toHaveBeenCalled();
expect(mockReviewStep).not.toHaveBeenCalled();
expect(retryStore.moveTask).not.toHaveBeenCalled();
expect(retryStore.updateTask).toHaveBeenCalledWith(taskId, expect.objectContaining({
status: "failed",
error: expect.stringContaining("failed deterministic validation"),
nextRecoveryAt: null,
}));
expect(readFileSync(promptPath, "utf-8")).toBe(prompt);
} finally {
await cleanupTriageFixtureRoot(tempRoot);
}
});
it.each(["REVISE", "RETHINK"] as const)("moves Plan Review retry to needs-replan when reviewer returns %s", async (verdict) => {
const tempRoot = await createTriageFixtureRoot("fusion-triage-plan-review-retry-revise-");
const taskId = `FN-PLAN-RETRY-${verdict}`;
const promptPath = join(tempRoot, ".fusion", "tasks", taskId, "PROMPT.md");
const prompt = `# Task: ${taskId} - Retry review\n\n## Mission\n\nReviewer may request a real revision.\n`;
const feedback = `${verdict} feedback: add acceptance criteria.`;
try {
await mkdir(join(tempRoot, ".fusion", "tasks", taskId), { recursive: true });
await writeFile(promptPath, prompt, "utf-8");
const retryTask = createTriageTask({
id: taskId,
title: "Retry review revision",
status: "plan-review-unavailable",
nextRecoveryAt: "2026-01-01T00:00:00.000Z",
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,
review: feedback,
summary: "Needs revision.",
});
await retryProcessor.specifyTask(retryTask);
expect(mockCreateFnAgent).not.toHaveBeenCalled();
expect(mockReviewStep).toHaveBeenCalledWith(
tempRoot,
taskId,
0,
"PROMPT.md",
"plan",
prompt,
undefined,
expect.objectContaining({ taskId }),
);
expect(readFileSync(promptPath, "utf-8")).toBe(prompt);
expect(retryStore.moveTask).not.toHaveBeenCalled();
expect(retryStore.updateTask).toHaveBeenCalledWith(taskId, expect.objectContaining({
status: "needs-replan",
error: null,
nextRecoveryAt: null,
}));
expect(retryStore.logEntry).toHaveBeenCalledWith(
taskId,
"AI spec revision requested",
expect.stringContaining(feedback),
);
expect(retryStore.updateTask).toHaveBeenCalledWith(taskId, expect.objectContaining({
workflowStepResults: expect.arrayContaining([
expect.objectContaining({ workflowStepId: "plan-review", status: "failed", verdict: "REVISE" }),
]),
}));
} 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: [] };
@@ -1568,6 +2038,140 @@ describe("TriageProcessor", () => {
expect(specifySpy).toHaveBeenCalledTimes(1);
expect(specifySpy).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-200" }));
});
/*
FNXC:PlanReview 2026-06-29-15:42:
Polling must honor Plan Review retry backoff as a dispatch boundary: future `nextRecoveryAt` rows stay parked, elapsed reviewer-outage rows bypass the planner, and ordinary null/needs-replan rows still launch planning.
*/
it("keeps future Plan Review backoff parked while elapsed retry uses the review-only path", async () => {
const tempRoot = await createTriageFixtureRoot("fusion-triage-plan-review-poll-retry-");
const elapsedTaskId = "FN-PR-ELAPSED";
const futureTaskId = "FN-PR-FUTURE";
const prompt = `# Task: ${elapsedTaskId} - Elapsed Plan Review retry\n\n## Mission\n\nReuse the accepted prompt.\n`;
try {
await mkdir(join(tempRoot, ".fusion", "tasks", elapsedTaskId), { recursive: true });
await writeFile(join(tempRoot, ".fusion", "tasks", elapsedTaskId, "PROMPT.md"), prompt, "utf-8");
const elapsedTask = createTriageTask({
id: elapsedTaskId,
title: "Elapsed retry",
status: "plan-review-unavailable",
nextRecoveryAt: new Date(Date.now() - 1_000).toISOString(),
enabledWorkflowSteps: ["plan-review", "code-review"],
} as Partial<Task>);
const futureTask = createTriageTask({
id: futureTaskId,
title: "Future retry",
status: "plan-review-unavailable",
nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(),
enabledWorkflowSteps: ["plan-review", "code-review"],
} as Partial<Task>);
const tasksById = new Map([elapsedTask, futureTask].map((task) => [task.id, task]));
const triageStore = createMockStore({
listTasks: vi.fn().mockResolvedValue([futureTask, elapsedTask]),
getTask: vi.fn().mockImplementation(async (id: string) => tasksById.get(id) ?? null),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 10,
maxTriageConcurrent: 10,
pollIntervalMs: 10_000,
groupOverlappingFiles: false,
autoMerge: true,
} as Settings),
});
const triageProcessor = new TriageProcessor(triageStore, tempRoot);
mockCreateFnAgent.mockClear();
mockReviewStep.mockResolvedValue({
verdict: "APPROVE",
review: "### Verdict: APPROVE\n\n### Summary\nReady.",
summary: "Ready.",
});
(triageProcessor as any).running = true;
await (triageProcessor as any).poll();
await vi.waitFor(() => {
expect(mockReviewStep).toHaveBeenCalledWith(
tempRoot,
elapsedTaskId,
0,
"PROMPT.md",
"plan",
prompt,
undefined,
expect.objectContaining({ taskId: elapsedTaskId }),
);
});
expect(mockCreateFnAgent).not.toHaveBeenCalled();
expect(mockReviewStep).not.toHaveBeenCalledWith(
tempRoot,
futureTaskId,
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
);
expect(triageStore.moveTask).toHaveBeenCalledWith(elapsedTaskId, "todo");
expect(triageStore.moveTask).not.toHaveBeenCalledWith(futureTaskId, expect.any(String));
} finally {
await cleanupTriageFixtureRoot(tempRoot);
}
});
it("continues dispatching unplanned and explicit replan tasks to the planning agent", async () => {
const tasks = [
createTriageTask({ id: "FN-PLANNER-UNDEFINED", status: undefined } as Partial<Task>),
createTriageTask({ id: "FN-PLANNER-NULL", status: null } as Partial<Task>),
createTriageTask({
id: "FN-PLANNER-REPLAN",
status: "needs-replan",
log: [{ action: "AI spec revision requested", outcome: "Add verification details." } as any],
} as Partial<Task>),
];
const tasksById = new Map(tasks.map((task) => [task.id, { ...task, attachments: [], comments: [] }]));
const triageStore = createMockStore({
listTasks: vi.fn().mockResolvedValue(tasks),
getTask: vi.fn().mockImplementation(async (id: string) => tasksById.get(id) ?? null),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 10,
maxTriageConcurrent: 10,
pollIntervalMs: 10_000,
groupOverlappingFiles: false,
autoMerge: true,
} as Settings),
});
const triageProcessor = new TriageProcessor(triageStore, rootDir);
const { promptWithFallback } = await import("../pi.js");
mockCreateFnAgent.mockClear();
mockCreateFnAgent.mockResolvedValue({
session: {
state: {},
sessionManager: { getLeafId: vi.fn().mockReturnValue(null) },
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
});
(promptWithFallback as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(triageProcessor as any).running = true;
await (triageProcessor as any).poll();
await vi.waitFor(() => {
expect(mockCreateFnAgent).toHaveBeenCalledTimes(3);
});
expect(mockCreateFnAgent.mock.calls.map(([options]) => options.taskId).sort()).toEqual([
"FN-PLANNER-NULL",
"FN-PLANNER-REPLAN",
"FN-PLANNER-UNDEFINED",
]);
for (const task of tasks) {
expect(triageStore.updateTask).toHaveBeenCalledWith(task.id, { status: "planning" });
}
});
});
it("runs deterministic validation without calling the spec reviewer", async () => {

View File

@@ -861,14 +861,27 @@ export class TriageProcessor {
try {
const detail = await this.store.getTask(task.id);
const currentTask = detail ?? task;
// Merge per-task effective workflow settings (U3, KTD-3) over the base so the
// planning-phase reads (requirePlanApproval, planning/validator model lanes)
// 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, currentTask, await this.store.getSettings());
const promptPath = `.fusion/tasks/${task.id}/PROMPT.md`;
if (task.status === "plan-review-unavailable") {
await this.retryUnavailablePlanReview(task, promptPath, settings);
/*
FNXC:PlanReview 2026-06-29-12:58:
`plan-review-unavailable` is a reviewer-outage retry state, not a planning request. Dispatch it before any createFnAgent path so the existing PROMPT.md is reused and only Plan Review/finalization reruns.
FNXC:PlanReview 2026-06-29-23:02:
Retry still launches the Plan Review reviewer lane, so it must consume the same global AgentSemaphore slot as planning work while continuing to avoid the planner session and PROMPT.md rewrite path.
*/
if (currentTask.status === "plan-review-unavailable") {
const retryWork = () => this.retryUnavailablePlanReview(currentTask, promptPath, settings);
if (this.options.semaphore) {
await this.options.semaphore.run(retryWork, PRIORITY_SPECIFY);
} else {
await retryWork();
}
return;
}
@@ -1196,7 +1209,7 @@ export class TriageProcessor {
if (this.pauseAborted.has(task.id)) {
this.pauseAborted.delete(task.id);
planLog.log(`${task.id} aborted by pause — clearing status`);
const restoreStatus = task.status === "needs-replan" ? "needs-replan" : null;
const restoreStatus = this.restoreStatusAfterInterruptedTriageWork(task);
await this.store.updateTask(task.id, { status: restoreStatus }).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
planLog.warn(`${task.id}: failed to restore status to '${restoreStatus}' during pause-abort cleanup: ${msg}`);
@@ -1282,7 +1295,7 @@ export class TriageProcessor {
`Generated plan failed deterministic validation (${deterministicSpecFailure}) — retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay}.`;
planLog.warn(`${task.id} ${retryMessage}`);
await this.store.logEntry(task.id, retryMessage);
const restoreStatus = task.status === "needs-replan" ? "needs-replan" : null;
const restoreStatus = this.restoreStatusAfterInterruptedTriageWork(task);
await this.store.updateTask(task.id, {
status: restoreStatus,
error: null,
@@ -1359,9 +1372,9 @@ export class TriageProcessor {
// Pause (global or engine) — clear planning status without reporting an error
this.pauseAborted.delete(task.id);
planLog.log(`${task.id} aborted by pause — clearing status`);
// For re-planning, restore needs-replan status; otherwise clear to null
// so the next poll can re-pick this task up.
const restoreStatus = task.status === "needs-replan" ? "needs-replan" : null;
// For interrupted recovery states, restore the original triage-held status;
// otherwise clear to null so the next poll can re-pick ordinary tasks up.
const restoreStatus = this.restoreStatusAfterInterruptedTriageWork(task);
await this.store.updateTask(task.id, { status: restoreStatus }).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
planLog.warn(`${task.id}: failed to restore status to '${restoreStatus}' during pause-abort error cleanup: ${msg}`);
@@ -1395,7 +1408,7 @@ export class TriageProcessor {
planLog.warn(`${task.id}: failed to log transient-error retry entry: ${msg}`);
});
}
const restoreStatus = task.status === "needs-replan" ? "needs-replan" : null;
const restoreStatus = this.restoreStatusAfterInterruptedTriageWork(task);
await this.store.updateTask(task.id, {
status: restoreStatus,
recoveryRetryCount: decision.nextState.recoveryRetryCount,
@@ -1424,9 +1437,9 @@ export class TriageProcessor {
this.options.onSpecifyError?.(task, err instanceof Error ? err : new Error(errorMessage));
return;
}
// For re-planning, restore needs-replan status so it can be retried;
// otherwise clear to null so the next poll can re-pick the task up.
const restoreStatus = task.status === "needs-replan" ? "needs-replan" : null;
// For interrupted recovery states, restore the original triage-held status;
// otherwise clear to null so the next poll can re-pick ordinary tasks up.
const restoreStatus = this.restoreStatusAfterInterruptedTriageWork(task);
await this.store.updateTask(task.id, { status: restoreStatus }).catch((restoreErr: unknown) => {
const msg = restoreErr instanceof Error ? restoreErr.message : String(restoreErr);
planLog.warn(`${task.id}: failed to restore status to '${restoreStatus}' after planning error: ${msg}`);
@@ -1749,18 +1762,27 @@ export class TriageProcessor {
return [taskList, taskSearch, taskShow, taskCreate];
}
private restoreStatusAfterInterruptedTriageWork(task: Task): Task["status"] | null {
/*
FNXC:PlanReview 2026-06-29-16:56:
Reviewer-outage retry is not an unplanned task. If a lifecycle write fails while rerunning Plan Review, preserve `plan-review-unavailable` so the next poll returns to the review-only retry path instead of clearing status and launching the planner.
*/
if (task.status === "needs-replan" || task.status === "plan-review-unavailable") {
return task.status;
}
return null;
}
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}`;
const parkInvalidRetry = async (failure: string): Promise<void> => {
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}`);
planLog.warn(`${task.id}: failed to log invalid PROMPT.md during Plan Review retry: ${logMessage}`);
});
await this.store.updateTask(task.id, {
status: "failed",
@@ -1768,12 +1790,30 @@ export class TriageProcessor {
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}`);
planLog.warn(`${task.id}: failed to persist invalid PROMPT.md Plan Review retry failure: ${updateMessage}`);
});
return "";
};
const written = await readFile(join(this.rootDir, promptPath), "utf-8").catch(async (error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
await parkInvalidRetry(`Plan Review retry could not read existing PROMPT.md (${promptPath}): ${message}`);
return null;
});
if (written === null) {
return;
}
if (!written.trim()) {
await parkInvalidRetry(`Plan Review retry found existing PROMPT.md (${promptPath}) but it is empty or whitespace-only.`);
return;
}
const deterministicSpecFailure = await this.validateGeneratedPrompt(task.id, written);
if (deterministicSpecFailure) {
await parkInvalidRetry(
`Plan Review retry PROMPT.md failed deterministic validation (${deterministicSpecFailure}). Fix the existing PROMPT.md or request a replan; reviewer-outage retry will not restart planning.`,
);
return;
}
@@ -1786,7 +1826,10 @@ export class TriageProcessor {
{ ...task, status: "planning" },
written,
settings,
{ recoveryLogAction: "Plan Review retry approved existing PROMPT.md — moved to execution" },
{
recoveryLogAction: "Plan Review retry approved existing PROMPT.md — moved to execution",
preservePromptContent: true,
},
);
}
@@ -1832,7 +1875,11 @@ export class TriageProcessor {
}
private async recordPlanReviewWorkflowResult(task: Task, result: WorkflowStepResult): Promise<void> {
const live = await this.store.getTask(task.id).catch(() => task);
const live = await this.store.getTask(task.id).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
planLog.warn(`${task.id}: failed to load existing Plan Review workflow results; preserving in-memory result baseline: ${message}`);
return task;
});
const existing = Array.isArray(live?.workflowStepResults)
? [...live.workflowStepResults]
: [];
@@ -2006,6 +2053,7 @@ export class TriageProcessor {
isReplan?: boolean;
feedback?: string;
recoveryLogAction?: string;
preservePromptContent?: boolean;
} = {},
): Promise<void> {
let written = writtenInput;
@@ -2112,15 +2160,17 @@ export class TriageProcessor {
// Fail open on persisted PROMPT.md parsing and keep using the in-memory parse.
}
const promptWithFrontendUxCriteria = applyFrontendUxCriteria(written, parsedFileScope);
if (promptWithFrontendUxCriteria !== written) {
const promptPath = join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
try {
await writeFile(promptPath, promptWithFrontendUxCriteria, "utf-8");
written = promptWithFrontendUxCriteria;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
planLog.warn(`${task.id}: failed to write Frontend UX Criteria to PROMPT.md (${message})`);
if (!options.preservePromptContent) {
const promptWithFrontendUxCriteria = applyFrontendUxCriteria(written, parsedFileScope);
if (promptWithFrontendUxCriteria !== written) {
const promptPath = join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
try {
await writeFile(promptPath, promptWithFrontendUxCriteria, "utf-8");
written = promptWithFrontendUxCriteria;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
planLog.warn(`${task.id}: failed to write Frontend UX Criteria to PROMPT.md (${message})`);
}
}
}