feat(FN-2608): merge fusion/fn-2608 (auto-resolved)

- feat(FN-2608): complete Step 6 — verify lint test build gates
- feat(FN-2608): complete Step 5 — resume auto-merge on in-review unpause
- feat(FN-2608): complete Step 4 — pause-aware self-healing recovery
- feat(FN-2608): complete Step 3 — interrupt active merges on pause
- feat(FN-2608): complete Step 2 — guard auto-merge for paused review tasks
- feat(FN-2608): complete Step 1 — pause status for in-review tasks
This commit is contained in:
Fusion
2026-04-26 13:39:27 -07:00
committed by gsxdsm
parent 16ec2047cb
commit 79ce48c51e
7 changed files with 390 additions and 9 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix pausing behavior for in-review tasks so stop fully halts merge activity. Paused in-review tasks are now marked with paused status, removed from merge queues, active merge sessions are aborted/disposed, self-healing recovery skips paused tasks, and unpausing re-enqueues eligible review tasks for auto-merge.

View File

@@ -3474,6 +3474,21 @@ describe("TaskStore", () => {
expect(unpaused.status).toBeUndefined();
});
it("sets and clears paused status for in-review tasks", async () => {
const task = await createTestTask();
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
const paused = await store.pauseTask(task.id, true);
expect(paused.paused).toBe(true);
expect(paused.status).toBe("paused");
const unpaused = await store.pauseTask(task.id, false);
expect(unpaused.paused).toBeUndefined();
expect(unpaused.status).toBeUndefined();
});
it("round-trips pause/unpause correctly", async () => {
const task = await createTestTask();

View File

@@ -2828,9 +2828,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
task.paused = paused || undefined;
// When pausing an in-progress task, set status so the UI can show the state.
// When pausing an in-progress/in-review task, set status so the UI can show the state.
// When unpausing, clear the "paused" status.
if (task.column === "in-progress") {
if (task.column === "in-progress" || task.column === "in-review") {
task.status = paused ? "paused" : undefined;
}
const now = new Date().toISOString();

View File

@@ -100,8 +100,14 @@ function createMockStore(initialSettings: Record<string, unknown>) {
const store = {
getSettings: vi.fn(async () => structuredClone(settings)),
listTasks: vi.fn(async () => []),
getTask: vi.fn(async (taskId: string) => ({ id: taskId, column: "in-review", mergeRetries: 0, status: null })),
listTasks: vi.fn(async (): Promise<Array<Record<string, unknown>>> => []),
getTask: vi.fn(async (taskId: string): Promise<Record<string, unknown>> => ({
id: taskId,
column: "in-review",
paused: false,
mergeRetries: 0,
status: null,
})),
updateTask: vi.fn(async () => undefined),
moveTask: vi.fn(async () => undefined),
updateSettings: vi.fn(async (patch: Record<string, unknown>) => {
@@ -826,6 +832,223 @@ describe("ProjectEngine shutdown merge handling", () => {
});
});
describe("ProjectEngine paused in-review auto-merge behavior", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("does not enqueue paused tasks from task:moved into in-review", async () => {
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
mocks.currentStore = mockStore.store;
const engine = createEngine();
const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void };
const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge");
await engine.start();
const taskMovedHandler = mockStore.store.on.mock.calls.find((c: unknown[]) => c[0] === "task:moved")?.[1] as
| ((payload: { task: { id: string; column: string; paused?: boolean }; to: string }) => Promise<void>)
| undefined;
if (!taskMovedHandler) throw new Error("task:moved handler was not registered");
await taskMovedHandler({
task: { id: "FN-paused", column: "in-review", paused: true },
to: "in-review",
});
expect(enqueueSpy).not.toHaveBeenCalledWith("FN-paused");
await engine.stop();
});
it("re-enqueues an in-review task when it is unpaused", async () => {
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
mocks.currentStore = mockStore.store;
const engine = createEngine();
const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void };
const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge");
await engine.start();
enqueueSpy.mockClear();
const taskUpdatedHandler = mockStore.store.on.mock.calls.find((c: unknown[]) => c[0] === "task:updated")?.[1] as
| ((task: { id: string; column: string; paused?: boolean; status?: string | null }) => Promise<void>)
| undefined;
if (!taskUpdatedHandler) throw new Error("task:updated handler was not registered");
await taskUpdatedHandler({ id: "FN-unpause", column: "in-review", paused: true, status: "paused" });
await taskUpdatedHandler({ id: "FN-unpause", column: "in-review", paused: false, status: null });
expect(enqueueSpy).toHaveBeenCalledWith("FN-unpause");
await engine.stop();
});
it("logs and skips paused tasks dequeued for auto-merge", async () => {
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
mockStore.store.getTask.mockResolvedValueOnce({
id: "FN-paused",
column: "in-review",
paused: true,
mergeRetries: 0,
status: null,
});
mocks.currentStore = mockStore.store;
const logSpy = vi.spyOn(runtimeLog, "log").mockImplementation(() => {});
const engine = createEngine();
await engine.start();
engine.enqueueMerge("FN-paused");
await vi.waitFor(() => {
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Auto-merge skipping FN-paused — task is paused"));
});
expect(mocks.aiMergeTask).not.toHaveBeenCalled();
logSpy.mockRestore();
await engine.stop();
});
it("aborts and disposes active merge session when an in-review task is paused", async () => {
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
mockStore.store.getTask.mockResolvedValue({
id: "FN-active",
column: "in-review",
paused: false,
mergeRetries: 0,
status: null,
});
mocks.currentStore = mockStore.store;
let capturedSignal: AbortSignal | undefined;
const disposeSession = vi.fn();
mocks.aiMergeTask.mockImplementationOnce(async (...args: unknown[]) => {
const options = args[3] as { signal?: AbortSignal; onSession?: (session: { dispose: () => void }) => void };
capturedSignal = options.signal;
options.onSession?.({ dispose: disposeSession });
await new Promise<never>((_, reject) => {
options.signal?.addEventListener(
"abort",
() => {
const abortError = new Error("merge aborted");
abortError.name = "MergeAbortedError";
reject(abortError);
},
{ once: true },
);
});
});
const engine = createEngine();
const privateEngine = engine as unknown as {
mergeQueue: string[];
mergeActive: Set<string>;
activeMergeSession: { dispose: () => void } | null;
mergeAbortController: AbortController | null;
activeMergeTaskId: string | null;
};
await engine.start();
engine.enqueueMerge("FN-active");
await vi.waitFor(() => {
expect(mocks.aiMergeTask).toHaveBeenCalledTimes(1);
});
const taskUpdatedHandler = mockStore.store.on.mock.calls.find((c: unknown[]) => c[0] === "task:updated")?.[1] as
| ((task: { id: string; column: string; paused?: boolean }) => void)
| undefined;
if (!taskUpdatedHandler) throw new Error("task:updated handler was not registered");
taskUpdatedHandler({ id: "FN-active", column: "in-review", paused: true });
await vi.waitFor(() => {
expect(capturedSignal?.aborted).toBe(true);
});
expect(disposeSession).toHaveBeenCalledTimes(1);
await vi.waitFor(() => {
expect(privateEngine.mergeQueue).not.toContain("FN-active");
expect(privateEngine.mergeActive.has("FN-active")).toBe(false);
expect(privateEngine.activeMergeSession).toBeNull();
expect(privateEngine.mergeAbortController).toBeNull();
expect(privateEngine.activeMergeTaskId).toBeNull();
});
await engine.stop();
});
it("startup merge sweep skips paused in-review tasks", async () => {
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
mockStore.store.listTasks.mockResolvedValueOnce([
{ id: "FN-paused", column: "in-review", paused: true, mergeRetries: 0, status: null },
{ id: "FN-ready", column: "in-review", paused: false, mergeRetries: 0, status: null },
]);
mocks.currentStore = mockStore.store;
const engine = createEngine();
const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void };
const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge");
await engine.start();
expect(enqueueSpy).toHaveBeenCalledWith("FN-ready");
expect(enqueueSpy).not.toHaveBeenCalledWith("FN-paused");
await engine.stop();
});
it("global unpause sweep does not enqueue paused in-review tasks", async () => {
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
mocks.currentStore = mockStore.store;
const engine = createEngine();
const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void };
const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge");
await engine.start();
enqueueSpy.mockClear();
mockStore.store.listTasks.mockResolvedValueOnce([
{ id: "FN-paused", column: "in-review", paused: true, mergeRetries: 0, status: null },
{ id: "FN-ready", column: "in-review", paused: false, mergeRetries: 0, status: null },
]);
await mockStore.emitSettingsUpdated(
{ ...baseSettings, autoMerge: true, globalPause: false },
{ ...baseSettings, autoMerge: true, globalPause: true },
);
expect(enqueueSpy).toHaveBeenCalledWith("FN-ready");
expect(enqueueSpy).not.toHaveBeenCalledWith("FN-paused");
await engine.stop();
});
it("engine unpause sweep does not enqueue paused in-review tasks", async () => {
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
mocks.currentStore = mockStore.store;
const engine = createEngine();
const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void };
const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge");
await engine.start();
enqueueSpy.mockClear();
mockStore.store.listTasks.mockResolvedValueOnce([
{ id: "FN-paused", column: "in-review", paused: true, mergeRetries: 0, status: null },
{ id: "FN-ready", column: "in-review", paused: false, mergeRetries: 0, status: null },
]);
await mockStore.emitSettingsUpdated(
{ ...baseSettings, autoMerge: true, enginePaused: false },
{ ...baseSettings, autoMerge: true, enginePaused: true },
);
expect(enqueueSpy).toHaveBeenCalledWith("FN-ready");
expect(enqueueSpy).not.toHaveBeenCalledWith("FN-paused");
await engine.stop();
});
});
describe("ProjectEngine swallowed error hardening", () => {
let warnSpy: ReturnType<typeof vi.spyOn>;

View File

@@ -1429,6 +1429,34 @@ describe("SelfHealingManager", () => {
managerWithRecovery.stop();
});
it("skips paused in-review tasks even when otherwise mergeable", async () => {
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
});
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-352-paused",
column: "in-review",
paused: true,
status: "paused",
error: null,
worktree: "/tmp/test-project/.worktrees/fn-352-paused",
steps: [{ name: "Ship it", status: "done" }],
workflowStepResults: [{ id: "ws-1", status: "passed", phase: "pre-merge" }],
mergeDetails: undefined,
log: [],
},
]);
const result = await managerWithRecovery.recoverMergeableReviewTasks();
expect(result).toBe(0);
expect(store.mergeTask).not.toHaveBeenCalled();
managerWithRecovery.stop();
});
it("ignores in-review tasks that are not yet mergeable", async () => {
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
@@ -1673,6 +1701,28 @@ describe("SelfHealingManager", () => {
managerWithoutCallback.stop();
});
it("skips paused tasks", async () => {
const recoverFn = vi.fn().mockResolvedValue(true);
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
recoverFailedPreMergeStep: recoverFn,
});
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
maxPostReviewFixes: 1,
});
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{ ...baseTask, paused: true, status: "paused" },
]);
const result = await managerWithRecovery.recoverReviewTasksWithFailedPreMergeSteps();
expect(result).toBe(0);
expect(recoverFn).not.toHaveBeenCalled();
expect(store.updateTask).not.toHaveBeenCalled();
managerWithRecovery.stop();
});
it("skips tasks without a worktree (cannot re-execute safely)", async () => {
const recoverFn = vi.fn().mockResolvedValue(true);
const managerWithRecovery = new SelfHealingManager(store, {

View File

@@ -123,8 +123,10 @@ export class ProjectEngine {
// ── Auto-merge state ──
private mergeQueue: string[] = [];
private mergeActive = new Set<string>();
private pausedReviewTaskIds = new Set<string>();
private mergeRunning = false;
private activeMergeSession: { dispose: () => void } | null = null;
private activeMergeTaskId: string | null = null;
private mergeAbortController: AbortController | null = null;
private mergeRetryTimer: ReturnType<typeof setTimeout> | null = null;
@@ -146,6 +148,7 @@ export class ProjectEngine {
// Event handler references for cleanup
private settingsHandlers: Array<(...args: any[]) => void> = [];
private taskMovedHandler?: (...args: any[]) => void;
private taskUpdatedHandler?: (...args: any[]) => void;
constructor(
private config: ProjectRuntimeConfig,
@@ -252,8 +255,9 @@ export class ProjectEngine {
// 5. Wire settings event listeners
this.wireSettingsListeners(store);
// 6. Wire auto-merge on task:moved
// 6. Wire auto-merge on task:moved and task:updated pause interruptions
this.wireAutoMerge(store, cwd);
this.wireTaskPauseMergeInterruption(store);
// 7. Auto-merge startup sweep
await this.startupMergeSweep(store);
@@ -283,6 +287,8 @@ export class ProjectEngine {
// Abort active/pending merge work before tearing down sessions.
this.mergeAbortController?.abort();
this.mergeAbortController = null;
this.activeMergeTaskId = null;
this.pausedReviewTaskIds.clear();
const queuedTaskIds = [...this.mergeQueue];
this.mergeQueue.length = 0;
@@ -311,6 +317,9 @@ export class ProjectEngine {
if (this.taskMovedHandler) {
store.off("task:moved", this.taskMovedHandler);
}
if (this.taskUpdatedHandler) {
store.off("task:updated", this.taskUpdatedHandler);
}
} catch {
// Store may not be initialized if start() failed partway
}
@@ -851,6 +860,10 @@ export class ProjectEngine {
if (!task || task.column !== "in-review") {
continue;
}
if (task.paused) {
runtimeLog.log(`Auto-merge skipping ${taskId} — task is paused`);
continue;
}
// Intentional cast to access Task properties needed by merge validation
@@ -929,6 +942,7 @@ export class ProjectEngine {
const mergeStrategy = this.options.getMergeStrategy?.(settings) ?? "direct";
if (mergeStrategy === "pull-request" && this.options.processPullRequestMerge) {
this.activeMergeTaskId = taskId;
runtimeLog.log(`${manualResolver ? "Manual" : "Auto"}-merge processing PR flow for ${taskId}...`);
const result = await this.options.processPullRequestMerge(store, cwd, taskId);
if (result === "merged") {
@@ -962,6 +976,7 @@ export class ProjectEngine {
const usageLimitPauser = (this.runtime as any).usageLimitPauser;
const rawMerge = () => {
this.activeMergeTaskId = taskId;
this.mergeAbortController = new AbortController();
return aiMergeTask(store, cwd, taskId, {
pool,
@@ -1121,6 +1136,9 @@ export class ProjectEngine {
}
}
} finally {
if (this.activeMergeTaskId === taskId) {
this.activeMergeTaskId = null;
}
this.mergeAbortController = null;
this.mergeActive.delete(taskId);
// If a manual merge was requested while this task was already in-flight,
@@ -1147,6 +1165,7 @@ export class ProjectEngine {
private wireAutoMerge(store: TaskStore, _cwd: string): void {
this.taskMovedHandler = async ({ task, to }: { task: Task; to: string }) => {
if (to !== "in-review") return;
if (task.paused) return;
if (this.options.getTaskMergeBlocker?.(task)) return;
try {
const settings = await store.getSettings();
@@ -1162,6 +1181,68 @@ export class ProjectEngine {
store.on("task:moved", this.taskMovedHandler);
}
private wireTaskPauseMergeInterruption(store: TaskStore): void {
this.taskUpdatedHandler = async (task: Task) => {
if (task.column !== "in-review") {
this.pausedReviewTaskIds.delete(task.id);
return;
}
if (task.paused) {
this.pausedReviewTaskIds.add(task.id);
const queueLengthBefore = this.mergeQueue.length;
this.mergeQueue = this.mergeQueue.filter((queuedTaskId) => queuedTaskId !== task.id);
const removedFromQueue = this.mergeQueue.length !== queueLengthBefore;
if (removedFromQueue) {
this.mergeActive.delete(task.id);
runtimeLog.log(`Paused in-review task removed from merge queue: ${task.id}`);
}
if (this.activeMergeTaskId !== task.id) {
return;
}
runtimeLog.log(`Paused in-review task interrupting active merge: ${task.id}`);
this.mergeAbortController?.abort();
this.mergeAbortController = null;
if (this.activeMergeSession) {
this.activeMergeSession.dispose();
this.activeMergeSession = null;
}
this.mergeActive.delete(task.id);
return;
}
const wasPaused = this.pausedReviewTaskIds.delete(task.id);
if (!wasPaused) {
return;
}
try {
const settings = await store.getSettings();
if (settings.globalPause || settings.enginePaused || !settings.autoMerge) {
return;
}
if (this.options.getTaskMergeBlocker?.(task)) {
return;
}
runtimeLog.log(`Unpaused in-review task re-enqueued for auto-merge: ${task.id}`);
this.internalEnqueueMerge(task.id);
} catch (err: unknown) {
runtimeLog.warn(
`In-review unpause: failed to re-enqueue ${task.id} for auto-merge: ${err instanceof Error ? err.message : String(err)}`,
);
}
};
store.on("task:updated", this.taskUpdatedHandler);
}
private async startupMergeSweep(store: TaskStore): Promise<void> {
try {
const tasks = await store.listTasks({ column: "in-review" });
@@ -1186,7 +1267,7 @@ export class ProjectEngine {
if (!settings.autoMerge) return;
const eligible = tasks.filter((t) => this.canMergeTask(t as any));
const eligible = tasks.filter((t) => !t.paused && this.canMergeTask(t as any));
if (eligible.length > 0) {
runtimeLog.log(`Auto-merge startup sweep: enqueueing ${eligible.length} task(s)`);
for (const t of eligible) {
@@ -1211,7 +1292,9 @@ export class ProjectEngine {
if (!settings.globalPause && !settings.enginePaused && settings.autoMerge) {
const tasks = await store.listTasks({ column: "in-review" });
for (const t of tasks) {
if (t.paused) {
continue;
}
if (this.canMergeTask(t as any)) {
this.internalEnqueueMerge(t.id);
}
@@ -1284,7 +1367,9 @@ export class ProjectEngine {
try {
const tasks = await store.listTasks({ column: "in-review" });
for (const t of tasks) {
if (t.paused) {
continue;
}
if (this.canMergeTask(t as any)) {
this.internalEnqueueMerge(t.id);
}
@@ -1327,7 +1412,9 @@ export class ProjectEngine {
try {
const tasks = await store.listTasks({ column: "in-review" });
for (const t of tasks) {
if (t.paused) {
continue;
}
if (this.canMergeTask(t as any)) {
this.internalEnqueueMerge(t.id);
}

View File

@@ -708,6 +708,7 @@ export class SelfHealingManager {
const mergeable = tasks.filter((t) =>
t.column === "in-review" &&
!t.paused &&
Boolean(t.worktree) &&
t.mergeDetails?.mergeConfirmed !== true &&
getTaskMergeBlocker(t) === undefined,