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

@@ -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`);
};
}