feat(FN-837): add workflow step phase support with pre-merge and post-merge execution

- Add phase field to workflow step definitions (pre-merge vs post-merge) with persistence and API
- Execute pre-merge steps in executor before merge; post-merge steps in merger after successful merge
- Pre-merge failures block merge and keep task in in-review; post-merge failures are logged only
- Expose phase controls and phase-aware results in the dashboard UI
- Add changeset for the published @gsxdsm/fusion package
This commit is contained in:
gsxdsm
2026-04-04 17:36:18 -07:00
parent c16c8b9965
commit 9a8d68fce8
22 changed files with 1493 additions and 83 deletions

View File

@@ -1,7 +1,7 @@
import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { getTaskMergeBlocker, type TaskStore, type MergeResult } from "@fusion/core";
import { createKbAgent, promptWithFallback } from "./pi.js";
import { getTaskMergeBlocker, type TaskStore, type MergeResult, type WorkflowStep, type WorkflowStepResult, type Settings } from "@fusion/core";
import { createKbAgent, describeModel, promptWithFallback } from "./pi.js";
import type { WorktreePool } from "./worktree-pool.js";
import { AgentLogger } from "./agent-logger.js";
import { mergerLog } from "./logger.js";
@@ -823,7 +823,15 @@ export async function aiMergeTask(
}
}
// 8. Move task to done
// 8. Run post-merge workflow steps (failures logged but do not block completion)
try {
await runPostMergeWorkflowSteps(store, taskId, rootDir, settings);
} catch (err: any) {
mergerLog.error(`${taskId}: post-merge workflow steps error: ${err.message}`);
// Non-fatal — task still moves to done
}
// 9. Move task to done
await completeTask(store, taskId, result);
return result;
}
@@ -1369,6 +1377,234 @@ function buildMergePrompt(params: MergePromptParams): string {
return parts.join("\n");
}
/**
* Run post-merge workflow steps for a task after the merge succeeds.
* These steps run in the root directory (after merge, worktree may be cleaned up).
* Failures are logged but do NOT block task completion — the merge is already committed.
*/
async function runPostMergeWorkflowSteps(
store: TaskStore,
taskId: string,
rootDir: string,
settings: Settings,
): Promise<void> {
const task = await store.getTask(taskId);
if (!task.enabledWorkflowSteps?.length) return;
// Get existing pre-merge results to append to
const existingResults: WorkflowStepResult[] = task.workflowStepResults || [];
for (const wsId of task.enabledWorkflowSteps) {
const ws = await store.getWorkflowStep(wsId);
if (!ws) {
mergerLog.log(`${taskId}: [post-merge] workflow step ${wsId} not found — skipping`);
continue;
}
// Normalize legacy steps: undefined phase → "pre-merge"
const stepPhase = ws.phase || "pre-merge";
// Only run post-merge steps here
if (stepPhase !== "post-merge") continue;
// Normalize legacy steps without mode to prompt-mode
const stepMode: "prompt" | "script" = ws.mode || "prompt";
// Skip validation per mode
if (stepMode === "prompt" && !ws.prompt?.trim()) {
await store.logEntry(taskId, `[post-merge] Workflow step '${ws.name}' has no prompt — skipping`);
existingResults.push({
workflowStepId: ws.id,
workflowStepName: ws.name,
phase: "post-merge",
status: "skipped",
output: "No prompt configured for this workflow step",
});
await store.updateTask(taskId, { workflowStepResults: existingResults });
continue;
}
if (stepMode === "script" && !ws.scriptName?.trim()) {
await store.logEntry(taskId, `[post-merge] Workflow step '${ws.name}' has no scriptName — skipping`);
existingResults.push({
workflowStepId: ws.id,
workflowStepName: ws.name,
phase: "post-merge",
status: "skipped",
output: "No scriptName configured for this workflow step",
});
await store.updateTask(taskId, { workflowStepResults: existingResults });
continue;
}
await store.logEntry(taskId, `[post-merge] Starting workflow step: ${ws.name} (${stepMode} mode)`);
mergerLog.log(`${taskId}: [post-merge] running workflow step: ${ws.name} (${stepMode} mode)`);
const startedAt = new Date().toISOString();
try {
const result = stepMode === "script"
? await executePostMergeScriptStep(store, taskId, ws, rootDir, settings)
: await executePostMergePromptStep(store, taskId, ws, rootDir, settings);
const completedAt = new Date().toISOString();
if (result.success) {
await store.logEntry(taskId, `[post-merge] Workflow step completed: ${ws.name}`);
mergerLog.log(`${taskId}: [post-merge] workflow step passed: ${ws.name}`);
existingResults.push({
workflowStepId: ws.id,
workflowStepName: ws.name,
phase: "post-merge",
status: "passed",
output: result.output,
startedAt,
completedAt,
});
} else {
// Post-merge failures are logged but do NOT block task completion
await store.logEntry(taskId, `[post-merge] Workflow step failed: ${ws.name}`, result.error || "Unknown error");
mergerLog.error(`${taskId}: [post-merge] workflow step failed: ${ws.name}${result.error}`);
existingResults.push({
workflowStepId: ws.id,
workflowStepName: ws.name,
phase: "post-merge",
status: "failed",
output: result.error || "Workflow step failed",
startedAt,
completedAt,
});
}
} catch (err: any) {
const completedAt = new Date().toISOString();
await store.logEntry(taskId, `[post-merge] Workflow step error: ${ws.name}`, err.message || "Unknown error");
mergerLog.error(`${taskId}: [post-merge] workflow step error: ${ws.name}${err.message}`);
existingResults.push({
workflowStepId: ws.id,
workflowStepName: ws.name,
phase: "post-merge",
status: "failed",
output: err.message || "Workflow step error",
startedAt,
completedAt,
});
}
// Save results after each step (partial results preserved on crash)
await store.updateTask(taskId, { workflowStepResults: existingResults });
}
}
/** Execute a script-mode post-merge workflow step */
async function executePostMergeScriptStep(
store: TaskStore,
taskId: string,
workflowStep: WorkflowStep,
rootDir: string,
settings: Settings,
): Promise<{ success: boolean; output?: string; error?: string }> {
const scriptName = workflowStep.scriptName!.trim();
const scripts = settings.scripts || {};
const scriptCommand = scripts[scriptName];
if (!scriptCommand) {
return { success: false, error: `Script '${scriptName}' not found in project settings` };
}
try {
const output = execSync(scriptCommand, {
cwd: rootDir,
encoding: "utf-8",
timeout: 120_000,
stdio: ["pipe", "pipe", "pipe"],
});
return { success: true, output: output.trim() };
} catch (err: any) {
const stderr = err.stderr?.toString()?.trim() || "";
const stdout = err.stdout?.toString()?.trim() || "";
const exitCode = err.status;
const parts: string[] = [];
if (exitCode !== undefined) parts.push(`Exit code: ${exitCode}`);
if (stdout) parts.push(`stdout: ${stdout}`);
if (stderr) parts.push(`stderr: ${stderr}`);
if (!parts.length) parts.push(err.message || "Unknown error");
return { success: false, error: parts.join("\n") };
}
}
/** Execute a prompt-mode post-merge workflow step using AI agent */
async function executePostMergePromptStep(
store: TaskStore,
taskId: string,
workflowStep: WorkflowStep,
rootDir: string,
settings: Settings,
): Promise<{ success: boolean; output?: string; error?: string }> {
const systemPrompt = `You are a post-merge workflow step agent executing: ${workflowStep.name}
Task Context:
- Task ID: ${taskId}
- The merge has already been completed successfully.
- You are running in the project's root directory with the merged code.
Your Instructions:
${workflowStep.prompt}
You have access to the file system to review the merged changes.
When your review is complete and everything looks good, simply state your findings.
If issues are found that need attention, describe them clearly.`;
const agentLogger = new AgentLogger({
store,
taskId,
agent: "merger",
});
try {
const stepProvider = workflowStep.modelProvider || settings.defaultProvider;
const stepModelId = workflowStep.modelId || settings.defaultModelId;
const useOverride = !!(workflowStep.modelProvider && workflowStep.modelId);
const { session } = await createKbAgent({
cwd: rootDir,
systemPrompt,
tools: "readonly",
defaultProvider: stepProvider,
defaultModelId: stepModelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
});
mergerLog.log(`${taskId}: [post-merge] workflow step '${workflowStep.name}' using model ${describeModel(session)}${useOverride ? " (workflow step override)" : ""}`);
await store.logEntry(taskId, `[post-merge] Workflow step '${workflowStep.name}' using model: ${describeModel(session)}${useOverride ? " (workflow step override)" : ""}`);
let output = "";
session.subscribe((event) => {
if (event.type === "message_update") {
const msgEvent = event.assistantMessageEvent;
if (msgEvent.type === "text_delta") {
output += msgEvent.delta;
}
}
});
await promptWithFallback(
session,
`Execute the post-merge workflow step "${workflowStep.name}" for task ${taskId}.\n\n` +
`Review the merged code in the project root and evaluate it against your instructions.`,
);
checkSessionError(session);
session.dispose();
await agentLogger.flush();
return { success: true, output };
} catch (err: any) {
await agentLogger.flush();
return { success: false, error: err.message };
}
}
async function completeTask(
store: TaskStore,
taskId: string,