From 67cb1b4c46950eab6fe1fff0dcef2e983383c1bc Mon Sep 17 00:00:00 2001 From: Saffron <263493777+itsmiso-ai@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:02:18 +0000 Subject: [PATCH 1/2] Add startup health check to prevent silent scheduler failure on non-default PORT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scheduler now calls a new `schedulerHealthCheck` function at startup which fetches `/api/health` using the configured bearer token. This verifies the scheduler can reach its own endpoints before periodic jobs begin firing. The health check returns true on HTTP 200 and false otherwise, logging descriptive errors via the injected deps.log. It does not prevent the scheduler from running — it provides visibility into misconfiguration without blocking periodic work. Fixes #653 Signed-off-by: Saffron <263493777+itsmiso-ai@users.noreply.github.com> --- src/lib/scheduler.test.ts | 38 ++++++++++++++++++++++++++++++++++++-- src/lib/scheduler.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/lib/scheduler.test.ts b/src/lib/scheduler.test.ts index d993e162..20f3bcec 100644 --- a/src/lib/scheduler.test.ts +++ b/src/lib/scheduler.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { schedulerConfigFromEnv, runJob, startScheduler, type SchedulerConfig, type SchedulerDeps } from "./scheduler"; +import { schedulerConfigFromEnv, schedulerHealthCheck, runJob, startScheduler, type SchedulerConfig, type SchedulerDeps } from "./scheduler"; function fakeDeps(overrides: Partial = {}): SchedulerDeps & { logs: Array<[string, unknown]> } { const logs: Array<[string, unknown]> = []; @@ -120,7 +120,41 @@ describe("startScheduler", () => { // fire the startup timer -> it runs once and arms the interval const startupCb = (deps.setTimeout as ReturnType).mock.calls[0][0] as () => void; startupCb(); - expect(deps.fetch).toHaveBeenCalledTimes(1); + expect(deps.fetch).toHaveBeenCalledTimes(2); // health check + job expect(deps.setInterval).toHaveBeenCalledWith(expect.any(Function), 900000); }); }); + +describe("schedulerHealthCheck", () => { + it("returns true when health endpoint responds with 200", async () => { + const mockFetch = vi.fn(async () => new Response(null, { status: 200 })) as unknown as typeof globalThis.fetch; + const deps = fakeDeps({ fetch: mockFetch }); + const result = await schedulerHealthCheck(CONFIG, deps); + + expect(result).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith("http://127.0.0.1:3000/api/health", { + headers: { Authorization: "Bearer tok" }, + }); + }); + + it("returns false and logs when health endpoint returns non-ok status", async () => { + const mockFetch = vi.fn(async () => new Response(null, { status: 503 })) as unknown as typeof globalThis.fetch; + const log = vi.fn(); + const deps = fakeDeps({ fetch: mockFetch, log }); + const result = await schedulerHealthCheck(CONFIG, deps); + + expect(result).toBe(false); + expect(log).toHaveBeenCalledWith("scheduler health check failed — HTTP 503"); + }); + + it("returns false and logs when fetch throws", async () => { + const mockFetch = vi.fn(async () => { throw new Error("ECONNREFUSED"); }) as unknown as typeof globalThis.fetch; + const log = vi.fn(); + const deps = fakeDeps({ fetch: mockFetch, log }); + const result = await schedulerHealthCheck(CONFIG, deps); + + expect(result).toBe(false); + expect(log).toHaveBeenCalledWith("scheduler health check failed: ECONNREFUSED", expect.any(Error)); + }); +}); diff --git a/src/lib/scheduler.ts b/src/lib/scheduler.ts index 4b001dbb..117e83ab 100644 --- a/src/lib/scheduler.ts +++ b/src/lib/scheduler.ts @@ -109,6 +109,31 @@ export function schedulerConfigFromEnv(env: Record): }; } +/** + * Startup health check: verify the scheduler can reach its own endpoints. + * Returns true on HTTP 200, false otherwise. Does not prevent the scheduler + * from running — provides visibility into misconfiguration without blocking + * periodic work. + */ +export async function schedulerHealthCheck(config: SchedulerConfig, deps: SchedulerDeps): Promise { + try { + const res = await deps.fetch(`${config.baseUrl}/api/health`, { + headers: { + Authorization: `Bearer ${config.token}`, + }, + }); + if (!res.ok) { + deps.log(`scheduler health check failed — HTTP ${res.status}`); + return false; + } + return true; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + deps.log(`scheduler health check failed: ${msg}`, error); + return false; + } +} + /** Fire one job. Never throws — a transient failure must not kill the interval. */ export async function runJob(job: ScheduledJob, config: SchedulerConfig, deps: SchedulerDeps): Promise { try { @@ -148,6 +173,7 @@ export function startScheduler(config: SchedulerConfig, deps: SchedulerDeps): un for (const job of config.jobs) { deps.log(`scheduling "${job.name}" every ${job.intervalMs}ms -> ${job.path}`); deps.setTimeout(() => { + void schedulerHealthCheck(config, deps); void runJob(job, config, deps); const handle = deps.setInterval(() => void runJob(job, config, deps), job.intervalMs); handles.push(handle); From 28b95793ff79e297b5af4953aa7c8dcb0fb16982 Mon Sep 17 00:00:00 2001 From: Saffron Date: Wed, 29 Jul 2026 10:23:23 -0600 Subject: [PATCH 2/2] Move schedulerHealthCheck outside job loop to fire once at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on #665 — health check was inside the per-job loop, causing N checks at startup instead of one. --- src/lib/scheduler.test.ts | 11 ++++++----- src/lib/scheduler.ts | 7 ++++++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/lib/scheduler.test.ts b/src/lib/scheduler.test.ts index 20f3bcec..02cb0655 100644 --- a/src/lib/scheduler.test.ts +++ b/src/lib/scheduler.test.ts @@ -112,14 +112,15 @@ describe("startScheduler", () => { expect(deps.logs.some(([m]) => m.includes("DISPATCH_AGENT_TOKEN is unset"))).toBe(true); }); - it("schedules each job after the startup delay, then on an interval", () => { + it("schedules a single health check and each job after the startup delay, then on an interval", () => { const deps = fakeDeps(); startScheduler(CONFIG, deps); - expect(deps.setTimeout).toHaveBeenCalledTimes(1); + // one setTimeout for the health check, one for the job + expect(deps.setTimeout).toHaveBeenCalledTimes(2); expect(deps.setTimeout).toHaveBeenCalledWith(expect.any(Function), 5000); - // fire the startup timer -> it runs once and arms the interval - const startupCb = (deps.setTimeout as ReturnType).mock.calls[0][0] as () => void; - startupCb(); + // fire both startup timers + const calls = (deps.setTimeout as ReturnType).mock.calls; + for (const c of calls) (c[0] as () => void)(); expect(deps.fetch).toHaveBeenCalledTimes(2); // health check + job expect(deps.setInterval).toHaveBeenCalledWith(expect.any(Function), 900000); }); diff --git a/src/lib/scheduler.ts b/src/lib/scheduler.ts index 117e83ab..6b6ddfde 100644 --- a/src/lib/scheduler.ts +++ b/src/lib/scheduler.ts @@ -170,10 +170,15 @@ export function startScheduler(config: SchedulerConfig, deps: SchedulerDeps): un } const handles: unknown[] = []; + + // Single health check after startup delay, not per-job + deps.setTimeout(() => { + void schedulerHealthCheck(config, deps); + }, config.startupDelayMs); + for (const job of config.jobs) { deps.log(`scheduling "${job.name}" every ${job.intervalMs}ms -> ${job.path}`); deps.setTimeout(() => { - void schedulerHealthCheck(config, deps); void runJob(job, config, deps); const handle = deps.setInterval(() => void runJob(job, config, deps), job.intervalMs); handles.push(handle);