fix(FN-2546): harden scheduled automation catch-up reliability

- Preserve overdue nextRunAt when schedule updates only touch non-cadence fields
- Recompute nextRunAt only when cadence changes, schedules are re-enabled, or nextRunAt is missing
- Sync memory dreams automation during ProjectEngine startup before CronRunner begins ticking
- Add core/engine regression coverage and a patch changeset for @runfusion/fusion release notes
This commit is contained in:
Fusion
2026-04-25 17:06:38 -07:00
committed by gsxdsm
parent 856146cced
commit 39622f0398
5 changed files with 112 additions and 5 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix scheduled automations so overdue runs catch up reliably after server downtime. Startup/settings sync no longer pushes unchanged overdue schedules into the future, and memory dreams automation is now synchronized during engine startup before cron begins ticking.

View File

@@ -317,6 +317,72 @@ describe("AutomationStore", () => {
expect(reenabled.nextRunAt).toBeTruthy(); expect(reenabled.nextRunAt).toBeTruthy();
}); });
it("preserves overdue nextRunAt when updating non-cadence fields", async () => {
const schedule = await store.createSchedule({
name: "Catch-up",
command: "echo catch-up",
scheduleType: "hourly",
});
const overdue = new Date(Date.now() - 60_000).toISOString();
store["db"].prepare("UPDATE automations SET nextRunAt = ? WHERE id = ?").run(overdue, schedule.id);
const updated = await store.updateSchedule(schedule.id, {
description: "updated description",
});
expect(updated.nextRunAt).toBe(overdue);
});
it("recomputes nextRunAt when cadence changes", async () => {
const schedule = await store.createSchedule({
name: "Cadence",
command: "echo cadence",
scheduleType: "hourly",
});
const overdue = new Date(Date.now() - 60_000).toISOString();
store["db"].prepare("UPDATE automations SET nextRunAt = ? WHERE id = ?").run(overdue, schedule.id);
const updated = await store.updateSchedule(schedule.id, {
scheduleType: "custom",
cronExpression: "*/5 * * * *",
});
expect(updated.nextRunAt).not.toBe(overdue);
expect(new Date(updated.nextRunAt ?? 0).getTime()).toBeGreaterThan(Date.now() - 1000);
});
it("recomputes nextRunAt when enabling from disabled state", async () => {
const schedule = await store.createSchedule({
name: "Enable",
command: "echo enable",
scheduleType: "hourly",
enabled: false,
});
const updated = await store.updateSchedule(schedule.id, {
enabled: true,
});
expect(updated.nextRunAt).toBeTruthy();
expect(new Date(updated.nextRunAt ?? 0).getTime()).toBeGreaterThan(Date.now() - 1000);
});
it("recomputes nextRunAt when missing on enabled schedule", async () => {
const schedule = await store.createSchedule({
name: "Missing next run",
command: "echo missing",
scheduleType: "hourly",
});
store["db"].prepare("UPDATE automations SET nextRunAt = NULL WHERE id = ?").run(schedule.id);
const updated = await store.updateSchedule(schedule.id, {
command: "echo changed",
});
expect(updated.nextRunAt).toBeTruthy();
expect(new Date(updated.nextRunAt ?? 0).getTime()).toBeGreaterThan(Date.now() - 1000);
});
it("rejects empty name", async () => { it("rejects empty name", async () => {
const schedule = await store.createSchedule({ const schedule = await store.createSchedule({
name: "Test", name: "Test",

View File

@@ -254,6 +254,9 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
async updateSchedule(id: string, updates: ScheduledTaskUpdateInput): Promise<ScheduledTask> { async updateSchedule(id: string, updates: ScheduledTaskUpdateInput): Promise<ScheduledTask> {
return this.withScheduleLock(id, async () => { return this.withScheduleLock(id, async () => {
const schedule = await this.getSchedule(id); const schedule = await this.getSchedule(id);
const previousEnabled = schedule.enabled;
const previousScheduleType = schedule.scheduleType;
const previousCronExpression = schedule.cronExpression;
if (updates.name !== undefined) { if (updates.name !== undefined) {
if (!updates.name.trim()) throw new Error("Name cannot be empty"); if (!updates.name.trim()) throw new Error("Name cannot be empty");
@@ -299,11 +302,16 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
schedule.enabled = updates.enabled; schedule.enabled = updates.enabled;
} }
// Recompute next run if enabled const cadenceChanged =
if (schedule.enabled) { schedule.scheduleType !== previousScheduleType ||
schedule.nextRunAt = this.computeNextRun(schedule.cronExpression); schedule.cronExpression !== previousCronExpression;
} else { const enabledFromDisabled = !previousEnabled && schedule.enabled;
const missingNextRunAt = !schedule.nextRunAt;
if (!schedule.enabled) {
schedule.nextRunAt = undefined; schedule.nextRunAt = undefined;
} else if (cadenceChanged || enabledFromDisabled || missingNextRunAt) {
schedule.nextRunAt = this.computeNextRun(schedule.cronExpression);
} }
schedule.updatedAt = new Date().toISOString(); schedule.updatedAt = new Date().toISOString();

View File

@@ -5,6 +5,7 @@ import { runtimeLog } from "../logger.js";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
syncInsightExtractionAutomation: vi.fn(), syncInsightExtractionAutomation: vi.fn(),
syncAutoSummarizeAutomation: vi.fn(), syncAutoSummarizeAutomation: vi.fn(),
syncMemoryDreamsAutomation: vi.fn(),
automationStoreInit: vi.fn(async () => undefined), automationStoreInit: vi.fn(async () => undefined),
createAiPromptExecutor: vi.fn(async () => vi.fn()), createAiPromptExecutor: vi.fn(async () => vi.fn()),
cronRunnerStart: vi.fn(), cronRunnerStart: vi.fn(),
@@ -25,6 +26,7 @@ vi.mock("@fusion/core", async () => {
AutomationStore: MockAutomationStore, AutomationStore: MockAutomationStore,
syncInsightExtractionAutomation: mocks.syncInsightExtractionAutomation, syncInsightExtractionAutomation: mocks.syncInsightExtractionAutomation,
syncAutoSummarizeAutomation: mocks.syncAutoSummarizeAutomation, syncAutoSummarizeAutomation: mocks.syncAutoSummarizeAutomation,
syncMemoryDreamsAutomation: mocks.syncMemoryDreamsAutomation,
}; };
}); });
@@ -119,6 +121,8 @@ const baseSettings: Record<string, unknown> = {
memoryAutoSummarizeEnabled: false, memoryAutoSummarizeEnabled: false,
memoryAutoSummarizeThresholdChars: 50_000, memoryAutoSummarizeThresholdChars: 50_000,
memoryAutoSummarizeSchedule: "0 3 * * *", memoryAutoSummarizeSchedule: "0 3 * * *",
memoryDreamsEnabled: false,
memoryDreamsSchedule: "0 4 * * *",
insightExtractionEnabled: false, insightExtractionEnabled: false,
insightExtractionSchedule: "0 3 * * *", insightExtractionSchedule: "0 3 * * *",
insightExtractionMinIntervalMs: 0, insightExtractionMinIntervalMs: 0,
@@ -145,17 +149,31 @@ describe("ProjectEngine auto-summarize wiring", () => {
mocks.currentStore = mockStore.store; mocks.currentStore = mockStore.store;
}); });
it("syncs auto-summarize automation on startup using one settings snapshot", async () => { it("syncs startup memory automations using one settings snapshot", async () => {
const engine = createEngine(); const engine = createEngine();
await engine.start(); await engine.start();
expect(mocks.syncInsightExtractionAutomation).toHaveBeenCalledTimes(1); expect(mocks.syncInsightExtractionAutomation).toHaveBeenCalledTimes(1);
expect(mocks.syncAutoSummarizeAutomation).toHaveBeenCalledTimes(1); expect(mocks.syncAutoSummarizeAutomation).toHaveBeenCalledTimes(1);
expect(mocks.syncMemoryDreamsAutomation).toHaveBeenCalledTimes(1);
const insightSettings = mocks.syncInsightExtractionAutomation.mock.calls[0][1]; const insightSettings = mocks.syncInsightExtractionAutomation.mock.calls[0][1];
const autoSummarizeSettings = mocks.syncAutoSummarizeAutomation.mock.calls[0][1]; const autoSummarizeSettings = mocks.syncAutoSummarizeAutomation.mock.calls[0][1];
const memoryDreamsSettings = mocks.syncMemoryDreamsAutomation.mock.calls[0][1];
expect(autoSummarizeSettings).toBe(insightSettings); expect(autoSummarizeSettings).toBe(insightSettings);
expect(memoryDreamsSettings).toBe(insightSettings);
const cronRunnerStartOrder = mocks.cronRunnerStart.mock.invocationCallOrder[0];
expect(mocks.syncInsightExtractionAutomation.mock.invocationCallOrder[0]).toBeLessThan(
cronRunnerStartOrder,
);
expect(mocks.syncAutoSummarizeAutomation.mock.invocationCallOrder[0]).toBeLessThan(
cronRunnerStartOrder,
);
expect(mocks.syncMemoryDreamsAutomation.mock.invocationCallOrder[0]).toBeLessThan(
cronRunnerStartOrder,
);
await engine.stop(); await engine.stop();
}); });

View File

@@ -190,6 +190,16 @@ export class ProjectEngine {
// syncAutoSummarizeAutomation may not be exported yet // syncAutoSummarizeAutomation may not be exported yet
} }
// Sync memory dreams automation on startup
try {
const { syncMemoryDreamsAutomation } = await import("@fusion/core");
if (typeof syncMemoryDreamsAutomation === "function") {
await syncMemoryDreamsAutomation(this.automationStore, settings);
}
} catch {
// syncMemoryDreamsAutomation may not be exported yet
}
this.cronRunner.start(); this.cronRunner.start();
runtimeLog.log("CronRunner initialized and started"); runtimeLog.log("CronRunner initialized and started");
} catch (err) { } catch (err) {