feat(FN-2571): enforce merger aborts through shutdown and git flow

- Add merger abort primitives and track active merge runs for coordinated cancellation
- Abort in-flight merges during engine shutdown and propagate AbortError through fallback catch paths
- Honor abort signals before commit, push, and dependency sync to prevent post-cancel side effects
- Expand merger and project-engine tests to cover abort propagation and merge-abort-on-stop behavior
This commit is contained in:
Fusion
2026-04-25 21:39:35 -07:00
committed by gsxdsm
parent 473b53d306
commit 9aff532787
4 changed files with 364 additions and 19 deletions

View File

@@ -112,6 +112,7 @@ import {
summarizeVerificationOutput,
inferDefaultTestCommand,
resolveTaskDiffBaseRef,
MergeAbortedError,
type ConflictCategory,
} from "../merger.js";
import { mergerLog } from "../logger.js";
@@ -245,6 +246,67 @@ describe("findWorktreeUser", () => {
});
});
describe("aiMergeTask abort handling", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
setupHappyPathExecSync();
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
it("throws immediately when signal is already aborted", async () => {
const store = createMockStore();
const controller = new AbortController();
controller.abort();
await expect(
aiMergeTask(store, "/tmp/root", "FN-050", { signal: controller.signal }),
).rejects.toBeInstanceOf(MergeAbortedError);
expect(store.getTask).not.toHaveBeenCalled();
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
});
it("throws MergeAbortedError when aborted during deterministic verification", async () => {
const controller = new AbortController();
const store = createMockStore();
store.getSettings = vi.fn().mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "pnpm test",
});
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr === "pnpm test") {
controller.abort();
return "" as any;
}
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
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("merge -X theirs --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" 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("");
});
await expect(
aiMergeTask(store, "/tmp/root", "FN-050", { signal: controller.signal }),
).rejects.toBeInstanceOf(MergeAbortedError);
});
});
describe("aiMergeTask — conditional worktree cleanup", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProjectEngine } from "../project-engine.js";
import { runtimeLog } from "../logger.js";
import { aiMergeTask } from "../merger.js";
const mocks = vi.hoisted(() => ({
syncInsightExtractionAutomation: vi.fn(),
@@ -12,6 +13,7 @@ const mocks = vi.hoisted(() => ({
cronRunnerStop: vi.fn(),
runtimeStart: vi.fn(async () => undefined),
runtimeStop: vi.fn(async () => undefined),
aiMergeTask: vi.fn(),
currentStore: null as Record<string, unknown> | null,
}));
@@ -40,6 +42,10 @@ vi.mock("../cron-runner.js", () => {
};
});
vi.mock("../merger.js", () => ({
aiMergeTask: mocks.aiMergeTask,
}));
vi.mock("../pr-monitor.js", () => ({
PrMonitor: vi.fn().mockImplementation(() => ({
onNewComments: vi.fn(),
@@ -85,6 +91,12 @@ function createMockStore(initialSettings: Record<string, unknown>) {
const store = {
getSettings: vi.fn(async () => ({ ...settings })),
listTasks: vi.fn(async () => []),
getTask: vi.fn(async (taskId: string) => ({ id: taskId, column: "in-review", mergeRetries: 0, status: null })),
updateTask: vi.fn(async () => undefined),
moveTask: vi.fn(async () => undefined),
logEntry: vi.fn(async () => undefined),
addTaskComment: vi.fn(async () => undefined),
getActiveMergingTask: vi.fn(() => null),
on: vi.fn((event: string, handler: (...args: unknown[]) => void | Promise<void>) => {
if (event === "settings:updated") {
settingsHandlers.add(handler as (payload: SettingsHandlerPayload) => void | Promise<void>);
@@ -147,6 +159,13 @@ describe("ProjectEngine auto-summarize wiring", () => {
vi.clearAllMocks();
const mockStore = createMockStore(baseSettings);
mocks.currentStore = mockStore.store;
mocks.aiMergeTask.mockResolvedValue({
task: { id: "FN-001", column: "done" },
branch: "fusion/fn-001",
merged: true,
worktreeRemoved: false,
branchDeleted: true,
});
});
it("syncs startup memory automations using one settings snapshot", async () => {
@@ -215,6 +234,67 @@ describe("ProjectEngine auto-summarize wiring", () => {
});
});
describe("ProjectEngine shutdown merge handling", () => {
beforeEach(() => {
vi.clearAllMocks();
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
mocks.currentStore = mockStore.store;
});
it("aborts active merges, clears pending queue, and blocks new merges after stop", async () => {
const engine = createEngine();
await engine.start();
const privateEngine = engine as unknown as {
mergeQueue: string[];
mergeActive: Set<string>;
mergeAbortController: AbortController | null;
mergeRetryTimer: ReturnType<typeof setTimeout> | null;
activeMergeSession: { dispose: () => void } | null;
};
let capturedSignal: AbortSignal | undefined;
mocks.aiMergeTask.mockImplementationOnce(async (...args: unknown[]) => {
const options = args[3] as { signal?: AbortSignal } | undefined;
capturedSignal = options?.signal;
await new Promise<never>((_, reject) => {
options?.signal?.addEventListener("abort", () => {
const abortError = new Error("merge aborted");
abortError.name = "MergeAbortedError";
reject(abortError);
}, { once: true });
});
});
const manualMergePromise = engine.onMerge("FN-123");
engine.enqueueMerge("FN-queued");
await vi.waitFor(() => {
expect(mocks.aiMergeTask).toHaveBeenCalledTimes(1);
});
expect(capturedSignal?.aborted).toBe(false);
await engine.stop();
await expect(manualMergePromise).rejects.toThrow("Engine shutting down");
expect(capturedSignal?.aborted).toBe(true);
expect(privateEngine.mergeQueue).toHaveLength(0);
expect(privateEngine.mergeRetryTimer).toBeNull();
expect(privateEngine.activeMergeSession).toBeNull();
await vi.waitFor(() => {
expect(privateEngine.mergeActive.has("FN-123")).toBe(false);
});
expect(privateEngine.mergeAbortController).toBeNull();
const mergeCallsBeforeRequeue = mocks.aiMergeTask.mock.calls.length;
engine.enqueueMerge("FN-after-stop");
expect(privateEngine.mergeQueue).toHaveLength(0);
expect(mocks.aiMergeTask).toHaveBeenCalledTimes(mergeCallsBeforeRequeue);
});
});
describe("ProjectEngine swallowed error hardening", () => {
let warnSpy: ReturnType<typeof vi.spyOn>;

View File

@@ -340,10 +340,12 @@ async function syncDependenciesForMerge(
store: TaskStore,
rootDir: string,
taskId: string,
signal?: AbortSignal,
): Promise<void> {
const installCommand = getDependencySyncCommand(rootDir);
if (!installCommand) return;
throwIfAborted(signal, taskId);
mergerLog.log(`${taskId}: syncing dependencies before merge build verification`);
await store.logEntry(taskId, `Syncing dependencies before merge build verification: ${installCommand}`);
try {
@@ -353,7 +355,9 @@ async function syncDependenciesForMerge(
maxBuffer: 10 * 1024 * 1024,
timeout: 300_000,
});
throwIfAborted(signal, taskId);
} catch (error: any) {
throwIfAborted(signal, taskId);
const details = error?.stderr || error?.stdout || error?.message || String(error);
throw new Error(`Dependency sync failed for ${taskId}: ${details}`.trim());
}
@@ -479,6 +483,25 @@ export class VerificationError extends Error {
}
}
/** Raised when a merge is explicitly cancelled (for example engine shutdown). */
export class MergeAbortedError extends Error {
constructor(message: string) {
super(message);
this.name = "MergeAbortedError";
}
}
export function throwIfAborted(signal: AbortSignal | undefined, taskId: string): void {
if (!signal?.aborted) return;
throw new MergeAbortedError(`Merge aborted for ${taskId}: engine shutdown requested`);
}
function rethrowIfMergeAborted(error: unknown): void {
if (error instanceof Error && error.name === "MergeAbortedError") {
throw error;
}
}
async function runDeterministicVerification(
store: TaskStore,
rootDir: string,
@@ -487,6 +510,7 @@ async function runDeterministicVerification(
buildCommand?: string,
testSource?: "explicit" | "inferred",
buildSource?: "explicit" | "inferred",
signal?: AbortSignal,
): Promise<VerificationResult> {
const result: VerificationResult = { allPassed: true };
@@ -520,7 +544,7 @@ async function runDeterministicVerification(
// Run test command first if configured
if (hasTestCommand) {
const testResult = await runVerificationCommand(
store, rootDir, taskId, normalizedTestCommand!, "test",
store, rootDir, taskId, normalizedTestCommand!, "test", signal,
);
result.testResult = testResult;
@@ -542,7 +566,7 @@ async function runDeterministicVerification(
// Run build command second if configured
if (hasBuildCommand) {
const buildResult = await runVerificationCommand(
store, rootDir, taskId, normalizedBuildCommand!, "build",
store, rootDir, taskId, normalizedBuildCommand!, "build", signal,
);
result.buildResult = buildResult;
@@ -572,7 +596,9 @@ async function runVerificationCommand(
taskId: string,
command: string,
type: "test" | "build",
signal?: AbortSignal,
): Promise<VerificationCommandResult> {
throwIfAborted(signal, taskId);
mergerLog.log(`${taskId}: running ${type} command: ${command}`);
await store.logEntry(taskId, `[verification] Running ${type} command: ${command}`);
@@ -593,6 +619,8 @@ async function runVerificationCommand(
maxBuffer: VERIFICATION_COMMAND_MAX_BUFFER,
});
throwIfAborted(signal, taskId);
result.stdout = stdout?.toString?.() || "";
result.stderr = stderr?.toString?.() || "";
result.exitCode = 0;
@@ -603,6 +631,7 @@ async function runVerificationCommand(
await store.logEntry(taskId, `[timing] [verification] ${type} command succeeded (exit 0) in ${verificationDurationMs}ms`);
return result;
} catch (error: any) {
throwIfAborted(signal, taskId);
const verificationDurationMs = Date.now() - verificationStartedAt;
result.stdout = error?.stdout?.toString?.() || "";
result.stderr = error?.stderr?.toString?.() || "";
@@ -688,6 +717,7 @@ async function attemptInMergeVerificationFix(
}
// Create the fix agent session
throwIfAborted(options.signal, taskId);
const { session } = await createResolvedAgentSession({
sessionPurpose: "merger",
pluginRunner: options.pluginRunner,
@@ -742,12 +772,14 @@ ${failureContext.output.slice(0, VERIFICATION_LOG_MAX_CHARS)}
// Run the agent with rate limit retry
await withRateLimitRetry(async () => {
throwIfAborted(options.signal, taskId);
await promptWithFallback(session, fixPrompt);
}, {
onRetry: (attempt, delayMs, error) => {
const delaySec = Math.round(delayMs / 1000);
mergerLog.warn(`${taskId} in-merge fix rate limited — retry ${attempt} in ${delaySec}s: ${error.message}`);
},
signal: options.signal,
});
// Re-run deterministic verification command after the fix attempt.
@@ -756,7 +788,12 @@ ${failureContext.output.slice(0, VERIFICATION_LOG_MAX_CHARS)}
`Re-running deterministic merge verification (attempt ${fixAttemptNumber ?? "unknown"})`,
);
const reRunResult = await runVerificationCommand(
store, rootDir, taskId, failureContext.command, failureContext.type,
store,
rootDir,
taskId,
failureContext.command,
failureContext.type,
options.signal,
);
return reRunResult.success;
@@ -766,6 +803,7 @@ ${failureContext.output.slice(0, VERIFICATION_LOG_MAX_CHARS)}
await session.dispose();
}
} catch (err: unknown) {
rethrowIfMergeAborted(err);
const errorMessage = err instanceof Error ? err.message : String(err);
mergerLog.warn(`${taskId}: in-merge fix agent error: ${errorMessage}`);
await store.logEntry(taskId, "In-merge verification fix agent encountered an error", errorMessage);
@@ -1460,6 +1498,8 @@ export interface MergerOptions {
* caller (e.g. dashboard.ts) to track and externally dispose the session
* when a global pause is triggered. */
onSession?: (session: { dispose: () => void }) => void;
/** Abort signal used to stop an in-flight merge when the engine is shutting down. */
signal?: AbortSignal;
/** AgentStore for resolving per-agent custom instructions. */
agentStore?: import("@fusion/core").AgentStore;
/** Plugin runner for runtime selection. When provided, enables plugin runtime lookup. */
@@ -1527,7 +1567,7 @@ async function resolveComplexRebaseConflictsWithAi(
taskId: string,
settings: Settings,
conflictedFiles: string[],
options?: { onAgentText?: (delta: string) => void; pluginRunner?: import("./plugin-runner.js").PluginRunner },
options?: { onAgentText?: (delta: string) => void; pluginRunner?: import("./plugin-runner.js").PluginRunner; signal?: AbortSignal },
): Promise<void> {
mergerLog.log(`${taskId}: resolving ${conflictedFiles.length} complex rebase conflict(s) with AI`);
@@ -1552,6 +1592,7 @@ You are assisting with a paused \`git pull --rebase\`.
: undefined,
});
throwIfAborted(options?.signal, taskId);
const { session } = await createResolvedAgentSession({
sessionPurpose: "merger",
pluginRunner: options?.pluginRunner,
@@ -1578,6 +1619,7 @@ You are assisting with a paused \`git pull --rebase\`.
try {
await withRateLimitRetry(async () => {
throwIfAborted(options?.signal, taskId);
await promptWithFallback(session, prompt);
checkSessionError(session);
}, {
@@ -1586,6 +1628,7 @@ You are assisting with a paused \`git pull --rebase\`.
`${taskId}: rate limited while resolving rebase conflicts — retry ${attempt} in ${Math.round(delayMs / 1000)}s: ${error.message}`,
);
},
signal: options?.signal,
});
} finally {
session.dispose();
@@ -1597,7 +1640,7 @@ async function resolveRebaseConflictSet(
rootDir: string,
taskId: string,
settings: Settings,
options?: { onAgentText?: (delta: string) => void },
options?: { onAgentText?: (delta: string) => void; signal?: AbortSignal },
): Promise<void> {
const conflictedFiles = await getConflictedFiles(rootDir);
if (conflictedFiles.length === 0) return;
@@ -1640,10 +1683,11 @@ async function pullWithRebaseAndResolveConflicts(
settings: Settings,
remote: string,
branch: string,
options?: { onAgentText?: (delta: string) => void },
options?: { onAgentText?: (delta: string) => void; signal?: AbortSignal },
): Promise<void> {
const pullCommand = `git pull --rebase ${quoteArg(remote)} ${quoteArg(branch)}`;
try {
throwIfAborted(options?.signal, taskId);
await execAsync(pullCommand, {
cwd: rootDir,
timeout: PULL_REBASE_TIMEOUT_MS,
@@ -1666,12 +1710,14 @@ async function pullWithRebaseAndResolveConflicts(
await resolveRebaseConflictSet(store, rootDir, taskId, settings, options);
for (let attempt = 1; attempt <= 10; attempt++) {
throwIfAborted(options?.signal, taskId);
if (!isRebaseInProgress(rootDir)) {
mergerLog.log(`${taskId}: rebase conflicts resolved`);
return;
}
try {
throwIfAborted(options?.signal, taskId);
await execAsync("GIT_EDITOR=true git rebase --continue", {
cwd: rootDir,
timeout: PULL_REBASE_TIMEOUT_MS,
@@ -1713,6 +1759,7 @@ async function pullWithRebaseAndResolveConflicts(
}
}
rethrowIfMergeAborted(resolutionError);
throw new Error(`unable to resolve rebase conflicts: ${getCommandErrorMessage(resolutionError)}`);
}
}
@@ -1727,13 +1774,15 @@ export async function pushToRemoteAfterMerge(
rootDir: string,
taskId: string,
settings: Settings,
options?: { onAgentText?: (delta: string) => void },
options?: { onAgentText?: (delta: string) => void; signal?: AbortSignal },
): Promise<{ pushed: boolean; error?: string }> {
let target: { remote: string; branch: string };
try {
throwIfAborted(options?.signal, taskId);
target = parsePushRemoteTarget(rootDir, settings.pushRemote);
} catch (error: unknown) {
rethrowIfMergeAborted(error);
const message = getCommandErrorMessage(error);
mergerLog.error(`${taskId}: invalid push remote configuration: ${message}`);
return { pushed: false, error: message };
@@ -1743,8 +1792,10 @@ export async function pushToRemoteAfterMerge(
mergerLog.log(`${taskId}: push-after-merge enabled; syncing ${remote}/${branch}`);
try {
throwIfAborted(options?.signal, taskId);
await pullWithRebaseAndResolveConflicts(store, rootDir, taskId, settings, remote, branch, options);
} catch (error: unknown) {
rethrowIfMergeAborted(error);
const message = getCommandErrorMessage(error);
mergerLog.error(`${taskId}: pull --rebase before push failed: ${message}`);
return { pushed: false, error: message };
@@ -1753,6 +1804,7 @@ export async function pushToRemoteAfterMerge(
const pushCommand = `git push ${quoteArg(remote)} ${quoteArg(branch)}`;
try {
throwIfAborted(options?.signal, taskId);
await execAsync(pushCommand, {
cwd: rootDir,
timeout: PUSH_TIMEOUT_MS,
@@ -1772,7 +1824,9 @@ export async function pushToRemoteAfterMerge(
mergerLog.log(`${taskId}: push rejected as non-fast-forward; retrying pull --rebase and push once`);
try {
throwIfAborted(options?.signal, taskId);
await pullWithRebaseAndResolveConflicts(store, rootDir, taskId, settings, remote, branch, options);
throwIfAborted(options?.signal, taskId);
await execAsync(pushCommand, {
cwd: rootDir,
timeout: PUSH_TIMEOUT_MS,
@@ -1782,6 +1836,7 @@ export async function pushToRemoteAfterMerge(
mergerLog.log(`${taskId}: push succeeded after non-fast-forward retry`);
return { pushed: true };
} catch (retryError: unknown) {
rethrowIfMergeAborted(retryError);
const retryMessage = getCommandErrorMessage(retryError);
mergerLog.error(`${taskId}: push retry failed: ${retryMessage}`);
return { pushed: false, error: retryMessage };
@@ -1844,6 +1899,8 @@ export async function aiMergeTask(
taskId: string,
options: MergerOptions = {},
): Promise<MergeResult> {
throwIfAborted(options.signal, taskId);
// 1. Validate task state
const task = await store.getTask(taskId);
const mergeBlocker = getTaskMergeBlocker(task);
@@ -1921,6 +1978,7 @@ export async function aiMergeTask(
// Without this, a merge could land on whatever branch was last checked out,
// causing feature code to be committed to the wrong lineage.
try {
throwIfAborted(options.signal, taskId);
const currentBranch = execSync("git symbolic-ref --short HEAD", {
cwd: rootDir,
encoding: "utf-8",
@@ -1939,13 +1997,17 @@ export async function aiMergeTask(
// Audit trail: record git checkout (FN-1404)
await audit.git({ type: "branch:checkout", target: mainBranch });
}
} catch {
} catch (error: unknown) {
rethrowIfMergeAborted(error);
// Fallback: try checking out main directly
try {
throwIfAborted(options.signal, taskId);
await execAsync("git checkout main", { cwd: rootDir });
// Audit trail: record git checkout (FN-1404)
await audit.git({ type: "branch:checkout", target: "main" });
} catch {
} catch (fallbackError: unknown) {
rethrowIfMergeAborted(fallbackError);
mergerLog.warn(`${taskId}: unable to verify/checkout main branch — proceeding on current HEAD`);
}
}
@@ -2004,6 +2066,7 @@ export async function aiMergeTask(
if (!remote) {
mergerLog.log(`${taskId}: no remote resolvable — skipping pre-merge rebase`);
} else {
throwIfAborted(options.signal, taskId);
mergerLog.log(`${taskId}: fetching ${remote} before merge`);
await execAsync(`git fetch "${remote}"`, { cwd: rootDir });
@@ -2023,12 +2086,14 @@ export async function aiMergeTask(
// Rebase in the task's worktree (so the rootDir's HEAD isn't
// disturbed). This is the same worktree the executor used.
if (worktreePath) {
throwIfAborted(options.signal, taskId);
await execAsync(`git rebase "${remoteRef}"`, { cwd: worktreePath });
mergerLog.log(`${taskId}: rebased ${branch} onto ${remoteRef}`);
} else {
mergerLog.warn(`${taskId}: no worktreePath — skipping task branch rebase`);
}
} catch (rebaseErr) {
rethrowIfMergeAborted(rebaseErr);
const msg = rebaseErr instanceof Error ? rebaseErr.message : String(rebaseErr);
mergerLog.warn(`${taskId}: pre-merge rebase failed (${msg}) — aborting rebase and falling through to smart/AI merge`);
if (worktreePath) {
@@ -2041,6 +2106,7 @@ export async function aiMergeTask(
}
}
} catch (err) {
rethrowIfMergeAborted(err);
const msg = err instanceof Error ? err.message : String(err);
mergerLog.warn(`${taskId}: pre-merge rebase pipeline failed (${msg}) — proceeding without rebase`);
}
@@ -2172,6 +2238,15 @@ export async function aiMergeTask(
return false;
} catch (error: any) {
if (error instanceof Error && error.name === "MergeAbortedError") {
try {
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
} catch {
// best-effort abort cleanup
}
throw error;
}
// Check if it's a deterministic verification failure (testCommand or buildCommand failed)
// Try in-merge fix attempts before propagating
if (error.name === "VerificationError") {
@@ -2197,6 +2272,7 @@ export async function aiMergeTask(
mergerLog.log(`${taskId}: in-merge verification fix attempt ${fixAttempt}/${maxFixRetries}`);
await store.logEntry(taskId, `In-merge verification fix attempt ${fixAttempt}/${maxFixRetries}`);
throwIfAborted(options.signal, taskId);
fixSuccess = await attemptInMergeVerificationFix(
store, rootDir, taskId,
{
@@ -2256,6 +2332,7 @@ export async function aiMergeTask(
mergerLog.log(`${taskId}: in-merge verification fix attempt ${fixAttempt}/${maxFixRetries}`);
await store.logEntry(taskId, `In-merge verification fix attempt ${fixAttempt}/${maxFixRetries}`);
throwIfAborted(options.signal, taskId);
fixSuccess = await attemptInMergeVerificationFix(
store, rootDir, taskId,
{
@@ -2451,6 +2528,7 @@ export async function aiMergeTask(
}
// 7. Run post-merge workflow steps (in temporary worktree for isolation)
throwIfAborted(options.signal, taskId);
const hasPostMergeSteps = await hasEnabledPostMergeWorkflowSteps(store, taskId, task.enabledWorkflowSteps);
if (hasPostMergeSteps) {
const postMergeWorktree = await createPostMergeWorktree(rootDir, taskId);
@@ -2464,6 +2542,7 @@ export async function aiMergeTask(
try {
await runPostMergeWorkflowSteps(store, taskId, rootDir, postMergeCwd, settings, options);
} catch (err: any) {
rethrowIfMergeAborted(err);
mergerLog.error(`${taskId}: post-merge workflow steps error: ${err.message}`);
// Non-fatal — task still moves to done
} finally {
@@ -2474,6 +2553,7 @@ export async function aiMergeTask(
}
// 8. Clean up worktree
throwIfAborted(options.signal, taskId);
if (worktreePath && existsSync(worktreePath)) {
const otherUser = await findWorktreeUser(store, worktreePath, taskId);
if (otherUser) {
@@ -2497,6 +2577,7 @@ export async function aiMergeTask(
// 8b. Push to remote if configured
if (settings.pushAfterMerge && settings.mergeStrategy !== "pull-request") {
try {
throwIfAborted(options.signal, taskId);
const pushResult = await pushToRemoteAfterMerge(store, rootDir, taskId, settings, options);
if (pushResult.pushed) {
mergerLog.log(`${taskId}: pushed merged result to remote`);
@@ -2640,7 +2721,9 @@ async function executeMergeAttempt(
await execAsync(`git merge --squash "${branch}"`, {
cwd: rootDir,
});
} catch {
throwIfAborted(options.signal, taskId);
} catch (error: unknown) {
rethrowIfMergeAborted(error);
// Merge exits with code 1 when conflicts exist - this is expected
mergeExitedWithConflicts = true;
}
@@ -2694,6 +2777,7 @@ async function executeMergeAttempt(
}).trim();
if (staged !== "0") {
throwIfAborted(options.signal, taskId);
const escapedLog = commitLog.replace(/"/g, '\\"');
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
const authorArg = getCommitAuthorArg(settings);
@@ -2705,7 +2789,17 @@ async function executeMergeAttempt(
}
// Run deterministic verification before completing the merge
if (testCommand || buildCommand) {
await runDeterministicVerification(store, rootDir, taskId, testCommand, buildCommand, testSource, buildSource);
throwIfAborted(options.signal, taskId);
await runDeterministicVerification(
store,
rootDir,
taskId,
testCommand,
buildCommand,
testSource,
buildSource,
options.signal,
);
}
return true;
}
@@ -2723,7 +2817,17 @@ async function executeMergeAttempt(
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, testSource, buildSource);
throwIfAborted(options.signal, taskId);
await runDeterministicVerification(
store,
rootDir,
taskId,
testCommand,
buildCommand,
testSource,
buildSource,
options.signal,
);
}
return true;
}
@@ -2734,6 +2838,7 @@ async function executeMergeAttempt(
await execAsync(`git merge --squash "${branch}"`, {
cwd: rootDir,
});
throwIfAborted(options.signal, taskId);
// Check if squash is empty
const squashIsEmpty = execSync(
@@ -2745,7 +2850,17 @@ async function executeMergeAttempt(
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, testSource, buildSource);
throwIfAborted(options.signal, taskId);
await runDeterministicVerification(
store,
rootDir,
taskId,
testCommand,
buildCommand,
testSource,
buildSource,
options.signal,
);
}
return true;
}
@@ -2769,9 +2884,10 @@ async function executeMergeAttempt(
}
if (buildCommand) {
throwIfAborted(options.signal, taskId);
const stagedFiles = await getStagedFiles(rootDir);
if (shouldSyncDependenciesForMerge(stagedFiles, hasInstallState(rootDir))) {
await syncDependenciesForMerge(store, rootDir, taskId);
await syncDependenciesForMerge(store, rootDir, taskId, options.signal);
}
}
@@ -2779,6 +2895,7 @@ async function executeMergeAttempt(
// - No conflicts (attempt 1) - AI writes commit message
// - Complex conflicts remain after attempt 2 auto-resolution - AI resolves them
// Spawn AI agent
throwIfAborted(options.signal, taskId);
aiTracker.aiWasInvoked = true; // Track that AI was invoked
const agentResult = await runAiAgentForCommit({
store,
@@ -2816,11 +2933,30 @@ async function executeMergeAttempt(
// Run deterministic verification after AI agent commits
if (testCommand || buildCommand) {
await runDeterministicVerification(store, rootDir, taskId, testCommand, buildCommand, testSource, buildSource);
throwIfAborted(options.signal, taskId);
await runDeterministicVerification(
store,
rootDir,
taskId,
testCommand,
buildCommand,
testSource,
buildSource,
options.signal,
);
}
return true;
} catch (error: any) {
if (error instanceof Error && error.name === "MergeAbortedError") {
try {
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
} catch {
// best-effort abort cleanup
}
throw error;
}
// Check if it's a build verification failure - don't retry, propagate immediately
if (error.message?.includes("Build verification failed")) {
throw error; // Fatal - don't retry build failures
@@ -2851,6 +2987,7 @@ async function attemptWithTheirsStrategy(params: MergeAttemptParams): Promise<bo
try {
// Use -X theirs to auto-resolve conflicts favoring the incoming branch
throwIfAborted(params.options.signal, taskId);
await execAsync(`git merge -X theirs --squash "${branch}"`, {
cwd: rootDir,
});
@@ -2876,12 +3013,23 @@ async function attemptWithTheirsStrategy(params: MergeAttemptParams): Promise<bo
// Nothing staged - already merged
// Run deterministic verification even when nothing is staged
if (testCommand || buildCommand) {
await runDeterministicVerification(store, rootDir, taskId, testCommand, buildCommand, testSource, buildSource);
throwIfAborted(params.options.signal, taskId);
await runDeterministicVerification(
store,
rootDir,
taskId,
testCommand,
buildCommand,
testSource,
buildSource,
params.options.signal,
);
}
return true;
}
// Commit with fallback message
throwIfAborted(params.options.signal, taskId);
const escapedLog = commitLog.replace(/"/g, '\\"');
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
const authorArg = getCommitAuthorArg(settings);
@@ -2893,11 +3041,24 @@ async function attemptWithTheirsStrategy(params: MergeAttemptParams): Promise<bo
// Run deterministic verification after committing
if (testCommand || buildCommand) {
await runDeterministicVerification(store, rootDir, taskId, testCommand, buildCommand, testSource, buildSource);
throwIfAborted(params.options.signal, taskId);
await runDeterministicVerification(
store,
rootDir,
taskId,
testCommand,
buildCommand,
testSource,
buildSource,
params.options.signal,
);
}
return true;
} catch (error) {
if (error instanceof Error && error.name === "MergeAbortedError") {
throw error;
}
mergerLog.error(`${taskId}: -X theirs merge failed: ${error}`);
return false;
}
@@ -3015,6 +3176,8 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
mergerInstructions,
);
throwIfAborted(options.signal, taskId);
// Build skill selection context (assigned agent skills take precedence over role fallback)
let skillContext = undefined;
if (options.agentStore) {
@@ -3071,6 +3234,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
try {
await withRateLimitRetry(async () => {
throwIfAborted(options.signal, taskId);
await promptWithFallback(session, prompt);
checkSessionError(session);
}, {
@@ -3078,6 +3242,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
const delaySec = Math.round(delayMs / 1000);
mergerLog.warn(`${taskId} rate limited — retry ${attempt} in ${delaySec}s: ${error.message}`);
},
signal: options.signal,
});
} catch (err: unknown) {
// Context-limit error after promptWithFallback's auto-compaction already attempted recovery.
@@ -3103,6 +3268,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
try {
await withRateLimitRetry(async () => {
throwIfAborted(options.signal, taskId);
await promptWithFallback(session, truncatedPrompt);
checkSessionError(session);
}, {
@@ -3110,6 +3276,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
const delaySec = Math.round(delayMs / 1000);
mergerLog.warn(`${taskId} rate limited during truncated retry — retry ${attempt} in ${delaySec}s: ${error.message}`);
},
signal: options.signal,
});
} catch (retryErr: unknown) {
// Truncated retry also failed: propagate original error
@@ -3144,6 +3311,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
// Only use fallback commit if no build command was configured
// If build command was configured, agent should have committed or reported failure
if (!buildCommand) {
throwIfAborted(options.signal, taskId);
mergerLog.log("Agent didn't commit — committing with fallback message");
const escapedLog = commitLog.replace(/"/g, '\\"');
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
@@ -3298,6 +3466,7 @@ async function runPostMergeWorkflowSteps(
settings: Settings,
mergeOptions: MergerOptions = {},
): Promise<void> {
throwIfAborted(mergeOptions.signal, taskId);
const task = await store.getTask(taskId);
if (!task.enabledWorkflowSteps?.length) return;

View File

@@ -96,6 +96,7 @@ export class ProjectEngine {
private mergeActive = new Set<string>();
private mergeRunning = false;
private activeMergeSession: { dispose: () => void } | null = null;
private mergeAbortController: AbortController | null = null;
private mergeRetryTimer: ReturnType<typeof setTimeout> | null = null;
/**
@@ -227,6 +228,10 @@ export class ProjectEngine {
/**
* Gracefully stop the engine and all subsystems.
*
* If a merge is currently running, its abort signal is triggered before the
* active merge session is disposed so merge pipeline checkpoints can exit
* promptly without continuing git/verification work after shutdown starts.
*/
async stop(): Promise<void> {
this.shuttingDown = true;
@@ -237,6 +242,16 @@ export class ProjectEngine {
this.mergeRetryTimer = null;
}
// Abort active/pending merge work before tearing down sessions.
this.mergeAbortController?.abort();
this.mergeAbortController = null;
const queuedTaskIds = [...this.mergeQueue];
this.mergeQueue.length = 0;
for (const queuedTaskId of queuedTaskIds) {
this.mergeActive.delete(queuedTaskId);
}
// Terminate active merge session
if (this.activeMergeSession) {
this.activeMergeSession.dispose();
@@ -437,6 +452,7 @@ export class ProjectEngine {
}
private internalEnqueueMerge(taskId: string): void {
if (this.shuttingDown) return;
if (this.mergeActive.has(taskId)) return;
this.mergeActive.add(taskId);
this.mergeQueue.push(taskId);
@@ -584,15 +600,18 @@ export class ProjectEngine {
const usageLimitPauser = (this.runtime as any).usageLimitPauser;
const rawMerge = () =>
aiMergeTask(store, cwd, taskId, {
const rawMerge = () => {
this.mergeAbortController = new AbortController();
return aiMergeTask(store, cwd, taskId, {
pool,
usageLimitPauser,
agentStore,
signal: this.mergeAbortController.signal,
onSession: (session) => {
this.activeMergeSession = session;
},
});
};
let result: MergeResult;
if (semaphore) {
@@ -618,6 +637,20 @@ export class ProjectEngine {
} catch (err: unknown) {
this.activeMergeSession = null;
const errorMsg = err instanceof Error ? err.message : String(err);
const mergeWasAborted = err instanceof Error && err.name === "MergeAbortedError";
if (mergeWasAborted) {
runtimeLog.log(`${manualResolver ? "Manual" : "Auto"}-merge aborted for ${taskId}: ${errorMsg}`);
this.mergeAbortController = null;
if (manualResolver) {
this.manualMergeResolvers.delete(taskId);
manualResolver.reject(err instanceof Error ? err : new Error(errorMsg));
} else {
await store.updateTask(taskId, { status: null }).catch(() => undefined);
}
continue;
}
runtimeLog.error(`${manualResolver ? "Manual" : "Auto"}-merge failed for ${taskId}: ${errorMsg}`);
// If this was a manual merge, reject the promise and skip auto-retry logic
@@ -727,6 +760,7 @@ export class ProjectEngine {
}
}
} finally {
this.mergeAbortController = null;
this.mergeActive.delete(taskId);
// If a manual merge was requested while this task was already in-flight,
// the resolver was set but not consumed above. Resolve it now.