feat(FN-1539): add deterministic merge verification runner

- Add verification runner that executes testCommand then buildCommand before merge completion
- Verification runs on all merge paths (AI resolve, auto-resolve, -X theirs)
- If verification fails, merge is aborted and task stays out of done
- Add comprehensive tests for merger verification logic
- Fix routine-store test variable reference bug (created.id vs routine.id)
- Add changeset for @gsxdsm/fusion patch release
This commit is contained in:
gsxdsm
2026-04-10 12:20:56 -07:00
parent 5cb368d7cf
commit ed396fc6b6
4 changed files with 587 additions and 11 deletions

View File

@@ -0,0 +1,18 @@
---
"@gsxdsm/fusion": patch
---
Enforce deterministic merge verification so tests stay green
When `testCommand` or `buildCommand` are configured in project settings, these commands now run as deterministic engine-level gates before merge completion. Previously, verification was only mediated through AI agent prompts, which could be unreliable.
**Changes:**
- Added deterministic verification runner that executes `testCommand` first, then `buildCommand`
- Verification runs on all merge paths (AI resolve, auto-resolve, and `-X theirs`)
- If verification fails, the merge is aborted and the task stays out of `done`
- Detailed logging of verification results to the task log
**Behavior:**
- Tasks with failing tests/builds will no longer reach `done`
- This ensures repository health is maintained automatically
- Agent prompt instructions are still included as a secondary check

View File

