diff --git a/.changeset/deterministic-merge-verification.md b/.changeset/deterministic-merge-verification.md new file mode 100644 index 000000000..2f26209ec --- /dev/null +++ b/.changeset/deterministic-merge-verification.md @@ -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 diff --git a/packages/core/src/routine-store.test.ts b/packages/core/src/routine-store.test.ts index ccb047170..1853ea47a 100644 --- a/packages/core/src/routine-store.test.ts +++ b/packages/core/src/routine-store.test.ts @@ -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"); }); }); diff --git a/packages/engine/src/merger.test.ts b/packages/engine/src/merger.test.ts index 76291ff1c..d211f1623 100644 --- a/packages/engine/src/merger.test.ts +++ b/packages/engine/src/merger.test.ts @@ -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).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).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).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).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).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); diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index 1de910735..70c58f0d6 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -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 { + 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 { + 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 => { 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 { - 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