feat(FN-3201): restore engine unpause merge sweep and document soft-pause b
The merge restores the engine's unpause merge sweep logic in `project-engine.ts` and documents the soft-pause merge resume behavior across architecture and settings reference docs, with associated test coverage added. Fusion-Task-Id: FN-3201
This commit is contained in:
@@ -335,6 +335,7 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan.
|
|||||||
- `SelfHealingManager` (`self-healing.ts`) — auto-unpause/maintenance recovery actions
|
- `SelfHealingManager` (`self-healing.ts`) — auto-unpause/maintenance recovery actions
|
||||||
- `recoverGhostReviewTasks()` is a fallback only for idle, non-terminal `in-review` states. Terminal/actionable states (notably `status: "failed"`) are preserved and **not** auto-kicked back to `todo`.
|
- `recoverGhostReviewTasks()` is a fallback only for idle, non-terminal `in-review` states. Terminal/actionable states (notably `status: "failed"`) are preserved and **not** auto-kicked back to `todo`.
|
||||||
- `recoverMergeableReviewTasks()` only re-enqueues truly eligible tasks; retry-exhausted review tasks are skipped to avoid re-enqueue/no-op loops that keep refreshing `updatedAt`.
|
- `recoverMergeableReviewTasks()` only re-enqueues truly eligible tasks; retry-exhausted review tasks are skipped to avoid re-enqueue/no-op loops that keep refreshing `updatedAt`.
|
||||||
|
- `ProjectEngine` settings lifecycle handlers (`project-engine.ts`) treat `enginePaused` as a soft pause: clearing it dispatches runtime resume and, when `autoMerge` is enabled, performs an `in-review` eligibility sweep to requeue mergeable review tasks.
|
||||||
- `UsageLimitPauser` (`usage-limit-detector.ts`) and `withRateLimitRetry` (`rate-limit-retry.ts`)
|
- `UsageLimitPauser` (`usage-limit-detector.ts`) and `withRateLimitRetry` (`rate-limit-retry.ts`)
|
||||||
|
|
||||||
### Worktree and naming helpers
|
### Worktree and naming helpers
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
|
|||||||
|---|---|---:|---|
|
|---|---|---:|---|
|
||||||
| `globalPause` | `boolean` | `false` | Hard stop: terminate active engine sessions and pause scheduling immediately. |
|
| `globalPause` | `boolean` | `false` | Hard stop: terminate active engine sessions and pause scheduling immediately. |
|
||||||
| `globalPauseReason` | `string` | `undefined` | Optional reason for `globalPause` (`"rate-limit"` for automatic pauses, `"manual"` for user-triggered pauses). Cleared on unpause. |
|
| `globalPauseReason` | `string` | `undefined` | Optional reason for `globalPause` (`"rate-limit"` for automatic pauses, `"manual"` for user-triggered pauses). Cleared on unpause. |
|
||||||
| `enginePaused` | `boolean` | `false` | Soft pause: stop dispatching new work while letting active sessions finish. |
|
| `enginePaused` | `boolean` | `false` | Soft pause: stop dispatching new work while letting active sessions finish. Clearing `enginePaused` resumes runtime scheduling and, when `autoMerge` is enabled, immediately re-sweeps eligible `in-review` tasks back into the auto-merge queue (paused/blocked/failed review tasks remain skipped). |
|
||||||
| `maxConcurrent` | `number` | `2` | Max concurrent task-lane AI agents (planning, executor, merge). |
|
| `maxConcurrent` | `number` | `2` | Max concurrent task-lane AI agents (planning, executor, merge). |
|
||||||
| `maxTriageConcurrent` | `number` | `2` | Max concurrent planning agents. |
|
| `maxTriageConcurrent` | `number` | `2` | Max concurrent planning agents. |
|
||||||
| `globalMaxConcurrent` | `number` | `4` | System-wide max concurrent agents across all projects. |
|
| `globalMaxConcurrent` | `number` | `4` | System-wide max concurrent agents across all projects. |
|
||||||
|
|||||||
@@ -1479,10 +1479,12 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("engine unpause sweep does not enqueue paused in-review tasks", async () => {
|
it("engine unpause sweep re-enqueues only merge-eligible in-review tasks", async () => {
|
||||||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||||||
mocks.currentStore = mockStore.store;
|
mocks.currentStore = mockStore.store;
|
||||||
const engine = createEngine();
|
const engine = createEngine({
|
||||||
|
getTaskMergeBlocker: (task) => (task.id === "FN-blocked" ? "blocked" : null),
|
||||||
|
});
|
||||||
const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void };
|
const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void };
|
||||||
const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge");
|
const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge");
|
||||||
|
|
||||||
@@ -1490,6 +1492,8 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => {
|
|||||||
enqueueSpy.mockClear();
|
enqueueSpy.mockClear();
|
||||||
mockStore.store.listTasks.mockResolvedValueOnce([
|
mockStore.store.listTasks.mockResolvedValueOnce([
|
||||||
{ id: "FN-paused", column: "in-review", paused: true, mergeRetries: 0, status: null },
|
{ id: "FN-paused", column: "in-review", paused: true, mergeRetries: 0, status: null },
|
||||||
|
{ id: "FN-failed", column: "in-review", paused: false, mergeRetries: 0, status: "failed" },
|
||||||
|
{ id: "FN-blocked", column: "in-review", paused: false, mergeRetries: 0, status: null },
|
||||||
{ id: "FN-ready", column: "in-review", paused: false, mergeRetries: 0, status: null },
|
{ id: "FN-ready", column: "in-review", paused: false, mergeRetries: 0, status: null },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -1500,6 +1504,8 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => {
|
|||||||
|
|
||||||
expect(enqueueSpy).toHaveBeenCalledWith("FN-ready");
|
expect(enqueueSpy).toHaveBeenCalledWith("FN-ready");
|
||||||
expect(enqueueSpy).not.toHaveBeenCalledWith("FN-paused");
|
expect(enqueueSpy).not.toHaveBeenCalledWith("FN-paused");
|
||||||
|
expect(enqueueSpy).not.toHaveBeenCalledWith("FN-failed");
|
||||||
|
expect(enqueueSpy).not.toHaveBeenCalledWith("FN-blocked");
|
||||||
|
|
||||||
await engine.stop();
|
await engine.stop();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1790,6 +1790,39 @@ export class ProjectEngine {
|
|||||||
|
|
||||||
// ── Settings event listeners ──
|
// ── Settings event listeners ──
|
||||||
|
|
||||||
|
private async resumeAfterUnpauseAndSweepInReview(
|
||||||
|
store: TaskStore,
|
||||||
|
settings: Settings,
|
||||||
|
source: "Global unpause" | "Engine unpause",
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const runtime = this.runtime as any;
|
||||||
|
runtime.resumeAfterUnpause?.().catch((err: Error) =>
|
||||||
|
runtimeLog.error(
|
||||||
|
`Failed to resume agentic activity on ${source.toLowerCase()}:`,
|
||||||
|
err,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
runtimeLog.warn(
|
||||||
|
`${source}: failed to dispatch resumeAfterUnpause: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.globalPause || settings.enginePaused || !settings.autoMerge) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tasks = await store.listTasks({ column: "in-review" });
|
||||||
|
this.enqueueEligibleInReviewTasks(tasks as Task[]);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
runtimeLog.warn(
|
||||||
|
`${source}: failed to scan in-review tasks for auto-merge: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private wireSettingsListeners(store: TaskStore): void {
|
private wireSettingsListeners(store: TaskStore): void {
|
||||||
// 1. Global pause — terminate active merge session AND abort any running
|
// 1. Global pause — terminate active merge session AND abort any running
|
||||||
// deterministic verification (pnpm test/build). The abort controller gates
|
// deterministic verification (pnpm test/build). The abort controller gates
|
||||||
@@ -1822,28 +1855,7 @@ export class ProjectEngine {
|
|||||||
}) => {
|
}) => {
|
||||||
if (prev.globalPause && !s.globalPause) {
|
if (prev.globalPause && !s.globalPause) {
|
||||||
runtimeLog.log("Global unpause — resuming agentic activity");
|
runtimeLog.log("Global unpause — resuming agentic activity");
|
||||||
|
await this.resumeAfterUnpauseAndSweepInReview(store, s, "Global unpause");
|
||||||
try {
|
|
||||||
const runtime = this.runtime as any;
|
|
||||||
runtime.resumeAfterUnpause?.().catch((err: Error) =>
|
|
||||||
runtimeLog.error("Failed to resume agentic activity on unpause:", err),
|
|
||||||
);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
runtimeLog.warn(
|
|
||||||
`Global unpause: failed to dispatch resumeAfterUnpause: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (s.autoMerge) {
|
|
||||||
try {
|
|
||||||
const tasks = await store.listTasks({ column: "in-review" });
|
|
||||||
this.enqueueEligibleInReviewTasks(tasks as Task[]);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
runtimeLog.warn(
|
|
||||||
`Global unpause: failed to scan in-review tasks for auto-merge: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
store.on("settings:updated", onGlobalUnpause);
|
store.on("settings:updated", onGlobalUnpause);
|
||||||
@@ -1859,28 +1871,7 @@ export class ProjectEngine {
|
|||||||
}) => {
|
}) => {
|
||||||
if (prev.enginePaused && !s.enginePaused) {
|
if (prev.enginePaused && !s.enginePaused) {
|
||||||
runtimeLog.log("Engine unpaused — resuming agentic activity");
|
runtimeLog.log("Engine unpaused — resuming agentic activity");
|
||||||
|
await this.resumeAfterUnpauseAndSweepInReview(store, s, "Engine unpause");
|
||||||
try {
|
|
||||||
const runtime = this.runtime as any;
|
|
||||||
runtime.resumeAfterUnpause?.().catch((err: Error) =>
|
|
||||||
runtimeLog.error("Failed to resume agentic activity on engine unpause:", err),
|
|
||||||
);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
runtimeLog.warn(
|
|
||||||
`Engine unpause: failed to dispatch resumeAfterUnpause: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (s.autoMerge) {
|
|
||||||
try {
|
|
||||||
const tasks = await store.listTasks({ column: "in-review" });
|
|
||||||
this.enqueueEligibleInReviewTasks(tasks as Task[]);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
runtimeLog.warn(
|
|
||||||
`Engine unpause: failed to scan in-review tasks for auto-merge: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
store.on("settings:updated", onEngineUnpause);
|
store.on("settings:updated", onEngineUnpause);
|
||||||
|
|||||||
Reference in New Issue
Block a user