@@ -375,7 +375,7 @@ describe("RoutineStore", () => {
});
it("emits routine:deleted event", async () => {
const routine = await store.createRoutine({
const created = await store.createRoutine({
name: "Delete test",
agentId: "test-agent",
trigger: { type: "manual" },
@@ -384,13 +384,13 @@ describe("RoutineStore", () => {
const listener = vi.fn();
store.on("routine:deleted", listener);
await store.deleteRoutine(routine.id);
expect(listener).toHaveBeenCalled();
const emittedRoutine = listener.mock.calls[0][0];
// Verify the emitted routine has the expected fields
expect(emittedRoutine.id).toBe(routine.id);
expect(emittedRoutine.name).toBe("Delete test");
expect(emittedRoutine.agentId).toBe("test-agent");
await store.deleteRoutine(created.id);
// The emitted routine comes from getRoutine() which adds extra fields
expect(listener).toHaveBeenCalledTimes(1);
const emitted = listener.mock.calls[0][0];
expect(emitted.id).toBe(created.id);
expect(emitted.name).toBe("Delete test");
expect(emitted.agentId).toBe("test-agent");
});
});

View File

@@ -2002,6 +2002,342 @@ describe("aiMergeTask — build verification", () => {
});
});
// ── Deterministic Merge Verification Tests ──────────────────────────────
describe("aiMergeTask — deterministic merge 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("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") && !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("runs testCommand before buildCommand when both are configured", async () => {
const verificationOrder: string[] = [];
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("");
// Record verification command calls
if (cmdStr.includes("vitest run")) {
verificationOrder.push("test");
return Buffer.from("");
}
if (cmdStr.includes("pnpm build")) {
verificationOrder.push("build");
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("");
});
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
// Simulate commit
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("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("vitest run")) {
verificationOrder.push("test");
return Buffer.from("");
}
if (cmdStr.includes("pnpm build")) {
verificationOrder.push("build");
return Buffer.from("");
}
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,
testCommand: "vitest run",
buildCommand: "pnpm build",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(verificationOrder).toEqual(["test", "build"]);
});
it("fails merge when testCommand fails and does not move task to done", async () => {
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("");
// Fail the test command
if (cmdStr.includes("vitest run")) {
const error = new Error("Test failed") as any;
error.status = 1;
error.stdout = "FAIL: some test failed";
error.stderr = "";
throw error;
}
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
return {
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,
testCommand: "vitest run",
});
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
"Deterministic test verification failed",
);
// Verify task was NOT moved to done
expect(store.moveTask).not.toHaveBeenCalled();
// Verify log entry was made
expect(store.logEntry).toHaveBeenCalledWith(
"FN-050",
expect.stringContaining("Deterministic test verification failed"),
"VerificationError",
);
});
it("fails merge when buildCommand fails and does not move task to done", async () => {
// Setup exec mock that will be updated after agent commits
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("");
// Initial diff check - staged changes exist
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
// After agent "commits", update mock to handle verification commands
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("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
// test passes
if (cmdStr.includes("vitest run")) return Buffer.from("");
// Fail the build command
if (cmdStr.includes("pnpm build")) {
const error = new Error("Build failed") as any;
error.status = 1;
error.stdout = "";
error.stderr = "Type error in src/utils.ts";
throw error;
}
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,
testCommand: "vitest run",
buildCommand: "pnpm build",
});
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
"Deterministic build verification failed",
);
// Verify task was NOT moved to done
expect(store.moveTask).not.toHaveBeenCalled();
});
it("enforces verification when merge uses fallback commit", async () => {
const verificationCalls: string[] = [];
// Initial exec mock - will be updated after agent commits
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("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
// After agent "commits", update mock for verification
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("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
// Track verification commands
if (cmdStr.includes("vitest run")) {
verificationCalls.push("test");
return Buffer.from("");
}
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,
testCommand: "vitest run",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
// Verification should have run
expect(verificationCalls).toContain("test");
});
it("skips verification when neither testCommand nor buildCommand is configured", async () => {
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") && !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("");
return Buffer.from("");
});
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
// Simulate commit
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("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
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],
);
// Neither testCommand nor buildCommand configured
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
// Verify no verification commands were called
const verificationCalls = mockedExecSync.mock.calls.filter(
(call) => String(call[0]).includes("vitest") || String(call[0]).includes("pnpm build"),
);
expect(verificationCalls).toHaveLength(0);
});
});
describe("shouldSyncDependenciesForMerge", () => {
it("returns true when install state is missing", () => {
expect(shouldSyncDependenciesForMerge([], false)).toBe(true);

View File

@@ -167,6 +167,171 @@ async function syncDependenciesForMerge(
}
}
// ── Deterministic merge verification ──────────────────────────────────
/** Result of running a single verification command */
export interface VerificationCommandResult {
command: string;
exitCode: number | null;
stdout: string;
stderr: string;
success: boolean;
}
/** Result of running all verification commands */
export interface VerificationResult {
testResult?: VerificationCommandResult;
buildResult?: VerificationCommandResult;
allPassed: boolean;
failedCommand?: string;
}
/**
* Run verification commands deterministically in the engine.
* Executes testCommand first, then buildCommand (when both are configured).
* Returns structured results so failures are logged with actionable detail.
* Throws VerificationError on failure with command details.
*/
export class VerificationError extends Error {
constructor(
message: string,
public readonly verificationResult: VerificationResult,
) {
super(message);
this.name = "VerificationError";
}
}
async function runDeterministicVerification(
store: TaskStore,
rootDir: string,
taskId: string,
testCommand?: string,
buildCommand?: string,
): Promise<VerificationResult> {
const result: VerificationResult = { allPassed: true };
// Nothing to verify
if (!testCommand && !buildCommand) {
mergerLog.log(`${taskId}: no verification commands configured — skipping`);
return result;
}
const normalizedTestCommand = testCommand?.trim();
const normalizedBuildCommand = buildCommand?.trim();
const hasTestCommand = !!normalizedTestCommand;
const hasBuildCommand = !!normalizedBuildCommand;
mergerLog.log(
`${taskId}: running deterministic verification` +
(hasTestCommand ? ` [test: ${normalizedTestCommand}]` : "") +
(hasBuildCommand ? ` [build: ${normalizedBuildCommand}]` : ""),
);
await store.logEntry(
taskId,
"Running deterministic merge verification" +
(hasTestCommand ? ` (testCommand: ${normalizedTestCommand})` : "") +
(hasBuildCommand ? ` (buildCommand: ${normalizedBuildCommand})` : ""),
);
// Run test command first if configured
if (hasTestCommand) {
const testResult = await runVerificationCommand(
store, rootDir, taskId, normalizedTestCommand!, "test",
);
result.testResult = testResult;
if (!testResult.success) {
result.allPassed = false;
result.failedCommand = "testCommand";
await store.logEntry(
taskId,
`Deterministic test verification failed (exit ${testResult.exitCode}): ${testResult.stderr || testResult.stdout}`.trim(),
"VerificationError",
);
throw new VerificationError(
`Deterministic test verification failed for ${taskId}`,
result,
);
}
}
// Run build command second if configured
if (hasBuildCommand) {
const buildResult = await runVerificationCommand(
store, rootDir, taskId, normalizedBuildCommand!, "build",
);
result.buildResult = buildResult;
if (!buildResult.success) {
result.allPassed = false;
result.failedCommand = "buildCommand";
await store.logEntry(
taskId,
`Deterministic build verification failed (exit ${buildResult.exitCode}): ${buildResult.stderr || buildResult.stdout}`.trim(),
"VerificationError",
);
throw new VerificationError(
`Deterministic build verification failed for ${taskId}`,
result,
);
}
}
mergerLog.log(`${taskId}: deterministic verification passed`);
await store.logEntry(taskId, "Deterministic merge verification passed");
return result;
}
async function runVerificationCommand(
store: TaskStore,
rootDir: string,
taskId: string,
command: string,
type: "test" | "build",
): Promise<VerificationCommandResult> {
mergerLog.log(`${taskId}: running ${type} command: ${command}`);
await store.logEntry(taskId, `[verification] Running ${type} command: ${command}`);
const result: VerificationCommandResult = {
command,
exitCode: null,
stdout: "",
stderr: "",
success: false,
};
try {
// Execute the command with timeout
const output = execSync(command, {
cwd: rootDir,
encoding: "utf-8",
timeout: 300_000, // 5 minute timeout for verification commands
stdio: ["pipe", "pipe", "pipe"],
});
result.stdout = output;
result.exitCode = 0;
result.success = true;
mergerLog.log(`${taskId}: ${type} command succeeded`);
await store.logEntry(taskId, `[verification] ${type} command succeeded (exit 0)`);
} catch (error: any) {
result.stdout = error.stdout?.toString() || "";
result.stderr = error.stderr?.toString() || "";
result.exitCode = error.status ?? null;
result.success = false;
// Build a useful error summary
const summary = result.stderr || result.stdout || error.message || "Unknown error";
mergerLog.error(`${taskId}: ${type} command failed (exit ${result.exitCode}): ${summary.trim()}`);
await store.logEntry(
taskId,
`[verification] ${type} command failed (exit ${result.exitCode}): ${summary.trim()}`,
);
}
return result;
}
// ── Pre-merge diffstat scope validation ──────────────────────────────
interface DiffFileEntry {
@@ -847,7 +1012,8 @@ export async function aiMergeTask(
const mergeAttempt = async (attemptNum: 1 | 2 | 3): Promise<boolean> => {
mergerLog.log(`${taskId}: merge attempt ${attemptNum}/3...`);
// Normalize buildCommand: treat empty string as undefined
// Normalize verification commands: treat empty string as undefined
const testCommand = settings.testCommand?.trim() || undefined;
const buildCommand = settings.buildCommand?.trim() || undefined;
try {
@@ -864,6 +1030,7 @@ export async function aiMergeTask(
attemptNum,
options,
result,
testCommand,
buildCommand,
}, aiTracker);
@@ -887,6 +1054,13 @@ export async function aiMergeTask(
return false;
} catch (error: any) {
// Check if it's a deterministic verification failure (testCommand or buildCommand failed)
// VerificationError is fatal - don't retry, propagate immediately
if (error.name === "VerificationError") {
mergerLog.error(`${taskId}: deterministic verification failed — aborting merge`);
throw error; // Fatal - verification failures don't retry
}
// Check if it's a build verification failure
if (error.message?.includes("Build verification failed")) {
const buildRetryCount = settings.buildRetryCount ?? 0;
@@ -1101,6 +1275,7 @@ interface MergeAttemptParams {
attemptNum: 1 | 2 | 3;
options: MergerOptions;
result: MergeResult;
testCommand?: string;
buildCommand?: string;
}
@@ -1130,6 +1305,7 @@ async function executeMergeAttempt(
attemptNum,
options,
result,
testCommand,
buildCommand,
} = params;
@@ -1213,6 +1389,10 @@ async function executeMergeAttempt(
);
mergerLog.log(`${taskId}: committed after auto-resolving all conflicts`);
}
// Run deterministic verification before completing the merge
if (testCommand || buildCommand) {
await runDeterministicVerification(store, rootDir, taskId, testCommand, buildCommand);
}
return true;
}
@@ -1227,6 +1407,10 @@ async function executeMergeAttempt(
if (squashIsEmpty) {
mergerLog.log(`${taskId}: squash merge staged nothing — already merged`);
// Run deterministic verification (nothing staged but still verify)
if (testCommand || buildCommand) {
await runDeterministicVerification(store, rootDir, taskId, testCommand, buildCommand);
}
return true;
}
// No conflicts but has staged changes - continue to AI for commit message
@@ -1246,6 +1430,10 @@ async function executeMergeAttempt(
if (squashIsEmpty) {
mergerLog.log(`${taskId}: squash merge staged nothing — already merged`);
// Run deterministic verification (nothing staged but still verify)
if (testCommand || buildCommand) {
await runDeterministicVerification(store, rootDir, taskId, testCommand, buildCommand);
}
return true;
}
@@ -1290,6 +1478,7 @@ async function executeMergeAttempt(
hasConflicts,
simplifiedContext: attemptNum === 2,
options,
testCommand,
buildCommand,
});
@@ -1309,6 +1498,11 @@ async function executeMergeAttempt(
throw new Error(`Build verification failed for ${taskId}: ${errorMessage}`);
}
// Run deterministic verification after AI agent commits
if (testCommand || buildCommand) {
await runDeterministicVerification(store, rootDir, taskId, testCommand, buildCommand);
}
return true;
} catch (error: any) {
// Check if it's a build verification failure - don't retry, propagate immediately
@@ -1335,7 +1529,7 @@ async function executeMergeAttempt(
* Attempt 3: Use git merge -X theirs --squash strategy
*/
async function attemptWithTheirsStrategy(params: MergeAttemptParams): Promise<boolean> {
const { rootDir, branch, commitLog, includeTaskId, taskId } = params;
const { rootDir, branch, commitLog, includeTaskId, taskId, store, testCommand, buildCommand } = params;
mergerLog.log(`${taskId}: attempting merge with -X theirs strategy`);
@@ -1365,6 +1559,10 @@ async function attemptWithTheirsStrategy(params: MergeAttemptParams): Promise<bo
if (staged === "0") {
// Nothing staged - already merged
// Run deterministic verification even when nothing is staged
if (testCommand || buildCommand) {
await runDeterministicVerification(store, rootDir, taskId, testCommand, buildCommand);
}
return true;
}
@@ -1376,6 +1574,12 @@ async function attemptWithTheirsStrategy(params: MergeAttemptParams): Promise<bo
{ cwd: rootDir, stdio: "pipe" },
);
mergerLog.log(`${taskId}: committed with -X theirs auto-resolution`);
// Run deterministic verification after committing
if (testCommand || buildCommand) {
await runDeterministicVerification(store, rootDir, taskId, testCommand, buildCommand);
}
return true;
} catch (error) {
mergerLog.error(`${taskId}: -X theirs merge failed: ${error}`);
@@ -1394,6 +1598,7 @@ interface AiAgentParams {
hasConflicts: boolean;
simplifiedContext: boolean;
options: MergerOptions;
testCommand?: string;
buildCommand?: string;
}
@@ -1430,6 +1635,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
hasConflicts,
simplifiedContext,
options,
testCommand,
buildCommand,
} = params;
@@ -1517,6 +1723,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
diffStat,
hasConflicts,
simplifiedContext,
testCommand,
buildCommand,
});
@@ -1627,11 +1834,12 @@ interface MergePromptParams {
diffStat: string;
hasConflicts: boolean;
simplifiedContext?: boolean;
testCommand?: string;
buildCommand?: string;
}
function buildMergePrompt(params: MergePromptParams): string {
const { taskId, branch, commitLog, diffStat, hasConflicts, simplifiedContext, buildCommand } = params;
const { taskId, branch, commitLog, diffStat, hasConflicts, simplifiedContext, testCommand, buildCommand } = params;
const parts = [
`Finalize the merge of branch \`${branch}\` for task ${taskId}.`,
@@ -1669,6 +1877,20 @@ function buildMergePrompt(params: MergePromptParams): string {
);
}
// Add test command section if provided
if (testCommand) {
parts.push(
"",
"## Test command",
`Test command: \`${testCommand}\``,
"",
"This command is mandatory before commit.",
"Run it with the bash tool in the current worktree and inspect the actual exit code.",
"Only proceed if it exits 0.",
"If it exits non-zero, call `report_build_failure` with the concrete error output and stop without committing.",
);
}
// Add build command section if provided
if (buildCommand) {
parts.push(