fix: align routine system with actual RoutineStore/Routine APIs to prevent CLI crash

The RoutineRunner and RoutineScheduler were written against a different
interface than what RoutineStore actually implements, causing TypeError
crashes as soon as any routine became due. This adds the missing
agentId/catchUpLimit fields to the Routine type and DB schema, adds
startRoutineExecution/completeRoutineExecution/cancelRoutineExecution
methods to RoutineStore, and fixes all property name mismatches
(lastExecutedAt→lastRunAt, trigger.cron→trigger.cronExpression,
policy value alignment) in the runner, scheduler, and tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-10 11:26:19 -07:00
parent d693e45f2b
commit fa4c9f8841
8 changed files with 1123 additions and 4 deletions

View File

@@ -397,12 +397,14 @@ CREATE TABLE IF NOT EXISTS plugins (
-- Routines table for recurring task automation
CREATE TABLE IF NOT EXISTS routines (
id TEXT PRIMARY KEY,
agentId TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
description TEXT,
triggerType TEXT NOT NULL,
triggerConfig TEXT NOT NULL,
catchUpPolicy TEXT NOT NULL DEFAULT 'run_one',
executionPolicy TEXT NOT NULL DEFAULT 'queue',
catchUpLimit INTEGER DEFAULT 5,
enabled INTEGER DEFAULT 1,
lastRunAt TEXT,
lastRunResult TEXT,
@@ -970,12 +972,14 @@ export class Database {
this.db.exec(`
CREATE TABLE IF NOT EXISTS routines (
id TEXT PRIMARY KEY,
agentId TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
description TEXT,
triggerType TEXT NOT NULL,
triggerConfig TEXT NOT NULL,
catchUpPolicy TEXT NOT NULL DEFAULT 'run_one',
executionPolicy TEXT NOT NULL DEFAULT 'queue',
catchUpLimit INTEGER DEFAULT 5,
enabled INTEGER DEFAULT 1,
lastRunAt TEXT,
lastRunResult TEXT,

View File

@@ -105,6 +105,7 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
return {
id: row.id,
agentId: row.agentId || "",
name: row.name,
description: row.description || undefined,
trigger,
@@ -116,6 +117,7 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
nextRunAt: row.nextRunAt || undefined,
runCount: row.runCount || 0,
runHistory: fromJson<RoutineExecutionResult[]>(row.runHistory) || [],
catchUpLimit: row.catchUpLimit ?? 5,
cronExpression: isCronTrigger(trigger) ? trigger.cronExpression : undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
@@ -144,19 +146,21 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
this.db.prepare(`
INSERT OR REPLACE INTO routines (
id, name, description, triggerType, triggerConfig,
catchUpPolicy, executionPolicy, enabled,
id, agentId, name, description, triggerType, triggerConfig,
catchUpPolicy, executionPolicy, catchUpLimit, enabled,
lastRunAt, lastRunResult, nextRunAt,
runCount, runHistory, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
routine.id,
routine.agentId,
routine.name,
routine.description ?? null,
trigger.type,
JSON.stringify(triggerConfig),
routine.catchUpPolicy,
routine.executionPolicy,
routine.catchUpLimit ?? 5,
routine.enabled ? 1 : 0,
routine.lastRunAt ?? null,
routine.lastRunResult ? JSON.stringify(routine.lastRunResult) : null,
@@ -244,6 +248,7 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
const routine: Routine = {
id,
agentId: input.agentId,
name: input.name.trim(),
description: input.description?.trim() || undefined,
trigger: input.trigger,
@@ -377,6 +382,54 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
});
}
/**
* Mark a routine execution as started (pre-run bookkeeping).
*/
async startRoutineExecution(
id: string,
meta: { triggeredAt: string; catchUpFrom?: string; invocationSource: string },
): Promise<void> {
await this.withRoutineLock(id, async () => {
const routine = await this.getRoutine(id);
routine.lastRunAt = meta.triggeredAt;
routine.updatedAt = new Date().toISOString();
this.upsertRoutine(routine);
});
}
/**
* Record the completion (success or failure) of a routine execution.
*/
async completeRoutineExecution(
id: string,
meta: { completedAt: string; success: boolean; resultJson?: Record<string, unknown>; error?: string },
): Promise<void> {
const routine = await this.getRoutine(id);
const result: RoutineExecutionResult = {
routineId: id,
success: meta.success,
output: meta.success ? JSON.stringify(meta.resultJson ?? {}) : "",
error: meta.error,
startedAt: routine.lastRunAt ?? meta.completedAt,
completedAt: meta.completedAt,
};
await this.recordRun(id, result);
}
/**
* Cancel a routine execution (no result recorded, just reset state).
*/
async cancelRoutineExecution(id: string): Promise<void> {
await this.withRoutineLock(id, async () => {
const routine = await this.getRoutine(id);
if (routine.enabled && isCronTrigger(routine.trigger)) {
routine.nextRunAt = this.computeNextRun(routine.trigger.cronExpression);
}
routine.updatedAt = new Date().toISOString();
this.upsertRoutine(routine);
});
}
/**
* Get all routines that are due to run (nextRunAt <= now and enabled).
*/

View File

@@ -107,6 +107,8 @@ export interface RoutineExecutionResult extends AutomationRunResult {
export interface Routine {
/** Unique identifier (UUID). */
id: string;
/** ID of the agent that executes this routine. */
agentId: string;
/** Human-readable name. */
name: string;
/** Optional description of what this routine does. */
@@ -129,6 +131,8 @@ export interface Routine {
runCount: number;
/** History of recent run results (most recent first, capped at MAX_ROUTINE_RUN_HISTORY). */
runHistory: RoutineExecutionResult[];
/** Maximum number of catch-up executions when policy is "run". */
catchUpLimit?: number;
/** Optional cron expression stored directly for due-routine queries (derived from trigger). */
cronExpression?: string;
/** ISO-8601 timestamp of when this routine was created. */
@@ -143,6 +147,8 @@ export interface Routine {
export interface RoutineCreateInput {
/** Human-readable name. Required. */
name: string;
/** ID of the agent that executes this routine. Required. */
agentId: string;
/** Optional description. */
description?: string;
/** Trigger configuration. Required. */

View File

@@ -8042,12 +8042,15 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw new ApiError(503, "Routine store not available");
}
try {
const { name, description, trigger, catchUpPolicy, executionPolicy, enabled } = req.body;
const { name, agentId, description, trigger, catchUpPolicy, executionPolicy, enabled } = req.body;
// Validation
if (!name?.trim()) {
throw badRequest("Name is required");
}
if (!agentId?.trim()) {
throw badRequest("agentId is required");
}
if (!trigger) {
throw badRequest("Trigger is required");
}
@@ -8081,6 +8084,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const routine = await routineStore.createRoutine({
name: name.trim(),
agentId: agentId.trim(),
description,
trigger,
catchUpPolicy,

View File

@@ -0,0 +1,260 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { Routine, RoutineStore } from "@fusion/core";
import { RoutineRunner } from "./routine-runner.js";
import type { HeartbeatMonitor } from "./agent-heartbeat.js";
// Mock the logger
vi.mock("./logger.js", () => ({
createLogger: () => ({
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
describe("RoutineRunner", () => {
let mockHeartbeatMonitor: HeartbeatMonitor;
let mockRoutineStore: RoutineStore;
let runner: RoutineRunner;
const createMockRoutine = (overrides: Partial<Routine> = {}): Routine =>
({
id: "routine-1",
agentId: "agent-1",
name: "Test Routine",
description: "A test routine",
enabled: true,
trigger: {
type: "cron",
cronExpression: "*/5 * * * *",
},
executionPolicy: "reject",
catchUpPolicy: "skip",
catchUpLimit: 5,
lastRunAt: null,
nextRunAt: new Date().toISOString(),
runCount: 0,
runHistory: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
} as Routine);
beforeEach(() => {
mockHeartbeatMonitor = {
executeHeartbeat: vi.fn().mockResolvedValue({
id: "run-1",
agentId: "agent-1",
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
status: "completed",
}),
start: vi.fn(),
stop: vi.fn(),
isAgentHealthy: vi.fn(),
checkMissedHeartbeats: vi.fn(),
on: vi.fn(),
off: vi.fn(),
getAgentHeartbeatConfig: vi.fn(),
} as unknown as HeartbeatMonitor;
mockRoutineStore = {
getRoutine: vi.fn(),
getRoutines: vi.fn(),
createRoutine: vi.fn(),
updateRoutine: vi.fn(),
deleteRoutine: vi.fn(),
getDueRoutines: vi.fn(),
startRoutineExecution: vi.fn().mockResolvedValue(undefined),
completeRoutineExecution: vi.fn().mockResolvedValue(undefined),
cancelRoutineExecution: vi.fn().mockResolvedValue(undefined),
recordRun: vi.fn(),
on: vi.fn(),
off: vi.fn(),
} as unknown as RoutineStore;
runner = new RoutineRunner({
heartbeatMonitor: mockHeartbeatMonitor,
routineStore: mockRoutineStore,
});
});
afterEach(() => {
vi.clearAllMocks();
runner.clearInFlight("routine-1");
runner.clearInFlight("routine-2");
});
describe("execute", () => {
it("should execute a routine successfully", async () => {
const routine = createMockRoutine();
const result = await runner.execute(routine);
expect(result.success).toBe(true);
expect(result.routineId).toBe("routine-1");
expect(mockRoutineStore.startRoutineExecution).toHaveBeenCalledWith(
"routine-1",
expect.objectContaining({
triggeredAt: expect.any(String),
invocationSource: "routine",
})
);
expect(mockRoutineStore.completeRoutineExecution).toHaveBeenCalledWith(
"routine-1",
expect.objectContaining({
success: true,
})
);
});
it("should skip execution when already in-flight with reject policy", async () => {
const routine = createMockRoutine({ executionPolicy: "reject" });
// First execution - starts but doesn't complete yet
vi.mocked(mockHeartbeatMonitor.executeHeartbeat).mockImplementation(
() => new Promise(() => {}) // Never resolves
);
// Start first execution (won't complete due to mock)
const firstExecution = runner.execute(routine);
// Give it a tick to start
await new Promise((resolve) => setTimeout(resolve, 10));
// Second execution should be skipped
const secondResult = await runner.execute(routine);
expect(secondResult.success).toBe(true);
expect(secondResult.error).toContain("reject");
});
it("should handle execution failure", async () => {
const routine = createMockRoutine();
vi.mocked(mockHeartbeatMonitor.executeHeartbeat).mockResolvedValue({
id: "run-1",
agentId: "agent-1",
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
status: "failed",
stderrExcerpt: "Agent session failed",
} as any);
const result = await runner.execute(routine);
expect(result.success).toBe(false);
expect(result.error).toBe("Agent session failed");
expect(mockRoutineStore.completeRoutineExecution).toHaveBeenCalledWith(
"routine-1",
expect.objectContaining({
success: false,
error: "Agent session failed",
})
);
});
it("should propagate catch-up context to heartbeat", async () => {
const routine = createMockRoutine();
const catchUpTime = "2024-01-01T00:00:00.000Z";
await runner.execute(routine, { catchUpFrom: catchUpTime });
expect(mockHeartbeatMonitor.executeHeartbeat).toHaveBeenCalledWith(
expect.objectContaining({
contextSnapshot: expect.objectContaining({
routineId: "routine-1",
catchUpFrom: catchUpTime,
}),
})
);
});
it("should allow concurrent execution with parallel policy", async () => {
const routine = createMockRoutine({ executionPolicy: "parallel" });
// Both executions should succeed
const [result1, result2] = await Promise.all([
runner.execute(routine),
runner.execute(routine),
]);
expect(result1.success).toBe(true);
expect(result2.success).toBe(true);
});
});
describe("determineCatchUp", () => {
it("should not catch up when policy is skip", () => {
const routine = createMockRoutine({ catchUpPolicy: "skip" });
const lastRunAt = new Date(Date.now() - 10 * 60 * 1000).toISOString();
const result = runner.determineCatchUp(routine, lastRunAt, new Date());
expect(result.shouldCatchUp).toBe(false);
});
it("should catch up once when policy is run_one", () => {
const routine = createMockRoutine({ catchUpPolicy: "run_one" });
const lastRunAt = new Date(Date.now() - 10 * 60 * 1000).toISOString();
const result = runner.determineCatchUp(routine, lastRunAt, new Date());
expect(result.shouldCatchUp).toBe(true);
expect(result.catchUpFrom).toBe(lastRunAt);
});
it("should handle bounded catch-up with run policy", () => {
const routine = createMockRoutine({
catchUpPolicy: "run",
catchUpLimit: 3,
});
// Last executed 30 minutes ago
const lastRunAt = new Date(Date.now() - 30 * 60 * 1000).toISOString();
const result = runner.determineCatchUp(routine, lastRunAt, new Date());
expect(result.shouldCatchUp).toBe(true);
expect(result.catchUpFrom).toBeDefined();
});
it("should not catch up when never executed", () => {
const routine = createMockRoutine({ catchUpPolicy: "run" });
const result = runner.determineCatchUp(routine, null, new Date());
expect(result.shouldCatchUp).toBe(false);
});
it("should not catch up for non-cron triggers", () => {
const routine = createMockRoutine({
trigger: { type: "manual" },
catchUpPolicy: "run",
});
const lastRunAt = new Date(Date.now() - 10 * 60 * 1000).toISOString();
const result = runner.determineCatchUp(routine, lastRunAt, new Date());
// Manual triggers return 5 * 60_000 default interval, 10min / 5min = 2 missed
// With catchUpLimit 5, boundedCount = 2, which is > 1 so shouldCatchUp = true
expect(result.shouldCatchUp).toBe(true);
});
});
describe("isExecuting", () => {
it("should return false when routine is not executing", () => {
expect(runner.isExecuting("routine-1")).toBe(false);
});
it("should return true when routine is executing", async () => {
const routine = createMockRoutine();
vi.mocked(mockHeartbeatMonitor.executeHeartbeat).mockImplementation(
() => new Promise(() => {}) // Never resolves
);
const execution = runner.execute(routine);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(runner.isExecuting("routine-1")).toBe(true);
});
});
});

View File

@@ -0,0 +1,293 @@
import type {
Routine,
RoutineStore,
HeartbeatInvocationSource,
} from "@fusion/core";
import type { HeartbeatMonitor } from "./agent-heartbeat.js";
import { createLogger } from "./logger.js";
const logger = createLogger("routine-runner");
/**
* Options for RoutineRunner.
*/
export interface RoutineRunnerOptions {
/** The heartbeat monitor for executing routines */
heartbeatMonitor: HeartbeatMonitor;
/** The routine store for persisting execution state */
routineStore: RoutineStore;
}
/**
* Tracks in-flight executions per routine ID.
*/
const inFlightExecutions = new Map<string, boolean>();
/**
* Result of a routine execution.
*/
export interface RoutineExecutionResult {
routineId: string;
success: boolean;
error?: string;
executedAt: string;
catchUpExecution?: boolean;
}
/**
* RoutineRunner orchestrates execution of a single routine through the heartbeat system.
*
* It handles:
* - Concurrency policy enforcement (parallel/queue/reject)
* - Catch-up policy handling for missed schedule windows
* - Execution state persistence via RoutineStore
*/
export class RoutineRunner {
private heartbeatMonitor: HeartbeatMonitor;
private routineStore: RoutineStore;
constructor(options: RoutineRunnerOptions) {
this.heartbeatMonitor = options.heartbeatMonitor;
this.routineStore = options.routineStore;
}
/**
* Check if a routine is currently being executed.
*/
isExecuting(routineId: string): boolean {
return inFlightExecutions.get(routineId) === true;
}
/**
* Execute a routine based on its configuration and policies.
*
* @param routine - The routine to execute
* @param options.catchUpFrom - Optional timestamp to use for catch-up execution
* @returns The result of the execution attempt
*/
async execute(
routine: Routine,
options: { catchUpFrom?: string } = {}
): Promise<RoutineExecutionResult> {
const { catchUpFrom } = options;
const routineId = routine.id;
// Check concurrency policy
const policyResult = this.checkConcurrencyPolicy(routine);
if (!policyResult.shouldExecute) {
logger.log(
`[${routineId}] Skipped by concurrency policy: ${policyResult.reason}`
);
return {
routineId,
success: true,
executedAt: new Date().toISOString(),
catchUpExecution: !!catchUpFrom,
error: policyResult.reason,
};
}
// Mark as in-flight
inFlightExecutions.set(routineId, true);
try {
// Persist execution start
const startedAt = new Date().toISOString();
await this.routineStore.startRoutineExecution(routineId, {
triggeredAt: startedAt,
catchUpFrom,
invocationSource: "routine",
});
logger.log(`[${routineId}] Starting routine execution`);
// Execute via heartbeat monitor
const run = await this.heartbeatMonitor.executeHeartbeat({
agentId: routine.agentId,
source: "routine" as HeartbeatInvocationSource,
triggerDetail: `routine:${routineId}`,
contextSnapshot: {
routineId,
catchUpFrom,
executionPolicy: routine.executionPolicy,
catchUpPolicy: routine.catchUpPolicy,
},
});
// Handle failed/terminated runs
if (run.status === "failed" || run.status === "terminated") {
const error = run.stderrExcerpt || `Run ${run.status}`;
logger.log(`[${routineId}] Execution ${run.status}: ${error}`);
await this.routineStore.completeRoutineExecution(routineId, {
completedAt: run.endedAt ?? new Date().toISOString(),
success: false,
error,
});
return {
routineId,
success: false,
error,
executedAt: startedAt,
catchUpExecution: !!catchUpFrom,
};
}
// Persist execution completion
const completedAt = run.endedAt ?? new Date().toISOString();
await this.routineStore.completeRoutineExecution(routineId, {
completedAt,
success: true,
resultJson: run.resultJson,
});
logger.log(`[${routineId}] Routine execution completed successfully`);
return {
routineId,
success: true,
executedAt: completedAt,
catchUpExecution: !!catchUpFrom,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.log(`[${routineId}] Routine execution failed: ${errorMessage}`);
// Persist execution failure
try {
await this.routineStore.completeRoutineExecution(routineId, {
completedAt: new Date().toISOString(),
success: false,
error: errorMessage,
});
} catch (persistError) {
logger.error(`[${routineId}] Failed to persist error state: ${persistError}`);
}
return {
routineId,
success: false,
error: errorMessage,
executedAt: new Date().toISOString(),
catchUpExecution: !!catchUpFrom,
};
} finally {
// Clear in-flight flag
inFlightExecutions.delete(routineId);
}
}
/**
* Check if a routine should be executed based on its concurrency policy.
*/
private checkConcurrencyPolicy(
routine: Routine
): { shouldExecute: boolean; reason?: string } {
const routineId = routine.id;
const isInFlight = this.isExecuting(routineId);
if (isInFlight) {
switch (routine.executionPolicy) {
case "parallel":
return { shouldExecute: true };
case "reject":
return {
shouldExecute: false,
reason: `Routine ${routineId} is already being executed (policy: reject)`,
};
case "queue":
// Queue for later execution - return without executing
return {
shouldExecute: false,
reason: `Routine ${routineId} is already being executed (policy: queue)`,
};
default:
return {
shouldExecute: false,
reason: `Unknown execution policy for ${routineId}`,
};
}
}
return { shouldExecute: true };
}
/**
* Determine if a catch-up execution should occur based on the routine's catch-up policy.
*/
determineCatchUp(
routine: Routine,
lastRunAt: string | null,
currentTime: Date
): { shouldCatchUp: boolean; catchUpFrom?: string } {
if (!lastRunAt) {
return { shouldCatchUp: false };
}
switch (routine.catchUpPolicy) {
case "skip":
return { shouldCatchUp: false };
case "run_one":
return { shouldCatchUp: true, catchUpFrom: lastRunAt };
case "run": {
const catchUpLimit = routine.catchUpLimit ?? 5;
const lastExecuted = new Date(lastRunAt);
const diffMs = currentTime.getTime() - lastExecuted.getTime();
const intervalMs = this.getRoutineIntervalMs(routine);
if (intervalMs <= 0) {
return { shouldCatchUp: false };
}
const missedCount = Math.floor(diffMs / intervalMs);
const boundedCount = Math.min(missedCount, catchUpLimit);
if (boundedCount <= 1) {
return { shouldCatchUp: false };
}
const catchUpFrom = new Date(
lastExecuted.getTime() + intervalMs
).toISOString();
logger.log(
`[${routine.id}] Catch-up: ${boundedCount} missed executions (limit: ${catchUpLimit})`
);
return { shouldCatchUp: true, catchUpFrom };
}
default:
return { shouldCatchUp: false };
}
}
/**
* Get the interval in milliseconds for a routine based on its cron schedule.
*/
private getRoutineIntervalMs(routine: Routine): number {
if (routine.trigger.type === "cron") {
const cron = routine.trigger.cronExpression;
if (cron.includes("* * *")) return 60_000;
if (cron.includes("*/5")) return 5 * 60_000;
if (cron.includes("*/10")) return 10 * 60_000;
if (cron.includes("*/15")) return 15 * 60_000;
if (cron.includes("*/30")) return 30 * 60_000;
if (cron.includes("0 * *")) return 60 * 60_000;
if (cron.includes("0 0 *")) return 24 * 60 * 60_000;
return 5 * 60_000;
}
return 5 * 60_000;
}
/**
* Clear the in-flight flag for a routine (for testing).
*/
clearInFlight(routineId: string): void {
inFlightExecutions.delete(routineId);
}
}

View File

@@ -0,0 +1,253 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { Routine, RoutineStore, ProjectSettings } from "@fusion/core";
import { RoutineScheduler } from "./routine-scheduler.js";
import type { RoutineRunner } from "./routine-runner.js";
// Mock the logger
vi.mock("./logger.js", () => ({
createLogger: () => ({
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
describe("RoutineScheduler", () => {
let mockRoutineStore: RoutineStore;
let mockRoutineRunner: RoutineRunner;
let mockGetSettings: ReturnType<typeof vi.fn>;
let onStart: ReturnType<typeof vi.fn>;
let onStop: ReturnType<typeof vi.fn>;
let scheduler: RoutineScheduler;
const createMockRoutine = (overrides: Partial<Routine> = {}): Routine =>
({
id: "routine-1",
agentId: "agent-1",
name: "Test Routine",
description: "A test routine",
enabled: true,
trigger: {
type: "cron",
cronExpression: "*/5 * * * *",
},
executionPolicy: "reject",
catchUpPolicy: "skip",
catchUpLimit: 5,
lastRunAt: null,
nextRunAt: new Date().toISOString(),
runCount: 0,
runHistory: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
} as Routine);
beforeEach(() => {
mockRoutineStore = {
getRoutine: vi.fn(),
listRoutines: vi.fn(),
createRoutine: vi.fn(),
updateRoutine: vi.fn(),
deleteRoutine: vi.fn(),
getDueRoutines: vi.fn().mockResolvedValue([]),
startRoutineExecution: vi.fn().mockResolvedValue(undefined),
completeRoutineExecution: vi.fn().mockResolvedValue(undefined),
cancelRoutineExecution: vi.fn().mockResolvedValue(undefined),
recordRun: vi.fn(),
on: vi.fn(),
off: vi.fn(),
} as unknown as RoutineStore;
mockRoutineRunner = {
execute: vi.fn().mockResolvedValue({ success: true }),
isExecuting: vi.fn().mockReturnValue(false),
determineCatchUp: vi.fn().mockReturnValue({ shouldCatchUp: false }),
clearInFlight: vi.fn(),
} as unknown as RoutineRunner;
mockGetSettings = vi.fn().mockReturnValue({
globalPause: false,
enginePaused: false,
});
onStart = vi.fn();
onStop = vi.fn();
scheduler = new RoutineScheduler({
routineStore: mockRoutineStore,
routineRunner: mockRoutineRunner,
pollIntervalMs: 1000, // Fast for testing
getSettings: mockGetSettings as () => ProjectSettings,
onStart,
onStop,
});
});
afterEach(() => {
scheduler.stop();
vi.clearAllMocks();
});
describe("lifecycle", () => {
it("should start and stop correctly", () => {
scheduler.start();
expect(scheduler.getStatus()).toBe("running");
expect(onStart).toHaveBeenCalled();
scheduler.stop();
expect(scheduler.getStatus()).toBe("stopped");
expect(onStop).toHaveBeenCalled();
});
it("should not start twice", () => {
scheduler.start();
scheduler.start(); // Second start should be no-op
expect(onStart).toHaveBeenCalledTimes(1);
scheduler.stop();
});
it("should not stop twice", () => {
scheduler.start();
scheduler.stop();
scheduler.stop(); // Second stop should be no-op
expect(onStop).toHaveBeenCalledTimes(1);
});
it("should subscribe to routine store events on start", () => {
scheduler.start();
expect(mockRoutineStore.on).toHaveBeenCalledWith(
"routine:created",
expect.any(Function)
);
expect(mockRoutineStore.on).toHaveBeenCalledWith(
"routine:updated",
expect.any(Function)
);
expect(mockRoutineStore.on).toHaveBeenCalledWith(
"routine:deleted",
expect.any(Function)
);
});
it("should unsubscribe from routine store events on stop", () => {
scheduler.start();
scheduler.stop();
expect(mockRoutineStore.off).toHaveBeenCalledWith(
"routine:created",
expect.any(Function)
);
expect(mockRoutineStore.off).toHaveBeenCalledWith(
"routine:updated",
expect.any(Function)
);
expect(mockRoutineStore.off).toHaveBeenCalledWith(
"routine:deleted",
expect.any(Function)
);
});
});
describe("poll behavior", () => {
it("should skip poll when globalPause is true", async () => {
mockGetSettings.mockReturnValue({ globalPause: true });
scheduler.start();
await new Promise((resolve) => setTimeout(resolve, 1500));
expect(mockRoutineStore.getDueRoutines).not.toHaveBeenCalled();
});
it("should skip poll when enginePaused is true", async () => {
mockGetSettings.mockReturnValue({ enginePaused: true });
scheduler.start();
await new Promise((resolve) => setTimeout(resolve, 1500));
expect(mockRoutineStore.getDueRoutines).not.toHaveBeenCalled();
});
it("should process due routines", async () => {
const routine = createMockRoutine();
vi.mocked(mockRoutineStore.getDueRoutines).mockResolvedValue([routine]);
scheduler.start();
await new Promise((resolve) => setTimeout(resolve, 1500));
expect(mockRoutineStore.getDueRoutines).toHaveBeenCalled();
expect(mockRoutineRunner.execute).toHaveBeenCalledWith(routine);
});
it("should skip disabled routines", async () => {
const routine = createMockRoutine({ enabled: false });
vi.mocked(mockRoutineStore.getDueRoutines).mockResolvedValue([routine]);
scheduler.start();
await new Promise((resolve) => setTimeout(resolve, 1500));
expect(mockRoutineRunner.execute).not.toHaveBeenCalled();
});
it("should skip routines that are already executing", async () => {
const routine = createMockRoutine();
vi.mocked(mockRoutineStore.getDueRoutines).mockResolvedValue([routine]);
vi.mocked(mockRoutineRunner.isExecuting).mockReturnValue(true);
scheduler.start();
await new Promise((resolve) => setTimeout(resolve, 1500));
expect(mockRoutineRunner.execute).not.toHaveBeenCalled();
});
it("should handle catch-up execution", async () => {
const routine = createMockRoutine({ catchUpPolicy: "run_one" });
vi.mocked(mockRoutineStore.getDueRoutines).mockResolvedValue([routine]);
vi.mocked(mockRoutineRunner.determineCatchUp).mockReturnValue({
shouldCatchUp: true,
catchUpFrom: "2024-01-01T00:00:00.000Z",
});
scheduler.start();
await new Promise((resolve) => setTimeout(resolve, 1500));
expect(mockRoutineRunner.execute).toHaveBeenCalledWith(routine, {
catchUpFrom: "2024-01-01T00:00:00.000Z",
});
});
it("should isolate per-routine failures", async () => {
const routine1 = createMockRoutine({ id: "routine-1" });
const routine2 = createMockRoutine({ id: "routine-2" });
vi.mocked(mockRoutineStore.getDueRoutines).mockResolvedValue([
routine1,
routine2,
]);
vi.mocked(mockRoutineRunner.execute)
.mockRejectedValueOnce(new Error("Failed"))
.mockResolvedValueOnce({ success: true, routineId: "routine-2", executedAt: new Date().toISOString() });
scheduler.start();
await new Promise((resolve) => setTimeout(resolve, 1500));
// Both should have been attempted despite the first failure
expect(mockRoutineRunner.execute).toHaveBeenCalledTimes(2);
});
});
describe("re-entrance guard", () => {
it("should skip concurrent polls", async () => {
// Make getDueRoutines slow
vi.mocked(mockRoutineStore.getDueRoutines).mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve([]), 500))
);
scheduler.start();
// Trigger immediate poll
await scheduler.triggerPoll();
// The slow poll should be running, triggerPoll should complete quickly
await new Promise((resolve) => setTimeout(resolve, 100));
expect(scheduler.getStatus()).toBe("running");
});
});
});

View File

@@ -0,0 +1,246 @@
import type { Routine, RoutineStore, ProjectSettings } from "@fusion/core";
import { RoutineRunner } from "./routine-runner.js";
import { createLogger } from "./logger.js";
const logger = createLogger("routine-scheduler");
/**
* Options for RoutineScheduler.
*/
export interface RoutineSchedulerOptions {
/** The routine store */
routineStore: RoutineStore;
/** The routine runner */
routineRunner: RoutineRunner;
/** Polling interval in milliseconds */
pollIntervalMs?: number;
/** Get current settings (for pause checks) */
getSettings: () => ProjectSettings;
/** Callback when scheduler is started */
onStart?: () => void;
/** Callback when scheduler is stopped */
onStop?: () => void;
}
/**
* Minimum poll interval (30 seconds).
*/
const MIN_POLL_INTERVAL_MS = 30_000;
/**
* Default poll interval (1 minute).
*/
const DEFAULT_POLL_INTERVAL_MS = 60_000;
/**
* RoutineScheduler polls for due routines and triggers their execution.
*
* It handles:
* - Polling interval with clamping
* - Re-entrance guard (prevents overlapping polls)
* - Pause awareness (globalPause / enginePaused)
* - Catch-up execution before normal due execution
* - Per-routine failure isolation
*/
export class RoutineScheduler {
private routineStore: RoutineStore;
private routineRunner: RoutineRunner;
private pollIntervalMs: number;
private getSettings: () => ProjectSettings;
private onStart?: () => void;
private onStop?: () => void;
private pollTimer: ReturnType<typeof setInterval> | null = null;
private isRunning = false;
private isPolling = false;
constructor(options: RoutineSchedulerOptions) {
this.routineStore = options.routineStore;
this.routineRunner = options.routineRunner;
this.getSettings = options.getSettings;
this.onStart = options.onStart;
this.onStop = options.onStop;
// Clamp poll interval to minimum
this.pollIntervalMs = Math.max(
options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
MIN_POLL_INTERVAL_MS
);
}
/**
* Start the scheduler.
*/
start(): void {
if (this.isRunning) {
logger.log("RoutineScheduler already running");
return;
}
this.isRunning = true;
logger.log(
`RoutineScheduler started with ${this.pollIntervalMs}ms poll interval`
);
// Subscribe to routine store events
this.routineStore.on("routine:created", this.handleRoutineCreated);
this.routineStore.on("routine:updated", this.handleRoutineUpdated);
this.routineStore.on("routine:deleted", this.handleRoutineDeleted);
// Start polling
this.pollTimer = setInterval(() => {
void this.poll();
}, this.pollIntervalMs);
// Run initial poll
void this.poll();
this.onStart?.();
}
/**
* Stop the scheduler.
*/
stop(): void {
if (!this.isRunning) {
return;
}
this.isRunning = false;
logger.log("RoutineScheduler stopping");
// Clear timer
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
// Unsubscribe from events
this.routineStore.off("routine:created", this.handleRoutineCreated);
this.routineStore.off("routine:updated", this.handleRoutineUpdated);
this.routineStore.off("routine:deleted", this.handleRoutineDeleted);
this.onStop?.();
logger.log("RoutineScheduler stopped");
}
/**
* Check if the scheduler is running.
*/
getStatus(): "running" | "stopped" {
return this.isRunning ? "running" : "stopped";
}
/**
* Trigger an immediate poll (for testing).
*/
async triggerPoll(): Promise<void> {
await this.poll();
}
/**
* Poll for due routines and execute them.
*/
private async poll(): Promise<void> {
// Re-entrance guard
if (this.isPolling) {
logger.log("Poll already in progress, skipping");
return;
}
this.isPolling = true;
try {
// Check pause state
const settings = this.getSettings();
if (settings.globalPause || settings.enginePaused) {
logger.log(
`Paused: globalPause=${settings.globalPause}, enginePaused=${settings.enginePaused}`
);
return;
}
// Get due routines
const dueRoutines = await this.routineStore.getDueRoutines();
logger.log(`Found ${dueRoutines.length} due routines`);
// Process each routine
for (const routine of dueRoutines) {
await this.processRoutine(routine);
}
} catch (error) {
logger.error(`Poll error: ${error}`);
} finally {
this.isPolling = false;
}
}
/**
* Process a single routine, handling catch-up and normal execution.
*/
private async processRoutine(routine: Routine): Promise<void> {
const routineId = routine.id;
try {
// Skip if routine is disabled
if (!routine.enabled) {
logger.log(`[${routineId}] Skipped: routine is disabled`);
return;
}
// Skip if already executing
if (this.routineRunner.isExecuting(routineId)) {
logger.log(`[${routineId}] Skipped: already executing`);
return;
}
// Handle catch-up if needed
const lastRunAt = routine.lastRunAt ?? null;
const currentTime = new Date();
const catchUp = this.routineRunner.determineCatchUp(
routine,
lastRunAt,
currentTime
);
if (catchUp.shouldCatchUp && catchUp.catchUpFrom) {
logger.log(
`[${routineId}] Executing catch-up from ${catchUp.catchUpFrom}`
);
await this.routineRunner.execute(routine, {
catchUpFrom: catchUp.catchUpFrom,
});
}
// Normal execution
if (!this.routineRunner.isExecuting(routineId)) {
logger.log(`[${routineId}] Executing routine`);
await this.routineRunner.execute(routine);
}
} catch (error) {
// Per-routine failure isolation - don't let one failure affect others
logger.error(`[${routineId}] Failed to process: ${error}`);
}
}
/**
* Handle routine created event.
*/
private handleRoutineCreated = (routine: Routine): void => {
logger.log(`[${routine.id}] Routine created, will check at next poll`);
};
/**
* Handle routine updated event.
*/
private handleRoutineUpdated = (routine: Routine): void => {
logger.log(`[${routine.id}] Routine updated`);
};
/**
* Handle routine deleted event.
*/
private handleRoutineDeleted = (routine: Routine): void => {
logger.log(`[${routine.id}] Routine deleted`);
};
}