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:
5
.changeset/fix-stop-in-review-tasks.md
Normal file
5
.changeset/fix-stop-in-review-tasks.md
Normal 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.
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user