import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 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"; // 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 { 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 = {}): 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), getDueRoutinesAllScopes: 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>, ): RoutineScheduler { return new RoutineScheduler({ taskStore: taskStore ?? createMockTaskStore(), routineStore: routineStore ?? createMockRoutineStore(), routineRunner: routineRunner ?? createMockRoutineRunner(), pollIntervalMs: options?.pollIntervalMs, scope: options?.scope, }); } describe("RoutineScheduler", () => { let scheduler: RoutineScheduler; afterEach(() => { if (scheduler) { scheduler.stop(); } vi.clearAllMocks(); }); describe("constructor", () => { it("clamps poll interval to minimum 10000ms", () => { scheduler = createRoutineScheduler( undefined, undefined, undefined, { pollIntervalMs: 100 }, // Below minimum ); // The scheduler should have clamped to 10000ms expect(scheduler["pollIntervalMs"]).toBe(10000); }); it("uses default poll interval of 60000ms when not specified", () => { scheduler = createRoutineScheduler(); expect(scheduler["pollIntervalMs"]).toBe(60000); }); }); describe("start/stop", () => { it("sets running = true after start", () => { scheduler = createRoutineScheduler(); scheduler.start(); expect(scheduler.isActive()).toBe(true); }); it("clears interval and sets running = false after stop", () => { scheduler = createRoutineScheduler(); scheduler.start(); scheduler.stop(); expect(scheduler.isActive()).toBe(false); }); 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(); expect(routineRunner.handleCatchUp).toHaveBeenCalled(); expect(routineRunner.executeRoutine).toHaveBeenCalled(); }); it("is idempotent on start", () => { scheduler = createRoutineScheduler(); scheduler.start(); scheduler.start(); // Should not throw expect(scheduler.isActive()).toBe(true); }); it("is safe to stop when not started", () => { scheduler = createRoutineScheduler(); expect(() => scheduler.stop()).not.toThrow(); }); }); describe("tick", () => { it("skips when globalPause is true", async () => { const routineStore = createMockRoutineStore([createMockRoutine()]); const routineRunner = createMockRoutineRunner(); scheduler = createRoutineScheduler( createMockTaskStore({ globalPause: true }), routineStore, routineRunner, ); await scheduler.tick(); expect(routineStore.getDueRoutines).not.toHaveBeenCalled(); }); it("skips when enginePaused is true", async () => { const routineStore = createMockRoutineStore([createMockRoutine()]); const routineRunner = createMockRoutineRunner(); scheduler = createRoutineScheduler( createMockTaskStore({ enginePaused: true }), routineStore, routineRunner, ); await scheduler.tick(); expect(routineStore.getDueRoutines).not.toHaveBeenCalled(); }); 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" }); 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("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("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(); }); it("returns false after stop", () => { scheduler = createRoutineScheduler(); scheduler.start(); scheduler.stop(); expect(scheduler.isActive()).toBe(false); }); }); // ── Scoped lane regression tests (FN-1766) ───────────────────────────────── describe("scoped lane polling", () => { it("scope='project' calls getDueRoutines with 'project'", async () => { const routineStore = createMockRoutineStore([]); scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, createMockRoutineRunner(), { scope: "project" }); await scheduler.tick(); expect(routineStore.getDueRoutines).toHaveBeenCalledWith("project"); expect(routineStore.getDueRoutinesAllScopes).not.toHaveBeenCalled(); }); it("scope='global' calls getDueRoutines with 'global'", async () => { const routineStore = createMockRoutineStore([]); scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, createMockRoutineRunner(), { scope: "global" }); await scheduler.tick(); expect(routineStore.getDueRoutines).toHaveBeenCalledWith("global"); expect(routineStore.getDueRoutinesAllScopes).not.toHaveBeenCalled(); }); it("scope='all' calls getDueRoutinesAllScopes", async () => { const routineStore = createMockRoutineStore([]); scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, createMockRoutineRunner(), { scope: "all" }); await scheduler.tick(); expect(routineStore.getDueRoutinesAllScopes).toHaveBeenCalled(); expect(routineStore.getDueRoutines).not.toHaveBeenCalled(); }); it("scope='project' skips global-scoped routines", async () => { const globalRoutine = createMockRoutine({ id: "global-routine", scope: "global" }); const routineStore = createMockRoutineStore([globalRoutine]); const routineRunner = createMockRoutineRunner(); scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "project" }); await scheduler.tick(); // Should NOT execute the global routine expect(routineRunner.handleCatchUp).not.toHaveBeenCalled(); expect(routineRunner.executeRoutine).not.toHaveBeenCalled(); }); it("scope='global' skips project-scoped routines", async () => { const projectRoutine = createMockRoutine({ id: "project-routine", scope: "project" }); const routineStore = createMockRoutineStore([projectRoutine]); const routineRunner = createMockRoutineRunner(); scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "global" }); await scheduler.tick(); // Should NOT execute the project routine expect(routineRunner.handleCatchUp).not.toHaveBeenCalled(); expect(routineRunner.executeRoutine).not.toHaveBeenCalled(); }); it("scope='all' executes both global and project routines", async () => { const globalRoutine = createMockRoutine({ id: "global-rout", name: "Global", scope: "global" }); const projectRoutine = createMockRoutine({ id: "project-rout", name: "Project", scope: "project" }); const routineStore = createMockRoutineStore([globalRoutine, projectRoutine]); (routineStore.getDueRoutinesAllScopes as ReturnType).mockResolvedValue([globalRoutine, projectRoutine]); const routineRunner = createMockRoutineRunner(); scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "all" }); await scheduler.tick(); // Both routines should be processed expect(routineRunner.handleCatchUp).toHaveBeenCalledTimes(2); expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(2); }); it("scope='all' deduplicates by routine ID — no double execution", async () => { // Same routine ID in both scopes const sharedRoutine = createMockRoutine({ id: "shared-id", name: "Shared", scope: "project" }); const routineStore = createMockRoutineStore([sharedRoutine]); (routineStore.getDueRoutinesAllScopes as ReturnType).mockResolvedValue([sharedRoutine]); const routineRunner = createMockRoutineRunner(); scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "all" }); await scheduler.tick(); // Should only execute once expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(1); }); it("scope='all' skips routine from wrong scope after scope mismatch", async () => { // Routine says global but scheduler is polling project scope const mismatchedRoutine = createMockRoutine({ id: "mismatch", name: "Mismatched", scope: "global" }); const routineStore = createMockRoutineStore([mismatchedRoutine]); const routineRunner = createMockRoutineRunner(); scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "project" }); await scheduler.tick(); // Should NOT execute - routine is global but scheduler is in project scope expect(routineRunner.handleCatchUp).not.toHaveBeenCalled(); expect(routineRunner.executeRoutine).not.toHaveBeenCalled(); }); it("scope='project' default when not specified", () => { const routineStore = createMockRoutineStore([]); scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, createMockRoutineRunner()); expect(scheduler["scope"]).toBe("project"); }); it("scope is preserved when invoking handleCatchUp", async () => { const projectRoutine = createMockRoutine({ id: "project-catchup", name: "Project", scope: "project" }); const routineStore = createMockRoutineStore([projectRoutine]); const routineRunner = createMockRoutineRunner(); scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "project" }); await scheduler.tick(); // handleCatchUp should be called with the routine (preserving scope context) expect(routineRunner.handleCatchUp).toHaveBeenCalledWith(projectRoutine); }); it("scope is preserved when invoking executeRoutine", async () => { const projectRoutine = createMockRoutine({ id: "project-exec", name: "Project", scope: "project" }); const routineStore = createMockRoutineStore([projectRoutine]); const routineRunner = createMockRoutineRunner(); scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "project" }); await scheduler.tick(); // executeRoutine should be called with the routine ID and trigger type expect(routineRunner.executeRoutine).toHaveBeenCalledWith("project-exec", "cron"); }); }); describe("scoped lane pause regressions", () => { it("scope='project' skips when initially paused", async () => { const routineStore = createMockRoutineStore([createMockRoutine({ scope: "project" })]); const routineRunner = createMockRoutineRunner(); scheduler = createRoutineScheduler( createMockTaskStore({ globalPause: true }), routineStore, routineRunner, { scope: "project" }, ); await scheduler.tick(); expect(routineStore.getDueRoutines).not.toHaveBeenCalled(); }); it("scope='global' skips when initially paused", async () => { const routineStore = createMockRoutineStore([createMockRoutine({ scope: "global" })]); const routineRunner = createMockRoutineRunner(); scheduler = createRoutineScheduler( createMockTaskStore({ enginePaused: true }), routineStore, routineRunner, { scope: "global" }, ); await scheduler.tick(); expect(routineStore.getDueRoutines).not.toHaveBeenCalled(); }); it("scope='all' skips when initially paused", async () => { const globalRoutine = createMockRoutine({ scope: "global" }); const projectRoutine = createMockRoutine({ scope: "project" }); const routineStore = createMockRoutineStore([globalRoutine, projectRoutine]); (routineStore.getDueRoutinesAllScopes as ReturnType).mockResolvedValue([globalRoutine, projectRoutine]); const routineRunner = createMockRoutineRunner(); scheduler = createRoutineScheduler( createMockTaskStore({ globalPause: true }), routineStore, routineRunner, { scope: "all" }, ); await scheduler.tick(); expect(routineStore.getDueRoutinesAllScopes).not.toHaveBeenCalled(); }); it("scope='project' halts mid-loop when pause flips", async () => { const projectRoutine1 = createMockRoutine({ id: "r1", name: "Routine 1", scope: "project" }); const projectRoutine2 = createMockRoutine({ id: "r2", name: "Routine 2", scope: "project" }); const routineStore = createMockRoutineStore([projectRoutine1, projectRoutine2]); // Mock getSettings to pause after first routine let getSettingsCalls = 0; const mockTaskStore: TaskStore = { getSettings: vi.fn().mockImplementation(async () => { getSettingsCalls++; return { ...DEFAULT_SETTINGS, globalPause: getSettingsCalls >= 3, // Pause on 3rd call (before r2) }; }), on: vi.fn(), off: vi.fn(), } as unknown as TaskStore; const routineRunner = createMockRoutineRunner(); scheduler = createRoutineScheduler(mockTaskStore, routineStore, routineRunner, { scope: "project" }); await scheduler.tick(); // Should only process first routine expect(routineRunner.handleCatchUp).toHaveBeenCalledTimes(1); expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(1); expect(routineRunner.handleCatchUp).toHaveBeenCalledWith(projectRoutine1); }); }); describe("scoped lane ID-overlap regressions", () => { it("identical IDs across global and project scopes must not cross lane boundaries", async () => { // Same ID in both scopes - store returns both const globalRoutine = createMockRoutine({ id: "overlap-id", name: "Global Overlap", scope: "global" }); const projectRoutine = createMockRoutine({ id: "overlap-id", name: "Project Overlap", scope: "project" }); const routineStore = createMockRoutineStore([globalRoutine, projectRoutine]); (routineStore.getDueRoutinesAllScopes as ReturnType).mockResolvedValue([globalRoutine, projectRoutine]); const routineRunner = createMockRoutineRunner(); scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "all" }); await scheduler.tick(); // With scope="all", both routines share the same ID // The scheduler should execute once (first occurrence wins due to deduplication) expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(1); expect(routineRunner.executeRoutine).toHaveBeenCalledWith("overlap-id", "cron"); }); it("scope='project' with overlapping ID only processes project-scoped version", async () => { const projectRoutine = createMockRoutine({ id: "overlap-id", name: "Project Only", scope: "project" }); const routineStore = createMockRoutineStore([projectRoutine]); const routineRunner = createMockRoutineRunner(); scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "project" }); await scheduler.tick(); expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(1); expect(routineRunner.executeRoutine).toHaveBeenCalledWith("overlap-id", "cron"); }); it("scope='global' with overlapping ID only processes global-scoped version", async () => { const globalRoutine = createMockRoutine({ id: "overlap-id", name: "Global Only", scope: "global" }); const routineStore = createMockRoutineStore([globalRoutine]); const routineRunner = createMockRoutineRunner(); scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "global" }); await scheduler.tick(); expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(1); expect(routineRunner.executeRoutine).toHaveBeenCalledWith("overlap-id", "cron"); }); }); describe("utility-lane semaphore boundary", () => { it("RoutineScheduler does not use task-lane semaphore operations", async () => { scheduler = createRoutineScheduler(); // Cast to any to access internal state for semaphore trap const schedulerAny = scheduler as any; // Verify no semaphore-related methods exist on the scheduler expect(schedulerAny.acquire).toBeUndefined(); expect(schedulerAny.release).toBeUndefined(); expect(schedulerAny.run).toBeUndefined(); expect(schedulerAny.semaphore).toBeUndefined(); expect(schedulerAny["_semaphore"]).toBeUndefined(); }); it("RoutineScheduler constructor does not accept semaphore parameter", () => { scheduler = createRoutineScheduler(); // Verify internal state doesn't have semaphore references const keys = Object.keys(scheduler); const hasSemaphore = keys.some(k => k.toLowerCase().includes("semaphore") || k.toLowerCase().includes("acquire")); expect(hasSemaphore).toBe(false); }); }); // ── Cross-package integration tests (FN-1743) ───────────────────────────────── // // These tests verify end-to-end scoped routine scheduling wiring across packages. // They ensure that: // 1. RoutineScheduler bound to a project store executes scoped routines only for that project lane // 2. RoutineScheduler polls both global and project lanes when configured with both stores // 3. Lane identity is preserved through handleCatchUp → executeRoutine // 4. Identical routine IDs in different lanes do not cause cross-lane execution // 5. Lane diagnostics are verifiable in test assertions describe("cross-package dual-lane execution integration", () => { it("scope='all' polls both global and project lanes in the same tick", async () => { const globalRoutine = createMockRoutine({ id: "global-rout-all", name: "Global", scope: "global" }); const projectRoutine = createMockRoutine({ id: "project-rout-all", name: "Project", scope: "project" }); const routineStore = createMockRoutineStore([globalRoutine, projectRoutine]); (routineStore.getDueRoutinesAllScopes as ReturnType).mockResolvedValue([globalRoutine, projectRoutine]); const routineRunner = createMockRoutineRunner(); scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "all" }); await scheduler.tick(); // Both lanes should be polled expect(routineStore.getDueRoutinesAllScopes).toHaveBeenCalledTimes(1); expect(routineStore.getDueRoutines).not.toHaveBeenCalled(); // Both routines should be processed expect(routineRunner.handleCatchUp).toHaveBeenCalledTimes(2); expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(2); }); it("scope='all' lane identity is preserved through handleCatchUp → executeRoutine", async () => { const globalRoutine = createMockRoutine({ id: "global-lane-id", name: "Global Lane", scope: "global" }); const projectRoutine = createMockRoutine({ id: "project-lane-id", name: "Project Lane", scope: "project" }); const routineStore = createMockRoutineStore([globalRoutine, projectRoutine]); (routineStore.getDueRoutinesAllScopes as ReturnType).mockResolvedValue([globalRoutine, projectRoutine]); const routineRunner = createMockRoutineRunner(); scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "all" }); await scheduler.tick(); // Verify both lanes were processed with correct IDs expect(routineRunner.executeRoutine).toHaveBeenCalledWith("global-lane-id", "cron"); expect(routineRunner.executeRoutine).toHaveBeenCalledWith("project-lane-id", "cron"); }); it("pause applies uniformly across both lanes when scope='all'", async () => { const globalRoutine = createMockRoutine({ id: "global-rout-pause", name: "Global", scope: "global" }); const projectRoutine = createMockRoutine({ id: "project-rout-pause", name: "Project", scope: "project" }); const routineStore = createMockRoutineStore([globalRoutine, projectRoutine]); (routineStore.getDueRoutinesAllScopes as ReturnType).mockResolvedValue([globalRoutine, projectRoutine]); const routineRunner = createMockRoutineRunner(); scheduler = createRoutineScheduler( createMockTaskStore({ globalPause: true }), routineStore, routineRunner, { scope: "all" }, ); await scheduler.tick(); // Neither lane should be polled when paused expect(routineStore.getDueRoutinesAllScopes).not.toHaveBeenCalled(); expect(routineRunner.handleCatchUp).not.toHaveBeenCalled(); expect(routineRunner.executeRoutine).not.toHaveBeenCalled(); }); it("identical routine IDs across global and project lanes execute once (deduplication)", async () => { // Same ID in both scopes const globalRoutine = createMockRoutine({ id: "shared-rout", name: "Shared Global", scope: "global" }); const projectRoutine = createMockRoutine({ id: "shared-rout", name: "Shared Project", scope: "project" }); const routineStore = createMockRoutineStore([globalRoutine, projectRoutine]); (routineStore.getDueRoutinesAllScopes as ReturnType).mockResolvedValue([globalRoutine, projectRoutine]); const routineRunner = createMockRoutineRunner(); scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "all" }); await scheduler.tick(); // Only one execution should occur due to deduplication expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(1); }); }); describe("cross-package scoped isolation integration", () => { it("RoutineScheduler bound to project store never executes global-lane routines", async () => { const globalRoutine = createMockRoutine({ id: "global-iso-rout", name: "Global", scope: "global" }); const routineStore = createMockRoutineStore([globalRoutine]); const routineRunner = createMockRoutineRunner(); scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "project" }); await scheduler.tick(); // Project-scoped scheduler should not execute global routine expect(routineRunner.handleCatchUp).not.toHaveBeenCalled(); expect(routineRunner.executeRoutine).not.toHaveBeenCalled(); }); it("RoutineScheduler bound to global store never executes project-lane routines", async () => { const projectRoutine = createMockRoutine({ id: "project-iso-rout", name: "Project", scope: "project" }); const routineStore = createMockRoutineStore([projectRoutine]); const routineRunner = createMockRoutineRunner(); scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "global" }); await scheduler.tick(); // Global-scoped scheduler should not execute project routine expect(routineRunner.handleCatchUp).not.toHaveBeenCalled(); expect(routineRunner.executeRoutine).not.toHaveBeenCalled(); }); it("scope='all' processes routines from both lanes but never crosses lane boundaries", async () => { const globalRoutine = createMockRoutine({ id: "global-boundary-rout", name: "Global", scope: "global" }); const projectRoutine = createMockRoutine({ id: "project-boundary-rout", name: "Project", scope: "project" }); const routineStore = createMockRoutineStore([globalRoutine, projectRoutine]); (routineStore.getDueRoutinesAllScopes as ReturnType).mockResolvedValue([globalRoutine, projectRoutine]); const routineRunner = createMockRoutineRunner(); scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "all" }); await scheduler.tick(); // Both lanes should be processed expect(routineRunner.handleCatchUp).toHaveBeenCalledTimes(2); expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(2); // Each routine is executed exactly once (no double execution) expect(routineRunner.executeRoutine).toHaveBeenCalledWith("global-boundary-rout", "cron"); expect(routineRunner.executeRoutine).toHaveBeenCalledWith("project-boundary-rout", "cron"); }); it("scope='project' lane identity is preserved through handleCatchUp → executeRoutine", async () => { const projectRoutine = createMockRoutine({ id: "project-preserved-id", name: "Project Preserved", scope: "project" }); const routineStore = createMockRoutineStore([projectRoutine]); const routineRunner = createMockRoutineRunner(); scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "project" }); await scheduler.tick(); // Lane identity preserved through the execution pipeline expect(routineRunner.handleCatchUp).toHaveBeenCalledWith(projectRoutine); expect(routineRunner.executeRoutine).toHaveBeenCalledWith("project-preserved-id", "cron"); }); }); });