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 () => {
|
it("emits routine:deleted event", async () => {
|
||||||
const routine = await store.createRoutine({
|
const created = await store.createRoutine({
|
||||||
name: "Delete test",
|
name: "Delete test",
|
||||||
agentId: "test-agent",
|
agentId: "test-agent",
|
||||||
trigger: { type: "manual" },
|
trigger: { type: "manual" },
|
||||||
@@ -384,13 +384,13 @@ describe("RoutineStore", () => {
|
|||||||
const listener = vi.fn();
|
const listener = vi.fn();
|
||||||
store.on("routine:deleted", listener);
|
store.on("routine:deleted", listener);
|
||||||
|
|
||||||
await store.deleteRoutine(routine.id);
|
await store.deleteRoutine(created.id);
|
||||||
expect(listener).toHaveBeenCalled();
|
// The emitted routine comes from getRoutine() which adds extra fields
|
||||||
const emittedRoutine = listener.mock.calls[0][0];
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
// Verify the emitted routine has the expected fields
|
const emitted = listener.mock.calls[0][0];
|
||||||
expect(emittedRoutine.id).toBe(routine.id);
|
expect(emitted.id).toBe(created.id);
|
||||||
expect(emittedRoutine.name).toBe("Delete test");
|
expect(emitted.name).toBe("Delete test");
|
||||||
expect(emittedRoutine.agentId).toBe("test-agent");
|
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", () => {
|
describe("shouldSyncDependenciesForMerge", () => {
|
||||||
it("returns true when install state is missing", () => {
|
it("returns true when install state is missing", () => {
|
||||||
expect(shouldSyncDependenciesForMerge([], false)).toBe(true);
|
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 ──────────────────────────────
|
// ── Pre-merge diffstat scope validation ──────────────────────────────
|
||||||
|
|
||||||
interface DiffFileEntry {
|
interface DiffFileEntry {
|
||||||
@@ -847,7 +1012,8 @@ 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
|
// Normalize verification commands: treat empty string as undefined
|
||||||
|
const testCommand = settings.testCommand?.trim() || undefined;
|
||||||
const buildCommand = settings.buildCommand?.trim() || undefined;
|
const buildCommand = settings.buildCommand?.trim() || undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -864,6 +1030,7 @@ export async function aiMergeTask(
|
|||||||
attemptNum,
|
attemptNum,
|
||||||
options,
|
options,
|
||||||
result,
|
result,
|
||||||
|
testCommand,
|
||||||
buildCommand,
|
buildCommand,
|
||||||
}, aiTracker);
|
}, aiTracker);
|
||||||
|
|
||||||
@@ -887,6 +1054,13 @@ export async function aiMergeTask(
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
} catch (error: any) {
|
} 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
|
// Check if it's a build verification failure
|
||||||
if (error.message?.includes("Build verification failed")) {
|
if (error.message?.includes("Build verification failed")) {
|
||||||
const buildRetryCount = settings.buildRetryCount ?? 0;
|
const buildRetryCount = settings.buildRetryCount ?? 0;
|
||||||
@@ -1101,6 +1275,7 @@ interface MergeAttemptParams {
|
|||||||
attemptNum: 1 | 2 | 3;
|
attemptNum: 1 | 2 | 3;
|
||||||
options: MergerOptions;
|
options: MergerOptions;
|
||||||
result: MergeResult;
|
result: MergeResult;
|
||||||
|
testCommand?: string;
|
||||||
buildCommand?: string;
|
buildCommand?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1130,6 +1305,7 @@ async function executeMergeAttempt(
|
|||||||
attemptNum,
|
attemptNum,
|
||||||
options,
|
options,
|
||||||
result,
|
result,
|
||||||
|
testCommand,
|
||||||
buildCommand,
|
buildCommand,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
@@ -1213,6 +1389,10 @@ async function executeMergeAttempt(
|
|||||||
);
|
);
|
||||||
mergerLog.log(`${taskId}: committed after auto-resolving all conflicts`);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1227,6 +1407,10 @@ async function executeMergeAttempt(
|
|||||||
|
|
||||||
if (squashIsEmpty) {
|
if (squashIsEmpty) {
|
||||||
mergerLog.log(`${taskId}: squash merge staged nothing — already merged`);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
// No conflicts but has staged changes - continue to AI for commit message
|
// No conflicts but has staged changes - continue to AI for commit message
|
||||||
@@ -1246,6 +1430,10 @@ async function executeMergeAttempt(
|
|||||||
|
|
||||||
if (squashIsEmpty) {
|
if (squashIsEmpty) {
|
||||||
mergerLog.log(`${taskId}: squash merge staged nothing — already merged`);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1290,6 +1478,7 @@ async function executeMergeAttempt(
|
|||||||
hasConflicts,
|
hasConflicts,
|
||||||
simplifiedContext: attemptNum === 2,
|
simplifiedContext: attemptNum === 2,
|
||||||
options,
|
options,
|
||||||
|
testCommand,
|
||||||
buildCommand,
|
buildCommand,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1309,6 +1498,11 @@ async function executeMergeAttempt(
|
|||||||
throw new Error(`Build verification failed for ${taskId}: ${errorMessage}`);
|
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;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Check if it's a build verification failure - don't retry, propagate immediately
|
// 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
|
* Attempt 3: Use git merge -X theirs --squash strategy
|
||||||
*/
|
*/
|
||||||
async function attemptWithTheirsStrategy(params: MergeAttemptParams): Promise<boolean> {
|
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`);
|
mergerLog.log(`${taskId}: attempting merge with -X theirs strategy`);
|
||||||
|
|
||||||
@@ -1365,6 +1559,10 @@ async function attemptWithTheirsStrategy(params: MergeAttemptParams): Promise<bo
|
|||||||
|
|
||||||
if (staged === "0") {
|
if (staged === "0") {
|
||||||
// Nothing staged - already merged
|
// Nothing staged - already merged
|
||||||
|
// Run deterministic verification even when nothing is staged
|
||||||
|
if (testCommand || buildCommand) {
|
||||||
|
await runDeterministicVerification(store, rootDir, taskId, testCommand, buildCommand);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1376,6 +1574,12 @@ async function attemptWithTheirsStrategy(params: MergeAttemptParams): Promise<bo
|
|||||||
{ cwd: rootDir, stdio: "pipe" },
|
{ cwd: rootDir, stdio: "pipe" },
|
||||||
);
|
);
|
||||||
mergerLog.log(`${taskId}: committed with -X theirs auto-resolution`);
|
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;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
mergerLog.error(`${taskId}: -X theirs merge failed: ${error}`);
|
mergerLog.error(`${taskId}: -X theirs merge failed: ${error}`);
|
||||||
@@ -1394,6 +1598,7 @@ interface AiAgentParams {
|
|||||||
hasConflicts: boolean;
|
hasConflicts: boolean;
|
||||||
simplifiedContext: boolean;
|
simplifiedContext: boolean;
|
||||||
options: MergerOptions;
|
options: MergerOptions;
|
||||||
|
testCommand?: string;
|
||||||
buildCommand?: string;
|
buildCommand?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1430,6 +1635,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
|
|||||||
hasConflicts,
|
hasConflicts,
|
||||||
simplifiedContext,
|
simplifiedContext,
|
||||||
options,
|
options,
|
||||||
|
testCommand,
|
||||||
buildCommand,
|
buildCommand,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
@@ -1517,6 +1723,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
|
|||||||
diffStat,
|
diffStat,
|
||||||
hasConflicts,
|
hasConflicts,
|
||||||
simplifiedContext,
|
simplifiedContext,
|
||||||
|
testCommand,
|
||||||
buildCommand,
|
buildCommand,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1627,11 +1834,12 @@ interface MergePromptParams {
|
|||||||
diffStat: string;
|
diffStat: string;
|
||||||
hasConflicts: boolean;
|
hasConflicts: boolean;
|
||||||
simplifiedContext?: boolean;
|
simplifiedContext?: boolean;
|
||||||
|
testCommand?: string;
|
||||||
buildCommand?: string;
|
buildCommand?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMergePrompt(params: MergePromptParams): 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 = [
|
const parts = [
|
||||||
`Finalize the merge of branch \`${branch}\` for task ${taskId}.`,
|
`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
|
// Add build command section if provided
|
||||||
if (buildCommand) {
|
if (buildCommand) {
|
||||||
parts.push(
|
parts.push(
|
||||||
|
|||||||
Reference in New Issue
Block a user