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

@@ -6060,6 +6060,259 @@ describe("Workflow Steps Execution", () => {
// Task should move to in-review
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
});
// ── Workflow Step Phase Filtering ────────────────────────────────────
it("skips post-merge workflow steps during executor pre-merge execution", async () => {
const store = createMockStore();
store.getTask.mockResolvedValue({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [{ name: "Preflight", status: "pending" }],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001", "WS-002"],
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
store.getWorkflowStep.mockImplementation(async (id: string) => {
if (id === "WS-001") {
return {
id: "WS-001",
name: "Pre-merge Check",
description: "Before merge",
prompt: "Run pre-merge checks",
phase: "pre-merge",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
if (id === "WS-002") {
return {
id: "WS-002",
name: "Post-merge Notify",
description: "After merge",
prompt: "Send notifications",
phase: "post-merge",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
return undefined;
});
// Main agent calls task_done, then a workflow step agent for pre-merge only
let callIdx = 0;
mockedCreateHaiAgent.mockImplementation((async (opts: any) => {
callIdx++;
if (callIdx === 1) {
const customTools = opts.customTools || [];
const session = {
prompt: vi.fn().mockImplementation(async () => {
const taskDoneTool = customTools.find((t: any) => t.name === "task_done");
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
}),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
state: {},
};
return { session };
} else {
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
state: {},
},
};
}
}) as any);
const executor = new TaskExecutor(store, "/tmp/test", {});
await executor.execute({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [{ name: "Preflight", status: "pending" }],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001", "WS-002"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// createKbAgent called twice: main agent + 1 pre-merge step (post-merge skipped)
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(2);
// Verify the workflow step results only contain pre-merge
const updateCalls = store.updateTask.mock.calls;
const resultsCall = updateCalls.find((c: any) =>
c[1]?.workflowStepResults?.length > 0
);
expect(resultsCall).toBeDefined();
const results = resultsCall![1].workflowStepResults;
expect(results).toHaveLength(1);
expect(results[0].workflowStepId).toBe("WS-001");
expect(results[0].phase).toBe("pre-merge");
});
it("normalizes legacy workflow steps without phase as pre-merge", async () => {
const store = createMockStore();
store.getTask.mockResolvedValue({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [{ name: "Preflight", status: "pending" }],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// Legacy step without phase field
store.getWorkflowStep.mockResolvedValue({
id: "WS-001",
name: "Legacy Check",
description: "No phase field",
prompt: "Run checks",
// phase is undefined — should be treated as pre-merge
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
let callIdx = 0;
mockedCreateHaiAgent.mockImplementation((async (opts: any) => {
callIdx++;
if (callIdx === 1) {
const customTools = opts.customTools || [];
const session = {
prompt: vi.fn().mockImplementation(async () => {
const taskDoneTool = customTools.find((t: any) => t.name === "task_done");
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
}),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
state: {},
};
return { session };
} else {
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
state: {},
},
};
}
}) as any);
const executor = new TaskExecutor(store, "/tmp/test", {});
await executor.execute({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [{ name: "Preflight", status: "pending" }],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// Legacy step should have been executed (treated as pre-merge)
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(2);
// Verify result has phase: "pre-merge"
const updateCalls = store.updateTask.mock.calls;
const resultsCall = updateCalls.find((c: any) =>
c[1]?.workflowStepResults?.some((r: any) => r.workflowStepId === "WS-001")
);
expect(resultsCall).toBeDefined();
const results = resultsCall![1].workflowStepResults;
expect(results[0].phase).toBe("pre-merge");
});
it("only runs post-merge steps when all are post-merge (skips all in executor)", async () => {
const store = createMockStore();
store.getTask.mockResolvedValue({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [{ name: "Preflight", status: "pending" }],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
store.getWorkflowStep.mockResolvedValue({
id: "WS-001",
name: "Post-merge Notify",
description: "After merge",
prompt: "Send notifications",
phase: "post-merge",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
createAgentWithTaskDone();
const executor = new TaskExecutor(store, "/tmp/test", {});
await executor.execute({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [{ name: "Preflight", status: "pending" }],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// Only main agent called (no workflow step agent since all are post-merge)
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
// Task should still move to in-review
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
});
});
describe("Real-time steering injection", () => {

View File

@@ -1450,10 +1450,11 @@ export class TaskExecutor {
for (const wsId of workflowStepIds) {
const ws = await this.store.getWorkflowStep(wsId);
if (!ws) {
await this.store.logEntry(task.id, `Workflow step ${wsId} not found — skipping`);
await this.store.logEntry(task.id, `[pre-merge] Workflow step ${wsId} not found — skipping`);
results.push({
workflowStepId: wsId,
workflowStepName: "Unknown",
phase: "pre-merge",
status: "skipped",
output: "Workflow step definition not found",
});
@@ -1461,15 +1462,22 @@ export class TaskExecutor {
continue;
}
// Normalize legacy steps: undefined phase → "pre-merge"
const stepPhase = ws.phase || "pre-merge";
// Skip post-merge steps — those run in the merger after merge
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 this.store.logEntry(task.id, `Workflow step '${ws.name}' has no prompt — skipping`);
await this.store.logEntry(task.id, `[pre-merge] Workflow step '${ws.name}' has no prompt — skipping`);
results.push({
workflowStepId: ws.id,
workflowStepName: ws.name,
phase: stepPhase,
status: "skipped",
output: "No prompt configured for this workflow step",
});
@@ -1478,10 +1486,11 @@ export class TaskExecutor {
}
if (stepMode === "script" && !ws.scriptName?.trim()) {
await this.store.logEntry(task.id, `Workflow step '${ws.name}' has no scriptName — skipping`);
await this.store.logEntry(task.id, `[pre-merge] Workflow step '${ws.name}' has no scriptName — skipping`);
results.push({
workflowStepId: ws.id,
workflowStepName: ws.name,
phase: stepPhase,
status: "skipped",
output: "No scriptName configured for this workflow step",
});
@@ -1489,8 +1498,8 @@ export class TaskExecutor {
continue;
}
await this.store.logEntry(task.id, `Starting workflow step: ${ws.name} (${stepMode} mode)`);
executorLog.log(`${task.id} — running workflow step: ${ws.name} (${stepMode} mode)`);
await this.store.logEntry(task.id, `[pre-merge] Starting workflow step: ${ws.name} (${stepMode} mode)`);
executorLog.log(`${task.id} [pre-merge] running workflow step: ${ws.name} (${stepMode} mode)`);
const startedAt = new Date().toISOString();
@@ -1501,11 +1510,12 @@ export class TaskExecutor {
const completedAt = new Date().toISOString();
if (result.success) {
await this.store.logEntry(task.id, `Workflow step completed: ${ws.name}`);
executorLog.log(`${task.id} — workflow step passed: ${ws.name}`);
await this.store.logEntry(task.id, `[pre-merge] Workflow step completed: ${ws.name}`);
executorLog.log(`${task.id} [pre-merge] workflow step passed: ${ws.name}`);
results.push({
workflowStepId: ws.id,
workflowStepName: ws.name,
phase: stepPhase,
status: "passed",
output: result.output,
startedAt,
@@ -1515,13 +1525,14 @@ export class TaskExecutor {
} else {
await this.store.logEntry(
task.id,
`Workflow step failed: ${ws.name}`,
`[pre-merge] Workflow step failed: ${ws.name}`,
result.error || "Unknown error",
);
executorLog.error(`${task.id} — workflow step failed: ${ws.name}${result.error}`);
executorLog.error(`${task.id} [pre-merge] workflow step failed: ${ws.name}${result.error}`);
results.push({
workflowStepId: ws.id,
workflowStepName: ws.name,
phase: stepPhase,
status: "failed",
output: result.error || "Workflow step failed",
startedAt,
@@ -1534,13 +1545,14 @@ export class TaskExecutor {
const completedAt = new Date().toISOString();
await this.store.logEntry(
task.id,
`Workflow step failed: ${ws.name}`,
`[pre-merge] Workflow step failed: ${ws.name}`,
err.message || "Unknown error",
);
executorLog.error(`${task.id} — workflow step error: ${ws.name}${err.message}`);
executorLog.error(`${task.id} [pre-merge] workflow step error: ${ws.name}${err.message}`);
results.push({
workflowStepId: ws.id,
workflowStepName: ws.name,
phase: stepPhase,
status: "failed",
output: err.message || "Workflow step error",
startedAt,

View File

@@ -2102,3 +2102,286 @@ describe("validateDiffScope", () => {
expect(result.warnings).toHaveLength(0);
});
});
describe("aiMergeTask — post-merge workflow steps", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
setupHappyPathExecSync();
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
state: {},
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
},
} as any);
});
it("runs post-merge workflow steps after successful merge", async () => {
const store = createMockStore();
// Add getWorkflowStep to mock
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Post-merge Notify",
description: "Send notifications after merge",
prompt: "Check the merged code and confirm all is well.",
phase: "post-merge",
mode: "prompt",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// Override getTask to include enabledWorkflowSteps
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
// getWorkflowStep should have been called for the post-merge step
expect((store as any).getWorkflowStep).toHaveBeenCalledWith("WS-001");
// Task should still move to done even though post-merge step ran
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("does not run pre-merge workflow steps in merger", async () => {
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Pre-merge Check",
description: "Check before merge",
prompt: "Run pre-merge checks.",
phase: "pre-merge",
mode: "prompt",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
await aiMergeTask(store, "/tmp/root", "FN-050");
// getWorkflowStep may be called but pre-merge steps should not trigger agent creation
// beyond the merge agent itself. We verify createKbAgent was called only once (merge agent)
// since pre-merge steps are skipped in the merger
const mergeAgentCalls = mockedCreateHaiAgent.mock.calls.filter(
(c: any) => c[0]?.systemPrompt?.includes("You are a merge agent")
);
const postMergeCalls = mockedCreateHaiAgent.mock.calls.filter(
(c: any) => c[0]?.systemPrompt?.includes("post-merge")
);
// No post-merge agent should be created for a pre-merge step
expect(postMergeCalls).toHaveLength(0);
});
it("appends post-merge results to existing pre-merge results", async () => {
const existingPreMergeResults = [{
workflowStepId: "WS-001",
workflowStepName: "Pre-merge Check",
phase: "pre-merge",
status: "passed",
output: "All good",
}];
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-002",
name: "Post-merge Verify",
description: "Verify after merge",
prompt: "Check merged state.",
phase: "post-merge",
mode: "prompt",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001", "WS-002"],
workflowStepResults: existingPreMergeResults,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
await aiMergeTask(store, "/tmp/root", "FN-050");
// Should have called updateTask with workflow results containing both pre and post
const updateCalls = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls;
const resultsCall = updateCalls.find((c: any) =>
Array.isArray(c[1]?.workflowStepResults) && c[1].workflowStepResults.length > 1
);
if (resultsCall) {
const results = resultsCall[1].workflowStepResults;
// Should contain both pre-merge and post-merge results
expect(results.some((r: any) => r.phase === "pre-merge")).toBe(true);
expect(results.some((r: any) => r.phase === "post-merge")).toBe(true);
}
});
it("moves task to done even when post-merge step fails", async () => {
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Post-merge Fail",
description: "Will fail",
prompt: "Fail this check.",
phase: "post-merge",
mode: "prompt",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
// Make the post-merge agent throw
mockedCreateHaiAgent.mockImplementation((async (opts: any) => {
if (opts.systemPrompt?.includes("post-merge")) {
return {
session: {
prompt: vi.fn().mockRejectedValue(new Error("Post-merge agent failed")),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
state: {},
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
},
};
}
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
state: {},
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
},
};
}) as any);
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
// Merge should succeed regardless of post-merge step failure
expect(result.merged).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("runs script-mode post-merge steps", async () => {
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Post-merge Build",
description: "Verify build passes",
phase: "post-merge",
mode: "script",
scriptName: "build",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
// Override settings to include scripts
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
scripts: { build: "pnpm build" },
});
// Mock execSync to handle the script execution
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr === "pnpm build") return "Build successful" as any;
return Buffer.from("");
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
});

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,