fix(FN-7225): honor plan review approvals in workflows
Fusion-Task-Id: FN-7225
This commit is contained in:
7
.changeset/fn-7225-plan-review-approval.md
Normal file
7
.changeset/fn-7225-plan-review-approval.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Prevent Plan Review approvals from looping back into triage as failures.
|
||||||
|
category: fix
|
||||||
|
dev: Parses explicit reviewer prose verdicts and applies default-on optional workflow steps when no explicit selection exists.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { getBuiltinWorkflow } from "../builtin-workflows.js";
|
import { getBuiltinWorkflow } from "../builtin-workflows.js";
|
||||||
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
|
import { BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR } from "../builtin-stepwise-final-review-coding-workflow-ir.js";
|
||||||
import type { WorkflowIr } from "../workflow-ir-types.js";
|
import type { WorkflowIr } from "../workflow-ir-types.js";
|
||||||
import {
|
import {
|
||||||
resolveWorkflowIrForTask,
|
resolveWorkflowIrForTask,
|
||||||
@@ -73,20 +73,20 @@ describe("resolveWorkflowIrForTask", () => {
|
|||||||
defs: { "wf-gone": undefined },
|
defs: { "wf-gone": undefined },
|
||||||
});
|
});
|
||||||
const ir = await resolveWorkflowIrForTask(store, "t1");
|
const ir = await resolveWorkflowIrForTask(store, "t1");
|
||||||
expect(ir).toBe(BUILTIN_CODING_WORKFLOW_IR);
|
expect(ir).toBe(BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the default when there is no selection", async () => {
|
it("falls back to the default when there is no selection", async () => {
|
||||||
const store = makeStore({ selection: undefined });
|
const store = makeStore({ selection: undefined });
|
||||||
const ir = await resolveWorkflowIrForTask(store, "t1");
|
const ir = await resolveWorkflowIrForTask(store, "t1");
|
||||||
expect(ir).toBe(BUILTIN_CODING_WORKFLOW_IR);
|
expect(ir).toBe(BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR);
|
||||||
expect(store.getWorkflowDefinition).not.toHaveBeenCalled();
|
expect(store.getWorkflowDefinition).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("degrades to the default when the selection lookup throws", async () => {
|
it("degrades to the default when the selection lookup throws", async () => {
|
||||||
const store = makeStore({ selectionThrows: true });
|
const store = makeStore({ selectionThrows: true });
|
||||||
const ir = await resolveWorkflowIrForTask(store, "t1");
|
const ir = await resolveWorkflowIrForTask(store, "t1");
|
||||||
expect(ir).toBe(BUILTIN_CODING_WORKFLOW_IR);
|
expect(ir).toBe(BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("caches by workflowId so the definition is fetched once across calls", async () => {
|
it("caches by workflowId so the definition is fetched once across calls", async () => {
|
||||||
@@ -104,11 +104,11 @@ describe("resolveWorkflowIrForTask", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveWorkflowIrById", () => {
|
describe("resolveWorkflowIrById", () => {
|
||||||
it("resolves builtin:coding to the canonical authored v2 IR with review column traits", async () => {
|
it("resolves builtin:coding to the catalog default stepwise final-review IR", async () => {
|
||||||
const store = makeStore({});
|
const store = makeStore({});
|
||||||
const ir = await resolveWorkflowIrById(store, "builtin:coding");
|
const ir = await resolveWorkflowIrById(store, "builtin:coding");
|
||||||
|
|
||||||
expect(ir).toBe(BUILTIN_CODING_WORKFLOW_IR);
|
expect(ir).toBe(BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR);
|
||||||
expect(ir).toBe(getBuiltinWorkflow("builtin:coding")!.ir);
|
expect(ir).toBe(getBuiltinWorkflow("builtin:coding")!.ir);
|
||||||
expect(ir.version).toBe("v2");
|
expect(ir.version).toBe("v2");
|
||||||
if (ir.version !== "v2") throw new Error("expected v2");
|
if (ir.version !== "v2") throw new Error("expected v2");
|
||||||
@@ -123,14 +123,14 @@ describe("resolveWorkflowIrById", () => {
|
|||||||
it("resolves an explicit builtin:coding task selection through the canonical IR path", async () => {
|
it("resolves an explicit builtin:coding task selection through the canonical IR path", async () => {
|
||||||
const store = makeStore({ selection: { workflowId: "builtin:coding", stepIds: [] } });
|
const store = makeStore({ selection: { workflowId: "builtin:coding", stepIds: [] } });
|
||||||
const ir = await resolveWorkflowIrForTask(store, "t1");
|
const ir = await resolveWorkflowIrForTask(store, "t1");
|
||||||
expect(ir).toBe(BUILTIN_CODING_WORKFLOW_IR);
|
expect(ir).toBe(BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR);
|
||||||
expect(store.getWorkflowDefinition).not.toHaveBeenCalled();
|
expect(store.getWorkflowDefinition).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the canonical IR for an unknown built-in id", async () => {
|
it("falls back to the canonical IR for an unknown built-in id", async () => {
|
||||||
const store = makeStore({});
|
const store = makeStore({});
|
||||||
const ir = await resolveWorkflowIrById(store, "builtin:missing");
|
const ir = await resolveWorkflowIrById(store, "builtin:missing");
|
||||||
expect(ir).toBe(BUILTIN_CODING_WORKFLOW_IR);
|
expect(ir).toBe(BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR);
|
||||||
expect(store.getWorkflowDefinition).not.toHaveBeenCalled();
|
expect(store.getWorkflowDefinition).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ describe("resolveWorkflowIrById", () => {
|
|||||||
|
|
||||||
const ir = await resolveWorkflowIrById(store, "builtin:coding");
|
const ir = await resolveWorkflowIrById(store, "builtin:coding");
|
||||||
|
|
||||||
expect(ir).toBe(BUILTIN_CODING_WORKFLOW_IR);
|
expect(ir).toBe(BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR);
|
||||||
expect(store.getWorkflowSettingsProjectId).toHaveBeenCalledTimes(1);
|
expect(store.getWorkflowSettingsProjectId).toHaveBeenCalledTimes(1);
|
||||||
expect(store.getWorkflowPromptOverrides).not.toHaveBeenCalled();
|
expect(store.getWorkflowPromptOverrides).not.toHaveBeenCalled();
|
||||||
expect(store.getWorkflowDefinition).not.toHaveBeenCalled();
|
expect(store.getWorkflowDefinition).not.toHaveBeenCalled();
|
||||||
@@ -162,7 +162,7 @@ describe("resolveWorkflowIrById", () => {
|
|||||||
it("keeps project-scoped prompt overrides and cache keys when project identity resolves", async () => {
|
it("keeps project-scoped prompt overrides and cache keys when project identity resolves", async () => {
|
||||||
const store = makeStore({
|
const store = makeStore({
|
||||||
projectId: "proj-override",
|
projectId: "proj-override",
|
||||||
promptOverrides: { planning: "Project-specific plan" },
|
promptOverrides: { plan: "Project-specific plan" },
|
||||||
});
|
});
|
||||||
const cache = new Map<string, WorkflowIr>();
|
const cache = new Map<string, WorkflowIr>();
|
||||||
|
|
||||||
@@ -170,8 +170,8 @@ describe("resolveWorkflowIrById", () => {
|
|||||||
const second = await resolveWorkflowIrById(store, "builtin:coding", cache);
|
const second = await resolveWorkflowIrById(store, "builtin:coding", cache);
|
||||||
|
|
||||||
expect(first).toBe(second);
|
expect(first).toBe(second);
|
||||||
expect(first).not.toBe(BUILTIN_CODING_WORKFLOW_IR);
|
expect(first).not.toBe(BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR);
|
||||||
expect(first.nodes.find((node) => node.id === "planning")?.config?.prompt).toBe("Project-specific plan");
|
expect(first.nodes.find((node) => node.id === "plan")?.config?.prompt).toBe("Project-specific plan");
|
||||||
expect(cache.get("builtin:coding\u0000proj-override")).toBe(first);
|
expect(cache.get("builtin:coding\u0000proj-override")).toBe(first);
|
||||||
expect(store.getWorkflowPromptOverrides).toHaveBeenCalledTimes(1);
|
expect(store.getWorkflowPromptOverrides).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { join } from "node:path";
|
|||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
|
|
||||||
import { BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR } from "../builtin-stepwise-final-review-coding-workflow-ir.js";
|
import { BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR } from "../builtin-stepwise-final-review-coding-workflow-ir.js";
|
||||||
import type { TaskStore } from "../store.js";
|
import type { TaskStore } from "../store.js";
|
||||||
import type { WorkflowRunStepInstance } from "../types.js";
|
import type { WorkflowRunStepInstance } from "../types.js";
|
||||||
@@ -287,9 +286,9 @@ describe("workflow restart durability for explicit selections", () => {
|
|||||||
|
|
||||||
expect(store().getTaskWorkflowSelection(selectedTask.id)).toEqual(selectionBefore);
|
expect(store().getTaskWorkflowSelection(selectedTask.id)).toEqual(selectionBefore);
|
||||||
expect(await taskJsonEnabledWorkflowSteps(selectedTask.id)).toEqual(enabledBefore);
|
expect(await taskJsonEnabledWorkflowSteps(selectedTask.id)).toEqual(enabledBefore);
|
||||||
// Current hot-path resolution degrades a dangling custom definition to the built-in IR instead of throwing.
|
// Current hot-path resolution degrades a dangling custom definition to the default built-in Coding IR instead of throwing.
|
||||||
// The explicit materialization APIs below must still fail closed when asked to write that missing id again.
|
// The explicit materialization APIs below must still fail closed when asked to write that missing id again.
|
||||||
expect(privateStore().resolveTaskWorkflowIrSync(selectedTask.id)).toEqual(BUILTIN_CODING_WORKFLOW_IR);
|
expect(privateStore().resolveTaskWorkflowIrSync(selectedTask.id)).toEqual(BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR);
|
||||||
|
|
||||||
await expect(store().selectTaskWorkflow(untouchedTask.id, workflow.id)).rejects.toThrow(
|
await expect(store().selectTaskWorkflow(untouchedTask.id, workflow.id)).rejects.toThrow(
|
||||||
`Workflow '${workflow.id}' not found`,
|
`Workflow '${workflow.id}' not found`,
|
||||||
|
|||||||
@@ -15697,19 +15697,24 @@ ${stepsSection}`;
|
|||||||
private resolveTaskWorkflowIrSync(taskId: string): WorkflowIr {
|
private resolveTaskWorkflowIrSync(taskId: string): WorkflowIr {
|
||||||
const selection = this.getTaskWorkflowSelection(taskId);
|
const selection = this.getTaskWorkflowSelection(taskId);
|
||||||
const workflowId = selection?.workflowId;
|
const workflowId = selection?.workflowId;
|
||||||
if (!workflowId) return this.applyBuiltInPromptOverridesSync("builtin:coding", BUILTIN_CODING_WORKFLOW_IR);
|
/*
|
||||||
|
* FNXC:WorkflowBuiltins 2026-06-29-02:18:
|
||||||
|
* The built-in id `builtin:coding` now points at the stepwise final-review workflow. No-selection tasks must resolve through the built-in catalog, otherwise dashboard/operator defaults say "Coding" while the engine silently executes legacy coding.
|
||||||
|
*/
|
||||||
|
const defaultCodingIr = getBuiltinWorkflow("builtin:coding")?.ir ?? BUILTIN_CODING_WORKFLOW_IR;
|
||||||
|
if (!workflowId) return this.applyBuiltInPromptOverridesSync("builtin:coding", defaultCodingIr);
|
||||||
if (isBuiltinWorkflowId(workflowId)) {
|
if (isBuiltinWorkflowId(workflowId)) {
|
||||||
const builtin = getBuiltinWorkflow(workflowId);
|
const builtin = getBuiltinWorkflow(workflowId);
|
||||||
return this.applyBuiltInPromptOverridesSync(workflowId, builtin?.ir ?? BUILTIN_CODING_WORKFLOW_IR);
|
return this.applyBuiltInPromptOverridesSync(workflowId, builtin?.ir ?? defaultCodingIr);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const row = this.db
|
const row = this.db
|
||||||
.prepare("SELECT ir FROM workflows WHERE id = ?")
|
.prepare("SELECT ir FROM workflows WHERE id = ?")
|
||||||
.get(workflowId) as { ir: string } | undefined;
|
.get(workflowId) as { ir: string } | undefined;
|
||||||
if (!row) return BUILTIN_CODING_WORKFLOW_IR;
|
if (!row) return defaultCodingIr;
|
||||||
return parseWorkflowIr(row.ir);
|
return parseWorkflowIr(row.ir);
|
||||||
} catch {
|
} catch {
|
||||||
return BUILTIN_CODING_WORKFLOW_IR;
|
return defaultCodingIr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ import { parseWorkflowIr } from "./workflow-ir.js";
|
|||||||
import { applyPromptOverridesToIr } from "./workflow-prompt-overrides.js";
|
import { applyPromptOverridesToIr } from "./workflow-prompt-overrides.js";
|
||||||
import type { WorkflowIr } from "./workflow-ir-types.js";
|
import type { WorkflowIr } from "./workflow-ir-types.js";
|
||||||
|
|
||||||
|
function defaultCodingWorkflowIr(): WorkflowIr {
|
||||||
|
/*
|
||||||
|
* FNXC:WorkflowBuiltins 2026-06-29-02:18:
|
||||||
|
* `builtin:coding` is the operator-facing default workflow id, not the legacy monolithic IR export. Resolve the catalog entry first so no-selection tasks follow the new stepwise default; keep the old IR only as a missing-catalog safety fallback.
|
||||||
|
*/
|
||||||
|
const builtin = getBuiltinWorkflow("builtin:coding");
|
||||||
|
const ir = builtin?.ir ?? BUILTIN_CODING_WORKFLOW_IR;
|
||||||
|
return typeof ir === "string" ? parseWorkflowIr(ir) : ir;
|
||||||
|
}
|
||||||
|
|
||||||
/** Minimal store surface the resolver needs (public APIs only). */
|
/** Minimal store surface the resolver needs (public APIs only). */
|
||||||
export interface WorkflowIrResolverStore {
|
export interface WorkflowIrResolverStore {
|
||||||
getTaskWorkflowSelection(taskId: string): { workflowId: string; stepIds: string[] } | undefined;
|
getTaskWorkflowSelection(taskId: string): { workflowId: string; stepIds: string[] } | undefined;
|
||||||
@@ -104,7 +114,7 @@ export async function resolveWorkflowIrById(
|
|||||||
|
|
||||||
if (isBuiltinWorkflowId(workflowId)) {
|
if (isBuiltinWorkflowId(workflowId)) {
|
||||||
const builtin = getBuiltinWorkflow(workflowId);
|
const builtin = getBuiltinWorkflow(workflowId);
|
||||||
const ir = builtin?.ir ?? BUILTIN_CODING_WORKFLOW_IR;
|
const ir = builtin?.ir ?? defaultCodingWorkflowIr();
|
||||||
const resolved = typeof ir === "string" ? parseWorkflowIr(ir) : ir;
|
const resolved = typeof ir === "string" ? parseWorkflowIr(ir) : ir;
|
||||||
const overrides = projectId ? store.getWorkflowPromptOverrides?.(workflowId, projectId) : undefined;
|
const overrides = projectId ? store.getWorkflowPromptOverrides?.(workflowId, projectId) : undefined;
|
||||||
// FNXC:CustomWorkflows 2026-06-21-19:12:
|
// FNXC:CustomWorkflows 2026-06-21-19:12:
|
||||||
@@ -116,12 +126,12 @@ export async function resolveWorkflowIrById(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const def = await store.getWorkflowDefinition(workflowId);
|
const def = await store.getWorkflowDefinition(workflowId);
|
||||||
if (!def) return BUILTIN_CODING_WORKFLOW_IR;
|
if (!def) return defaultCodingWorkflowIr();
|
||||||
const ir = typeof def.ir === "string" ? parseWorkflowIr(def.ir) : def.ir;
|
const ir = typeof def.ir === "string" ? parseWorkflowIr(def.ir) : def.ir;
|
||||||
irCache?.set(cacheKey, ir);
|
irCache?.set(cacheKey, ir);
|
||||||
return ir;
|
return ir;
|
||||||
} catch {
|
} catch {
|
||||||
return BUILTIN_CODING_WORKFLOW_IR;
|
return defaultCodingWorkflowIr();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +148,7 @@ export async function resolveWorkflowIrForTask(
|
|||||||
try {
|
try {
|
||||||
workflowId = store.getTaskWorkflowSelection(taskId)?.workflowId;
|
workflowId = store.getTaskWorkflowSelection(taskId)?.workflowId;
|
||||||
} catch {
|
} catch {
|
||||||
return BUILTIN_CODING_WORKFLOW_IR;
|
return defaultCodingWorkflowIr();
|
||||||
}
|
}
|
||||||
if (!workflowId) return resolveWorkflowIrById(store, "builtin:coding", irCache);
|
if (!workflowId) return resolveWorkflowIrById(store, "builtin:coding", irCache);
|
||||||
return resolveWorkflowIrById(store, workflowId, irCache);
|
return resolveWorkflowIrById(store, workflowId, irCache);
|
||||||
|
|||||||
@@ -1672,6 +1672,33 @@ describe("requirePlanApproval setting", () => {
|
|||||||
expect(store.moveTask).not.toHaveBeenCalled();
|
expect(store.moveTask).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears stale workflow step instances when a fresh accepted plan replaces existing steps", async () => {
|
||||||
|
const task = createTriageTask({
|
||||||
|
id: "FN-7224",
|
||||||
|
title: "Rebuilt plan task",
|
||||||
|
status: "planning",
|
||||||
|
steps: [{ name: "Old step", status: "pending" }],
|
||||||
|
} as Partial<Task>);
|
||||||
|
const clearWorkflowRunStepInstances = vi.fn();
|
||||||
|
const store = createMockStore({
|
||||||
|
getTask: vi.fn().mockResolvedValue(task),
|
||||||
|
parseStepsFromPrompt: vi.fn().mockResolvedValue([{ name: "Fresh step", status: "pending" }]),
|
||||||
|
clearWorkflowRunStepInstances,
|
||||||
|
} as Partial<TaskStore>);
|
||||||
|
const processor = new TriageProcessor(store, rootDir);
|
||||||
|
|
||||||
|
await (processor as unknown as {
|
||||||
|
finalizeApprovedTask(task: Task, writtenInput: string, settings: Settings): Promise<void>;
|
||||||
|
}).finalizeApprovedTask(
|
||||||
|
task,
|
||||||
|
"# Task: FN-7224 - Rebuilt plan task\n\n## Steps\n\n### Step 1: Fresh step\n- Execute the fresh plan.\n",
|
||||||
|
{ requirePlanApproval: false } as Settings,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(clearWorkflowRunStepInstances).toHaveBeenCalledWith("FN-7224");
|
||||||
|
expect(store.moveTask).toHaveBeenCalledWith("FN-7224", "todo");
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{ mode: "workflow" as const, requirePlanApproval: true, expectedApproval: true },
|
{ mode: "workflow" as const, requirePlanApproval: true, expectedApproval: true },
|
||||||
{ mode: undefined, requirePlanApproval: false, expectedApproval: false },
|
{ mode: undefined, requirePlanApproval: false, expectedApproval: false },
|
||||||
|
|||||||
@@ -165,6 +165,28 @@ describe("WorkflowGraphExecutor optional-group", () => {
|
|||||||
expect(disabledResult.outcome).toBe("success");
|
expect(disabledResult.outcome).toBe("success");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("runs default-on optional groups when the task has no explicit optional-step selection", async () => {
|
||||||
|
const ir = optionalGroupIr();
|
||||||
|
const group = ir.nodes.find((node) => node.id === "group");
|
||||||
|
if (group?.config) group.config.defaultOn = true;
|
||||||
|
const calls: string[] = [];
|
||||||
|
const executor = new WorkflowGraphExecutor({
|
||||||
|
handlers: {
|
||||||
|
prompt: async (node) => {
|
||||||
|
calls.push(node.id);
|
||||||
|
return { outcome: "success" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsetResult = await executor.run(taskWith(undefined), settingsOn(), ir);
|
||||||
|
const explicitEmptyResult = await executor.run(taskWith([]), settingsOn(), ir);
|
||||||
|
|
||||||
|
expect(calls.filter((id) => id === "optstep")).toHaveLength(1);
|
||||||
|
expect(unsetResult.visitedNodeIds).toContain("group::optstep");
|
||||||
|
expect(explicitEmptyResult.visitedNodeIds).not.toContain("group::optstep");
|
||||||
|
});
|
||||||
|
|
||||||
it("runs an enabled group's template exactly once (single pass, not per-step/looped)", async () => {
|
it("runs an enabled group's template exactly once (single pass, not per-step/looped)", async () => {
|
||||||
const runTemplate = vi.fn<WorkflowNodeHandler>(async () => ({ outcome: "success" }));
|
const runTemplate = vi.fn<WorkflowNodeHandler>(async () => ({ outcome: "success" }));
|
||||||
const executor = new WorkflowGraphExecutor({
|
const executor = new WorkflowGraphExecutor({
|
||||||
@@ -444,6 +466,64 @@ describe("WorkflowGraphExecutor optional-group", () => {
|
|||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not synthesize a Plan Review replan from advisory malformed output", async () => {
|
||||||
|
const requestFix = vi.fn(async () => true);
|
||||||
|
const calls: string[] = [];
|
||||||
|
const records: unknown[] = [];
|
||||||
|
const ir: WorkflowIr = {
|
||||||
|
version: "v2",
|
||||||
|
name: "plan-review-advisory-malformed",
|
||||||
|
columns: [{ id: "work", name: "Work", traits: [] }],
|
||||||
|
nodes: [
|
||||||
|
{ id: "start", kind: "start" },
|
||||||
|
{
|
||||||
|
id: "plan-review",
|
||||||
|
kind: "optional-group",
|
||||||
|
config: {
|
||||||
|
name: "Plan Review",
|
||||||
|
defaultOn: true,
|
||||||
|
template: {
|
||||||
|
nodes: [{ id: "plan-review-step", kind: "prompt", config: { prompt: "review plan" } }],
|
||||||
|
edges: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: "execute", kind: "prompt", config: { prompt: "execute" } },
|
||||||
|
{ id: "end", kind: "end" },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: "start", to: "plan-review" },
|
||||||
|
{ from: "plan-review", to: "execute", condition: "success" },
|
||||||
|
{ from: "execute", to: "end" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const executor = new WorkflowGraphExecutor({
|
||||||
|
handlers: {
|
||||||
|
prompt: async (node) => {
|
||||||
|
calls.push(node.id);
|
||||||
|
return node.id === "plan-review-step"
|
||||||
|
? { outcome: "success", value: "advisory_failure", contextPatch: { output: "malformed output — no verdict extracted" } }
|
||||||
|
: { outcome: "success" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
recordWorkflowStepResult: async (_taskId, result) => { records.push(result); },
|
||||||
|
requestPreMergeOptionalStepFix: requestFix,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executor.run(taskWith(["plan-review"]), settingsOn(), ir);
|
||||||
|
|
||||||
|
expect(requestFix).not.toHaveBeenCalled();
|
||||||
|
expect(calls).toContain("execute");
|
||||||
|
expect(result.outcome).toBe("success");
|
||||||
|
expect(records).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
workflowStepId: "plan-review",
|
||||||
|
status: "advisory_failure",
|
||||||
|
output: "malformed output — no verdict extracted",
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
it("cycles REVISE findings across graph runs until APPROVE, and falls through only after the budget seam declines", async () => {
|
it("cycles REVISE findings across graph runs until APPROVE, and falls through only after the budget seam declines", async () => {
|
||||||
const verdicts = ["REVISE", "REVISE", "APPROVE"];
|
const verdicts = ["REVISE", "REVISE", "APPROVE"];
|
||||||
const requestFix = vi.fn(async () => true);
|
const requestFix = vi.fn(async () => true);
|
||||||
|
|||||||
@@ -28,6 +28,21 @@ describe("inferWorkflowStepVerdictFromProse", () => {
|
|||||||
expect(inferWorkflowStepVerdictFromProse("looks good")).toEqual({ verdict: "APPROVE", notes: "" });
|
expect(inferWorkflowStepVerdictFromProse("looks good")).toEqual({ verdict: "APPROVE", notes: "" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("infers explicit markdown verdicts from reviewer-style output", () => {
|
||||||
|
expect(inferWorkflowStepVerdictFromProse("## Spec Review\n\n### Verdict: APPROVE\n\nThe plan is ready.")).toEqual({
|
||||||
|
verdict: "APPROVE",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
expect(inferWorkflowStepVerdictFromProse("Status: APPROVE_WITH_NOTES\n\nProceed with notes.")).toEqual({
|
||||||
|
verdict: "APPROVE_WITH_NOTES",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
expect(inferWorkflowStepVerdictFromProse("Verdict: REVISE\n\nFix the plan.")).toEqual({
|
||||||
|
verdict: "REVISE",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("returns null for unrelated prose", () => {
|
it("returns null for unrelated prose", () => {
|
||||||
expect(inferWorkflowStepVerdictFromProse("lorem ipsum")).toBeNull();
|
expect(inferWorkflowStepVerdictFromProse("lorem ipsum")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ import type { PreparedWorktree, WorkflowRuntimePrimitives } from "../runtime-pri
|
|||||||
|
|
||||||
const task = { id: "FN-9002" } as TaskDetail;
|
const task = { id: "FN-9002" } as TaskDetail;
|
||||||
const flagOff = { experimentalFeatures: {} } as unknown as Pick<Settings, "experimentalFeatures">;
|
const flagOff = { experimentalFeatures: {} } as unknown as Pick<Settings, "experimentalFeatures">;
|
||||||
|
const promptWithOneStep = "# Task: FN-9002 - Runtime default\n\n## Steps\n\n### Step 1: Implement runtime default\n- Exercise the default workflow.\n";
|
||||||
|
|
||||||
|
const parseStepsDeps = {
|
||||||
|
readArtifact: async (_task: TaskDetail, key: string) => key === "PROMPT.md" ? promptWithOneStep : undefined,
|
||||||
|
writeSteps: async (target: TaskDetail, steps: TaskDetail["steps"]) => {
|
||||||
|
target.steps = steps;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
function selectedIr(): WorkflowIr {
|
function selectedIr(): WorkflowIr {
|
||||||
return {
|
return {
|
||||||
@@ -35,6 +43,7 @@ function recordingPrimitives(
|
|||||||
observed: {
|
observed: {
|
||||||
prepared?: PreparedWorktree;
|
prepared?: PreparedWorktree;
|
||||||
executedTasks?: TaskDetail[];
|
executedTasks?: TaskDetail[];
|
||||||
|
stepTasks?: TaskDetail[];
|
||||||
mergeAttempt?: number;
|
mergeAttempt?: number;
|
||||||
mergeRunId?: string;
|
mergeRunId?: string;
|
||||||
mergeWorkflowId?: string;
|
mergeWorkflowId?: string;
|
||||||
@@ -55,7 +64,7 @@ function recordingPrimitives(
|
|||||||
: overrides.prepareData ?? prepared,
|
: overrides.prepareData ?? prepared,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
readArtifact: async () => undefined,
|
readArtifact: async (_ctx, _task, key) => key === "PROMPT.md" ? promptWithOneStep : undefined,
|
||||||
writeArtifact: async (_ctx, _task, key) => ({ outcome: "success", data: { key } }),
|
writeArtifact: async (_ctx, _task, key) => ({ outcome: "success", data: { key } }),
|
||||||
runPlanningSession: async () => {
|
runPlanningSession: async () => {
|
||||||
calls.push("planning");
|
calls.push("planning");
|
||||||
@@ -73,7 +82,12 @@ function recordingPrimitives(
|
|||||||
data: { taskDone: override?.outcome !== "failure", modifiedFiles: [] },
|
data: { taskDone: override?.outcome !== "failure", modifiedFiles: [] },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
runTaskStep: async () => ({ outcome: "success" }),
|
runTaskStep: async (_ctx, _task, stepIndex) => {
|
||||||
|
calls.push(`step:${stepIndex}`);
|
||||||
|
observed.stepTasks?.push(_task);
|
||||||
|
observed.executedTasks?.push(_task);
|
||||||
|
return { outcome: "success" };
|
||||||
|
},
|
||||||
resetTaskStep: async () => ({ ok: true }),
|
resetTaskStep: async () => ({ ok: true }),
|
||||||
runReview: async (_ctx, _task, input) => {
|
runReview: async (_ctx, _task, input) => {
|
||||||
calls.push(input.stepIndex === undefined ? "review" : "step-review");
|
calls.push(input.stepIndex === undefined ? "review" : "step-review");
|
||||||
@@ -94,7 +108,10 @@ function recordingPrimitives(
|
|||||||
data: { allPassed: override?.value !== "remediation-scheduled" },
|
data: { allPassed: override?.value !== "remediation-scheduled" },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
updateSteps: async (_ctx, _task, steps) => ({ outcome: "success", data: { count: steps.length } }),
|
updateSteps: async (_ctx, _task, steps) => {
|
||||||
|
_task.steps = steps;
|
||||||
|
return { outcome: "success", data: { count: steps.length } };
|
||||||
|
},
|
||||||
transitionTask: async () => {
|
transitionTask: async () => {
|
||||||
calls.push("schedule");
|
calls.push("schedule");
|
||||||
return { outcome: "success" };
|
return { outcome: "success" };
|
||||||
@@ -118,6 +135,7 @@ describe("WorkflowTaskRuntime", () => {
|
|||||||
store: {
|
store: {
|
||||||
getTaskWorkflowSelection: () => undefined,
|
getTaskWorkflowSelection: () => undefined,
|
||||||
getWorkflowDefinition: async () => undefined,
|
getWorkflowDefinition: async () => undefined,
|
||||||
|
getTaskDocument: async (_taskId, key) => key === "PROMPT.md" ? { key } : null,
|
||||||
},
|
},
|
||||||
runCustomNode: async () => ({ outcome: "success" }),
|
runCustomNode: async () => ({ outcome: "success" }),
|
||||||
};
|
};
|
||||||
@@ -148,6 +166,7 @@ describe("WorkflowTaskRuntime", () => {
|
|||||||
calls.push(`custom:${node.id}`);
|
calls.push(`custom:${node.id}`);
|
||||||
return { outcome: "success" };
|
return { outcome: "success" };
|
||||||
},
|
},
|
||||||
|
parseStepsDeps,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runtime.run(task, flagOff);
|
const result = await runtime.run(task, flagOff);
|
||||||
@@ -191,6 +210,7 @@ describe("WorkflowTaskRuntime", () => {
|
|||||||
calls.push(`custom:${node.id}`);
|
calls.push(`custom:${node.id}`);
|
||||||
return { outcome: "success" };
|
return { outcome: "success" };
|
||||||
},
|
},
|
||||||
|
parseStepsDeps,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runtime.run(attachmentTask, flagOff);
|
const result = await runtime.run(attachmentTask, flagOff);
|
||||||
@@ -218,20 +238,22 @@ describe("WorkflowTaskRuntime", () => {
|
|||||||
store: {
|
store: {
|
||||||
getTaskWorkflowSelection: () => undefined,
|
getTaskWorkflowSelection: () => undefined,
|
||||||
getWorkflowDefinition: async () => undefined,
|
getWorkflowDefinition: async () => undefined,
|
||||||
|
getTaskDocument: async (_taskId, key) => key === "PROMPT.md" ? { key } : null,
|
||||||
},
|
},
|
||||||
primitives: recordingPrimitives(calls, undefined, observed),
|
primitives: recordingPrimitives(calls, undefined, observed),
|
||||||
runCustomNode: async (node) => {
|
runCustomNode: async (node) => {
|
||||||
calls.push(`custom:${node.id}`);
|
calls.push(`custom:${node.id}`);
|
||||||
return { outcome: "success" };
|
return { outcome: "success" };
|
||||||
},
|
},
|
||||||
|
parseStepsDeps,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runtime.run(attachmentTask, flagOff);
|
const result = await runtime.run(attachmentTask, flagOff);
|
||||||
|
|
||||||
expect(result.disposition).toBe("completed");
|
expect(result.disposition).toBe("completed");
|
||||||
// U6: the coding built-in no longer runs a `workflow-step` seam; the pre-merge
|
// Default Coding is stepwise: planning writes PROMPT.md, parse projects steps,
|
||||||
// browser-verification optional-group is default-OFF and bypassed (no call).
|
// then foreach runs `runTaskStep` before merge. No legacy execute/review seam.
|
||||||
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "review", "merge"]);
|
expect(calls).toEqual(["planning", "custom:plan-review-step", "step:0", "custom:code-review-step", "merge"]);
|
||||||
expect(observed.executedTasks).toHaveLength(1);
|
expect(observed.executedTasks).toHaveLength(1);
|
||||||
expect(observed.executedTasks[0]?.attachments).toEqual(attachments);
|
expect(observed.executedTasks[0]?.attachments).toEqual(attachments);
|
||||||
});
|
});
|
||||||
@@ -242,9 +264,11 @@ describe("WorkflowTaskRuntime", () => {
|
|||||||
store: {
|
store: {
|
||||||
getTaskWorkflowSelection: () => undefined,
|
getTaskWorkflowSelection: () => undefined,
|
||||||
getWorkflowDefinition: async () => undefined,
|
getWorkflowDefinition: async () => undefined,
|
||||||
|
getTaskDocument: async (_taskId, key) => key === "PROMPT.md" ? { key } : null,
|
||||||
},
|
},
|
||||||
primitives: recordingPrimitives([], undefined, observed),
|
primitives: recordingPrimitives([], undefined, observed),
|
||||||
runCustomNode: async () => ({ outcome: "success" }),
|
runCustomNode: async () => ({ outcome: "success" }),
|
||||||
|
parseStepsDeps,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runtime.run(task, flagOff);
|
const result = await runtime.run(task, flagOff);
|
||||||
@@ -284,29 +308,28 @@ describe("WorkflowTaskRuntime", () => {
|
|||||||
store: {
|
store: {
|
||||||
getTaskWorkflowSelection: () => undefined,
|
getTaskWorkflowSelection: () => undefined,
|
||||||
getWorkflowDefinition: async () => undefined,
|
getWorkflowDefinition: async () => undefined,
|
||||||
|
getTaskDocument: async (_taskId, key) => key === "PROMPT.md" ? { key } : null,
|
||||||
},
|
},
|
||||||
primitives: recordingPrimitives(calls),
|
primitives: recordingPrimitives(calls),
|
||||||
runCustomNode: async (node) => {
|
runCustomNode: async (node) => {
|
||||||
calls.push(`custom:${node.id}`);
|
calls.push(`custom:${node.id}`);
|
||||||
return { outcome: "success" };
|
return { outcome: "success" };
|
||||||
},
|
},
|
||||||
|
parseStepsDeps,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runtime.run(task, flagOff);
|
const defaultTask = { ...task, enabledWorkflowSteps: ["plan-review", "code-review"] } as TaskDetail;
|
||||||
|
const result = await runtime.run(defaultTask, flagOff);
|
||||||
|
|
||||||
expect(result.disposition).toBe("completed");
|
expect(result.disposition).toBe("completed");
|
||||||
// U6: no `workflow-step` seam; the bypassed browser-verification group node
|
expect(calls).toEqual(["planning", "custom:plan-review-step", "step:0", "custom:code-review-step", "merge"]);
|
||||||
// sits between execute and review in the visited sequence.
|
expect(result.visitedNodeIds).toContain("plan");
|
||||||
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "review", "merge"]);
|
expect(result.visitedNodeIds).toContain("plan-review");
|
||||||
expect(result.visitedNodeIds).toEqual([
|
expect(result.visitedNodeIds).toContain("parse");
|
||||||
"start",
|
expect(result.visitedNodeIds).toContain("steps");
|
||||||
"planning",
|
expect(result.visitedNodeIds).toContain("code-review");
|
||||||
"execute",
|
expect(result.visitedNodeIds).not.toContain("execute");
|
||||||
"browser-verification",
|
expect(result.visitedNodeIds).not.toContain("review");
|
||||||
"code-review",
|
|
||||||
"review",
|
|
||||||
"merge",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs the pre-merge browser-verification optional-group once when enabled, before review", async () => {
|
it("runs the pre-merge browser-verification optional-group once when enabled, before review", async () => {
|
||||||
@@ -319,37 +342,33 @@ describe("WorkflowTaskRuntime", () => {
|
|||||||
store: {
|
store: {
|
||||||
getTaskWorkflowSelection: () => undefined,
|
getTaskWorkflowSelection: () => undefined,
|
||||||
getWorkflowDefinition: async () => undefined,
|
getWorkflowDefinition: async () => undefined,
|
||||||
|
getTaskDocument: async (_taskId, key) => key === "PROMPT.md" ? { key } : null,
|
||||||
},
|
},
|
||||||
primitives: recordingPrimitives(calls),
|
primitives: recordingPrimitives(calls),
|
||||||
runCustomNode: async (node) => {
|
runCustomNode: async (node) => {
|
||||||
calls.push(`custom:${node.id}`);
|
calls.push(`custom:${node.id}`);
|
||||||
return { outcome: "success" };
|
return { outcome: "success" };
|
||||||
},
|
},
|
||||||
|
parseStepsDeps,
|
||||||
});
|
});
|
||||||
|
|
||||||
const enabledTask = { ...task, enabledWorkflowSteps: ["browser-verification"] } as TaskDetail;
|
const enabledTask = { ...task, enabledWorkflowSteps: ["plan-review", "browser-verification", "code-review"] } as TaskDetail;
|
||||||
const result = await runtime.run(enabledTask, flagOff);
|
const result = await runtime.run(enabledTask, flagOff);
|
||||||
|
|
||||||
expect(result.disposition).toBe("completed");
|
expect(result.disposition).toBe("completed");
|
||||||
expect(calls).toEqual([
|
expect(calls).toEqual([
|
||||||
"planning",
|
"planning",
|
||||||
"prepare-worktree",
|
"custom:plan-review-step",
|
||||||
"execute",
|
"step:0",
|
||||||
"custom:browser-verification-step",
|
"custom:browser-verification-step",
|
||||||
"review",
|
"custom:code-review-step",
|
||||||
"merge",
|
|
||||||
]);
|
|
||||||
expect(result.visitedNodeIds).toEqual([
|
|
||||||
"start",
|
|
||||||
"planning",
|
|
||||||
"execute",
|
|
||||||
// The group container node, then its inner template step (run once).
|
|
||||||
"browser-verification",
|
|
||||||
"browser-verification::browser-verification-step",
|
|
||||||
"code-review",
|
|
||||||
"review",
|
|
||||||
"merge",
|
"merge",
|
||||||
]);
|
]);
|
||||||
|
expect(result.visitedNodeIds).toContain("plan-review::plan-review-step");
|
||||||
|
expect(result.visitedNodeIds).toContain("browser-verification::browser-verification-step");
|
||||||
|
expect(result.visitedNodeIds).toContain("code-review::code-review-step");
|
||||||
|
expect(result.visitedNodeIds).not.toContain("execute");
|
||||||
|
expect(result.visitedNodeIds).not.toContain("review");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails selected workflow lookup misses instead of running the built-in workflow", async () => {
|
it("fails selected workflow lookup misses instead of running the built-in workflow", async () => {
|
||||||
@@ -426,6 +445,7 @@ describe("WorkflowTaskRuntime", () => {
|
|||||||
},
|
},
|
||||||
primitives: recordingPrimitives([]),
|
primitives: recordingPrimitives([]),
|
||||||
runCustomNode: async () => ({ outcome: "success" }),
|
runCustomNode: async () => ({ outcome: "success" }),
|
||||||
|
parseStepsDeps,
|
||||||
branchPersistence: {
|
branchPersistence: {
|
||||||
loadBranchStates: (_taskId, runId) => {
|
loadBranchStates: (_taskId, runId) => {
|
||||||
observedRunIds.push(runId);
|
observedRunIds.push(runId);
|
||||||
|
|||||||
@@ -1128,12 +1128,26 @@ export function parseWorkflowStepVerdict(rawOutput: string): { verdict: "APPROVE
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function inferWorkflowStepVerdictFromProse(rawOutput: string): { verdict: "APPROVE" | "REVISE"; notes: string } | null {
|
export function inferWorkflowStepVerdictFromProse(rawOutput: string): { verdict: "APPROVE" | "APPROVE_WITH_NOTES" | "REVISE"; notes: string } | null {
|
||||||
const trimmed = rawOutput.trim();
|
const trimmed = rawOutput.trim();
|
||||||
const revisionMatch = trimmed.match(/^REQUEST REVISION\s*\n*/i);
|
const revisionMatch = trimmed.match(/^REQUEST REVISION\s*\n*/i);
|
||||||
if (revisionMatch) {
|
if (revisionMatch) {
|
||||||
return { verdict: "REVISE", notes: trimmed.slice(revisionMatch[0].length).trim() || "Revision requested" };
|
return { verdict: "REVISE", notes: trimmed.slice(revisionMatch[0].length).trim() || "Revision requested" };
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
* FNXC:PlanReview 2026-06-29-02:05:
|
||||||
|
* Plan Review runs through reviewer-style agents that often emit a markdown
|
||||||
|
* section such as `### Verdict: APPROVE` even when the prompt asks for trailing
|
||||||
|
* JSON. Treat that explicit verdict as authoritative so a real approval does
|
||||||
|
* not collapse into a synthetic pre-execution plan failure loop.
|
||||||
|
*/
|
||||||
|
const explicitVerdictMatch = trimmed.match(/(?:^|\n)\s*(?:#{1,6}\s*)?(?:verdict|status)\s*:\s*(APPROVE_WITH_NOTES|APPROVE|REVISE)\b/i);
|
||||||
|
if (explicitVerdictMatch) {
|
||||||
|
return {
|
||||||
|
verdict: explicitVerdictMatch[1].toUpperCase() as "APPROVE" | "APPROVE_WITH_NOTES" | "REVISE",
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
if (/\b(approve|approved|looks good|no issues|out of scope)\b/i.test(trimmed)) {
|
if (/\b(approve|approved|looks good|no issues|out of scope)\b/i.test(trimmed)) {
|
||||||
return { verdict: "APPROVE", notes: "" };
|
return { verdict: "APPROVE", notes: "" };
|
||||||
}
|
}
|
||||||
@@ -6714,9 +6728,17 @@ export class TaskExecutor {
|
|||||||
const contextPatch: Record<string, unknown> = {};
|
const contextPatch: Record<string, unknown> = {};
|
||||||
if (typeof stepOutput === "string") contextPatch.output = stepOutput;
|
if (typeof stepOutput === "string") contextPatch.output = stepOutput;
|
||||||
if (typeof stepNotes === "string" && stepNotes) contextPatch.notes = stepNotes;
|
if (typeof stepNotes === "string" && stepNotes) contextPatch.notes = stepNotes;
|
||||||
|
/*
|
||||||
|
* FNXC:PlanReview 2026-06-29-02:05:
|
||||||
|
* Advisory graph steps still need a distinct non-pass value when their
|
||||||
|
* review output is malformed. Returning plain `failed` made optional-group
|
||||||
|
* recovery synthesize a Plan Review REVISE even when no reviewer requested
|
||||||
|
* one; `advisory_failure` preserves visibility without inventing feedback.
|
||||||
|
*/
|
||||||
|
const advisoryFailureValue = (outcome as { malformed?: boolean }).malformed ? "advisory_failure" : "failed";
|
||||||
return {
|
return {
|
||||||
outcome: outcome.success || !blocking ? "success" : "failure",
|
outcome: outcome.success || !blocking ? "success" : "failure",
|
||||||
value: verdict ?? (outcome.success ? "passed" : "failed"),
|
value: verdict ?? (outcome.success ? "passed" : advisoryFailureValue),
|
||||||
...(Object.keys(contextPatch).length > 0 ? { contextPatch } : {}),
|
...(Object.keys(contextPatch).length > 0 ? { contextPatch } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1867,6 +1867,9 @@ export class TriageProcessor {
|
|||||||
if (parsedSteps.length > 0) {
|
if (parsedSteps.length > 0) {
|
||||||
taskUpdates.steps = parsedSteps;
|
taskUpdates.steps = parsedSteps;
|
||||||
}
|
}
|
||||||
|
const shouldClearWorkflowRunStepInstances =
|
||||||
|
parsedSteps.length > 0
|
||||||
|
&& (options.isReplan === true || (task.steps?.length ?? 0) > 0);
|
||||||
|
|
||||||
const duplicateLineage = getTaskDuplicateLineage({
|
const duplicateLineage = getTaskDuplicateLineage({
|
||||||
id: task.id,
|
id: task.id,
|
||||||
@@ -2200,10 +2203,13 @@ export class TriageProcessor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.isReplan) {
|
if (shouldClearWorkflowRunStepInstances) {
|
||||||
/*
|
/*
|
||||||
FNXC:WorkflowReplan 2026-06-29-00:33:
|
FNXC:WorkflowReplan 2026-06-29-00:33:
|
||||||
AI spec revision replaces the task's step-source PROMPT.md, so graph foreach instance pins from the previous plan must be discarded before execution reparses steps. Otherwise rebuilt tasks can fail at parse with a stale pin-mismatch even though the new plan is valid.
|
AI spec revision replaces the task's step-source PROMPT.md, so graph foreach instance pins from the previous plan must be discarded before execution reparses steps. Otherwise rebuilt tasks can fail at parse with a stale pin-mismatch even though the new plan is valid.
|
||||||
|
|
||||||
|
FNXC:WorkflowReplan 2026-06-29-02:24:
|
||||||
|
User-triggered spec rebuilds can race an old paused graph run that writes step-instance rows after the route cleared them. Clear again when triage accepts a fresh parsed plan over an existing step projection, even if the task snapshot no longer has status `needs-replan`.
|
||||||
*/
|
*/
|
||||||
const maybeStore = this.store as unknown as {
|
const maybeStore = this.store as unknown as {
|
||||||
clearWorkflowRunStepInstances?: (taskId: string) => void;
|
clearWorkflowRunStepInstances?: (taskId: string) => void;
|
||||||
|
|||||||
@@ -557,7 +557,16 @@ export class WorkflowGraphExecutor {
|
|||||||
* the enabled one runs the body, the disabled one runs none and
|
* the enabled one runs the body, the disabled one runs none and
|
||||||
* still reaches the same downstream node.
|
* still reaches the same downstream node.
|
||||||
*/
|
*/
|
||||||
const enabled = task.enabledWorkflowSteps?.includes(node.id) ?? false;
|
/*
|
||||||
|
* FNXC:WorkflowOptionalSteps 2026-06-29-02:05:
|
||||||
|
* `enabledWorkflowSteps === undefined` means the task has no explicit
|
||||||
|
* optional-step selection, so default-on workflow nodes must run. An
|
||||||
|
* explicit empty array still means the operator disabled every optional
|
||||||
|
* step from Quick Add or task details.
|
||||||
|
*/
|
||||||
|
const enabled = Array.isArray(task.enabledWorkflowSteps)
|
||||||
|
? task.enabledWorkflowSteps.includes(node.id)
|
||||||
|
: node.config?.defaultOn === true;
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
// FNXC:WorkflowOptionalGroup 2026-06-21-16:30: record the group's own
|
// FNXC:WorkflowOptionalGroup 2026-06-21-16:30: record the group's own
|
||||||
// outcome on bypass too (mirrors the enabled path + every other node
|
// outcome on bypass too (mirrors the enabled path + every other node
|
||||||
@@ -632,6 +641,7 @@ export class WorkflowGraphExecutor {
|
|||||||
: undefined;
|
: undefined;
|
||||||
let stepStatus: WorkflowStepResult["status"];
|
let stepStatus: WorkflowStepResult["status"];
|
||||||
if (groupResult.outcome === "failure") stepStatus = "failed";
|
if (groupResult.outcome === "failure") stepStatus = "failed";
|
||||||
|
else if (groupResult.value === "advisory_failure") stepStatus = "advisory_failure";
|
||||||
else if (verdict === "REVISE") stepStatus = "advisory_failure";
|
else if (verdict === "REVISE") stepStatus = "advisory_failure";
|
||||||
else stepStatus = "passed";
|
else stepStatus = "passed";
|
||||||
const exitContextPatch = exitResult?.contextPatch;
|
const exitContextPatch = exitResult?.contextPatch;
|
||||||
@@ -676,10 +686,17 @@ export class WorkflowGraphExecutor {
|
|||||||
* seam with a synthesized REVISE verdict so the executor can route it back
|
* seam with a synthesized REVISE verdict so the executor can route it back
|
||||||
* to triage and then let approved replans continue through todo/execution.
|
* to triage and then let approved replans continue through todo/execution.
|
||||||
*/
|
*/
|
||||||
|
/*
|
||||||
|
* FNXC:PlanReview 2026-06-29-02:05:
|
||||||
|
* Plan Review should send a task back to triage only for an actual
|
||||||
|
* REVISE verdict or a hard step failure. A malformed advisory result is
|
||||||
|
* visible as `advisory_failure`, but it must not fabricate a plan-rewrite
|
||||||
|
* request after the reviewer already approved or failed to emit JSON.
|
||||||
|
*/
|
||||||
const shouldRequestPreMergeFix =
|
const shouldRequestPreMergeFix =
|
||||||
stepPhase === "pre-merge"
|
stepPhase === "pre-merge"
|
||||||
&& (stepStatus === "advisory_failure" || stepStatus === "failed")
|
&& (stepStatus === "advisory_failure" || stepStatus === "failed")
|
||||||
&& (verdict === "REVISE" || node.id === PLAN_REVIEW_GROUP_ID);
|
&& (verdict === "REVISE" || (node.id === PLAN_REVIEW_GROUP_ID && stepStatus === "failed"));
|
||||||
if (shouldRequestPreMergeFix) {
|
if (shouldRequestPreMergeFix) {
|
||||||
const feedback = stepOutput?.trim()
|
const feedback = stepOutput?.trim()
|
||||||
|| stepNotes?.trim()
|
|| stepNotes?.trim()
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Settings, TaskDetail, WorkflowIr, WorkflowIrArtifact, WorkflowIrNode, WorkflowWorkItem, WorkflowWorkItemState } from "@fusion/core";
|
import type { Settings, TaskDetail, WorkflowIr, WorkflowIrArtifact, WorkflowIrNode, WorkflowWorkItem, WorkflowWorkItemState } from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
BUILTIN_CODING_WORKFLOW_IR,
|
|
||||||
getBuiltinWorkflow,
|
getBuiltinWorkflow,
|
||||||
isBuiltinWorkflowId,
|
isBuiltinWorkflowId,
|
||||||
parseWorkflowIr,
|
parseWorkflowIr,
|
||||||
@@ -319,7 +318,14 @@ interface WorkflowRuntimeTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function builtinCodingTarget(): WorkflowRuntimeTarget {
|
function builtinCodingTarget(): WorkflowRuntimeTarget {
|
||||||
return { workflowId: "builtin:coding", ir: BUILTIN_CODING_WORKFLOW_IR };
|
/*
|
||||||
|
* FNXC:WorkflowBuiltins 2026-06-29-02:18:
|
||||||
|
* Runtime defaulting must follow the built-in catalog entry for `builtin:coding`; importing the legacy coding IR here would bypass the renamed default workflow and strand unselected tasks on the old monolithic graph.
|
||||||
|
*/
|
||||||
|
const builtin = getBuiltinWorkflow("builtin:coding");
|
||||||
|
if (!builtin) throw new Error("workflow-missing: builtin:coding");
|
||||||
|
const ir = typeof builtin.ir === "string" ? parseWorkflowIr(builtin.ir) : builtin.ir;
|
||||||
|
return { workflowId: "builtin:coding", ir };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWorkflowRuntimeSettings(
|
function buildWorkflowRuntimeSettings(
|
||||||
|
|||||||
Reference in New Issue
Block a user