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:
5
.changeset/fn-2546-scheduled-catchup-reliability.md
Normal file
5
.changeset/fn-2546-scheduled-catchup-reliability.md
Normal 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.
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user