Files
fusion/packages/engine/src/__tests__/routine-runner.test.ts
gsxdsm cb57093d03 refactor: domain folder layout (types, API, core, engine) (#2398)
## Summary

Wave 17 organizes Fusion into **domain folders** (stacks on #2397).

### Layout
- **core/types/** — board, task, agents, settings, merge, workflow,
mesh, …
- **core/src/** — agents, ai, async-stores, workflows, tasks, config,
db, …
- **dashboard/app/api/** — client, tasks, agents, git, missions,
planning, …
- **engine/src/** — agents, auth, execution, merge, missions, overseer,
worktree, …

Root keepers retained for large entrypoints (`store.ts`, `executor.ts`,
`merger.ts`, …).

Public barrels (`@fusion/core`, `@fusion/engine`, `app/api.ts` → legacy)
stay stable.

## Test plan
- [x] `@fusion/core` typecheck
- [x] `@fusion/engine` typecheck (pre-existing playwright-core noise
only)
- [ ] CI merge gate

**Stack:** #2394 → #2397 → **this PR**
2026-08-03 00:20:53 -07:00

793 lines
31 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { RoutineRunner, type RoutineRunnerOptions } from "../scheduling/routine-runner.js";
import type {
RoutineStore,
Routine,
RoutineExecutionResult,
AgentStore,
TaskStore,
Settings,
} from "@fusion/core";
import type { HeartbeatMonitor } from "../agent-heartbeat.js";
import { mkdtempSync, writeFileSync } from "node:fs";
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
const backupCommandState = vi.hoisted(() => ({
result: { success: true, output: "Backup created" },
error: null as Error | null,
}));
vi.mock("@fusion/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@fusion/core")>();
return {
...actual,
runBackupCommand: vi.fn(async () => {
if (backupCommandState.error) throw backupCommandState.error;
return backupCommandState.result;
}),
};
});
/*
FNXC:PgMigrationQuarantine 2026-07-18-01:40:
FN-8258 keeps RoutineRunner's cron/manual delegation assertions while replacing the removed
SQLite-file fixture with the async PostgreSQL backup command boundary. Backup implementation
is covered in core; this suite verifies routine dispatch, result persistence, and live output.
*/
// 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 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(),
})),
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;
}
function createMockTaskStore(overrides: { fusionDir?: string; settings?: Partial<Settings> } = {}): TaskStore {
return {
getFusionDir: vi.fn().mockReturnValue(overrides.fusionDir ?? "/tmp/.fusion"),
// FNXC:PgMigrationQuarantine 2026-07-18-01:35: backup routines resolve PostgreSQL settings through this async-era TaskStore accessor, so the fake must mirror the production contract.
getGlobalSettingsDir: vi.fn().mockReturnValue(overrides.fusionDir ?? "/tmp/.fusion"),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS, ...overrides.settings }),
on: vi.fn(),
off: vi.fn(),
} as unknown as TaskStore;
}
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;
}
function createRoutineRunner(options?: Partial<RoutineRunnerOptions>): RoutineRunner {
return new RoutineRunner({
...options,
routineStore: options?.routineStore ?? createMockRoutineStore(),
heartbeatMonitor: options?.heartbeatMonitor ?? createMockHeartbeatMonitor(),
rootDir: options?.rootDir ?? "/test/root",
});
}
describe("RoutineRunner", () => {
beforeEach(() => {
backupCommandState.result = { success: true, output: "Backup created" };
backupCommandState.error = null;
});
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 });
const result = await runner.executeRoutine("routine-1", "cron");
expect(result.routineId).toBe("routine-1");
expect(result.success).toBe(true);
expect(heartbeatMonitor.executeHeartbeat).toHaveBeenCalledTimes(1);
expect(heartbeatMonitor.executeHeartbeat).toHaveBeenCalledWith(
expect.objectContaining({
source: "routine",
triggerDetail: "routine:routine-1:cron",
}),
);
});
it("throws descriptive error when routine not found", async () => {
const routineStore = createMockRoutineStore([]);
const runner = createRoutineRunner({ routineStore });
await expect(runner.executeRoutine("nonexistent", "cron")).rejects.toThrow(
"Routine 'nonexistent' not found",
);
});
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 });
await expect(runner.executeRoutine("routine-disabled", "cron")).rejects.toThrow(
"Routine 'routine-disabled' is disabled",
);
});
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({
source: "routine",
triggerDetail: "routine:routine-trigger:webhook",
}),
);
});
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 });
await runner.executeRoutine("routine-context", "api");
expect(heartbeatMonitor.executeHeartbeat).toHaveBeenCalledWith(
expect.objectContaining({
contextSnapshot: expect.objectContaining({
routineId: "routine-context",
routineName: "Context Test",
triggerType: "api",
}),
}),
);
});
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 });
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("persists an actionable error for in-process Database Backup failures", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "routine-backup-detail-"));
const fusionDir = join(tempDir, ".fusion");
await mkdir(fusionDir, { recursive: true });
const routine = createMockRoutine({
id: "routine-backup-missing-db",
command: "fn backup --create",
agentId: "",
});
const routineStore = createMockRoutineStore([routine]);
const runner = createRoutineRunner({
routineStore,
taskStore: createMockTaskStore({ fusionDir }),
});
backupCommandState.error = new Error("backup command unavailable");
try {
const result = await runner.executeRoutine("routine-backup-missing-db", "cron");
expect(result.success).toBe(false);
expect(result.error).toContain("project PostgreSQL");
expect(result.error).toContain(`project state: ${fusionDir}`);
expect(result.error).toContain("cause:");
expect(result.error).not.toBe("");
expect(routineStore.completeRoutineExecution).toHaveBeenCalledWith(
"routine-backup-missing-db",
expect.objectContaining({
success: false,
error: result.error,
output: "",
}),
);
} finally {
await rm(tempDir, { recursive: true, force: 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("forwards ai-prompt allowedTools and live callbacks to the AI executor", async () => {
const routine = createMockRoutine({
id: "routine-ai",
agentId: undefined,
steps: [
{
id: "step-ai",
type: "ai-prompt",
name: "Analyze",
prompt: "Analyze this",
allowedTools: ["Read", "Grep"],
},
],
});
const routineStore = createMockRoutineStore([routine]);
const aiPromptExecutor = vi.fn().mockResolvedValue("ai output");
const liveCallbacks = { onText: vi.fn(), onStep: vi.fn() };
const runner = createRoutineRunner({ routineStore, aiPromptExecutor });
const result = await runner.executeRoutine("routine-ai", "api", undefined, liveCallbacks);
expect(result.success).toBe(true);
expect(aiPromptExecutor).toHaveBeenCalledWith("Analyze this", undefined, undefined, ["Read", "Grep"], undefined, liveCallbacks);
expect(liveCallbacks.onStep).toHaveBeenCalledWith(expect.objectContaining({ stepId: "step-ai", status: "started" }));
expect(liveCallbacks.onStep).toHaveBeenCalledWith(expect.objectContaining({ stepId: "step-ai", status: "completed", success: true }));
});
it("forwards explicit and omitted ai-prompt thinking levels to the AI executor", async () => {
const routine = createMockRoutine({
id: "routine-ai-thinking",
agentId: undefined,
steps: [
{
id: "step-ai-high",
type: "ai-prompt",
name: "Analyze deeply",
prompt: "Analyze deeply",
thinkingLevel: "high",
},
{
id: "step-ai-default",
type: "ai-prompt",
name: "Analyze normally",
prompt: "Analyze normally",
},
],
});
const routineStore = createMockRoutineStore([routine]);
const aiPromptExecutor = vi.fn().mockResolvedValue("ai output");
const runner = createRoutineRunner({ routineStore, aiPromptExecutor });
const result = await runner.executeRoutine("routine-ai-thinking", "api");
expect(result.success).toBe(true);
expect(aiPromptExecutor).toHaveBeenNthCalledWith(1, "Analyze deeply", undefined, undefined, undefined, "high", undefined);
expect(aiPromptExecutor).toHaveBeenNthCalledWith(2, "Analyze normally", undefined, undefined, undefined, undefined, undefined);
});
it("maps explicit and omitted create-task thinking levels onto spawned task input", async () => {
const routine = createMockRoutine({
id: "routine-task-thinking",
agentId: undefined,
steps: [
{
id: "step-task-high",
type: "create-task",
name: "Create high effort task",
taskDescription: "Investigate deeply",
thinkingLevel: "high",
},
{
id: "step-task-default",
type: "create-task",
name: "Create default task",
taskDescription: "Investigate normally",
},
],
});
const routineStore = createMockRoutineStore([routine]);
const createTask = vi
.fn()
.mockResolvedValueOnce({ id: "FN-7001", title: "", description: "Investigate deeply" })
.mockResolvedValueOnce({ id: "FN-7002", title: "", description: "Investigate normally" });
const taskStore = { ...createMockTaskStore(), createTask } as unknown as TaskStore;
const runner = createRoutineRunner({ routineStore, taskStore });
const result = await runner.executeRoutine("routine-task-thinking", "api");
expect(result.success).toBe(true);
expect(createTask).toHaveBeenNthCalledWith(1, expect.objectContaining({ thinkingLevel: "high" }));
expect(createTask).toHaveBeenNthCalledWith(2, expect.objectContaining({ thinkingLevel: undefined }));
});
/*
FNXC:Automations 2026-07-30-16:55 (greptile #2652):
The runner substituted `|| "triage"` for a step with no target column, so it created tasks into a
column U11 deletes from the default workflow — for EVERY routine, including ones saved through the
fixed editor, because the substitution happens after the step is read. Fixing the form alone changed
nothing at runtime, which is why this test drives the RUNNER.
Omitting the column is the fix, not defaulting it to `todo`: `createTask` resolves the workflow's own
intake column when none is given, which is the only answer correct for a custom board too.
*/
it("omits the column for a create-task step that names none, so workflow intake decides", async () => {
const routine = createMockRoutine({
id: "routine-task-no-column",
agentId: undefined,
steps: [
{ id: "step-no-col", type: "create-task", name: "No column", taskDescription: "Resolve my own intake" },
{ id: "step-explicit", type: "create-task", name: "Explicit", taskDescription: "Honour my column", taskColumn: "in-progress" },
],
});
const routineStore = createMockRoutineStore([routine]);
const createTask = vi
.fn()
.mockResolvedValueOnce({ id: "FN-7101", title: "", description: "Resolve my own intake" })
.mockResolvedValueOnce({ id: "FN-7102", title: "", description: "Honour my column" });
const taskStore = { ...createMockTaskStore(), createTask } as unknown as TaskStore;
const runner = createRoutineRunner({ routineStore, taskStore });
const result = await runner.executeRoutine("routine-task-no-column", "api");
expect(result.success).toBe(true);
// No column at all — NOT "triage", and not a substituted "todo" either.
expect(createTask).toHaveBeenNthCalledWith(1, expect.objectContaining({ column: undefined }));
expect(createTask.mock.calls[0][0].column).toBeUndefined();
// An explicit column is still honoured.
expect(createTask).toHaveBeenNthCalledWith(2, expect.objectContaining({ column: "in-progress" }));
});
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);
});
});
/*
FNXC:DatabaseBackup 2026-07-04-00:00:
FN-7537 Symptom Verification: the reported bug was "Database Backup" succeeding on cron but failing on a
manual dashboard run. Both triggers share this exact RoutineRunner.executeCommand in-process backup
branch (guarded by `isInProcessBackupCommand(command) && this.options.taskStore`), so these tests assert
the invariant directly on the shared code path for both trigger kinds ("cron" and "api", the latter being
what `triggerManual` uses) rather than only the originally-reported reproduction.
*/
describe("manual/cron backup parity and live output (FN-7537)", () => {
it("runs the in-process backup for both cron and manual (api) triggers, never shelling out", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "routine-backup-parity-"));
const fusionDir = join(tempDir, ".fusion");
await mkdir(fusionDir, { recursive: true });
writeFileSync(join(fusionDir, "postgres-backed-fixture"), "routine backup dispatch fixture");
try {
for (const triggerType of ["cron", "api"] as const) {
const routine = createMockRoutine({
id: `routine-backup-${triggerType}`,
command: "fn backup --create",
agentId: "",
});
const routineStore = createMockRoutineStore([routine]);
const runner = createRoutineRunner({
routineStore,
taskStore: createMockTaskStore({ fusionDir }),
});
const result = triggerType === "api"
? await runner.triggerManual(`routine-backup-${triggerType}`)
: await runner.executeRoutine(`routine-backup-${triggerType}`, "cron");
expect(result.success).toBe(true);
expect(result.output).toContain("Backup created");
}
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("emits a step-start live event before the terminal output for a manual command/backup run", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "routine-backup-live-"));
const fusionDir = join(tempDir, ".fusion");
await mkdir(fusionDir, { recursive: true });
writeFileSync(join(fusionDir, "postgres-backed-fixture"), "routine backup dispatch fixture");
try {
const routine = createMockRoutine({
id: "routine-backup-live",
command: "fn backup --create",
agentId: "",
});
const routineStore = createMockRoutineStore([routine]);
const runner = createRoutineRunner({
routineStore,
taskStore: createMockTaskStore({ fusionDir }),
});
const events: Array<{ kind: string; data?: unknown }> = [];
const result = await runner.triggerManual("routine-backup-live", {
onStep: (data) => events.push({ kind: "step", data }),
onText: (delta) => events.push({ kind: "text", data: delta }),
});
expect(result.success).toBe(true);
expect(events[0]).toEqual(expect.objectContaining({ kind: "step", data: expect.objectContaining({ status: "started" }) }));
const completedStepIndex = events.findIndex((e) => e.kind === "step" && (e.data as { status?: string }).status === "completed");
expect(completedStepIndex).toBeGreaterThan(0);
// A step-start (and, once available, output) event must be observed before any terminal signal;
// RoutineRunner itself has no "complete" event type, so the invariant here is simply that
// incremental events fired at all — not only the returned final result.
expect(events.some((e) => e.kind === "step" && (e.data as { status?: string }).status === "started")).toBe(true);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
});
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.executeRoutine("routine-parallel", "cron"),
runner.executeRoutine("routine-parallel", "cron"),
]);
// Both should succeed (parallel)
expect(result1.success).toBe(true);
expect(result2.success).toBe(true);
});
it("reject policy: returns failed result when another execution is in-flight", async () => {
const routine = createMockRoutine({
id: "routine-reject",
executionPolicy: "reject",
});
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 });
// Start first execution
const promise1 = runner.executeRoutine("routine-reject", "cron");
// 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);
});
});
});