feat(FN-670): add mandatory build verification before merge
- Add report_build_failure tool to merger agent for explicit build failure signaling - Run buildCommand before finalizing merge, abort if build fails with git reset --merge - Treat build failures as fatal errors (no retry) and keep task in in-review - Add build verification instruction to executor system prompt - Update executor to use steeringComments field for mid-execution guidance
This commit is contained in:
14
.changeset/build-verification-before-merge.md
Normal file
14
.changeset/build-verification-before-merge.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
"@gsxdsm/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add mandatory build verification before merging to main
|
||||||
|
|
||||||
|
The merger agent now verifies that the build passes before committing a squash merge:
|
||||||
|
|
||||||
|
- When `buildCommand` is configured in settings, the merger runs it before finalizing the merge
|
||||||
|
- If the build fails, the merge is aborted with `git reset --merge`, the failure is logged, and the task remains in "in-review" for human investigation
|
||||||
|
- A new `report_build_failure` tool allows the agent to signal build failures explicitly
|
||||||
|
- The executor prompt now reminds agents to verify the build passes before calling `task_done()`
|
||||||
|
|
||||||
|
This ensures broken code never reaches the main branch.
|
||||||
@@ -4904,6 +4904,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-cell-title {
|
.list-cell-title {
|
||||||
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -5163,6 +5164,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-cell-title {
|
.list-cell-title {
|
||||||
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -24,7 +24,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fusion/core": "workspace:*",
|
"@fusion/core": "workspace:*",
|
||||||
"@mariozechner/pi-ai": "^0.62.0",
|
"@mariozechner/pi-ai": "^0.62.0",
|
||||||
"@mariozechner/pi-coding-agent": "^0.62.0"
|
"@mariozechner/pi-coding-agent": "^0.62.0",
|
||||||
|
"@sinclair/typebox": "^0.34.48"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||||
|
|||||||
@@ -1617,7 +1617,7 @@ describe("buildExecutionPrompt", () => {
|
|||||||
|
|
||||||
it("includes Comments section when comments has entries", () => {
|
it("includes Comments section when comments has entries", () => {
|
||||||
const task = createMockTaskDetail({
|
const task = createMockTaskDetail({
|
||||||
comments: [
|
steeringComments: [
|
||||||
{
|
{
|
||||||
id: "1",
|
id: "1",
|
||||||
text: "Please handle the edge case",
|
text: "Please handle the edge case",
|
||||||
@@ -1637,7 +1637,7 @@ describe("buildExecutionPrompt", () => {
|
|||||||
it("formats multiple comments correctly", () => {
|
it("formats multiple comments correctly", () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const task = createMockTaskDetail({
|
const task = createMockTaskDetail({
|
||||||
comments: [
|
steeringComments: [
|
||||||
{
|
{
|
||||||
id: "1",
|
id: "1",
|
||||||
text: "First comment",
|
text: "First comment",
|
||||||
@@ -1660,8 +1660,8 @@ describe("buildExecutionPrompt", () => {
|
|||||||
expect(result).toContain("> Second comment");
|
expect(result).toContain("> Second comment");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits Comments section when comments is empty", () => {
|
it("omits Comments section when steeringComments is empty", () => {
|
||||||
const task = createMockTaskDetail({ comments: [] });
|
const task = createMockTaskDetail({ steeringComments: [] });
|
||||||
const result = buildExecutionPrompt(task);
|
const result = buildExecutionPrompt(task);
|
||||||
|
|
||||||
expect(result).not.toContain("## Steering Comments");
|
expect(result).not.toContain("## Steering Comments");
|
||||||
|
|||||||
@@ -1870,7 +1870,9 @@ Use \`task_update\` to report progress on every step transition.
|
|||||||
Use \`task_log\` for important actions and decisions.
|
Use \`task_log\` for important actions and decisions.
|
||||||
Use \`task_create\` if you find out-of-scope work that needs doing.
|
Use \`task_create\` if you find out-of-scope work that needs doing.
|
||||||
Commit at step boundaries: \`git commit -m "feat(${task.id}): complete Step N — description"\`
|
Commit at step boundaries: \`git commit -m "feat(${task.id}): complete Step N — description"\`
|
||||||
When all steps are complete: call \`task_done()\``;
|
When all steps are complete: call \`task_done()\`
|
||||||
|
|
||||||
|
Verify build passes using the configured build command before calling \`task_done()\`.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1665,3 +1665,232 @@ describe("isTrivialWhitespaceConflict", () => {
|
|||||||
expect(cmdStr).toContain(':3:"src/utils.ts"');
|
expect(cmdStr).toContain(':3:"src/utils.ts"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Build Verification Tests ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("aiMergeTask — build verification", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockedExistsSync.mockReturnValue(true);
|
||||||
|
// Default happy path exec mock
|
||||||
|
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("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") && !cmdStr.includes("--quiet")) return "" as any;
|
||||||
|
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||||
|
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||||
|
if (cmdStr.includes("reset --merge")) return Buffer.from("");
|
||||||
|
return Buffer.from("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("system prompt contains build verification section", async () => {
|
||||||
|
let capturedSystemPrompt: string | undefined;
|
||||||
|
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
|
||||||
|
capturedSystemPrompt = opts.systemPrompt;
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = createMockStore();
|
||||||
|
await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||||
|
|
||||||
|
expect(capturedSystemPrompt).toContain("## Build verification");
|
||||||
|
expect(capturedSystemPrompt).toContain("If a build command is configured for this project, you MUST run it");
|
||||||
|
expect(capturedSystemPrompt).toContain("BUILD FAILED:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes build command in merge prompt when configured", async () => {
|
||||||
|
let capturedArgs: any;
|
||||||
|
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
|
||||||
|
capturedArgs = opts;
|
||||||
|
// Simulate agent committing by returning session that results in clean state
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn().mockImplementation(async () => {
|
||||||
|
// Simulate commit happening by making staged check return "0" (clean)
|
||||||
|
mockedExecSync.mockImplementation((cmd: any) => {
|
||||||
|
const cmdStr = String(cmd);
|
||||||
|
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
|
||||||
|
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||||
|
if (cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||||
|
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||||
|
// After commit, diff shows clean
|
||||||
|
if (cmdStr.includes("diff --cached --quiet")) return "0" as any;
|
||||||
|
if (cmdStr.includes("branch -d")) return Buffer.from("");
|
||||||
|
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||||
|
return Buffer.from("");
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = createMockStore(
|
||||||
|
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||||
|
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||||
|
);
|
||||||
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
buildCommand: "pnpm build",
|
||||||
|
});
|
||||||
|
|
||||||
|
await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||||
|
|
||||||
|
// Verify custom tool was passed
|
||||||
|
expect(capturedArgs.customTools).toBeDefined();
|
||||||
|
expect(capturedArgs.customTools.some((t: any) => t.name === "report_build_failure")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merge succeeds when build passes (agent reports success)", async () => {
|
||||||
|
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn().mockImplementation(async () => {
|
||||||
|
// Simulate commit happening by making staged check return "0" (clean)
|
||||||
|
mockedExecSync.mockImplementation((cmd: any) => {
|
||||||
|
const cmdStr = String(cmd);
|
||||||
|
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
|
||||||
|
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||||
|
if (cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||||
|
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||||
|
// After commit, diff shows clean
|
||||||
|
if (cmdStr.includes("diff --cached --quiet")) return "0" as any;
|
||||||
|
if (cmdStr.includes("branch -d")) return Buffer.from("");
|
||||||
|
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||||
|
return Buffer.from("");
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = createMockStore(
|
||||||
|
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||||
|
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||||
|
);
|
||||||
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
buildCommand: "pnpm build",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||||
|
|
||||||
|
expect(result.merged).toBe(true);
|
||||||
|
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merge aborts when build fails via report_build_failure tool", async () => {
|
||||||
|
// Mock agent that calls the report_build_failure tool execute method
|
||||||
|
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
|
||||||
|
const reportTool = opts.customTools?.find((t: any) => t.name === "report_build_failure");
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn().mockImplementation(async () => {
|
||||||
|
// Simulate the agent calling the tool when session.prompt() is called
|
||||||
|
if (reportTool) {
|
||||||
|
await reportTool.execute("tool-call-123", { message: "Type error in src/utils.ts" });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetCalls: string[] = [];
|
||||||
|
mockedExecSync.mockImplementation((cmd: any) => {
|
||||||
|
const cmdStr = String(cmd);
|
||||||
|
if (cmdStr.includes("reset --merge")) {
|
||||||
|
resetCalls.push(cmdStr);
|
||||||
|
return Buffer.from("");
|
||||||
|
}
|
||||||
|
// Default happy path for other commands
|
||||||
|
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
|
||||||
|
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||||
|
if (cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||||
|
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||||
|
// Staged changes present (agent didn't commit due to build failure)
|
||||||
|
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
|
||||||
|
if (cmdStr.includes("branch -d")) return Buffer.from("");
|
||||||
|
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||||
|
return Buffer.from("");
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = createMockStore(
|
||||||
|
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||||
|
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||||
|
);
|
||||||
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
buildCommand: "pnpm build",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
|
||||||
|
"Build verification failed for FN-050: Type error in src/utils.ts",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify git reset --merge was called
|
||||||
|
expect(resetCalls.length).toBeGreaterThan(0);
|
||||||
|
// Verify task was NOT moved to done
|
||||||
|
expect(store.moveTask).not.toHaveBeenCalled();
|
||||||
|
// Verify log entry was made
|
||||||
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
|
"FN-050",
|
||||||
|
"Build verification failed during merge",
|
||||||
|
"Type error in src/utils.ts",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merge proceeds normally when no build command is configured", async () => {
|
||||||
|
mockedCreateHaiAgent.mockResolvedValue({
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const store = createMockStore(
|
||||||
|
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||||
|
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||||
|
);
|
||||||
|
// buildCommand is undefined by default in DEFAULT_SETTINGS
|
||||||
|
|
||||||
|
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||||
|
|
||||||
|
expect(result.merged).toBe(true);
|
||||||
|
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merge proceeds when buildCommand is empty string (treated as undefined)", async () => {
|
||||||
|
mockedCreateHaiAgent.mockResolvedValue({
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const store = createMockStore(
|
||||||
|
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||||
|
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||||
|
);
|
||||||
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
buildCommand: " ", // whitespace-only, should be treated as undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||||
|
|
||||||
|
expect(result.merged).toBe(true);
|
||||||
|
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type { WorktreePool } from "./worktree-pool.js";
|
|||||||
import { AgentLogger } from "./agent-logger.js";
|
import { AgentLogger } from "./agent-logger.js";
|
||||||
import { mergerLog } from "./logger.js";
|
import { mergerLog } from "./logger.js";
|
||||||
import { isUsageLimitError, checkSessionError, type UsageLimitPauser } from "./usage-limit-detector.js";
|
import { isUsageLimitError, checkSessionError, type UsageLimitPauser } from "./usage-limit-detector.js";
|
||||||
|
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
/** Conflict type classification for merge conflict resolution */
|
/** Conflict type classification for merge conflict resolution */
|
||||||
export type ConflictType =
|
export type ConflictType =
|
||||||
@@ -471,7 +473,19 @@ Look at the branch commits and diff to understand what was done, then run:
|
|||||||
${commitFormat}
|
${commitFormat}
|
||||||
|
|
||||||
Do NOT use generic messages like "merge branch" or "resolve conflicts".
|
Do NOT use generic messages like "merge branch" or "resolve conflicts".
|
||||||
Base the message on the ACTUAL work done in the branch commits.`;
|
Base the message on the ACTUAL work done in the branch commits.
|
||||||
|
|
||||||
|
## Build verification
|
||||||
|
|
||||||
|
If a build command is configured for this project, you MUST run it before committing.
|
||||||
|
|
||||||
|
1. Run the build command (shown in the prompt context below)
|
||||||
|
2. If the build succeeds (exit code 0), proceed with the commit
|
||||||
|
3. If the build fails (non-zero exit code), DO NOT commit. Instead:
|
||||||
|
- Respond with "BUILD FAILED: <error details>"
|
||||||
|
- Stop and do not proceed further
|
||||||
|
|
||||||
|
The merge will only be completed if the build passes or no build command is configured.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -596,6 +610,9 @@ export async function aiMergeTask(
|
|||||||
const mergeAttempt = async (attemptNum: 1 | 2 | 3): Promise<boolean> => {
|
const mergeAttempt = async (attemptNum: 1 | 2 | 3): Promise<boolean> => {
|
||||||
mergerLog.log(`${taskId}: merge attempt ${attemptNum}/3...`);
|
mergerLog.log(`${taskId}: merge attempt ${attemptNum}/3...`);
|
||||||
|
|
||||||
|
// Normalize buildCommand: treat empty string as undefined
|
||||||
|
const buildCommand = settings.buildCommand?.trim() || undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try the merge with appropriate strategy for this attempt
|
// Try the merge with appropriate strategy for this attempt
|
||||||
const success = await executeMergeAttempt({
|
const success = await executeMergeAttempt({
|
||||||
@@ -610,6 +627,7 @@ export async function aiMergeTask(
|
|||||||
attemptNum,
|
attemptNum,
|
||||||
options,
|
options,
|
||||||
result,
|
result,
|
||||||
|
buildCommand,
|
||||||
}, aiTracker);
|
}, aiTracker);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
@@ -630,6 +648,11 @@ export async function aiMergeTask(
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
// Check if it's a build verification failure - don't retry, propagate immediately
|
||||||
|
if (error.message?.includes("Build verification failed")) {
|
||||||
|
throw error; // Fatal - don't retry build failures
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up on error before potentially rethrowing or retrying
|
// Clean up on error before potentially rethrowing or retrying
|
||||||
if (attemptNum < 3 && smartConflictResolution) {
|
if (attemptNum < 3 && smartConflictResolution) {
|
||||||
mergerLog.log(`${taskId}: attempt ${attemptNum} error, cleaning up for retry...`);
|
mergerLog.log(`${taskId}: attempt ${attemptNum} error, cleaning up for retry...`);
|
||||||
@@ -751,6 +774,7 @@ interface MergeAttemptParams {
|
|||||||
attemptNum: 1 | 2 | 3;
|
attemptNum: 1 | 2 | 3;
|
||||||
options: MergerOptions;
|
options: MergerOptions;
|
||||||
result: MergeResult;
|
result: MergeResult;
|
||||||
|
buildCommand?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mutable flag to track AI agent invocation */
|
/** Mutable flag to track AI agent invocation */
|
||||||
@@ -779,6 +803,7 @@ async function executeMergeAttempt(
|
|||||||
attemptNum,
|
attemptNum,
|
||||||
options,
|
options,
|
||||||
result,
|
result,
|
||||||
|
buildCommand,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
// Attempt 3: Use -X theirs strategy
|
// Attempt 3: Use -X theirs strategy
|
||||||
@@ -920,7 +945,7 @@ async function executeMergeAttempt(
|
|||||||
// - Complex conflicts remain after attempt 2 auto-resolution - AI resolves them
|
// - Complex conflicts remain after attempt 2 auto-resolution - AI resolves them
|
||||||
// Spawn AI agent
|
// Spawn AI agent
|
||||||
aiTracker.aiWasInvoked = true; // Track that AI was invoked
|
aiTracker.aiWasInvoked = true; // Track that AI was invoked
|
||||||
return await runAiAgentForCommit({
|
const agentResult = await runAiAgentForCommit({
|
||||||
store,
|
store,
|
||||||
rootDir,
|
rootDir,
|
||||||
taskId,
|
taskId,
|
||||||
@@ -931,8 +956,32 @@ async function executeMergeAttempt(
|
|||||||
hasConflicts,
|
hasConflicts,
|
||||||
simplifiedContext: attemptNum === 2,
|
simplifiedContext: attemptNum === 2,
|
||||||
options,
|
options,
|
||||||
|
buildCommand,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle build failure
|
||||||
|
if (!agentResult.success) {
|
||||||
|
// Build verification failed - log, reset staged changes, and throw
|
||||||
|
const errorMessage = agentResult.error || "Build verification failed";
|
||||||
|
await store.logEntry(taskId, "Build verification failed during merge", errorMessage);
|
||||||
|
|
||||||
|
// Reset staged changes to abort the merge
|
||||||
|
try {
|
||||||
|
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
|
||||||
|
} catch {
|
||||||
|
// Ignore reset errors
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Build verification failed for ${taskId}: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
// Check if it's a build verification failure - don't retry, propagate immediately
|
||||||
|
if (error.message?.includes("Build verification failed")) {
|
||||||
|
throw error; // Fatal - don't retry build failures
|
||||||
|
}
|
||||||
|
|
||||||
// Check if it's a non-conflict merge failure
|
// Check if it's a non-conflict merge failure
|
||||||
if (error.message?.includes("Merge failed")) {
|
if (error.message?.includes("Merge failed")) {
|
||||||
throw error; // Fatal
|
throw error; // Fatal
|
||||||
@@ -1011,12 +1060,15 @@ interface AiAgentParams {
|
|||||||
hasConflicts: boolean;
|
hasConflicts: boolean;
|
||||||
simplifiedContext: boolean;
|
simplifiedContext: boolean;
|
||||||
options: MergerOptions;
|
options: MergerOptions;
|
||||||
|
buildCommand?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run the AI agent to resolve conflicts and/or write commit message.
|
* Run the AI agent to resolve conflicts and/or write commit message.
|
||||||
|
* Returns { success: true } on success, { success: false, error: string } on build failure.
|
||||||
|
* Throws on agent errors or unrecoverable failures.
|
||||||
*/
|
*/
|
||||||
async function runAiAgentForCommit(params: AiAgentParams): Promise<boolean> {
|
async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: boolean; error?: string }> {
|
||||||
const {
|
const {
|
||||||
store,
|
store,
|
||||||
rootDir,
|
rootDir,
|
||||||
@@ -1028,10 +1080,34 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<boolean> {
|
|||||||
hasConflicts,
|
hasConflicts,
|
||||||
simplifiedContext,
|
simplifiedContext,
|
||||||
options,
|
options,
|
||||||
|
buildCommand,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const settings = await store.getSettings();
|
const settings = await store.getSettings();
|
||||||
|
|
||||||
|
// Track build failure state
|
||||||
|
let buildFailed = false;
|
||||||
|
let buildErrorMessage = "";
|
||||||
|
|
||||||
|
// Create custom tool for reporting build failures
|
||||||
|
const reportBuildFailureTool: ToolDefinition = {
|
||||||
|
name: "report_build_failure",
|
||||||
|
label: "Report Build Failure",
|
||||||
|
description: "Report that the build verification failed. Use this when the build command returns a non-zero exit code. Provide the error details in the message parameter.",
|
||||||
|
parameters: Type.Object({
|
||||||
|
message: Type.String({ description: "Error message describing why the build failed" }),
|
||||||
|
}),
|
||||||
|
execute: async (_toolCallId: string, params: unknown) => {
|
||||||
|
const { message } = params as { message: string };
|
||||||
|
buildFailed = true;
|
||||||
|
buildErrorMessage = message;
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Build failure reported: ${message}` }],
|
||||||
|
details: undefined
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
mergerLog.log(`${taskId}: ${hasConflicts ? "resolving conflicts + " : ""}writing commit message`);
|
mergerLog.log(`${taskId}: ${hasConflicts ? "resolving conflicts + " : ""}writing commit message`);
|
||||||
|
|
||||||
const agentLogger = new AgentLogger({
|
const agentLogger = new AgentLogger({
|
||||||
@@ -1050,6 +1126,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<boolean> {
|
|||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
systemPrompt: buildMergeSystemPrompt(includeTaskId),
|
systemPrompt: buildMergeSystemPrompt(includeTaskId),
|
||||||
tools: "coding",
|
tools: "coding",
|
||||||
|
customTools: [reportBuildFailureTool],
|
||||||
onText: agentLogger.onText,
|
onText: agentLogger.onText,
|
||||||
onThinking: agentLogger.onThinking,
|
onThinking: agentLogger.onThinking,
|
||||||
onToolStart: agentLogger.onToolStart,
|
onToolStart: agentLogger.onToolStart,
|
||||||
@@ -1070,11 +1147,18 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<boolean> {
|
|||||||
diffStat,
|
diffStat,
|
||||||
hasConflicts,
|
hasConflicts,
|
||||||
simplifiedContext,
|
simplifiedContext,
|
||||||
|
buildCommand,
|
||||||
});
|
});
|
||||||
await session.prompt(prompt);
|
await session.prompt(prompt);
|
||||||
|
|
||||||
checkSessionError(session);
|
checkSessionError(session);
|
||||||
|
|
||||||
|
// Check if build failed
|
||||||
|
if (buildFailed) {
|
||||||
|
mergerLog.error(`Build verification failed for ${taskId}: ${buildErrorMessage}`);
|
||||||
|
return { success: false, error: buildErrorMessage };
|
||||||
|
}
|
||||||
|
|
||||||
// Verify commit happened
|
// Verify commit happened
|
||||||
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", {
|
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", {
|
||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
@@ -1082,16 +1166,24 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<boolean> {
|
|||||||
}).trim();
|
}).trim();
|
||||||
|
|
||||||
if (staged !== "0") {
|
if (staged !== "0") {
|
||||||
mergerLog.log("Agent didn't commit — committing with fallback message");
|
// Only use fallback commit if no build command was configured
|
||||||
const escapedLog = commitLog.replace(/"/g, '\\"');
|
// If build command was configured, agent should have committed or reported failure
|
||||||
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
|
if (!buildCommand) {
|
||||||
execSync(
|
mergerLog.log("Agent didn't commit — committing with fallback message");
|
||||||
`git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"`,
|
const escapedLog = commitLog.replace(/"/g, '\\"');
|
||||||
{ cwd: rootDir, stdio: "pipe" },
|
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
|
||||||
);
|
execSync(
|
||||||
|
`git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"`,
|
||||||
|
{ cwd: rootDir, stdio: "pipe" },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Build command was configured but agent didn't commit and didn't report failure
|
||||||
|
// This is an error condition - agent didn't follow instructions
|
||||||
|
throw new Error(`Agent did not commit and did not report build failure for ${taskId}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return { success: true };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
mergerLog.error(`Agent failed: ${err.message}`);
|
mergerLog.error(`Agent failed: ${err.message}`);
|
||||||
|
|
||||||
@@ -1113,10 +1205,11 @@ interface MergePromptParams {
|
|||||||
diffStat: string;
|
diffStat: string;
|
||||||
hasConflicts: boolean;
|
hasConflicts: boolean;
|
||||||
simplifiedContext?: boolean;
|
simplifiedContext?: boolean;
|
||||||
|
buildCommand?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMergePrompt(params: MergePromptParams): string {
|
function buildMergePrompt(params: MergePromptParams): string {
|
||||||
const { taskId, branch, commitLog, diffStat, hasConflicts, simplifiedContext } = params;
|
const { taskId, branch, commitLog, diffStat, hasConflicts, simplifiedContext, buildCommand } = params;
|
||||||
|
|
||||||
const parts = [
|
const parts = [
|
||||||
`Finalize the merge of branch \`${branch}\` for task ${taskId}.`,
|
`Finalize the merge of branch \`${branch}\` for task ${taskId}.`,
|
||||||
@@ -1154,6 +1247,17 @@ function buildMergePrompt(params: MergePromptParams): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add build command section if provided
|
||||||
|
if (buildCommand) {
|
||||||
|
parts.push(
|
||||||
|
"",
|
||||||
|
"## Build command",
|
||||||
|
`Build command: \`${buildCommand}\``,
|
||||||
|
"",
|
||||||
|
"Run this command via bash tool before committing to verify the build passes.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return parts.join("\n");
|
return parts.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user