feat(FN-1269): complete routine engine integration with RoutineRunner and RoutineScheduler

- Add RoutineRunner class for routine execution via heartbeat system
- Add RoutineScheduler class for cron-based routine polling
- Add triggerManual and triggerWebhook methods for API and webhook triggers
- Wire RoutineScheduler into InProcessRuntime lifecycle
- Add routine trigger and webhook API endpoints
- Fix type mismatches between PROMPT and actual FN-1519 types
This commit is contained in:
gsxdsm
2026-04-10 12:11:55 -07:00
parent 6600b0b97e
commit 5cb368d7cf
14 changed files with 1340 additions and 781 deletions

View File

@@ -385,7 +385,12 @@ describe("RoutineStore", () => {
store.on("routine:deleted", listener);
await store.deleteRoutine(routine.id);
expect(listener).toHaveBeenCalledWith(routine);
expect(listener).toHaveBeenCalled();
const emittedRoutine = listener.mock.calls[0][0];
// Verify the emitted routine has the expected fields
expect(emittedRoutine.id).toBe(routine.id);
expect(emittedRoutine.name).toBe("Delete test");
expect(emittedRoutine.agentId).toBe("test-agent");
});
});

View File

@@ -1907,7 +1907,7 @@ export interface AgentHeartbeatEvent {
}
/** What triggered a heartbeat run */
export type HeartbeatInvocationSource = "on_demand" | "timer" | "assignment" | "automation";
export type HeartbeatInvocationSource = "on_demand" | "timer" | "assignment" | "automation" | "routine";
/** Snapshot of the last blocked state for a task, used for dedup comparison. */
export interface BlockedStateSnapshot {

View File

@@ -8019,6 +8019,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// ── Routine Routes ──────────────────────────────────────────────────
const routineStore = options?.routineStore;
const routineRunner = options?.routineRunner;
// GET /routines — list all routines
router.get("/routines", async (_req: Request, res: Response) => {
@@ -8197,22 +8198,21 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
if (!routineStore) {
throw new ApiError(503, "Routine store not available");
}
if (!routineRunner) {
throw new ApiError(503, "Routine execution not available");
}
try {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const routine = await routineStore.getRoutine(id);
// Create a placeholder result for now (actual execution in future task)
const startedAt = new Date().toISOString();
const result: RoutineExecutionResult = {
routineId: id,
startedAt,
triggerType: routine.trigger.type,
success: true,
output: "Manual run triggered",
completedAt: new Date().toISOString(),
};
// Validate routine is enabled
if (!routine.enabled) {
throw badRequest("Routine is disabled");
}
const updated = await routineStore.recordRun(id, result);
// Execute via RoutineRunner
const result = await routineRunner.triggerManual(id);
const updated = await routineStore.getRoutine(id);
res.json({ routine: updated, result });
} catch (err: any) {
if (err instanceof ApiError) {
@@ -8250,6 +8250,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
if (!routineStore) {
throw new ApiError(503, "Routine store not available");
}
if (!routineRunner) {
throw new ApiError(503, "Routine execution not available");
}
try {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const routine = await routineStore.getRoutine(id);
@@ -8282,18 +8285,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
}
// Create a placeholder result for now (actual execution in future task)
const startedAt = new Date().toISOString();
const result: RoutineExecutionResult = {
routineId: id,
startedAt,
triggerType: "webhook",
success: true,
output: "Webhook trigger received",
completedAt: new Date().toISOString(),
};
const updated = await routineStore.recordRun(id, result);
// Execute via RoutineRunner
const payload = req.body;
const result = await routineRunner.triggerWebhook(id, payload, signatureHeader);
const updated = await routineStore.getRoutine(id);
res.json({ routine: updated, result });
} catch (err: any) {
if (err instanceof ApiError) {

View File

@@ -84,6 +84,11 @@ export interface ServerOptions {
automationStore?: AutomationStore;
/** Optional RoutineStore for recurring task automation */
routineStore?: RoutineStore;
/** Optional RoutineRunner for triggering routine execution via heartbeat */
routineRunner?: {
triggerManual(routineId: string): Promise<import("@fusion/core").RoutineExecutionResult>;
triggerWebhook(routineId: string, payload: Record<string, unknown>, signature?: string): Promise<import("@fusion/core").RoutineExecutionResult>;
};
/** Optional AiSessionStore — if not provided, one is created from the default store's database */
aiSessionStore?: AiSessionStore;
/** Optional MissionAutopilot for autonomous mission progression */

View File

@@ -26,7 +26,8 @@
"@fusion/core": "workspace:*",
"@mariozechner/pi-ai": "^0.62.0",
"@mariozechner/pi-coding-agent": "^0.62.0",
"@sinclair/typebox": "^0.34.48"
"@sinclair/typebox": "^0.34.48",
"cron-parser": "^5.5.0"
},
"devDependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",

View File

@@ -26,6 +26,8 @@ export { PrMonitor, type PrComment, type TrackedPr, type OnNewCommentsCallback }
export { PrCommentHandler } from "./pr-comment-handler.js";
export { NtfyNotifier, type NtfyNotifierOptions } from "./notifier.js";
export { CronRunner, type CronRunnerOptions, type AiPromptExecutor, createAiPromptExecutor } from "./cron-runner.js";
export { RoutineRunner, type RoutineRunnerOptions } from "./routine-runner.js";
export { RoutineScheduler, type RoutineSchedulerOptions } from "./routine-scheduler.js";
export { StuckTaskDetector, type StuckTaskDetectorOptions, type DisposableSession } from "./stuck-task-detector.js";
export { HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext } from "./agent-heartbeat.js";
export { TokenCapDetector, type TokenCapCheckResult } from "./token-cap-detector.js";

View File

@@ -1,260 +1,512 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { Routine, RoutineStore } from "@fusion/core";
import { RoutineRunner } from "./routine-runner.js";
import { RoutineRunner, type RoutineRunnerOptions } from "./routine-runner.js";
import type {
RoutineStore,
Routine,
RoutineExecutionResult,
AgentStore,
TaskStore,
Settings,
} from "@fusion/core";
import type { HeartbeatMonitor } from "./agent-heartbeat.js";
// Mock the logger
vi.mock("./logger.js", () => ({
createLogger: () => ({
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
// Default settings inline to avoid @fusion/core build dependency during tests
const DEFAULT_SETTINGS: Settings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 30000,
autoResolveConflicts: true,
requirePlanApproval: false,
recycleWorktrees: false,
worktreeNaming: "random",
globalPause: false,
enginePaused: false,
ntfyEnabled: false,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
planningProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
validatorProvider: "openai",
validatorModelId: "gpt-4o",
taskStuckTimeoutMs: undefined,
groupOverlappingFiles: false,
autoMerge: true,
};
describe("RoutineRunner", () => {
let mockHeartbeatMonitor: HeartbeatMonitor;
let mockRoutineStore: RoutineStore;
let runner: RoutineRunner;
function createMockRoutine(overrides: Partial<Routine> = {}): Routine {
return {
id: "test-routine-id",
agentId: "test-agent",
name: "Test Routine",
description: "A test routine",
trigger: { type: "cron", cronExpression: "0 * * * *" },
catchUpPolicy: "run_one",
executionPolicy: "parallel",
enabled: true,
runCount: 0,
runHistory: [],
cronExpression: "0 * * * *",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
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: [],
function createMockRoutineStore(routines: Routine[] = []): RoutineStore {
const routineMap = new Map(routines.map((r) => [r.id, r]));
return {
getRoutine: vi.fn().mockImplementation((id: string) => {
const routine = routineMap.get(id);
if (!routine) {
throw Object.assign(new Error(`Routine '${id}' not found`), { code: "ENOENT" });
}
return routine;
}),
listRoutines: vi.fn().mockResolvedValue(routines),
updateRoutine: vi.fn().mockImplementation((id: string, _updates: any) => {
const routine = routineMap.get(id);
if (!routine) {
throw Object.assign(new Error(`Routine '${id}' not found`), { code: "ENOENT" });
}
return routine;
}),
getDueRoutines: vi.fn().mockResolvedValue([]),
recordRun: vi.fn().mockImplementation((id: string, result: RoutineExecutionResult) => {
return createMockRoutine({ id, lastRunResult: result });
}),
startRoutineExecution: vi.fn().mockResolvedValue(undefined),
completeRoutineExecution: vi.fn().mockResolvedValue(undefined),
cancelRoutineExecution: vi.fn().mockResolvedValue(undefined),
init: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
off: vi.fn(),
} as unknown as RoutineStore;
}
function createMockAgentStore(): AgentStore {
return {
getAgent: vi.fn().mockImplementation(async (id: string) => ({
id,
name: "Test Agent",
role: "executor" as const,
state: "idle" as const,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
} as Routine);
})),
updateAgentState: vi.fn().mockResolvedValue(undefined),
getBudgetStatus: vi.fn().mockResolvedValue({
agentId: "",
currentUsage: 0,
budgetLimit: null,
usagePercent: null,
thresholdPercent: null,
isOverBudget: false,
isOverThreshold: false,
lastResetAt: null,
nextResetAt: null,
}),
on: vi.fn(),
off: vi.fn(),
} as unknown as AgentStore;
}
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;
function createMockTaskStore(): TaskStore {
return {
getSettings: vi.fn().mockResolvedValue(DEFAULT_SETTINGS),
on: vi.fn(),
off: vi.fn(),
} as unknown as TaskStore;
}
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;
function createMockHeartbeatMonitor(): HeartbeatMonitor {
return {
executeHeartbeat: vi.fn().mockResolvedValue({
id: "run-123",
agentId: "test-agent",
status: "completed" as const,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
}),
start: vi.fn(),
stop: vi.fn(),
trackAgent: vi.fn(),
on: vi.fn(),
off: vi.fn(),
} as unknown as HeartbeatMonitor;
}
runner = new RoutineRunner({
heartbeatMonitor: mockHeartbeatMonitor,
routineStore: mockRoutineStore,
});
function createRoutineRunner(options?: Partial<RoutineRunnerOptions>): RoutineRunner {
return new RoutineRunner({
routineStore: options?.routineStore ?? createMockRoutineStore(),
heartbeatMonitor: options?.heartbeatMonitor ?? createMockHeartbeatMonitor(),
rootDir: options?.rootDir ?? "/test/root",
});
}
afterEach(() => {
vi.clearAllMocks();
runner.clearInFlight("routine-1");
runner.clearInFlight("routine-2");
});
describe("RoutineRunner", () => {
describe("executeRoutine", () => {
it("successfully executes a routine with trigger type 'cron'", async () => {
const routine = createMockRoutine({ id: "routine-1", name: "Test Routine" });
const routineStore = createMockRoutineStore([routine]);
const heartbeatMonitor = createMockHeartbeatMonitor();
const runner = createRoutineRunner({ routineStore, heartbeatMonitor });
describe("execute", () => {
it("should execute a routine successfully", async () => {
const routine = createMockRoutine();
const result = await runner.executeRoutine("routine-1", "cron");
const result = await runner.execute(routine);
expect(result.success).toBe(true);
expect(result.routineId).toBe("routine-1");
expect(mockRoutineStore.startRoutineExecution).toHaveBeenCalledWith(
"routine-1",
expect(result.success).toBe(true);
expect(heartbeatMonitor.executeHeartbeat).toHaveBeenCalledTimes(1);
expect(heartbeatMonitor.executeHeartbeat).toHaveBeenCalledWith(
expect.objectContaining({
triggeredAt: expect.any(String),
invocationSource: "routine",
})
);
expect(mockRoutineStore.completeRoutineExecution).toHaveBeenCalledWith(
"routine-1",
expect.objectContaining({
success: true,
})
source: "routine",
triggerDetail: "routine:routine-1:cron",
}),
);
});
it("should skip execution when already in-flight with reject policy", async () => {
const routine = createMockRoutine({ executionPolicy: "reject" });
it("throws descriptive error when routine not found", async () => {
const routineStore = createMockRoutineStore([]);
const runner = createRoutineRunner({ routineStore });
// First execution - starts but doesn't complete yet
vi.mocked(mockHeartbeatMonitor.executeHeartbeat).mockImplementation(
() => new Promise(() => {}) // Never resolves
await expect(runner.executeRoutine("nonexistent", "cron")).rejects.toThrow(
"Routine 'nonexistent' not found",
);
// 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);
it("throws descriptive error when routine is disabled", async () => {
const routine = createMockRoutine({ id: "routine-disabled", enabled: false });
const routineStore = createMockRoutineStore([routine]);
const runner = createRoutineRunner({ routineStore });
const result = await runner.execute(routine);
await expect(runner.executeRoutine("routine-disabled", "cron")).rejects.toThrow(
"Routine 'routine-disabled' is disabled",
);
});
expect(result.success).toBe(false);
expect(result.error).toBe("Agent session failed");
expect(mockRoutineStore.completeRoutineExecution).toHaveBeenCalledWith(
"routine-1",
it("calls executeHeartbeat with source 'routine' and correct triggerDetail format", async () => {
const routine = createMockRoutine({ id: "routine-trigger", name: "Trigger Test" });
const routineStore = createMockRoutineStore([routine]);
const heartbeatMonitor = createMockHeartbeatMonitor();
const runner = createRoutineRunner({ routineStore, heartbeatMonitor });
await runner.executeRoutine("routine-trigger", "webhook");
expect(heartbeatMonitor.executeHeartbeat).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: "Agent session failed",
})
source: "routine",
triggerDetail: "routine:routine-trigger:webhook",
}),
);
});
it("should propagate catch-up context to heartbeat", async () => {
const routine = createMockRoutine();
it("includes routineId, routineName, triggerType in contextSnapshot", async () => {
const routine = createMockRoutine({ id: "routine-context", name: "Context Test" });
const routineStore = createMockRoutineStore([routine]);
const heartbeatMonitor = createMockHeartbeatMonitor();
const runner = createRoutineRunner({ routineStore, heartbeatMonitor });
const catchUpTime = "2024-01-01T00:00:00.000Z";
await runner.execute(routine, { catchUpFrom: catchUpTime });
await runner.executeRoutine("routine-context", "api");
expect(mockHeartbeatMonitor.executeHeartbeat).toHaveBeenCalledWith(
expect(heartbeatMonitor.executeHeartbeat).toHaveBeenCalledWith(
expect.objectContaining({
contextSnapshot: expect.objectContaining({
routineId: "routine-1",
catchUpFrom: catchUpTime,
routineId: "routine-context",
routineName: "Context Test",
triggerType: "api",
}),
})
}),
);
});
it("should allow concurrent execution with parallel policy", async () => {
const routine = createMockRoutine({ executionPolicy: "parallel" });
it("calls completeRoutineExecution after execution completes", async () => {
const routine = createMockRoutine({ id: "routine-record" });
const routineStore = createMockRoutineStore([routine]);
const heartbeatMonitor = createMockHeartbeatMonitor();
const runner = createRoutineRunner({ routineStore, heartbeatMonitor });
// Both executions should succeed
await runner.executeRoutine("routine-record", "cron");
// completeRoutineExecution is called once with the result
expect(routineStore.completeRoutineExecution).toHaveBeenCalledTimes(1);
expect(routineStore.completeRoutineExecution).toHaveBeenCalledWith(
"routine-record",
expect.objectContaining({
success: true,
}),
);
});
it("marks execution as failed when executeHeartbeat rejects", async () => {
const routine = createMockRoutine({ id: "routine-fail" });
const routineStore = createMockRoutineStore([routine]);
const heartbeatMonitor = createMockHeartbeatMonitor();
(heartbeatMonitor.executeHeartbeat as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("Heartbeat failed"),
);
const runner = createRoutineRunner({ routineStore, heartbeatMonitor });
const result = await runner.executeRoutine("routine-fail", "cron");
expect(result.success).toBe(false);
expect(result.error).toBe("Heartbeat failed");
});
it("cleans up inFlightExecutions map after successful completion", async () => {
const routine = createMockRoutine({ id: "routine-cleanup" });
const routineStore = createMockRoutineStore([routine]);
const heartbeatMonitor = createMockHeartbeatMonitor();
const runner = createRoutineRunner({ routineStore, heartbeatMonitor });
expect(runner.isRoutineRunning("routine-cleanup")).toBe(false);
await runner.executeRoutine("routine-cleanup", "cron");
// After completion, should not be in-flight
expect(runner.isRoutineRunning("routine-cleanup")).toBe(false);
});
it("cleans up inFlightExecutions map even on error", async () => {
const routine = createMockRoutine({ id: "routine-error-cleanup", enabled: false });
const routineStore = createMockRoutineStore([routine]);
const runner = createRoutineRunner({ routineStore });
try {
await runner.executeRoutine("routine-error-cleanup", "cron");
} catch {
// Expected to throw
}
// After an error, the routine should not be in the in-flight map
expect(runner.isRoutineRunning("routine-error-cleanup")).toBe(false);
});
});
describe("concurrency policies", () => {
it("parallel policy: runs even when another execution is in-flight", async () => {
const routine = createMockRoutine({
id: "routine-parallel",
executionPolicy: "parallel",
});
const routineStore = createMockRoutineStore([routine]);
const heartbeatMonitor = createMockHeartbeatMonitor();
// Make heartbeat slow
(heartbeatMonitor.executeHeartbeat as ReturnType<typeof vi.fn>).mockImplementation(
async () => {
await new Promise((r) => setTimeout(r, 50));
return {
id: "run-123",
agentId: "test-agent",
status: "completed" as const,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
};
},
);
const runner = createRoutineRunner({ routineStore, heartbeatMonitor });
// Start two executions
const [result1, result2] = await Promise.all([
runner.execute(routine),
runner.execute(routine),
runner.executeRoutine("routine-parallel", "cron"),
runner.executeRoutine("routine-parallel", "cron"),
]);
// Both should succeed (parallel)
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", () => {
it("reject policy: returns failed result when another execution is in-flight", async () => {
const routine = createMockRoutine({
catchUpPolicy: "run",
catchUpLimit: 3,
id: "routine-reject",
executionPolicy: "reject",
});
// 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 routineStore = createMockRoutineStore([routine]);
const heartbeatMonitor = createMockHeartbeatMonitor();
// Make heartbeat slow
(heartbeatMonitor.executeHeartbeat as ReturnType<typeof vi.fn>).mockImplementation(
async () => {
await new Promise((r) => setTimeout(r, 100));
return {
id: "run-123",
agentId: "test-agent",
status: "completed" as const,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
};
},
);
const runner = createRoutineRunner({ routineStore, heartbeatMonitor });
const execution = runner.execute(routine);
await new Promise((resolve) => setTimeout(resolve, 10));
// Start first execution
const promise1 = runner.executeRoutine("routine-reject", "cron");
expect(runner.isExecuting("routine-1")).toBe(true);
// Immediately try second execution - should be rejected
const result2 = await runner.executeRoutine("routine-reject", "cron");
expect(result2.success).toBe(false);
expect(result2.error).toBe("Routine rejected — already running");
expect(heartbeatMonitor.executeHeartbeat).toHaveBeenCalledTimes(1); // Only first call
await promise1; // Clean up
});
it("queue policy: waits for existing execution to complete", async () => {
const routine = createMockRoutine({
id: "routine-queue",
executionPolicy: "queue",
});
const routineStore = createMockRoutineStore([routine]);
const heartbeatMonitor = createMockHeartbeatMonitor();
let callCount = 0;
(heartbeatMonitor.executeHeartbeat as ReturnType<typeof vi.fn>).mockImplementation(
async () => {
callCount++;
await new Promise((r) => setTimeout(r, 50));
return {
id: "run-123",
agentId: "test-agent",
status: "completed" as const,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
};
},
);
const runner = createRoutineRunner({ routineStore, heartbeatMonitor });
// Start first execution
const [result1, result2] = await Promise.all([
runner.executeRoutine("routine-queue", "cron"),
runner.executeRoutine("routine-queue", "cron"),
]);
// Both should succeed (second waited for first)
expect(result1.success).toBe(true);
expect(result2.success).toBe(true);
// Both heartbeats should have been called (sequential due to queue)
expect(callCount).toBe(2);
});
});
describe("handleCatchUp", () => {
it("skip policy: does NOT call executeRoutine, only logs", async () => {
const routine = createMockRoutine({
id: "routine-catchup-skip",
catchUpPolicy: "skip",
lastRunAt: new Date(Date.now() - 7200000).toISOString(), // 2 hours ago
cronExpression: "0 * * * *",
});
const routineStore = createMockRoutineStore([routine]);
const heartbeatMonitor = createMockHeartbeatMonitor();
const runner = createRoutineRunner({ routineStore, heartbeatMonitor });
await runner.handleCatchUp(routine);
// No executions should have happened
expect(heartbeatMonitor.executeHeartbeat).not.toHaveBeenCalled();
});
it("never-run routine (lastRunAt undefined): skips catch-up", async () => {
const routine = createMockRoutine({
id: "routine-never-run",
lastRunAt: undefined,
cronExpression: "0 * * * *",
});
const routineStore = createMockRoutineStore([routine]);
const heartbeatMonitor = createMockHeartbeatMonitor();
const runner = createRoutineRunner({ routineStore, heartbeatMonitor });
await runner.handleCatchUp(routine);
// No executions should have happened
expect(heartbeatMonitor.executeHeartbeat).not.toHaveBeenCalled();
});
it("caps at MAX_CATCH_UP_INTERVALS (10) even when more intervals exist", async () => {
const twoHoursAgo = new Date(Date.now() - 7200000);
const routine = createMockRoutine({
id: "routine-many-missed",
catchUpPolicy: "run",
lastRunAt: twoHoursAgo.toISOString(),
cronExpression: "*/5 * * * *", // Every 5 minutes = 24 missed in 2 hours
});
const routineStore = createMockRoutineStore([routine]);
const heartbeatMonitor = createMockHeartbeatMonitor();
const runner = createRoutineRunner({ routineStore, heartbeatMonitor });
await runner.handleCatchUp(routine);
// Should be capped at 10
expect(heartbeatMonitor.executeHeartbeat).toHaveBeenCalledTimes(10);
});
});
describe("helper methods", () => {
it("getInFlightCount returns correct count", async () => {
const routine1 = createMockRoutine({ id: "routine-count-1" });
const routine2 = createMockRoutine({ id: "routine-count-2" });
const routineStore = createMockRoutineStore([routine1, routine2]);
const heartbeatMonitor = createMockHeartbeatMonitor();
// Make heartbeat slow to allow checking in-flight count
(heartbeatMonitor.executeHeartbeat as ReturnType<typeof vi.fn>).mockImplementation(
async () => {
await new Promise((r) => setTimeout(r, 100));
return {
id: "run-123",
agentId: "test-agent",
status: "completed" as const,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
};
},
);
const runner = createRoutineRunner({ routineStore, heartbeatMonitor });
expect(runner.getInFlightCount()).toBe(0);
// Start first execution
const promise1 = runner.executeRoutine("routine-count-1", "cron");
// Allow microtask to complete to see the in-flight state
await new Promise((r) => setTimeout(r, 10));
expect(runner.getInFlightCount()).toBe(1);
// Start second execution (will run in parallel since policy is "parallel")
const promise2 = runner.executeRoutine("routine-count-2", "cron");
await new Promise((r) => setTimeout(r, 10));
expect(runner.getInFlightCount()).toBe(2);
await Promise.all([promise1, promise2]);
expect(runner.getInFlightCount()).toBe(0);
});
it("isRoutineRunning returns true during execution, false after", async () => {
const routine = createMockRoutine({ id: "routine-running" });
const routineStore = createMockRoutineStore([routine]);
const heartbeatMonitor = createMockHeartbeatMonitor();
// Make heartbeat slow to allow checking in-flight state
(heartbeatMonitor.executeHeartbeat as ReturnType<typeof vi.fn>).mockImplementation(
async () => {
await new Promise((r) => setTimeout(r, 50));
return {
id: "run-123",
agentId: "test-agent",
status: "completed" as const,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
};
},
);
const runner = createRoutineRunner({ routineStore, heartbeatMonitor });
expect(runner.isRoutineRunning("routine-running")).toBe(false);
const promise = runner.executeRoutine("routine-running", "cron");
// Allow microtask to complete to see the in-flight state
await new Promise((r) => setTimeout(r, 10));
expect(runner.isRoutineRunning("routine-running")).toBe(true);
await promise;
expect(runner.isRoutineRunning("routine-running")).toBe(false);
});
});
});

View File

@@ -1,293 +1,334 @@
/**
* RoutineRunner — orchestrates routine execution via the heartbeat system.
*
* - Validates routine state before execution (enabled, has assigned agent)
* - Enforces concurrency policies (parallel/skip/queue/replace)
* - Handles catch-up for missed runs
* - Triggers heartbeat execution for routines
*/
import { CronExpressionParser } from "cron-parser";
import type {
Routine,
RoutineStore,
HeartbeatInvocationSource,
Routine,
RoutineExecutionResult,
} from "@fusion/core";
import type { HeartbeatMonitor } from "./agent-heartbeat.js";
import { createLogger } from "./logger.js";
const logger = createLogger("routine-runner");
const log = createLogger("routine-runner");
/**
* Options for RoutineRunner.
*/
/** Options for RoutineRunner constructor */
export interface RoutineRunnerOptions {
/** The heartbeat monitor for executing routines */
heartbeatMonitor: HeartbeatMonitor;
/** The routine store for persisting execution state */
/** RoutineStore for querying and updating routines */
routineStore: RoutineStore;
/** HeartbeatMonitor for triggering agent execution */
heartbeatMonitor: HeartbeatMonitor;
/** Project root directory */
rootDir: string;
}
/**
* Tracks in-flight executions per routine ID.
* Maximum number of catch-up executions to prevent runaway loops.
*/
const inFlightExecutions = new Map<string, boolean>();
const MAX_CATCH_UP_INTERVALS = 10;
/**
* 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.
* RoutineRunner orchestrates routine execution via the heartbeat system.
*
* It handles:
* - Concurrency policy enforcement (parallel/queue/reject)
* - Catch-up policy handling for missed schedule windows
* - Execution state persistence via RoutineStore
* Key behaviors:
* - Enforces concurrency policies before starting executions
* - Handles catch-up for missed runs based on catch-up policy
* - Triggers heartbeats with routine context in the trigger detail
*/
export class RoutineRunner {
private heartbeatMonitor: HeartbeatMonitor;
private routineStore: RoutineStore;
private options: RoutineRunnerOptions;
/** Tracks currently-running executions by routine ID */
private inFlightExecutions: Map<string, Promise<RoutineExecutionResult>> = new Map();
constructor(options: RoutineRunnerOptions) {
this.heartbeatMonitor = options.heartbeatMonitor;
this.routineStore = options.routineStore;
this.options = options;
}
/**
* 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.
* Execute a routine by ID with a given trigger type.
*
* @param routine - The routine to execute
* @param options.catchUpFrom - Optional timestamp to use for catch-up execution
* @returns The result of the execution attempt
* @param routineId - ID of the routine to execute
* @param triggerType - What triggered this execution: "cron", "webhook", or "api"
* @param context - Additional context passed to the heartbeat execution
* @returns The execution result
* @throws Error if routine not found or disabled
*/
async execute(
routine: Routine,
options: { catchUpFrom?: string } = {}
async executeRoutine(
routineId: string,
triggerType: "cron" | "webhook" | "api",
context?: Record<string, unknown>,
): Promise<RoutineExecutionResult> {
const { catchUpFrom } = options;
const routineId = routine.id;
// 1. Load routine
let routine: Routine;
try {
routine = await this.options.routineStore.getRoutine(routineId);
} catch {
throw new Error(`Routine '${routineId}' not found`);
}
// Check concurrency policy
const policyResult = this.checkConcurrencyPolicy(routine);
if (!policyResult.shouldExecute) {
logger.log(
`[${routineId}] Skipped by concurrency policy: ${policyResult.reason}`
);
// 2. Validate routine state
if (!routine.enabled) {
throw new Error(`Routine '${routineId}' is disabled`);
}
if (!routine.agentId) {
throw new Error(`Routine '${routineId}' has no assigned agent`);
}
// 3. Enforce concurrency policy
const concurrency = routine.executionPolicy ?? "queue";
if (concurrency === "reject" && this.inFlightExecutions.has(routineId)) {
log.log(`Routine ${routineId} rejected — already running`);
// Return a failed result without creating an execution record
return {
routineId,
success: true,
executedAt: new Date().toISOString(),
catchUpExecution: !!catchUpFrom,
error: policyResult.reason,
success: false,
output: "Routine rejected — already running",
error: "Routine rejected — already running",
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
}
// Mark as in-flight
inFlightExecutions.set(routineId, true);
// If queue, wait for existing execution
if (concurrency === "queue" && this.inFlightExecutions.has(routineId)) {
log.log(`Routine ${routineId} queued — waiting for existing execution`);
const existingResult = await this.inFlightExecutions.get(routineId);
if (existingResult) {
await existingResult;
}
}
// 4. Record execution start
const startedAt = new Date().toISOString();
// Set in-flight BEFORE starting execution to prevent race conditions
const executionPromise = this.runExecution(routine, triggerType, context, startedAt);
this.inFlightExecutions.set(routineId, executionPromise);
try {
// Persist execution start
const startedAt = new Date().toISOString();
await this.routineStore.startRoutineExecution(routineId, {
await this.options.routineStore.startRoutineExecution(routineId, {
triggeredAt: startedAt,
catchUpFrom,
invocationSource: "routine",
});
logger.log(`[${routineId}] Starting routine execution`);
const result = await executionPromise;
return result;
} finally {
this.inFlightExecutions.delete(routineId);
}
}
/**
* Internal execution logic for a routine.
*/
private async runExecution(
routine: Routine,
triggerType: string,
context: Record<string, unknown> | undefined,
startedAt: string,
): Promise<RoutineExecutionResult> {
const routineId = routine.id;
try {
// Execute via heartbeat monitor
const run = await this.heartbeatMonitor.executeHeartbeat({
const run = await this.options.heartbeatMonitor.executeHeartbeat({
agentId: routine.agentId,
source: "routine" as HeartbeatInvocationSource,
triggerDetail: `routine:${routineId}`,
source: "routine",
triggerDetail: `routine:${routine.id}:${triggerType}`,
contextSnapshot: {
routineId,
catchUpFrom,
executionPolicy: routine.executionPolicy,
catchUpPolicy: routine.catchUpPolicy,
routineId: routine.id,
routineName: routine.name,
triggerType,
...context,
},
});
// Handle failed/terminated runs
// Determine status from run
let success = true;
let output = "";
let error: string | undefined;
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,
};
success = false;
error = run.stderrExcerpt || `Run ${run.status}`;
output = error;
} else {
output = run.resultJson ? JSON.stringify(run.resultJson) : "Routine completed successfully";
}
// Persist execution completion
const completedAt = run.endedAt ?? new Date().toISOString();
await this.routineStore.completeRoutineExecution(routineId, {
completedAt,
success: true,
// Complete the execution
await this.options.routineStore.completeRoutineExecution(routineId, {
completedAt: new Date().toISOString(),
success,
resultJson: run.resultJson,
error,
});
logger.log(`[${routineId}] Routine execution completed successfully`);
return {
routineId,
success: true,
executedAt: completedAt,
catchUpExecution: !!catchUpFrom,
success,
output,
startedAt,
completedAt: new Date().toISOString(),
error,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.log(`[${routineId}] Routine execution failed: ${errorMessage}`);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
log.error(`Routine ${routineId} execution failed: ${errorMessage}`);
// Persist execution failure
// Record failure
try {
await this.routineStore.completeRoutineExecution(routineId, {
await this.options.routineStore.completeRoutineExecution(routineId, {
completedAt: new Date().toISOString(),
success: false,
error: errorMessage,
});
} catch (persistError) {
logger.error(`[${routineId}] Failed to persist error state: ${persistError}`);
log.error(`[${routineId}] Failed to persist error state: ${persistError}`);
}
return {
routineId,
success: false,
output: errorMessage,
startedAt,
completedAt: new Date().toISOString(),
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.
* Handle catch-up for missed routine executions based on the catch-up policy.
*
* @param routine - The routine to check for catch-up
*/
private checkConcurrencyPolicy(
routine: Routine
): { shouldExecute: boolean; reason?: string } {
const routineId = routine.id;
const isInFlight = this.isExecuting(routineId);
async handleCatchUp(routine: Routine): Promise<void> {
const catchUpPolicy = routine.catchUpPolicy ?? "skip";
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}`,
};
}
if (catchUpPolicy === "skip") {
return;
}
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 };
// "run_one" or "run" policy - need to catch up
if (!routine.lastRunAt) {
// Never run before — nothing to catch up
return;
}
switch (routine.catchUpPolicy) {
case "skip":
return { shouldCatchUp: false };
// Calculate missed intervals
if (!routine.cronExpression) {
return;
}
case "run_one":
return { shouldCatchUp: true, catchUpFrom: lastRunAt };
try {
const cronExpr = CronExpressionParser.parse(routine.cronExpression, {
currentDate: new Date(routine.lastRunAt ?? Date.now()),
});
const lastRun = new Date(routine.lastRunAt ?? Date.now());
const now = new Date();
case "run": {
const catchUpLimit = routine.catchUpLimit ?? 5;
const lastExecuted = new Date(lastRunAt);
const diffMs = currentTime.getTime() - lastExecuted.getTime();
const missedIntervals: Date[] = [];
const intervalMs = this.getRoutineIntervalMs(routine);
if (intervalMs <= 0) {
return { shouldCatchUp: false };
// Get next interval after lastRun, then iterate
let intervalDate = new Date(cronExpr.next().toISOString() ?? Date.now());
while (intervalDate.getTime() <= now.getTime() && missedIntervals.length < MAX_CATCH_UP_INTERVALS) {
if (intervalDate.getTime() > lastRun.getTime()) {
missedIntervals.push(new Date(intervalDate));
}
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 };
const nextIso = cronExpr.next().toISOString();
if (!nextIso) break;
intervalDate = new Date(nextIso);
}
default:
return { shouldCatchUp: false };
if (missedIntervals.length === 0) {
return;
}
log.log(`[${routine.id}] Running ${missedIntervals.length} catch-up executions`);
// Execute each missed interval
for (const missedInterval of missedIntervals) {
try {
await this.executeRoutine(routine.id, "cron", {
catchUp: true,
missedInterval: missedInterval.toISOString(),
});
} catch (err) {
log.error(`[${routine.id}] Catch-up execution failed: ${err}`);
}
}
} catch (err) {
log.error(`[${routine.id}] Error calculating catch-up intervals: ${err}`);
}
}
/**
* Get the interval in milliseconds for a routine based on its cron schedule.
* Trigger a routine manually (via API).
*
* @param routineId - The ID of the routine to trigger
* @returns The execution result
* @throws Error if routine not found or disabled
*/
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;
async triggerManual(routineId: string): Promise<RoutineExecutionResult> {
const routine = await this.options.routineStore.getRoutine(routineId);
if (!routine.enabled) {
throw new Error(`Routine '${routineId}' is disabled`);
}
return 5 * 60_000;
return this.executeRoutine(routineId, "api");
}
/**
* Clear the in-flight flag for a routine (for testing).
* Trigger a routine via webhook.
*
* @param routineId - The ID of the routine to trigger
* @param payload - The webhook payload
* @param _signature - The webhook signature (verified by RoutineScheduler)
* @returns The execution result
* @throws Error if routine not found, not a webhook trigger, or disabled
*/
clearInFlight(routineId: string): void {
inFlightExecutions.delete(routineId);
async triggerWebhook(
routineId: string,
payload: Record<string, unknown>,
_signature?: string
): Promise<RoutineExecutionResult> {
const routine = await this.options.routineStore.getRoutine(routineId);
if (routine.trigger.type !== "webhook") {
throw new Error(
`Routine '${routineId}' does not have webhook trigger type`
);
}
if (!routine.enabled) {
throw new Error(`Routine '${routineId}' is disabled`);
}
return this.executeRoutine(routineId, "webhook", { webhookPayload: payload });
}
/**
* Get the number of currently-running executions.
*/
getInFlightCount(): number {
return this.inFlightExecutions.size;
}
/**
* Check if a routine is currently being executed.
*/
isRoutineRunning(routineId: string): boolean {
return this.inFlightExecutions.has(routineId);
}
}

View File

@@ -1,253 +1,458 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { Routine, RoutineStore, ProjectSettings } from "@fusion/core";
import { RoutineScheduler } from "./routine-scheduler.js";
import { RoutineScheduler, type RoutineSchedulerOptions } from "./routine-scheduler.js";
import type { RoutineStore, Routine, TaskStore, RoutineExecutionResult, Settings } from "@fusion/core";
import type { RoutineRunner } from "./routine-runner.js";
// Mock the logger
vi.mock("./logger.js", () => ({
createLogger: () => ({
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
// Default settings inline to avoid @fusion/core build dependency during tests
const DEFAULT_SETTINGS: Settings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 30000,
autoResolveConflicts: true,
requirePlanApproval: false,
recycleWorktrees: false,
worktreeNaming: "random",
globalPause: false,
enginePaused: false,
ntfyEnabled: false,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
planningProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
validatorProvider: "openai",
validatorModelId: "gpt-4o",
taskStuckTimeoutMs: undefined,
groupOverlappingFiles: false,
autoMerge: true,
};
function createMockRoutine(overrides: Partial<Routine> = {}): Routine {
return {
id: "test-routine-id",
agentId: "test-agent",
name: "Test Routine",
description: "A test routine",
trigger: { type: "cron", cronExpression: "0 * * * *" },
catchUpPolicy: "run_one",
executionPolicy: "parallel",
enabled: true,
runCount: 0,
runHistory: [],
cronExpression: "0 * * * *",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function createMockTaskStore(settingsOverrides: Partial<Settings> = {}): TaskStore {
return {
getSettings: vi.fn().mockResolvedValue({
...DEFAULT_SETTINGS,
...settingsOverrides,
}),
on: vi.fn(),
off: vi.fn(),
} as unknown as TaskStore;
}
function createMockRoutineStore(routines: Routine[] = []): RoutineStore {
const routineMap = new Map(routines.map((r) => [r.id, r]));
return {
getRoutine: vi.fn().mockImplementation((id: string) => {
const routine = routineMap.get(id);
if (!routine) {
throw Object.assign(new Error(`Routine '${id}' not found`), { code: "ENOENT" });
}
return routine;
}),
listRoutines: vi.fn().mockResolvedValue(routines),
updateRoutine: vi.fn().mockImplementation((id: string, _updates: any) => {
const routine = routineMap.get(id);
if (!routine) {
throw Object.assign(new Error(`Routine '${id}' not found`), { code: "ENOENT" });
}
return routine;
}),
getDueRoutines: vi.fn().mockResolvedValue(routines),
recordRun: vi.fn().mockImplementation((id: string, result: RoutineExecutionResult) => {
return createMockRoutine({ id, lastRunResult: result });
}),
startRoutineExecution: vi.fn().mockResolvedValue(undefined),
completeRoutineExecution: vi.fn().mockResolvedValue(undefined),
cancelRoutineExecution: vi.fn().mockResolvedValue(undefined),
init: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
off: vi.fn(),
} as unknown as RoutineStore;
}
function createMockRoutineRunner(): RoutineRunner {
return {
executeRoutine: vi.fn().mockResolvedValue({
routineId: "test-routine",
success: true,
output: "Success",
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
} as RoutineExecutionResult),
handleCatchUp: vi.fn().mockResolvedValue(undefined),
getInFlightCount: vi.fn().mockReturnValue(0),
isRoutineRunning: vi.fn().mockReturnValue(false),
} as unknown as RoutineRunner;
}
function createRoutineScheduler(
taskStore?: TaskStore,
routineStore?: RoutineStore,
routineRunner?: RoutineRunner,
options?: Partial<Pick<RoutineSchedulerOptions, "pollIntervalMs">>,
): RoutineScheduler {
return new RoutineScheduler({
taskStore: taskStore ?? createMockTaskStore(),
routineStore: routineStore ?? createMockRoutineStore(),
routineRunner: routineRunner ?? createMockRoutineRunner(),
pollIntervalMs: options?.pollIntervalMs,
});
}
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();
if (scheduler) {
scheduler.stop();
}
vi.clearAllMocks();
});
describe("lifecycle", () => {
it("should start and stop correctly", () => {
scheduler.start();
expect(scheduler.getStatus()).toBe("running");
expect(onStart).toHaveBeenCalled();
describe("constructor", () => {
it("clamps poll interval to minimum 10000ms", () => {
scheduler = createRoutineScheduler(
undefined,
undefined,
undefined,
{ pollIntervalMs: 100 }, // Below minimum
);
scheduler.stop();
expect(scheduler.getStatus()).toBe("stopped");
expect(onStop).toHaveBeenCalled();
// The scheduler should have clamped to 10000ms
expect(scheduler["pollIntervalMs"]).toBe(10000);
});
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)
);
it("uses default poll interval of 60000ms when not specified", () => {
scheduler = createRoutineScheduler();
expect(scheduler["pollIntervalMs"]).toBe(60000);
});
});
describe("poll behavior", () => {
it("should skip poll when globalPause is true", async () => {
mockGetSettings.mockReturnValue({ globalPause: true });
describe("start/stop", () => {
it("sets running = true after start", () => {
scheduler = createRoutineScheduler();
scheduler.start();
await new Promise((resolve) => setTimeout(resolve, 1500));
expect(mockRoutineStore.getDueRoutines).not.toHaveBeenCalled();
expect(scheduler.isActive()).toBe(true);
});
it("should skip poll when enginePaused is true", async () => {
mockGetSettings.mockReturnValue({ enginePaused: true });
it("clears interval and sets running = false after stop", () => {
scheduler = createRoutineScheduler();
scheduler.start();
await new Promise((resolve) => setTimeout(resolve, 1500));
expect(mockRoutineStore.getDueRoutines).not.toHaveBeenCalled();
scheduler.stop();
expect(scheduler.isActive()).toBe(false);
});
it("should process due routines", async () => {
const routine = createMockRoutine();
vi.mocked(mockRoutineStore.getDueRoutines).mockResolvedValue([routine]);
it("runs first tick immediately on start", async () => {
const routineStore = createMockRoutineStore([createMockRoutine({ id: "due-routine" })]);
const routineRunner = createMockRoutineRunner();
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner);
scheduler.start();
// Wait for the tick to complete
await new Promise((r) => setTimeout(r, 10));
scheduler.stop();
await new Promise((resolve) => setTimeout(resolve, 1500));
expect(mockRoutineStore.getDueRoutines).toHaveBeenCalled();
expect(mockRoutineRunner.execute).toHaveBeenCalledWith(routine);
expect(routineRunner.handleCatchUp).toHaveBeenCalled();
expect(routineRunner.executeRoutine).toHaveBeenCalled();
});
it("should skip disabled routines", async () => {
const routine = createMockRoutine({ enabled: false });
vi.mocked(mockRoutineStore.getDueRoutines).mockResolvedValue([routine]);
it("is idempotent on start", () => {
scheduler = createRoutineScheduler();
scheduler.start();
await new Promise((resolve) => setTimeout(resolve, 1500));
expect(mockRoutineRunner.execute).not.toHaveBeenCalled();
scheduler.start(); // Should not throw
expect(scheduler.isActive()).toBe(true);
});
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();
it("is safe to stop when not started", () => {
scheduler = createRoutineScheduler();
expect(() => scheduler.stop()).not.toThrow();
});
});
await new Promise((resolve) => setTimeout(resolve, 1500));
describe("tick", () => {
it("skips when globalPause is true", async () => {
const routineStore = createMockRoutineStore([createMockRoutine()]);
const routineRunner = createMockRoutineRunner();
scheduler = createRoutineScheduler(
createMockTaskStore({ globalPause: true }),
routineStore,
routineRunner,
);
expect(mockRoutineRunner.execute).not.toHaveBeenCalled();
await scheduler.tick();
expect(routineStore.getDueRoutines).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();
it("skips when enginePaused is true", async () => {
const routineStore = createMockRoutineStore([createMockRoutine()]);
const routineRunner = createMockRoutineRunner();
scheduler = createRoutineScheduler(
createMockTaskStore({ enginePaused: true }),
routineStore,
routineRunner,
);
await new Promise((resolve) => setTimeout(resolve, 1500));
await scheduler.tick();
expect(mockRoutineRunner.execute).toHaveBeenCalledWith(routine, {
catchUpFrom: "2024-01-01T00:00:00.000Z",
});
expect(routineStore.getDueRoutines).not.toHaveBeenCalled();
});
it("should isolate per-routine failures", async () => {
it("skips when no routines are due", async () => {
const routineStore = createMockRoutineStore([]);
const routineRunner = createMockRoutineRunner();
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner);
await scheduler.tick();
expect(routineRunner.handleCatchUp).not.toHaveBeenCalled();
expect(routineRunner.executeRoutine).not.toHaveBeenCalled();
});
it("processes due routines in order", 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();
const routineStore = createMockRoutineStore([routine1, routine2]);
const routineRunner = createMockRoutineRunner();
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner);
await new Promise((resolve) => setTimeout(resolve, 1500));
await scheduler.tick();
// Both should have been attempted despite the first failure
expect(mockRoutineRunner.execute).toHaveBeenCalledTimes(2);
// Both routines should be processed
expect(routineRunner.handleCatchUp).toHaveBeenCalledTimes(2);
expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(2);
});
it("calls handleCatchUp then executeRoutine for each routine", async () => {
const routine1 = createMockRoutine({ id: "routine-order-1" });
const routine2 = createMockRoutine({ id: "routine-order-2" });
const routineStore = createMockRoutineStore([routine1, routine2]);
const routineRunner = createMockRoutineRunner();
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner);
await scheduler.tick();
// Both routines should be processed
expect(routineRunner.handleCatchUp).toHaveBeenCalledTimes(2);
expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(2);
});
it("handles errors in individual routine execution gracefully", async () => {
const routine = createMockRoutine({ id: "routine-error" });
const routineStore = createMockRoutineStore([routine]);
const routineRunner = createMockRoutineRunner();
vi.mocked(routineRunner.executeRoutine).mockRejectedValueOnce(
new Error("Execution failed"),
);
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner);
// Should not throw
await expect(scheduler.tick()).resolves.toBeUndefined();
});
it("re-entrance guard prevents overlapping ticks", async () => {
const routine = createMockRoutine({ id: "routine-reentrant" });
const routineStore = createMockRoutineStore([routine]);
const routineRunner = createMockRoutineRunner();
// Make execution slow
vi.mocked(routineRunner.executeRoutine).mockImplementation(async () => {
await new Promise((r) => setTimeout(r, 50));
return {
routineId: "routine-reentrant",
success: true,
output: "",
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
} as RoutineExecutionResult;
});
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner);
// Start two ticks concurrently
const [tick1, tick2] = [scheduler.tick(), scheduler.tick()];
await Promise.all([tick1, tick2]);
// Should only have executed once (second tick was blocked by re-entrance guard)
expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(1);
});
});
describe("re-entrance guard", () => {
it("should skip concurrent polls", async () => {
// Make getDueRoutines slow
vi.mocked(mockRoutineStore.getDueRoutines).mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve([]), 500))
describe("triggerManual", () => {
it("delegates to routineRunner.executeRoutine with 'api' trigger", async () => {
const routineRunner = createMockRoutineRunner();
scheduler = createRoutineScheduler(createMockTaskStore(), createMockRoutineStore(), routineRunner);
await scheduler.triggerManual("test-routine");
expect(routineRunner.executeRoutine).toHaveBeenCalledWith("test-routine", "api");
});
it("passes through the result from routineRunner", async () => {
const mockResult: RoutineExecutionResult = {
routineId: "test-routine",
success: true,
output: "Success",
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
const routineRunner = createMockRoutineRunner();
vi.mocked(routineRunner.executeRoutine).mockResolvedValue(mockResult);
scheduler = createRoutineScheduler(createMockTaskStore(), createMockRoutineStore(), routineRunner);
const result = await scheduler.triggerManual("test-routine");
expect(result).toEqual(mockResult);
});
});
describe("triggerWebhook", () => {
it("delegates to routineRunner.executeRoutine with 'webhook' trigger", async () => {
const routine = createMockRoutine({ id: "webhook-routine", trigger: { type: "webhook", webhookPath: "/test" } });
const routineStore = createMockRoutineStore([routine]);
const routineRunner = createMockRoutineRunner();
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner);
await scheduler.triggerWebhook("webhook-routine", { data: "test" });
expect(routineRunner.executeRoutine).toHaveBeenCalledWith(
"webhook-routine",
"webhook",
expect.objectContaining({ webhookPayload: { data: "test" } }),
);
});
it("throws for routine with non-webhook trigger type", async () => {
const routine = createMockRoutine({ id: "cron-routine", trigger: { type: "cron", cronExpression: "0 * * * *" } });
const routineStore = createMockRoutineStore([routine]);
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore);
await expect(scheduler.triggerWebhook("cron-routine", {})).rejects.toThrow(
"does not have webhook trigger type",
);
});
it("throws for nonexistent routine", async () => {
const routineStore = createMockRoutineStore([]);
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore);
await expect(scheduler.triggerWebhook("nonexistent", {})).rejects.toThrow(
"not found",
);
});
it("proceeds without signature check when no secret is configured", async () => {
// Clear the env var if it exists
const originalSecret = process.env.FUSION_ROUTINE_WEBHOOK_SECRET;
delete process.env.FUSION_ROUTINE_WEBHOOK_SECRET;
try {
const routine = createMockRoutine({ id: "webhook-no-secret", trigger: { type: "webhook", webhookPath: "/test" } });
const routineStore = createMockRoutineStore([routine]);
const routineRunner = createMockRoutineRunner();
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner);
// Should not throw even without signature
await scheduler.triggerWebhook("webhook-no-secret", { data: "test" });
expect(routineRunner.executeRoutine).toHaveBeenCalled();
} finally {
// Restore env var
if (originalSecret !== undefined) {
process.env.FUSION_ROUTINE_WEBHOOK_SECRET = originalSecret;
}
}
});
it("throws for invalid HMAC signature when secret is configured", async () => {
process.env.FUSION_ROUTINE_WEBHOOK_SECRET = "test-secret";
try {
const routine = createMockRoutine({ id: "webhook-with-secret", trigger: { type: "webhook", webhookPath: "/test" } });
const routineStore = createMockRoutineStore([routine]);
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore);
await expect(
scheduler.triggerWebhook("webhook-with-secret", { data: "test" }, "sha256=invalidsignature"),
).rejects.toThrow("Invalid webhook signature");
} finally {
delete process.env.FUSION_ROUTINE_WEBHOOK_SECRET;
}
});
it("accepts valid HMAC signature", async () => {
const secret = "test-secret-123";
process.env.FUSION_ROUTINE_WEBHOOK_SECRET = secret;
try {
const routine = createMockRoutine({ id: "webhook-valid-sig", trigger: { type: "webhook", webhookPath: "/test" } });
const routineStore = createMockRoutineStore([routine]);
const routineRunner = createMockRoutineRunner();
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner);
const payload = { data: "test" };
const crypto = await import("node:crypto");
const signature = "sha256=" + crypto.createHmac("sha256", secret).update(JSON.stringify(payload)).digest("hex");
await scheduler.triggerWebhook("webhook-valid-sig", payload, signature);
expect(routineRunner.executeRoutine).toHaveBeenCalled();
} finally {
delete process.env.FUSION_ROUTINE_WEBHOOK_SECRET;
}
});
it("throws when signature is missing but secret is configured", async () => {
process.env.FUSION_ROUTINE_WEBHOOK_SECRET = "test-secret";
try {
const routine = createMockRoutine({ id: "webhook-missing-sig", trigger: { type: "webhook", webhookPath: "/test" } });
const routineStore = createMockRoutineStore([routine]);
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore);
await expect(
scheduler.triggerWebhook("webhook-missing-sig", { data: "test" }),
).rejects.toThrow("Missing webhook signature");
} finally {
delete process.env.FUSION_ROUTINE_WEBHOOK_SECRET;
}
});
});
describe("isActive", () => {
it("returns false initially", () => {
scheduler = createRoutineScheduler();
expect(scheduler.isActive()).toBe(false);
});
it("returns true after start", () => {
scheduler = createRoutineScheduler();
scheduler.start();
expect(scheduler.isActive()).toBe(true);
scheduler.stop();
});
// 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");
it("returns false after stop", () => {
scheduler = createRoutineScheduler();
scheduler.start();
scheduler.stop();
expect(scheduler.isActive()).toBe(false);
});
});
});

View File

@@ -1,4 +1,16 @@
import type { Routine, RoutineStore, ProjectSettings } from "@fusion/core";
/**
* RoutineScheduler — polls for due routines and triggers their execution via RoutineRunner.
*
* Handles:
* - Polling interval with configurable interval
* - Re-entrance guard (prevents overlapping polls)
* - Pause awareness (globalPause / enginePaused)
* - Catch-up execution before normal due execution
* - Per-routine failure isolation
*/
import { CronExpressionParser } from "cron-parser";
import type { Routine, RoutineStore, TaskStore } from "@fusion/core";
import { RoutineRunner } from "./routine-runner.js";
import { createLogger } from "./logger.js";
@@ -8,151 +20,98 @@ const logger = createLogger("routine-scheduler");
* Options for RoutineScheduler.
*/
export interface RoutineSchedulerOptions {
/** The routine store */
/** TaskStore for checking pause state */
taskStore: TaskStore;
/** RoutineStore for querying routines */
routineStore: RoutineStore;
/** The routine runner */
/** RoutineRunner for executing routines */
routineRunner: RoutineRunner;
/** Polling interval in milliseconds */
/** Polling interval in milliseconds. Default: 60000 (60s). Minimum: 10000 (10s). */
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 taskStore: TaskStore;
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;
private running: boolean = false;
private ticking: boolean = false;
private pollInterval: ReturnType<typeof setInterval> | null = null;
constructor(options: RoutineSchedulerOptions) {
this.taskStore = options.taskStore;
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
);
this.pollIntervalMs = Math.max(10000, options.pollIntervalMs ?? 60000);
}
/**
* Start the scheduler.
*/
start(): void {
if (this.isRunning) {
if (this.running) {
logger.log("RoutineScheduler already running");
return;
}
this.isRunning = true;
logger.log(
`RoutineScheduler started with ${this.pollIntervalMs}ms poll interval`
);
this.running = 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);
// Run first tick immediately
void this.tick();
// Start polling
this.pollTimer = setInterval(() => {
void this.poll();
// Start polling interval
this.pollInterval = setInterval(() => {
void this.tick();
}, this.pollIntervalMs);
// Run initial poll
void this.poll();
this.onStart?.();
}
/**
* Stop the scheduler.
*/
stop(): void {
if (!this.isRunning) {
if (!this.running) {
return;
}
this.isRunning = false;
this.running = false;
logger.log("RoutineScheduler stopping");
// Clear timer
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = 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.
* Check if the scheduler is active (running).
*/
getStatus(): "running" | "stopped" {
return this.isRunning ? "running" : "stopped";
isActive(): boolean {
return this.running;
}
/**
* Trigger an immediate poll (for testing).
* Process a single tick — poll for due routines and execute them.
*/
async triggerPoll(): Promise<void> {
await this.poll();
}
/**
* Poll for due routines and execute them.
*/
private async poll(): Promise<void> {
async tick(): Promise<void> {
// Re-entrance guard
if (this.isPolling) {
logger.log("Poll already in progress, skipping");
if (this.ticking) {
logger.log("Tick already in progress, skipping");
return;
}
this.isPolling = true;
this.ticking = true;
try {
// Check pause state
const settings = this.getSettings();
const settings = await this.taskStore.getSettings();
if (settings.globalPause || settings.enginePaused) {
logger.log(
`Paused: globalPause=${settings.globalPause}, enginePaused=${settings.enginePaused}`
@@ -161,86 +120,117 @@ export class RoutineScheduler {
}
// Get due routines
const dueRoutines = await this.routineStore.getDueRoutines();
const dueRoutines = await this.getDueRoutines();
if (dueRoutines.length === 0) {
return;
}
logger.log(`Found ${dueRoutines.length} due routines`);
// Process each routine
for (const routine of dueRoutines) {
await this.processRoutine(routine);
// Re-check pause state (may have changed mid-loop)
const currentSettings = await this.taskStore.getSettings();
if (currentSettings.globalPause || currentSettings.enginePaused) {
logger.log("Paused mid-loop, stopping processing");
break;
}
try {
await this.processRoutine(routine);
} catch (err) {
logger.error(`[${routine.id}] Failed to process: ${err}`);
// Continue to next routine
}
}
} catch (error) {
logger.error(`Poll error: ${error}`);
} finally {
this.isPolling = false;
this.ticking = false;
}
}
/**
* Process a single routine, handling catch-up and normal execution.
* Process a single routine.
*/
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 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
await this.routineRunner.handleCatchUp(routine);
// Handle catch-up if needed
const lastRunAt = routine.lastRunAt ?? null;
const currentTime = new Date();
const catchUp = this.routineRunner.determineCatchUp(
routine,
lastRunAt,
currentTime
);
// Execute the routine
await this.routineRunner.executeRoutine(routineId, "cron");
if (catchUp.shouldCatchUp && catchUp.catchUpFrom) {
logger.log(
`[${routineId}] Executing catch-up from ${catchUp.catchUpFrom}`
);
await this.routineRunner.execute(routine, {
catchUpFrom: catchUp.catchUpFrom,
});
// Update next run time
if (routine.cronExpression) {
try {
const nextRun = CronExpressionParser.parse(routine.cronExpression).next();
// Note: We can't update nextRunAt directly as it's derived from trigger
// The RoutineStore handles this internally
} catch (err) {
logger.error(`[${routineId}] Failed to calculate next run: ${err}`);
}
// 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.
* Get routines that are due for execution.
*/
private handleRoutineCreated = (routine: Routine): void => {
logger.log(`[${routine.id}] Routine created, will check at next poll`);
};
private async getDueRoutines(): Promise<Routine[]> {
try {
return await this.routineStore.getDueRoutines();
} catch (err) {
logger.error(`Failed to get due routines: ${err}`);
return [];
}
}
/**
* Handle routine updated event.
* Trigger a routine manually via the API.
*/
private handleRoutineUpdated = (routine: Routine): void => {
logger.log(`[${routine.id}] Routine updated`);
};
async triggerManual(routineId: string): Promise<import("@fusion/core").RoutineExecutionResult> {
return this.routineRunner.executeRoutine(routineId, "api");
}
/**
* Handle routine deleted event.
* Trigger a routine via webhook.
*/
private handleRoutineDeleted = (routine: Routine): void => {
logger.log(`[${routine.id}] Routine deleted`);
};
async triggerWebhook(
routineId: string,
payload: Record<string, unknown>,
signature?: string
): Promise<import("@fusion/core").RoutineExecutionResult> {
// Load routine to validate webhook trigger type
const routine = await this.routineStore.getRoutine(routineId);
if (routine.trigger.type !== "webhook") {
throw new Error(`Routine '${routineId}' does not have webhook trigger type`);
}
// Verify webhook signature if secret is configured
const webhookSecret = process.env.FUSION_ROUTINE_WEBHOOK_SECRET;
if (webhookSecret) {
if (!signature) {
throw new Error("Missing webhook signature");
}
const { createHmac, timingSafeEqual } = await import("node:crypto");
const [algo, expectedSig] = signature.split("=");
if (algo !== "sha256") {
throw new Error("Invalid webhook signature algorithm");
}
const computed = createHmac("sha256", webhookSecret).update(JSON.stringify(payload)).digest("hex");
const sigBuffer = Buffer.from(expectedSig, "hex");
const computedBuffer = Buffer.from(computed, "hex");
if (sigBuffer.length !== computedBuffer.length || !timingSafeEqual(sigBuffer, computedBuffer)) {
throw new Error("Invalid webhook signature");
}
}
return this.routineRunner.executeRoutine(routineId, "webhook", { webhookPayload: payload });
}
}

View File

@@ -14,6 +14,8 @@ import { TaskExecutor, type TaskExecutorOptions } from "../executor.js";
import { WorktreePool } from "../worktree-pool.js";
import { AgentSemaphore } from "../concurrency.js";
import { HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext } from "../agent-heartbeat.js";
import { RoutineRunner, type RoutineRunnerOptions } from "../routine-runner.js";
import { RoutineScheduler } from "../routine-scheduler.js";
import type {
ProjectRuntime,
ProjectRuntimeConfig,
@@ -82,6 +84,8 @@ export class InProcessRuntime
private pluginRunner?: PluginRunner;
private pluginStore?: PluginStore;
private pluginLoader?: PluginLoader;
private routineRunner?: RoutineRunner;
private routineScheduler?: RoutineScheduler;
/**
* @param config - Runtime configuration
@@ -345,6 +349,39 @@ export class InProcessRuntime
runtimeLog.warn(`AgentStore initialization failed (continuing without agent monitoring):`, agentErr);
}
// Initialize RoutineScheduler (requires RoutineStore from FN-1519)
try {
const { RoutineStore: RoutineStoreClass } = await import("@fusion/core");
// Verify RoutineStore actually has the expected methods (FN-1519 complete)
if (typeof RoutineStoreClass.prototype.getDueRoutines === "function") {
const routineStore = new RoutineStoreClass(this.taskStore.getFusionDir());
await routineStore.init();
if (this.heartbeatMonitor) {
const routineRunnerOptions: RoutineRunnerOptions = {
routineStore,
heartbeatMonitor: this.heartbeatMonitor,
rootDir: this.config.workingDirectory,
};
this.routineRunner = new RoutineRunner(routineRunnerOptions);
this.routineScheduler = new RoutineScheduler({
taskStore: this.taskStore,
routineStore,
routineRunner: this.routineRunner,
pollIntervalMs: 60000,
});
this.routineScheduler.start();
runtimeLog.log("RoutineScheduler initialized and started");
}
} else {
runtimeLog.log("RoutineStore not available (FN-1519 types not complete) — skipping RoutineScheduler");
}
} catch (routineErr) {
// Non-fatal — RoutineStore may not be exported if FN-1519 is not complete
runtimeLog.warn("RoutineScheduler initialization skipped:", routineErr instanceof Error ? routineErr.message : routineErr);
}
// 7. Initialize SelfHealingManager
this.selfHealingManager = new SelfHealingManager(this.taskStore, {
rootDir: this.config.workingDirectory,
@@ -420,31 +457,37 @@ export class InProcessRuntime
runtimeLog.log("SelfHealingManager stopped");
}
// 2. Stop trigger scheduler
// 2. Stop routine scheduler (stops new routine triggers; in-flight executions continue)
if (this.routineScheduler) {
this.routineScheduler.stop();
runtimeLog.log("RoutineScheduler stopped");
}
// 3. Stop trigger scheduler
if (this.triggerScheduler) {
this.triggerScheduler.stop();
runtimeLog.log("TriggerScheduler stopped");
}
// 3. Stop stuck task detector
// 4. Stop stuck task detector
if (this.stuckTaskDetector) {
this.stuckTaskDetector.stop();
runtimeLog.log("StuckTaskDetector stopped");
}
// 4. Stop heartbeat monitor
// 5. Stop heartbeat monitor
if (this.heartbeatMonitor) {
this.heartbeatMonitor.stop();
runtimeLog.log("HeartbeatMonitor stopped");
}
// 5. Stop scheduler (prevents new task scheduling)
// 6. Stop scheduler (prevents new task scheduling)
if (this.scheduler) {
this.scheduler.stop();
runtimeLog.log("Scheduler stopped");
}
// 2. Wait for active tasks to complete (30 second timeout)
// 7. Wait for active tasks to complete (30 second timeout)
const shutdownTimeout = 30000;
const startTime = Date.now();
@@ -467,13 +510,13 @@ export class InProcessRuntime
);
}
// 6. Shutdown plugin runner
// 8. Shutdown plugin runner
if (this.pluginRunner) {
await this.pluginRunner.shutdown();
runtimeLog.log("PluginRunner shutdown complete");
}
// 7. Drain and cleanup worktree pool
// 9. Drain and cleanup worktree pool
if (this.worktreePool) {
const worktrees = this.worktreePool.drain();
if (worktrees.length > 0) {
@@ -560,6 +603,22 @@ export class InProcessRuntime
return this.triggerScheduler;
}
/**
* Get the RoutineRunner instance (if initialized).
* Returns undefined when RoutineStore is not available.
*/
getRoutineRunner(): RoutineRunner | undefined {
return this.routineRunner;
}
/**
* Get the RoutineScheduler instance (if initialized).
* Returns undefined when RoutineStore is not available.
*/
getRoutineScheduler(): RoutineScheduler | undefined {
return this.routineScheduler;
}
/**
* Execute a heartbeat run for an agent.
*