fix(workflows): reject missing plan review artifacts (#2390)

## Summary

Workflows could reach Plan Review without an authoritative PROMPT.md,
producing misleading approvals or stranding the task. Planning now
verifies durable prompt persistence before releasing the card, and every
workflow entry/review surface fails closed when its required plan is
absent. Confirmed absence triggers bounded automatic replanning;
TaskStore read outages retry in place; exhausted recovery parks visibly
without consuming review-fix budget or overriding pause, manual-review,
terminal, or merge-confirmed state.

Related: FN-8455

## Validation

- Focused workflow-artifact, graph-recovery, review, writer, and triage
regression suites pass.
- @fusion/engine typecheck passes.
- Repository lint, changeset validation, and diff checks pass.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Plan Review now fails closed when `PROMPT.md` is missing or blank,
returning a revision request with a typed `failureValue`.
* Required workflow artifacts are treated as missing unless they exist
with non-empty content; read failures are handled separately.
* Recovery now deterministically chooses replan vs “park-failed” with
bounded retries, and records a `task:required-artifact-missing` audit
event.

* **Workflow Improvements**
* Triage and approval now persist `PROMPT.md` through the dedicated
prompt-write flow and verify it was stored exactly.
* Optional-group remediation preserves typed required-artifact missing
failures for pre-merge fixes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-07-21 17:06:26 -07:00
committed by GitHub
parent f49f5eb39a
commit de2cad7535
17 changed files with 862 additions and 52 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Recover missing workflow plans before review instead of approving or stranding tasks.
category: fix
dev: Verifies prompt persistence, distinguishes storage outages, gates workflow entry, and retries planning with audit events.

View File

@@ -4,6 +4,7 @@ import {
createChatTaskDocumentTools,
createTaskDocumentReadTool,
createTaskDocumentWriteTool,
createTaskPromptWriteTool,
} from "../agent-tools.js";
vi.mock("@fusion/core", async (importOriginal) => {
@@ -154,6 +155,35 @@ describe("task_document_write tool", () => {
});
});
describe("task_prompt_write tool", () => {
it("reports success only after the authoritative store reads back the exact prompt", async () => {
const updateTask = vi.fn().mockResolvedValue({});
const getTask = vi.fn().mockResolvedValue({ id: TASK_ID, prompt: "# Verified plan" });
const store = { updateTask, getTask } as unknown as TaskStore;
const result = await runTool(createTaskPromptWriteTool(store, TASK_ID), "call-prompt", {
content: "# Verified plan",
});
expect(updateTask).toHaveBeenCalledWith(TASK_ID, { prompt: "# Verified plan" }, undefined);
expect(getTask).toHaveBeenCalledWith(TASK_ID);
expect(getText(result)).toBe(`Updated PROMPT.md for ${TASK_ID}.`);
});
it("fails closed when the authoritative prompt read-back is missing or different", async () => {
const updateTask = vi.fn().mockResolvedValue({});
const getTask = vi.fn().mockResolvedValue({ id: TASK_ID, prompt: "" });
const store = { updateTask, getTask } as unknown as TaskStore;
const result = await runTool(createTaskPromptWriteTool(store, TASK_ID), "call-prompt", {
content: "# Plan that must persist",
});
expect(getText(result)).toContain("ERROR:");
expect(getText(result)).toContain("could not be verified");
});
});
describe("task_document_read tool", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -943,16 +943,97 @@ Ship FIVE kinds. Do NOT add roadmap-item in this task.
const cap = captureSession();
vi.spyOn(executor as any, "readTaskArtifact").mockResolvedValue(undefined);
await (executor as any).executeWorkflowStep(
const result = await (executor as any).executeWorkflowStep(
baseStepTask({ description: "Original request: ship SIX kinds including roadmap-item." }),
makeStep({ name: "Code Review", optionalGroupId: "code-review", gateMode: "gate" }),
"/tmp/wt",
{},
);
expect(cap.last?.systemPrompt).toContain("Approved Task Contract Unavailable");
expect(cap.last?.systemPrompt).toContain("Task Description is historical input only and is not a substitute contract");
expect(cap.last?.systemPrompt).toContain("Return REVISE with the single reason that the approved contract could not be loaded");
expect(cap.all).toHaveLength(0);
expect(result).toMatchObject({
success: false,
verdict: "REVISE",
failureValue: 'required-artifact-missing:["PROMPT.md"]',
});
});
/*
FNXC:PlanReview 2026-07-21-16:30:
Execution must refuse to create a reviewer when the authoritative PROMPT.md is unavailable, preserving the fail-closed workflow contract at the actual session-creation seam.
*/
it("fails Plan Review closed before creating a reviewer when PROMPT.md is unavailable", async () => {
const store = createMockStore();
const { executor } = makeExecutor(store);
const cap = captureSession();
vi.spyOn(executor as any, "readTaskArtifact").mockResolvedValue(undefined);
const result = await (executor as any).executeWorkflowStep(
baseStepTask(),
makeStep({ id: "graph:plan-review-step", name: "Plan Review", optionalGroupId: "plan-review", gateMode: "gate" }),
"/tmp/wt",
{},
);
expect(cap.all).toHaveLength(0);
expect(result).toMatchObject({
success: false,
revisionRequested: true,
verdict: "REVISE",
failureValue: 'required-artifact-missing:["PROMPT.md"]',
notes: expect.stringContaining("PROMPT.md could not be loaded"),
});
expect(store.logEntry).toHaveBeenCalledWith(
"FN-CE-1",
expect.stringContaining("Plan Review refused to run without PROMPT.md"),
);
});
it("distinguishes a task-storage read error from a confirmed missing PROMPT.md", async () => {
const store = createMockStore();
const { executor } = makeExecutor(store);
const cap = captureSession();
vi.spyOn(executor as any, "readTaskArtifact").mockRejectedValue(new Error("database unavailable"));
const result = await (executor as any).executeWorkflowStep(
baseStepTask(),
makeStep({ id: "graph:plan-review-step", name: "Plan Review", optionalGroupId: "plan-review", gateMode: "gate" }),
"/tmp/wt",
{},
);
expect(cap.all).toHaveLength(0);
expect(result).toMatchObject({
success: false,
failureValue: "required-artifact-read-failed:PROMPT.md",
error: expect.stringContaining("task storage failed"),
});
expect(result.verdict).toBeUndefined();
});
it.each([
["Code Review", "code-review"],
["Browser Verification", "browser-verification"],
])("fails %s closed before creating a reviewer when PROMPT.md is unavailable", async (name, optionalGroupId) => {
const store = createMockStore();
const { executor } = makeExecutor(store);
const cap = captureSession();
vi.spyOn(executor as any, "readTaskArtifact").mockResolvedValue(undefined);
const result = await (executor as any).executeWorkflowStep(
baseStepTask(),
makeStep({ name, optionalGroupId, gateMode: "gate" }),
"/tmp/wt",
{},
);
expect(cap.all).toHaveLength(0);
expect(result).toMatchObject({
success: false,
revisionRequested: true,
verdict: "REVISE",
failureValue: 'required-artifact-missing:["PROMPT.md"]',
});
});
it.each([

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import type { WorkflowIr } from "@fusion/core";
import {
parseRequiredArtifactMissingValue,
requiredArtifactMissingValue,
isRequiredArtifactReadFailedValue,
requiredArtifactReadFailedValue,
workflowEntryArtifacts,
} from "../required-workflow-artifacts.js";
// FNXC:WorkflowArtifacts 2026-07-21-17:00: This suite locks the typed missing/read
// distinction and the non-empty planning/step-source contract used by every gate.
describe("required workflow artifact contracts", () => {
it("treats planning-owned and step-source declarations as workflow-entry inputs", () => {
const ir = {
version: "v2",
name: "artifact inputs",
columns: [],
nodes: [],
edges: [],
artifacts: [
{ key: "PROMPT.md", producedBy: "planning", role: "step-source" },
{ key: "manual-context", producedBy: "manual", role: "context" },
{ key: "steps", role: "step-source" },
],
} as unknown as WorkflowIr;
expect(workflowEntryArtifacts(ir).map((artifact) => artifact.key)).toEqual(["PROMPT.md", "steps"]);
});
it("keeps storage-read failures distinct from confirmed missing artifacts", () => {
const value = requiredArtifactReadFailedValue("PROMPT.md");
expect(value).toBe("required-artifact-read-failed:PROMPT.md");
expect(isRequiredArtifactReadFailedValue(value)).toBe(true);
expect(parseRequiredArtifactMissingValue(value)).toBeNull();
});
it("round-trips a deduplicated typed missing-artifact failure", () => {
const value = requiredArtifactMissingValue([" PROMPT.md ", "PROMPT.md", "steps"]);
expect(value).toBe('required-artifact-missing:["PROMPT.md","steps"]');
expect(parseRequiredArtifactMissingValue(value)).toEqual(["PROMPT.md", "steps"]);
expect(parseRequiredArtifactMissingValue("required-artifact-missing:PROMPT.md,steps")).toEqual(["PROMPT.md", "steps"]);
expect(parseRequiredArtifactMissingValue("failed")).toBeNull();
});
it("round-trips artifact keys containing the legacy delimiter", () => {
const value = requiredArtifactMissingValue(["plans,primary.md", "steps"]);
expect(parseRequiredArtifactMissingValue(value)).toEqual(["plans,primary.md", "steps"]);
});
it("rejects an empty missing-artifact payload instead of emitting an unparsable value", () => {
expect(() => requiredArtifactMissingValue(["", " "])).toThrow("At least one required artifact key is needed");
});
});

View File

@@ -1,4 +1,4 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -71,6 +71,7 @@ function createStore(task: Task, settings: Partial<Settings> = {}, overrides: Pa
listTasks: vi.fn().mockResolvedValue([]),
createTask: vi.fn(),
moveTask: vi.fn(),
moveTaskIf: vi.fn().mockResolvedValue({ moved: true }),
updateTask: vi.fn().mockResolvedValue(undefined),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
@@ -83,6 +84,8 @@ function createStore(task: Task, settings: Partial<Settings> = {}, overrides: Pa
...settings,
} as Settings),
updateSettings: vi.fn(),
withTaskLock: vi.fn(async (_taskId, operation) => operation()),
readTaskForMove: vi.fn().mockResolvedValue(createDetail(task)),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
@@ -127,11 +130,22 @@ async function captureBasePrompt(task: Task, store: TaskStore): Promise<string>
}
async function runPlanningSession(task: Task, store: TaskStore, rootDir: string): Promise<void> {
mockSession();
const capture: { customTools?: any[] } = {};
mockSession(capture);
vi.mocked(store.updateTask).mockImplementation(async (_taskId, patch) => {
if (typeof patch.prompt !== "string") return;
const promptDir = join(rootDir, ".fusion", "tasks", task.id);
await mkdir(promptDir, { recursive: true });
await writeFile(join(promptDir, "PROMPT.md"), patch.prompt, "utf8");
});
mockPromptWithFallback.mockImplementationOnce(async () => {
const promptPath = join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
await mkdir(join(rootDir, ".fusion", "tasks", task.id), { recursive: true });
await writeFile(promptPath, "# Task: FN-6236\n\n## Mission\n\nVerify fast policy.\n", "utf8");
const promptWriter = capture.customTools?.find((tool) => tool.name === "fn_task_prompt_write");
expect(promptWriter).toBeDefined();
await promptWriter.execute("persist-plan", {
content: "# Task: FN-6236\n\n## Mission\n\nVerify fast policy.\n",
});
await expect(readFile(join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md"), "utf8"))
.resolves.toContain("Verify fast policy");
});
await new TriageProcessor(store, rootDir).specifyTask(task);
@@ -215,7 +229,7 @@ describe("fast-mode workflow variant resolution", () => {
await runPlanningSession(task, store, rootDir);
expect(mockReviewStep).not.toHaveBeenCalled();
expect(store.moveTask).toHaveBeenCalledWith(task.id, "todo");
expect(store.moveTaskIf).toHaveBeenCalledWith(task.id, "todo", expect.any(Function));
});
it("finalizes standard tasks without invoking a separate spec reviewer", async () => {
@@ -227,7 +241,7 @@ describe("fast-mode workflow variant resolution", () => {
await runPlanningSession(task, store, rootDir);
expect(mockReviewStep).not.toHaveBeenCalled();
expect(store.moveTask).toHaveBeenCalledWith(task.id, "todo");
expect(store.moveTaskIf).toHaveBeenCalledWith(task.id, "todo", expect.any(Function));
});
it("ignores legacy autoApproveSpec because workflow Plan Review owns approval", async () => {
@@ -239,7 +253,7 @@ describe("fast-mode workflow variant resolution", () => {
await runPlanningSession(task, store, rootDir);
expect(mockReviewStep).not.toHaveBeenCalled();
expect(store.moveTask).toHaveBeenCalledWith(task.id, "todo");
expect(store.moveTaskIf).toHaveBeenCalledWith(task.id, "todo", expect.any(Function));
});
it("preserves user triage prompt override precedence over the fast variant", async () => {

View File

@@ -7,7 +7,7 @@ import {
resolveAgentPrompt,
resolvePlanningPromptFromIr,
} from "@fusion/core";
import { TriageProcessor } from "../triage.js";
import { buildSpecificationPrompt, TriageProcessor } from "../triage.js";
const { mockReviewStep, mockCreateFnAgent } = vi.hoisted(() => ({
mockReviewStep: vi.fn(),
@@ -136,6 +136,43 @@ describe("triage planning prompt single source", () => {
await expect(captureBasePrompt(task, store)).resolves.toBe(renderedCanonicalPlanningPrompt);
});
it("exposes the TaskStore-backed PROMPT.md writer to triage sessions", async () => {
const task = createTask({ id: "FN-6232-PROMPT-WRITER" });
const store = createStore(task);
let customTools: Array<{ name?: string }> = [];
let sessionTools: string | undefined;
mockCreateFnAgent.mockImplementationOnce(async (opts: any) => {
customTools = opts.customTools ?? [];
sessionTools = opts.tools;
return {
session: {
state: {},
sessionManager: { getLeafId: vi.fn().mockReturnValue(null) },
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
navigateTree: vi.fn(),
},
};
});
await new TriageProcessor(store, "/tmp/root").specifyTask(task);
expect(customTools.map((tool) => tool.name)).toContain("fn_task_prompt_write");
expect(sessionTools).toBe("readonly");
});
it("requires triage plans to use the durable prompt writer instead of generic filesystem writes", () => {
const task = createDetail(createTask({ id: "FN-6232-DURABLE-PROMPT" }));
const prompt = buildSpecificationPrompt(task, `.fusion/tasks/${task.id}/PROMPT.md`, {} as Settings);
expect(prompt).toContain("fn_task_prompt_write");
expect(prompt).toContain("Do not use the generic filesystem write tool");
expect(prompt).toContain("If it returns an error, correct the problem and retry");
expect(prompt).toContain("do not finish planning until the tool confirms");
expect(prompt).not.toContain("Use the write tool to write the specification file");
expect(prompt).not.toContain("exactly once");
});
it("uses the built-in workflow IR planning prompt when no workflow is selected", async () => {
const task = createTask({ id: "FN-6232-NO-SELECTION", executionMode: "standard" });
const store = createStore(task);

View File

@@ -1627,6 +1627,45 @@ Planner rewrote mission without the raw request.
}
});
it("does not release planning to todo when the authoritative PROMPT.md disappears before transition", async () => {
const task = createTriageTask({ id: "FN-MISSING-RELEASE", status: "planning", recoveryRetryCount: 0 });
const tempRoot = await mkdtemp(join(tmpdir(), "fusion-missing-release-"));
try {
const taskDir = join(tempRoot, ".fusion", "tasks", task.id);
await mkdir(taskDir, { recursive: true });
const written = `# Task: ${task.id} - Missing release artifact\n\n## Steps\n\n### Step 1: Implement\n\n- [ ] Do the work\n`;
await writeFile(join(taskDir, "PROMPT.md"), written, "utf-8");
const localStore = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...task, prompt: "" }),
recordRunAuditEvent: vi.fn().mockResolvedValue(undefined),
});
const localProcessor = new TriageProcessor(localStore, tempRoot);
await (localProcessor as any).finalizeApprovedTask(task, written, { requirePlanApproval: false } as Settings);
expect(localStore.moveTaskIf).not.toHaveBeenCalled();
for (const [, patch] of localStore.updateTask.mock.calls) {
expect(patch).not.toEqual(expect.objectContaining({
steps: expect.anything(),
}));
expect(patch).not.toEqual(expect.objectContaining({
sourceMetadataPatch: expect.anything(),
}));
}
expect(localStore.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({
status: null,
recoveryRetryCount: 1,
nextRecoveryAt: expect.any(String),
}));
expect(localStore.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
mutationType: "task:required-artifact-missing",
metadata: expect.objectContaining({ source: "planning-release", action: "replan" }),
}));
} 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: [] };

View File

@@ -317,6 +317,31 @@ describe("WorkflowGraphExecutor optional-group", () => {
]));
});
it("preserves a typed missing-artifact failure through the optional-group remediation seam", async () => {
const requestFix = vi.fn(async () => true);
const executor = new WorkflowGraphExecutor({
handlers: {
gate: async (node) => node.id === "review"
? {
outcome: "failure",
value: "required-artifact-missing:PROMPT.md",
contextPatch: { output: "PROMPT.md could not be loaded" },
}
: { outcome: "success" },
},
requestPreMergeOptionalStepFix: requestFix,
});
const result = await executor.run(taskWith(["group"]), settingsOn(), reviseGroupIr({ gateMode: "gate" }));
expect(requestFix).toHaveBeenCalledWith("FN-OG", expect.objectContaining({
stepName: "Code Review",
status: "failed",
failureValue: "required-artifact-missing:PROMPT.md",
}));
expect(result.context["node:group:fixScheduled"]).toBe(true);
});
it("threads optional-group maxRevisions into the pre-merge fix seam", async () => {
const requestFix = vi.fn(async () => true);
const executor = new WorkflowGraphExecutor({
@@ -510,6 +535,29 @@ describe("WorkflowGraphExecutor optional-group", () => {
]));
});
it("keeps Plan Review task-storage read failures in place without sending the task to planning", async () => {
const requestFix = vi.fn(async () => true);
const executor = new WorkflowGraphExecutor({
handlers: {
prompt: async (node) => node.id === "plan-review-step"
? {
outcome: "failure",
value: "required-artifact-read-failed:PROMPT.md",
contextPatch: { output: "PROMPT.md task storage read failed" },
}
: { outcome: "success" },
},
requestPreMergeOptionalStepFix: requestFix,
});
const result = await executor.run(taskWith(["plan-review"]), settingsOn(), BUILTIN_CODING_WORKFLOW_IR);
expect(requestFix).not.toHaveBeenCalled();
expect(result.outcome).toBe("failure");
expect(result.context["node:plan-review:value"]).toBe(PLAN_REVIEW_PROVIDER_FAILURE_HOLD_VALUE);
expect(result.visitedNodeIds).not.toContain("plan-replan");
});
it("uses an explicit graph replan node for Plan Review REVISE and does not execute before replan completes", async () => {
const requestFix = vi.fn(async () => true);
const calls: string[] = [];

View File

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Task } from "@fusion/core";
import { TaskExecutor } from "../executor.js";
import { MAX_RECOVERY_RETRIES } from "../recovery-policy.js";
import { createMockStore, resetExecutorMocks } from "./executor-test-helpers.js";
function task(overrides: Partial<Task> = {}): Task {
@@ -46,6 +47,149 @@ describe("TaskExecutor pre-merge optional-step fix seam", () => {
resetExecutorMocks();
});
it("requeues the planning owner for a missing required artifact without consuming review revision budget", async () => {
const store = createMockStore();
const liveTask = task({ column: "in-progress", recoveryRetryCount: 0, postReviewFixCount: 0 });
store.getTask.mockResolvedValue(liveTask);
store.getSettings.mockResolvedValue({ maxPostReviewFixes: 0 });
store.recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
const executor = new TaskExecutor(store, "/tmp/test");
const scheduled = await (executor as any).requestPreMergeOptionalStepFix(liveTask.id, liveTask, {
stepName: "Plan Review",
feedback: "PROMPT.md could not be loaded",
phase: "pre-merge",
status: "failed",
failureValue: "required-artifact-missing:PROMPT.md",
nodeId: "plan-review",
});
expect(scheduled).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith(liveTask.id, "triage");
expect(store.updateTask).toHaveBeenCalledWith(liveTask.id, expect.objectContaining({
status: "needs-replan",
recoveryRetryCount: 1,
nextRecoveryAt: expect.any(String),
}), undefined);
for (const [, patch] of store.updateTask.mock.calls) {
expect((patch as Partial<Task>).postReviewFixCount ?? 0).toBe(0);
}
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
mutationType: "task:required-artifact-missing",
metadata: expect.objectContaining({ artifactKeys: ["PROMPT.md"], action: "replan", attempt: 1 }),
}));
});
/*
FNXC:RequiredArtifactRecovery 2026-07-21-17:00:
Protected lifecycle states suppress missing-artifact replanning. Storage read
failures instead consume bounded graph-resume retries without being relabeled
as confirmed absence or terminal task failure.
*/
it.each([
{ label: "user-paused", patch: { paused: true, userPaused: true } },
{ label: "merged", patch: { column: "in-review", mergeDetails: { mergeConfirmed: true } } },
{ label: "manual-review", patch: { column: "in-review", autoMerge: false } },
])("does not replan a $label task when lifecycle state changes before recovery", async ({ patch }) => {
const store = createMockStore();
const initial = task({ recoveryRetryCount: 0 });
const protectedTask = task({ ...patch } as Partial<Task>);
store.getTask.mockResolvedValueOnce(initial).mockResolvedValue(protectedTask);
store.recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
const executor = new TaskExecutor(store, "/tmp/test");
const scheduled = await (executor as any).requestPreMergeOptionalStepFix(initial.id, initial, {
stepName: "Plan Review",
feedback: "PROMPT.md could not be loaded",
phase: "pre-merge",
status: "failed",
failureValue: "required-artifact-missing:PROMPT.md",
nodeId: "plan-review",
});
expect(scheduled).toBe(true);
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.updateTask).not.toHaveBeenCalled();
expect(store.recordRunAuditEvent).not.toHaveBeenCalled();
});
it("honors a pause that races recovery immediately before the replan move", async () => {
const store = createMockStore();
const initial = task({ recoveryRetryCount: 0 });
const paused = task({ paused: true, userPaused: true });
store.getTask
.mockResolvedValueOnce(initial)
.mockResolvedValueOnce(initial)
.mockResolvedValueOnce(paused);
store.recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
const executor = new TaskExecutor(store, "/tmp/test");
await (executor as any).requestPreMergeOptionalStepFix(initial.id, initial, {
stepName: "Plan Review",
feedback: "PROMPT.md could not be loaded",
phase: "pre-merge",
status: "failed",
failureValue: "required-artifact-missing:PROMPT.md",
nodeId: "plan-review",
});
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.updateTask).not.toHaveBeenCalled();
});
it("holds and retries a graph-entry storage read failure without replanning or failing", async () => {
const store = createMockStore();
const liveTask = task({ graphResumeRetryCount: 0 });
store.getTask.mockResolvedValue(liveTask);
const executor = new TaskExecutor(store, "/tmp/test");
await (executor as any).handleGraphFailure(liveTask, {
disposition: "failed",
outcome: "failure",
reason: "workflow-required-artifact-read-failed:PROMPT.md:database unavailable",
visitedNodeIds: ["workflow-entry-artifact"],
context: { "node:workflow-entry-artifact:value": "required-artifact-read-failed:PROMPT.md" },
});
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.updateTask).toHaveBeenCalledWith(liveTask.id, {
graphResumeRetryCount: 1,
}, undefined);
expect(store.updateTask).not.toHaveBeenCalledWith(
liveTask.id,
expect.objectContaining({ status: "failed" }),
undefined,
);
});
it("parks visibly when missing-artifact recovery is exhausted", async () => {
const store = createMockStore();
const liveTask = task({ recoveryRetryCount: MAX_RECOVERY_RETRIES });
store.getTask.mockResolvedValue(liveTask);
store.recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
const executor = new TaskExecutor(store, "/tmp/test");
const scheduled = await (executor as any).requestPreMergeOptionalStepFix(liveTask.id, liveTask, {
stepName: "Code Review",
feedback: "PROMPT.md could not be loaded",
phase: "pre-merge",
status: "failed",
failureValue: "required-artifact-missing:PROMPT.md",
nodeId: "code-review",
});
expect(scheduled).toBe(true);
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.updateTask).toHaveBeenCalledWith(liveTask.id, expect.objectContaining({
status: "failed",
error: expect.stringContaining("REQUIRED_ARTIFACT_RECOVERY_EXHAUSTED"),
}), undefined);
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
mutationType: "task:required-artifact-missing",
metadata: expect.objectContaining({ action: "park-failed" }),
}));
});
it("sends Code Review, Browser Verification, and gate-promoted pre-merge revisions back for remediation", async () => {
const cases = [
{ stepName: "Code Review", status: "advisory_failure" as const, feedback: "review finding" },

View File

@@ -5,8 +5,8 @@ import { WorkflowTaskRuntime } from "../workflow-task-runtime.js";
import type { WorkflowRuntimePrimitives } from "../runtime-primitives.js";
/*
FNXC:WorkflowGates 2026-06-17-18:24:
FN-6582 requires terminal workflow success to depend on declared task-document artifact key existence, not only graph node success. Missing declared keys keep the run incomplete/failed; empty document content still counts as present because the MVP artifact contract currently requires existence.
FNXC:WorkflowArtifacts 2026-07-21-17:00:
FN-6582 requires terminal workflow success to depend on declared task-document artifact key existence, not only graph node success. Planning-owned and step-source artifacts must also be non-empty because downstream execution cannot consume an empty contract.
*/
const task = { id: "FN-6582" } as TaskDetail;
@@ -79,7 +79,7 @@ describe("workflow required-artifact terminal gate", () => {
expect(getTaskDocument).toHaveBeenCalledWith(task.id, "plan");
});
it("completes when every declared task-document artifact key exists, including whitespace content", async () => {
it("fails when a planning input exists but contains only whitespace", async () => {
const ir = trivialIr([
{ key: "plan", role: "step-source" },
{ key: "evidence", role: "context" },
@@ -91,9 +91,20 @@ describe("workflow required-artifact terminal gate", () => {
const result = await runtime.run(task, settings);
expect(result.disposition).toBe("failed");
expect(result.reason).toBe("workflow-required-artifacts-missing:plan");
expect(result.context["workflow:required-artifacts:missing"]).toEqual(["plan"]);
});
it("allows an empty context artifact when its contract requires presence only", async () => {
const { runtime } = runtimeFor(
trivialIr([{ key: "evidence", role: "context" }]),
new Map([["evidence", ""]]),
);
const result = await runtime.run(task, settings);
expect(result.disposition).toBe("completed");
expect(result.outcome).toBe("success");
expect(result.context["workflow:required-artifacts:missing"]).toBeUndefined();
});
it("reports all missing keys for multi-artifact workflows", async () => {

View File

@@ -1698,12 +1698,16 @@ export function createTaskPromptWriteTool(store: TaskStore, taskId: string, runC
name: "fn_task_prompt_write",
label: "Write PROMPT.md",
description:
"Replace this task's PROMPT.md with revised plan/spec content. " +
"Use only during Plan Review/spec repair; provide the complete final PROMPT.md content.",
"Create or replace this task's PROMPT.md with complete plan/spec content. " +
"Use during fresh triage planning, replanning, or Plan Review repair; provide the complete final PROMPT.md content.",
parameters: taskPromptWriteParams,
execute: async (_id: string, params: Static<typeof taskPromptWriteParams>) => {
try {
await store.updateTask(taskId, { prompt: params.content }, runContext);
const persisted = await store.getTask(taskId);
if (persisted?.prompt !== params.content) {
throw new Error("authoritative PROMPT.md read-back did not match the requested content; persistence could not be verified");
}
return {
content: [{ type: "text" as const, text: `Updated PROMPT.md for ${taskId}.` }],
details: {},

View File

@@ -187,6 +187,13 @@ import {
formatExternalIntegrationEvidenceDiagnostic,
} from "./spec-validation/external-integration-evidence.js";
import { computeRecoveryDecision, formatDelay, MAX_RECOVERY_RETRIES } from "./recovery-policy.js";
import {
isRequiredArtifactReadFailedValue,
parseRequiredArtifactMissingValue,
requiredArtifactMissingValue,
requiredArtifactReadFailedValue,
workflowEntryArtifacts,
} from "./required-workflow-artifacts.js";
import type { StuckTaskDetector, StuckTaskEvent } from "./stuck-task-detector.js";
import type { PluginRunner } from "./plugin-runner.js";
import { isContextLimitError } from "./context-limit-detector.js";
@@ -1150,6 +1157,8 @@ export interface WorkflowStepOutcome {
timedOut?: boolean;
/** True when no structured or prose verdict could be inferred. */
malformed?: boolean;
/** Machine-readable graph failure used for deterministic recovery routing. */
failureValue?: string;
}
/**
@@ -4734,6 +4743,14 @@ export class TaskExecutor {
if (info.status !== "advisory_failure" && info.status !== "failed") return false;
const liveTask = await this.store.getTask(taskId).catch(() => fallbackTask);
const missingArtifactKeys = parseRequiredArtifactMissingValue(info.failureValue);
if (missingArtifactKeys) {
await this.recoverMissingRequiredArtifacts(liveTask, missingArtifactKeys, {
source: "workflow-step",
nodeId: info.nodeId,
});
return true;
}
const isPlanReview = info.nodeId === "plan-review" || info.stepName === "Plan Review";
if (isPlanReview) {
/*
@@ -4882,6 +4899,91 @@ export class TaskExecutor {
return true;
}
private async recoverMissingRequiredArtifacts(
task: Task,
artifactKeys: string[],
source: { source: "graph-entry" | "workflow-step"; nodeId?: string },
): Promise<void> {
const currentTask = await this.store.getTask(task.id).catch(() => null);
if (!currentTask || this.isRequiredArtifactRecoveryProtected(currentTask)) return;
task = currentTask;
const decision = computeRecoveryDecision({
recoveryRetryCount: task.recoveryRetryCount,
nextRecoveryAt: task.nextRecoveryAt,
});
const attempt = decision.nextState.recoveryRetryCount ?? MAX_RECOVERY_RETRIES;
const context = this.getRunContextFor(task.id);
const action = decision.shouldRetry ? "replan" : "park-failed";
await this.store.recordRunAuditEvent?.({
taskId: task.id,
agentId: "executor",
runId: context?.runId ?? generateSyntheticRunId("required-artifact-missing", task.id),
domain: "database",
mutationType: "task:required-artifact-missing",
target: task.id,
metadata: {
taskId: task.id,
artifactKeys,
owner: "planning",
source: source.source,
action,
attempt,
maxAttempts: MAX_RECOVERY_RETRIES,
...(source.nodeId ? { nodeId: source.nodeId } : {}),
},
});
if (!decision.shouldRetry) {
const liveTask = await this.store.getTask(task.id).catch(() => null);
if (!liveTask || this.isRequiredArtifactRecoveryProtected(liveTask)) return;
const error = `REQUIRED_ARTIFACT_RECOVERY_EXHAUSTED: ${artifactKeys.join(", ")} remained missing after ${MAX_RECOVERY_RETRIES} automatic planning retries.`;
await this.store.logEntry(task.id, error, undefined, context);
await this.store.updateTask(task.id, {
status: "failed",
error,
recoveryRetryCount: null,
nextRecoveryAt: null,
}, context);
return;
}
const replanColumn = await resolveReplanTargetColumn(this.store, task.id);
await this.store.logEntry(
task.id,
`Required workflow artifact missing — moved to ${replanColumn} for automatic planning recovery (attempt ${attempt}/${MAX_RECOVERY_RETRIES} in ${formatDelay(decision.delayMs)})`,
`Missing artifact keys: ${artifactKeys.join(", ")}`,
context,
);
this.workflowLifecycleMovesInFlight.add(task.id);
try {
const liveTask = await this.store.getTask(task.id).catch(() => null);
if (!liveTask || this.isRequiredArtifactRecoveryProtected(liveTask)) return;
await moveTaskToReplanColumn(this.store, { id: task.id, column: liveTask.column }, replanColumn);
} finally {
this.workflowLifecycleMovesInFlight.delete(task.id);
}
await this.store.updateTask(task.id, {
status: "needs-replan",
error: null,
recoveryRetryCount: decision.nextState.recoveryRetryCount,
nextRecoveryAt: decision.nextState.nextRecoveryAt,
graphResumeRetryCount: 0,
}, context);
}
private isRequiredArtifactRecoveryProtected(task: Task): boolean {
return Boolean(
task.deletedAt
|| task.paused
|| task.userPaused === true
|| task.column === "done"
|| task.column === "archived"
|| task.mergeDetails?.mergeConfirmed === true
|| (task.column === "in-review" && task.autoMerge === false),
);
}
/**
* Auto-revive an `in-review` task whose pre-merge workflow step(s) failed, by
* replaying the same send-back-for-fix flow the executor uses during a live
@@ -5504,6 +5606,31 @@ export class TaskExecutor {
} catch {
columnAgentIr = undefined;
}
if (columnAgentIr) {
const missingEntryArtifacts: string[] = [];
for (const artifact of workflowEntryArtifacts(columnAgentIr)) {
let content: string | undefined;
try {
content = await this.readTaskArtifact(task.id, artifact.key);
} catch (error) {
const failureValue = requiredArtifactReadFailedValue(artifact.key);
await this.handleGraphFailure(task, {
disposition: "failed",
outcome: "failure",
reason: `workflow-required-artifact-read-failed:${artifact.key}:${error instanceof Error ? error.message : String(error)}`,
visitedNodeIds: ["workflow-entry-artifact"],
context: { "node:workflow-entry-artifact:value": failureValue },
});
return;
}
if (typeof content !== "string" || !content.trim()) missingEntryArtifacts.push(artifact.key);
}
if (missingEntryArtifacts.length > 0) {
const liveTask = await this.store.getTask(task.id).catch(() => task);
await this.recoverMissingRequiredArtifacts(liveTask, missingEntryArtifacts, { source: "graph-entry" });
return;
}
}
const resolveBindingForNode = (nodeId: string): WorkflowColumnAgent | undefined =>
columnAgentIr ? resolveColumnAgentBinding(columnAgentIr, nodeId) : undefined;
// Column-agent seam wiring (U4): expose the same per-run resolver to the
@@ -6057,20 +6184,26 @@ export class TaskExecutor {
*/
private async readTaskArtifact(taskId: string, key: string): Promise<string | undefined> {
// Declared artifacts ride the task-documents layer.
let documentReadError: unknown;
try {
const doc = await this.store.getTaskDocument(taskId, key);
if (doc) return doc.content;
} catch {
// Fall through to the PROMPT fallback below.
} catch (error) {
documentReadError = error;
}
if (key === "PROMPT.md") {
try {
const detail = await this.store.getTask(taskId);
if (typeof detail.prompt === "string") return detail.prompt;
} catch {
// No PROMPT available.
return undefined;
} catch (error) {
throw new Error(
`Unable to read required artifact ${key} from task documents or task storage: ${error instanceof Error ? error.message : String(error)}`,
{ cause: documentReadError ?? error },
);
}
}
if (documentReadError) throw documentReadError;
return undefined;
}
@@ -8615,7 +8748,7 @@ export class TaskExecutor {
*/
return {
outcome: outcome.success || !blocking || malformed ? "success" : "failure",
value: verdict ?? (outcome.success ? "passed" : advisoryFailureValue),
value: (outcome as WorkflowStepOutcome).failureValue ?? verdict ?? (outcome.success ? "passed" : advisoryFailureValue),
...(Object.keys(contextPatch).length > 0 ? { contextPatch } : {}),
};
}
@@ -9660,6 +9793,43 @@ export class TaskExecutor {
await this.persistTokenUsage(task.id);
return;
}
if (isRequiredArtifactReadFailedValue(this.graphFailureValue(result))) {
/*
FNXC:WorkflowArtifacts 2026-07-21-17:00:
A TaskStore read outage is not proof that an artifact is absent. Keep the
task in place and use the bounded graph-resume budget instead of replanning
or terminalizing a possibly healthy workflow contract.
*/
const priorRetries = live.graphResumeRetryCount ?? 0;
if (priorRetries < MAX_TRANSIENT_GRAPH_RESUME_RETRIES) {
const nextRetries = priorRetries + 1;
const message = `Required workflow artifact could not be read — retrying in place (${nextRetries}/${MAX_TRANSIENT_GRAPH_RESUME_RETRIES})`;
await this.store.logEntry(task.id, message, undefined, this.getRunContextFor(task.id));
await this.store.updateTask(task.id, { graphResumeRetryCount: nextRetries }, this.getRunContextFor(task.id));
const scheduleRetry = () => {
void (async () => {
try {
const resumeTask = await this.store.getTask(task.id);
if (this.isRequiredArtifactRecoveryProtected(resumeTask) || resumeTask.status === "failed") return;
await this.execute(resumeTask);
} catch (err) {
executorLog.error(`Failed required-artifact read retry for ${task.id}:`, err);
}
})();
};
const handle = setTimeout(scheduleRetry, TRANSIENT_GRAPH_RESUME_RETRY_BACKOFF_MS);
handle.unref?.();
} else {
await this.store.logEntry(
task.id,
"Required workflow artifact read retry budget exhausted — task remains held in its current state",
undefined,
this.getRunContextFor(task.id),
);
}
await this.persistTokenUsage(task.id);
return;
}
if (this.graphFailureValue(result) === PLAN_REVIEW_PROVIDER_FAILURE_HOLD_VALUE) {
/*
* FNXC:PlanReviewReplan 2026-07-15-16:35:
@@ -16322,12 +16492,44 @@ ${scopeGuard}
* FNXC:WorkflowReviewSpecInjection 2026-07-18-18:15:
* FN-7561 established that review agents cannot reliably locate the project-root PROMPT.md from a task worktree. Load it once through the store and embed it for every review-type node. FN-8288 extends that invariant beyond Plan Review: approved planning revisions are authoritative, the original task description is historical, and a failed artifact read must stay visible instead of silently restoring superseded scope.
*/
const workflowReviewSpecArtifact = isReviewTypeWorkflowStep
? await this.readTaskArtifact(task.id, "PROMPT.md")
: undefined;
let workflowReviewSpecArtifact: string | undefined;
if (isReviewTypeWorkflowStep) {
try {
workflowReviewSpecArtifact = await this.readTaskArtifact(task.id, "PROMPT.md");
} catch (error) {
const diagnostic = `PROMPT.md could not be read because task storage failed; ${workflowStep.name} must retry without replanning. ${error instanceof Error ? error.message : String(error)}`;
await this.store.logEntry(task.id, `[pre-merge] ${workflowStep.name} artifact read failed: ${diagnostic}`);
return {
success: false,
error: diagnostic,
output: diagnostic,
failureValue: requiredArtifactReadFailedValue("PROMPT.md"),
};
}
}
const workflowReviewSpecText = typeof workflowReviewSpecArtifact === "string" ? workflowReviewSpecArtifact : "";
const planReviewSpecText = isPlanReviewStep ? workflowReviewSpecText : "";
/*
FNXC:PlanReview 2026-07-21-16:30:
Review steps must never approve or execute against an unavailable contract. Confirmed missing or whitespace-only PROMPT.md fails closed before reviewer creation; typed recovery routes ownership back to planning without spending the review-revision budget.
*/
if (isReviewTypeWorkflowStep && !workflowReviewSpecText.trim()) {
const diagnostic = `PROMPT.md could not be loaded; ${workflowStep.name} cannot approve without the authoritative task contract.`;
await this.store.logEntry(
task.id,
`[pre-merge] ${workflowStep.name} refused to run without PROMPT.md: ${diagnostic}`,
);
return {
success: false,
revisionRequested: true,
output: `REVISE: ${diagnostic}`,
verdict: "REVISE",
notes: diagnostic,
failureValue: requiredArtifactMissingValue(["PROMPT.md"]),
};
}
if (isPlanReviewStep && requireExternalIntegrationEvidence) {
/*
* FNXC:PlanValidation 2026-06-30-09:03:
@@ -16388,8 +16590,7 @@ ${scopeGuard}
* state and loop back to triage after the planner already approved the spec.
*/
const approvedContractBlock = isReviewTypeWorkflowStep && !isPlanReviewStep
? workflowReviewSpecText
? `
? `
Approved Task Contract:
- PROMPT.md is the authoritative current contract for this review. It includes any approved planning revisions and scope decisions.
@@ -16400,12 +16601,6 @@ Approved Task Contract:
--- BEGIN APPROVED PROMPT.md ---
${workflowReviewSpecText}
--- END APPROVED PROMPT.md ---`
: `
Approved Task Contract Unavailable:
- PROMPT.md could not be loaded for this review. The Task Description is historical input only and is not a substitute contract.
- Do not infer, reinstate, approve, or reject requirements from the Task Description.
- Return REVISE with the single reason that the approved contract could not be loaded so the workflow can retry with canonical scope.`
: "";
const scopeBlock = isPlanReviewStep
? `Plan Review Scope:
@@ -16415,7 +16610,7 @@ Approved Task Contract Unavailable:
- If the plan is internally consistent, complete, scoped, and verifiable, approve even when the worktree contains unrelated changes from another task.
--- BEGIN PROMPT.md ---
${planReviewSpecText || `(The plan artifact could not be loaded into this prompt. Read it read-only from the project root at .fusion/tasks/${task.id}/PROMPT.md before judging; do not treat an unavailable artifact as a plan defect.)`}
${planReviewSpecText}
--- END PROMPT.md ---`
: `Diff Scope (files changed by THIS task vs base):
${scopeFileBlock}${diffShortstat ? `\nDiff stat: ${diffShortstat}` : ""}

View File

@@ -0,0 +1,56 @@
import type { WorkflowIr, WorkflowIrArtifact } from "@fusion/core";
export const REQUIRED_ARTIFACT_MISSING_PREFIX = "required-artifact-missing:";
export const REQUIRED_ARTIFACT_READ_FAILED_PREFIX = "required-artifact-read-failed:";
/*
FNXC:WorkflowArtifacts 2026-07-21-17:00:
Planning-owned and step-source artifacts are executable workflow contracts, so
they must contain non-whitespace content before the graph can consume them.
*/
export function requiresNonEmptyWorkflowArtifact(artifact: WorkflowIrArtifact): boolean {
return artifact.producedBy === "planning" || artifact.role === "step-source";
}
export function workflowEntryArtifacts(ir: WorkflowIr): WorkflowIrArtifact[] {
const artifacts = "artifacts" in ir && Array.isArray(ir.artifacts) ? ir.artifacts : [];
return artifacts.filter(requiresNonEmptyWorkflowArtifact);
}
export function requiredArtifactMissingValue(keys: readonly string[]): string {
const normalizedKeys = [...new Set(keys.map((key) => key.trim()).filter(Boolean))];
if (normalizedKeys.length === 0) {
throw new Error("At least one required artifact key is needed");
}
return `${REQUIRED_ARTIFACT_MISSING_PREFIX}${JSON.stringify(normalizedKeys)}`;
}
export function parseRequiredArtifactMissingValue(value: string | undefined): string[] | null {
if (!value?.startsWith(REQUIRED_ARTIFACT_MISSING_PREFIX)) return null;
const payload = value.slice(REQUIRED_ARTIFACT_MISSING_PREFIX.length);
try {
const parsed = JSON.parse(payload) as unknown;
if (Array.isArray(parsed)) {
const keys = parsed
.filter((key): key is string => typeof key === "string")
.map((key) => key.trim())
.filter(Boolean);
return keys.length > 0 ? [...new Set(keys)] : null;
}
} catch {
// Pre-JSON signals used a comma-delimited payload; keep them recoverable.
}
const keys = payload
.split(",")
.map((key) => key.trim())
.filter(Boolean);
return keys.length > 0 ? [...new Set(keys)] : null;
}
export function requiredArtifactReadFailedValue(key: string): string {
return `${REQUIRED_ARTIFACT_READ_FAILED_PREFIX}${key}`;
}
export function isRequiredArtifactReadFailedValue(value: string | undefined): boolean {
return value?.startsWith(REQUIRED_ARTIFACT_READ_FAILED_PREFIX) === true;
}

View File

@@ -442,6 +442,8 @@ export type DatabaseMutationType =
| "task:steering-comment:add"
| "task:assign"
| "task:checkout"
/** Metadata: { taskId, artifactKeys, owner, source, action, attempt, maxAttempts, nodeId? } */
| "task:required-artifact-missing"
| "agent:auto-recover-error-state"
| "agent:reset-error-state-on-startup"
| "agent:error-retry-exhausted"

View File

@@ -170,6 +170,7 @@ import {
createWebFetchTool,
createTaskDocumentReadTool,
createTaskDocumentWriteTool,
createTaskPromptWriteTool,
createWorkflowListTool,
createWorkflowSelectTool,
} from "./agent-tools.js";
@@ -1403,6 +1404,10 @@ export class TriageProcessor {
source: "triage",
} as const;
/*
FNXC:TriagePromptPersistence 2026-07-21-16:30:
Planning sessions keep readonly built-in tools so they cannot mutate repository files, while the narrow TaskStore-backed prompt writer remains available as the only durable PROMPT.md creation and repair path.
*/
const customTools = [
...this.createTriageTools({
parentTaskId: task.id,
@@ -1411,6 +1416,7 @@ export class TriageProcessor {
}),
createTaskDocumentWriteTool(this.store, task.id),
createTaskDocumentReadTool(this.store, task.id),
createTaskPromptWriteTool(this.store, task.id, triageRunContext),
createWorkflowListTool(this.store),
createWorkflowSelectTool(this.store, task.id),
...(isResearchToolSurfaceEnabled(settings)
@@ -1600,6 +1606,10 @@ export class TriageProcessor {
)
: { provider: undefined, modelId: undefined };
/*
FNXC:TriagePromptPersistence 2026-07-21-16:30:
`tools: "readonly"` intentionally coexists with the custom prompt writer above: readonly governs general tools, while fn_task_prompt_write performs the one authorized synchronized task-artifact mutation.
*/
const { session } = await createResolvedAgentSession({
sessionPurpose: "triage",
runtimeHint: triageRuntimeHint,
@@ -1607,7 +1617,7 @@ export class TriageProcessor {
cwd: this.rootDir,
systemPrompt: triageSystemPromptFinal,
systemPromptLayers: triageLayers,
tools: "coding",
tools: "readonly",
customTools,
onText: agentLogger.onText,
onThinking: agentLogger.onThinking,
@@ -2572,6 +2582,68 @@ export class TriageProcessor {
}
}
/*
FNXC:WorkflowArtifacts 2026-07-21-17:00:
Planning cannot release a task unless authoritative TaskStore read-back proves
PROMPT.md survived persistence. Confirmed absence retries the planning owner
within the shared recovery budget, then parks visibly when that budget expires.
*/
private async recoverMissingPromptBeforeRelease(task: Task): Promise<boolean> {
const live = await Promise.resolve(this.store.getTask(task.id)).catch(() => null);
// Legacy/minimal stores may not expose prompt enrichment. Production TaskStore
// always does; only enforce the read-back when the authoritative field exists.
if (!live || !Object.prototype.hasOwnProperty.call(live, "prompt")) return false;
if (typeof live.prompt === "string" && live.prompt.trim()) return false;
const decision = computeRecoveryDecision({
recoveryRetryCount: live.recoveryRetryCount ?? task.recoveryRetryCount,
nextRecoveryAt: live.nextRecoveryAt ?? task.nextRecoveryAt,
});
const attempt = decision.nextState.recoveryRetryCount ?? MAX_RECOVERY_RETRIES;
const auditor = createRunAuditor(this.store, {
taskId: task.id,
agentId: task.assignedAgentId ?? "triage",
runId: generateSyntheticRunId("required-artifact-missing", task.id),
phase: "triage",
source: "triage",
});
await auditor.database({
type: "task:required-artifact-missing",
target: task.id,
metadata: {
taskId: task.id,
artifactKeys: ["PROMPT.md"],
owner: "planning",
source: "planning-release",
action: decision.shouldRetry ? "replan" : "park-failed",
attempt,
maxAttempts: MAX_RECOVERY_RETRIES,
},
});
if (decision.shouldRetry) {
const message = `PROMPT.md disappeared before planning release — retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${formatDelay(decision.delayMs)}.`;
await this.store.logEntry(task.id, message);
await this.updatePlanningStateIfStillCurrent(task, {
status: this.restoreStatusAfterInterruptedTriageWork(task),
error: null,
recoveryRetryCount: decision.nextState.recoveryRetryCount,
nextRecoveryAt: decision.nextState.nextRecoveryAt,
});
return true;
}
const error = `REQUIRED_ARTIFACT_RECOVERY_EXHAUSTED: PROMPT.md remained missing after ${MAX_RECOVERY_RETRIES} automatic planning retries.`;
await this.store.logEntry(task.id, error);
await this.updatePlanningStateIfStillCurrent(task, {
status: "failed",
error,
recoveryRetryCount: null,
nextRecoveryAt: null,
});
return true;
}
private async finalizeApprovedTaskBody(
task: Task,
writtenInput: string,
@@ -2584,6 +2656,10 @@ export class TriageProcessor {
} = {},
): Promise<void> {
let written = writtenInput;
// FNXC:WorkflowArtifacts 2026-07-21-17:00: Confirm the authoritative plan
// exists before persisting any dependencies, steps, metadata, or review state
// derived from it; a missing plan must leave no partially accepted projection.
if (await this.recoverMissingPromptBeforeRelease(task)) return;
const explicitDuplicateMarker = parseExplicitDuplicateMarker(written);
/*
@@ -3453,7 +3529,7 @@ ${existingPrompt}
## Revision Feedback
${feedback}
Revise the specification above to address this feedback. Write the complete revised PROMPT.md to \`${promptPath}\`.`;
Revise the specification above to address this feedback. Persist the complete revised PROMPT.md with \`fn_task_prompt_write\`.`;
} else if (isFreshRespecification) {
revisionSection = `
@@ -3465,7 +3541,7 @@ You are creating a fresh replacement specification based on Plan Review or user
## Revision Feedback
${feedback}
Please write the complete fresh PROMPT.md to \`${promptPath}\`.`;
Persist the complete fresh PROMPT.md with \`fn_task_prompt_write\`.`;
}
let subtaskSection = "";
@@ -3526,7 +3602,9 @@ The user did not explicitly request subtask breakdown. Default to keeping the ta
operator description verbatim. Deterministic finalize injection enforces the same
contract if the planner omits or rewrites it.
*/
return `${isRevision ? "Revise" : isFreshRespecification ? "Re-specify" : "Specify"} this task and write the result to \`${promptPath}\`.
return `${isRevision ? "Revise" : isFreshRespecification ? "Re-specify" : "Specify"} this task and persist the result with \`fn_task_prompt_write\`.
The authoritative artifact will be stored at \`${promptPath}\`. Do not use the generic filesystem write tool for PROMPT.md; only \`fn_task_prompt_write\` durably synchronizes the task store and artifact.
## Task
- **ID:** ${task.id}
@@ -3541,7 +3619,7 @@ ${task.breakIntoSubtasks ? "- **Break into subtasks:** Yes (user requested)" : "
${task.dependencies.length > 0 ? `- **Dependencies:** ${task.dependencies.join(", ")}` : ""}${revisionSection}${subtaskSection}
## Instructions
${isRevision ? "1. Read the existing specification and revision feedback carefully\n2. Apply surgical PROMPT.md edits that fully resolve every blocking feedback item — do not rewrite from title/description alone\n3. Keep structure stable unless feedback requires rethink; preserve uncriticized content\n4. Keep `## Original Description` at the top (after title/metadata) with the operator description **verbatim**\n5. Ensure the revised specification is still detailed enough for an AI agent to execute" : isFreshRespecification ? "1. Read the project structure to understand context (package.json, source files, etc.)\n2. Treat the current task title and description as mandatory primary inputs for a new spec\n3. Write a fresh complete PROMPT.md specification to the given path following the format in your system prompt\n4. Include `## Original Description` near the top with the exact Original Request text above (verbatim, never plan.md)\n5. Address the revision feedback without inventing extra scope\n6. Name actual files, functions, and patterns from the codebase — be specific" : "1. Read the project structure to understand context (package.json, source files, etc.)\n2. Write a complete PROMPT.md specification to the given path following the format in your system prompt\n3. Include `## Original Description` immediately after title/`Created`/`Size` with the exact Original Request text above (verbatim — do not paraphrase; never use plan.md)\n4. The specification must be detailed enough for an autonomous AI agent to implement without asking questions\n5. Name actual files, functions, and patterns from the codebase — be specific"}
${isRevision ? "1. Read the existing specification and revision feedback carefully\n2. Apply surgical PROMPT.md edits that fully resolve every blocking feedback item — do not rewrite from title/description alone\n3. Keep structure stable unless feedback requires rethink; preserve uncriticized content\n4. Keep `## Original Description` at the top (after title/metadata) with the operator description **verbatim**\n5. Ensure the revised specification is still detailed enough for an AI agent to execute" : isFreshRespecification ? "1. Read the project structure to understand context (package.json, source files, etc.)\n2. Treat the current task title and description as mandatory primary inputs for a new spec\n3. Produce a fresh complete PROMPT.md specification following the format in your system prompt\n4. Include `## Original Description` near the top with the exact Original Request text above (verbatim, never plan.md)\n5. Address the revision feedback without inventing extra scope\n6. Name actual files, functions, and patterns from the codebase — be specific" : "1. Read the project structure to understand context (package.json, source files, etc.)\n2. Produce a complete PROMPT.md specification following the format in your system prompt\n3. Include `## Original Description` immediately after title/`Created`/`Size` with the exact Original Request text above (verbatim — do not paraphrase; never use plan.md)\n4. The specification must be detailed enough for an autonomous AI agent to implement without asking questions\n5. Name actual files, functions, and patterns from the codebase — be specific"}
Use the write tool to write the specification file.${commandsSection}${completionDocumentationSection}${memorySection}${taskDefinitionLanguageSection}${attachmentsSection}${userCommentsSection}`;
Call \`fn_task_prompt_write\` after the complete final specification is ready. If it returns an error, correct the problem and retry; do not finish planning until the tool confirms the authoritative PROMPT.md read-back. Do not use the generic filesystem write tool for PROMPT.md.${commandsSection}${completionDocumentationSection}${memorySection}${taskDefinitionLanguageSection}${attachmentsSection}${userCommentsSection}`;
}

View File

@@ -11,6 +11,7 @@ import type {
} from "@fusion/core";
import { BUILTIN_CODING_WORKFLOW_IR, PLAN_REVIEW_GROUP_ID, WorkflowIrError, getWorkflowExtensionRegistry, resolveMaxReworkCycles, isExperimentalFeatureEnabled, GRAPH_NATIVE_POST_MERGE_FLAG, isCompletionSummaryNode, classifyReviewLease, isWorkflowOptionalGroupEnabled } from "@fusion/core";
import { isNonPlanDefectPlanReviewFailure } from "./transient-error-detector.js";
import { isRequiredArtifactReadFailedValue, parseRequiredArtifactMissingValue } from "./required-workflow-artifacts.js";
import {
createDefaultNodeHandlers,
@@ -916,11 +917,14 @@ export class WorkflowGraphExecutor {
const nonPlanDefectPlanReviewFailure =
node.id === PLAN_REVIEW_GROUP_ID
&& stepStatus === "failed"
&& isNonPlanDefectPlanReviewFailure({
verdict,
errorMessage: stepOutput ?? stepNotes,
failureValue: verdictRaw,
});
&& (
isRequiredArtifactReadFailedValue(verdictRaw)
|| isNonPlanDefectPlanReviewFailure({
verdict,
errorMessage: stepOutput ?? stepNotes,
failureValue: verdictRaw,
})
);
/*
* FNXC:PlanReview 2026-06-29-02:05:
* Plan Review should send a task back to triage only for an actual
@@ -931,7 +935,7 @@ export class WorkflowGraphExecutor {
const shouldRequestPreMergeFix =
stepPhase === "pre-merge"
&& (stepStatus === "advisory_failure" || stepStatus === "failed")
&& (verdict === "REVISE" || (
&& (verdict === "REVISE" || parseRequiredArtifactMissingValue(verdictRaw) !== null || (
node.id === PLAN_REVIEW_GROUP_ID
&& stepStatus === "failed"
&& !nonPlanDefectPlanReviewFailure
@@ -960,7 +964,7 @@ export class WorkflowGraphExecutor {
phase: stepPhase,
status: stepStatus,
verdict: verdict ?? (node.id === PLAN_REVIEW_GROUP_ID ? "REVISE" : undefined),
...(!verdict && verdictRaw !== undefined ? { failureValue: verdictRaw } : {}),
...(parseRequiredArtifactMissingValue(verdictRaw) ? { failureValue: verdictRaw } : {}),
nodeId: node.id,
maxRevisions: node.config?.maxRevisions,
};

View File

@@ -21,6 +21,7 @@ import {
} from "./workflow-node-handlers.js";
import type { WorkflowRuntimePrimitives } from "./runtime-primitives.js";
import { ensureWorkflowCompletionSummary } from "./workflow-completion-summary.js";
import { requiresNonEmptyWorkflowArtifact } from "./required-workflow-artifacts.js";
export type WorkflowTaskRuntimeDisposition = "completed" | "failed" | "manual-required";
@@ -270,7 +271,7 @@ export class WorkflowTaskRuntime {
/**
* FNXC:WorkflowGates 2026-06-17-18:20:
* Custom workflow success criteria require every declared task-document artifact key to exist before terminal success. Evaluate this at the runtime terminal seam so graph paths cannot falsely complete after nodes pass while required deliverables are absent. Empty document content still satisfies the requirement because the IR contract currently requires key existence, not non-empty content.
* Custom workflow success criteria require every declared task-document artifact key to exist before terminal success. Planning-owned and step-source artifacts must also be non-empty because they are executable inputs, not presence-only context.
*/
private async findMissingRequiredArtifacts(taskId: string, ir: WorkflowIr): Promise<string[]> {
const declaredArtifacts: WorkflowIrArtifact[] = "artifacts" in ir && Array.isArray(ir.artifacts) ? ir.artifacts : [];
@@ -282,7 +283,12 @@ export class WorkflowTaskRuntime {
const missing: string[] = [];
for (const artifact of declaredArtifacts) {
const document = await this.deps.store.getTaskDocument(taskId, artifact.key);
if (!document) missing.push(artifact.key);
const content = document && typeof (document as { content?: unknown }).content === "string"
? (document as { content: string }).content
: undefined;
if (!document || (requiresNonEmptyWorkflowArtifact(artifact) && !content?.trim())) {
missing.push(artifact.key);
}
}
return missing;
}