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:
18
.changeset/deterministic-merge-verification.md
Normal file
18
.changeset/deterministic-merge-verification.md
Normal 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
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user