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:
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user