Add scheduler with firing rules and failure dedupe

Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT
This commit is contained in:
EugeneTes
2026-09-06 15:53:16 +02:00
parent c1914a650b
commit cf0fdbd625
2 changed files with 397 additions and 0 deletions

View File

@@ -0,0 +1,164 @@
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createLogger } from "../log.js";
import type { ScriptOutcome } from "./runner.js";
import { Scheduler, validateJobInput } from "./scheduler.js";
import { JobStore } from "./store.js";
const logger = createLogger("error", () => {});
const PAST = "2020-01-01T00:00:00.000Z";
function harness(runScript?: (command: string) => Promise<ScriptOutcome>) {
const direct: string[] = [];
const agent: string[] = [];
const build = (dir: string) =>
new Scheduler({
store: new JobStore(path.join(dir, "jobs.json")),
workspaceDir: dir,
timezone: "UTC",
logger,
deliverDirect: async (t) => void direct.push(t),
deliverAgent: async (t) => void agent.push(t),
runScript: runScript ? (cmd) => runScript(cmd) : undefined,
});
return { direct, agent, build };
}
describe("Scheduler", () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(path.join(os.tmpdir(), "secretary-sched-"));
});
afterEach(async () => {
vi.useRealTimers();
await rm(dir, { recursive: true, force: true });
});
it("fires an overdue one-shot text job directly and removes it", async () => {
const h = harness();
const s = h.build(dir);
await s.start();
const job = await s.create({ name: "dentist", schedule: { at: PAST }, source: { kind: "text", text: "call them" }, deliver: "direct" });
await s.drain();
expect(h.direct).toEqual(["dentist: call them"]);
expect(s.get(job.id)).toBeUndefined();
expect(await new JobStore(path.join(dir, "jobs.json")).load()).toEqual([]);
await s.stop();
});
it("delivers to the agent with the job prefix", async () => {
const h = harness();
const s = h.build(dir);
await s.start();
await s.create({ name: "news", schedule: { at: PAST }, source: { kind: "text", text: "summarize the news" }, deliver: "agent" });
await s.drain();
expect(h.agent).toEqual(['Scheduled job "news" fired with this output:\n\nsummarize the news']);
await s.stop();
});
it("stays silent when a script prints nothing, but records the run", async () => {
vi.useFakeTimers({ now: new Date("2026-01-01T12:00:00Z") });
const h = harness(async () => ({ kind: "silent" }));
const s = h.build(dir);
await s.start();
const job = await s.create({ name: "watch", schedule: { cron: "* * * * *" }, source: { kind: "script", command: "python w.py" }, deliver: "agent" });
await vi.advanceTimersByTimeAsync(60_000);
await s.drain();
expect(h.agent).toEqual([]);
expect(h.direct).toEqual([]);
expect(s.get(job.id)?.lastRunAt).toBeDefined();
expect(s.get(job.id)?.lastResult?.ok).toBe(true);
await s.stop();
});
it("delivers script output to the agent", async () => {
const h = harness(async () => ({ kind: "output", text: "price dropped to 9" }));
const s = h.build(dir);
await s.start();
await s.create({ name: "price", schedule: { at: PAST }, source: { kind: "script", command: "python p.py" }, deliver: "agent" });
await s.drain();
expect(h.agent).toEqual(['Scheduled job "price" fired with this output:\n\nprice dropped to 9']);
await s.stop();
});
it("reports a failure once and again only after a success or a different exit code", async () => {
const outcomes: ScriptOutcome[] = [
{ kind: "failed", exitCode: 1, stderrTail: "no module", timedOut: false },
{ kind: "failed", exitCode: 1, stderrTail: "no module", timedOut: false },
{ kind: "failed", exitCode: 2, stderrTail: "worse", timedOut: false },
{ kind: "silent" },
{ kind: "failed", exitCode: 2, stderrTail: "worse again", timedOut: false },
];
let i = 0;
const h = harness(async () => outcomes[i++]);
const s = h.build(dir);
await s.start();
const job = await s.create({ name: "flaky", schedule: { cron: "* * * * *" }, source: { kind: "script", command: "x" }, deliver: "direct" });
for (let n = 0; n < outcomes.length; n++) {
await s.fireNow(job.id);
}
expect(h.direct).toHaveLength(3);
expect(h.direct[0]).toMatch(/⚠️ Job "flaky" failed \(exit 1\):\nno module/);
expect(h.direct[1]).toMatch(/exit 2/);
expect(h.direct[2]).toMatch(/worse again/);
await s.stop();
});
it("computes the next cron occurrence strictly in the future and fires when the timer elapses", async () => {
vi.useFakeTimers({ now: new Date("2026-01-01T12:00:00Z") });
const h = harness();
const s = h.build(dir);
await s.start();
const job = await s.create({ name: "tick", schedule: { cron: "*/5 * * * *" }, source: { kind: "text", text: "5 min passed" }, deliver: "direct" });
expect(s.get(job.id)?.nextRunAt).toBe("2026-01-01T12:05:00.000Z");
await vi.advanceTimersByTimeAsync(5 * 60_000);
await s.drain();
expect(h.direct).toEqual(["tick: 5 min passed"]);
expect(s.get(job.id)?.nextRunAt).toBe("2026-01-01T12:10:00.000Z");
await s.stop();
});
it("skips an occurrence while the previous run is still going", async () => {
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
let runs = 0;
const h = harness(async () => {
runs++;
await gate;
return { kind: "silent" };
});
const s = h.build(dir);
await s.start();
const job = await s.create({ name: "slow", schedule: { cron: "* * * * *" }, source: { kind: "script", command: "x" }, deliver: "direct" });
const first = s.fireNow(job.id);
await s.fireNow(job.id);
expect(runs).toBe(1);
release();
await first;
await s.stop();
});
it("delete returns false for unknown ids", async () => {
const s = harness().build(dir);
await s.start();
expect(await s.delete("nope1")).toBe(false);
await s.stop();
});
});
describe("validateJobInput", () => {
const ok = { name: "n", schedule: { at: "2030-01-01T00:00:00Z" }, source: { kind: "text" as const, text: "t" }, deliver: "agent" as const };
it("accepts a valid job", () => {
expect(validateJobInput(ok, "UTC")).toBeNull();
});
it("rejects missing name, bad schedule, empty text or command, bad deliver", () => {
expect(validateJobInput({ ...ok, name: " " }, "UTC")).toMatch(/name/);
expect(validateJobInput({ ...ok, schedule: { cron: "bad" } }, "UTC")).toMatch(/cron/);
expect(validateJobInput({ ...ok, source: { kind: "text", text: "" } }, "UTC")).toMatch(/text/);
expect(validateJobInput({ ...ok, source: { kind: "script", command: " " } }, "UTC")).toMatch(/command/);
expect(validateJobInput({ ...ok, source: { kind: "script", command: "x", timeoutSec: 0 } }, "UTC")).toMatch(/timeoutSec/);
expect(validateJobInput({ ...ok, deliver: "email" as never }, "UTC")).toMatch(/deliver/);
});
});

233
src/scheduler/scheduler.ts Normal file
View File

@@ -0,0 +1,233 @@
import type { Logger } from "../log.js";
import { runScript as defaultRunScript, type RunScriptOptions, type ScriptOutcome } from "./runner.js";
import { generateJobId, type JobStore, nextFireTime, validateSchedule } from "./store.js";
import { DEFAULT_SCRIPT_TIMEOUT_SEC, type Job, type NewJobInput } from "./types.js";
export interface SchedulerDeps {
store: JobStore;
workspaceDir: string;
timezone: string;
logger: Logger;
deliverDirect: (text: string) => Promise<void>;
deliverAgent: (text: string) => Promise<void>;
runScript?: (command: string, opts: RunScriptOptions) => Promise<ScriptOutcome>;
shellPath?: string;
now?: () => Date;
}
export interface JobView extends Job {
nextRunAt: string | null;
}
type FailedOutcome = Extract<ScriptOutcome, { kind: "failed" }>;
const MAX_TIMER_MS = 60 * 60 * 1000;
const STOP_GRACE_MS = 10_000;
export class Scheduler {
private jobs: Job[] = [];
private nextTimes = new Map<string, Date>();
private timer: NodeJS.Timeout | undefined;
private running = new Set<string>();
private inflight = new Set<Promise<void>>();
private stopped = false;
private readonly runScript: (command: string, opts: RunScriptOptions) => Promise<ScriptOutcome>;
private readonly now: () => Date;
constructor(private readonly deps: SchedulerDeps) {
this.runScript = deps.runScript ?? defaultRunScript;
this.now = deps.now ?? (() => new Date());
}
async start(): Promise<void> {
this.jobs = await this.deps.store.load();
this.stopped = false;
await this.tick();
}
async stop(): Promise<void> {
this.stopped = true;
if (this.timer) clearTimeout(this.timer);
this.timer = undefined;
await Promise.race([this.drain(), new Promise((r) => setTimeout(r, STOP_GRACE_MS))]);
}
/** Resolves when every in-flight job run has finished. */
async drain(): Promise<void> {
while (this.inflight.size > 0) await Promise.all([...this.inflight]);
}
list(): JobView[] {
const now = this.now();
return this.jobs.map((job) => ({
...job,
nextRunAt: (this.nextTimes.get(job.id) ?? nextFireTime(job, now, this.deps.timezone))?.toISOString() ?? null,
}));
}
get(id: string): JobView | undefined {
return this.list().find((j) => j.id === id);
}
async create(input: NewJobInput): Promise<JobView> {
const problem = validateJobInput(input, this.deps.timezone);
if (problem) throw new Error(problem);
let id = generateJobId();
while (this.jobs.some((j) => j.id === id)) id = generateJobId();
const job: Job = {
id,
name: input.name.trim(),
schedule: input.schedule,
source: input.source,
deliver: input.deliver,
createdAt: this.now().toISOString(),
};
this.jobs.push(job);
await this.persist();
await this.tick();
const view = this.get(id);
if (!view) throw new Error(`job ${id} vanished after creation`);
return view;
}
async delete(id: string): Promise<boolean> {
const before = this.jobs.length;
this.jobs = this.jobs.filter((j) => j.id !== id);
if (this.jobs.length === before) return false;
this.nextTimes.delete(id);
await this.persist();
await this.tick();
return true;
}
/** Fires every due job (without waiting for them) and arms the timer for the next one. */
async tick(): Promise<void> {
if (this.stopped) return;
if (this.timer) clearTimeout(this.timer);
this.timer = undefined;
const now = this.now();
for (const job of this.jobs) {
if (!this.nextTimes.has(job.id)) {
const t = nextFireTime(job, now, this.deps.timezone);
if (t) this.nextTimes.set(job.id, t);
}
}
for (const id of [...this.nextTimes.keys()]) {
if (!this.jobs.some((j) => j.id === id)) this.nextTimes.delete(id);
}
for (const job of this.jobs) {
const due = this.nextTimes.get(job.id);
if (!due || due.getTime() > now.getTime()) continue;
if ("cron" in job.schedule) {
const next = nextFireTime(job, now, this.deps.timezone);
if (next) this.nextTimes.set(job.id, next);
else this.nextTimes.delete(job.id);
} else {
this.nextTimes.delete(job.id);
}
this.track(this.fire(job));
}
this.arm();
}
/** Runs one job now, waiting for it. Used by tests and by tick(). */
async fireNow(id: string): Promise<void> {
const job = this.jobs.find((j) => j.id === id);
if (!job) throw new Error(`no job with id ${id}`);
await this.fire(job);
}
private track(p: Promise<void>): void {
const tracked: Promise<void> = p.finally(() => this.inflight.delete(tracked));
this.inflight.add(tracked);
}
private arm(): void {
if (this.stopped) return;
let soonest: Date | undefined;
for (const t of this.nextTimes.values()) if (!soonest || t < soonest) soonest = t;
if (!soonest) return;
const delay = Math.min(Math.max(soonest.getTime() - this.now().getTime(), 0), MAX_TIMER_MS);
this.timer = setTimeout(() => void this.tick(), delay);
}
private async fire(job: Job): Promise<void> {
if (this.running.has(job.id)) {
this.deps.logger.warn(`job ${job.id} (${job.name}) is still running, skipping this occurrence`);
return;
}
this.running.add(job.id);
try {
const startedAt = this.now().toISOString();
const outcome = await this.evaluate(job);
const previous = job.lastResult;
job.lastRunAt = startedAt;
if (outcome.kind === "failed") {
job.lastResult = { ok: false, exitCode: outcome.exitCode, stderrTail: outcome.stderrTail, at: startedAt };
const repeat = previous !== undefined && !previous.ok && previous.exitCode === outcome.exitCode;
if (!repeat) await this.safeDeliver(() => this.deps.deliverDirect(formatFailure(job, outcome)));
} else {
job.lastResult = { ok: true, at: startedAt };
if (outcome.kind === "output") {
if (job.deliver === "direct") {
await this.safeDeliver(() => this.deps.deliverDirect(`${job.name}: ${outcome.text}`));
} else {
await this.safeDeliver(() =>
this.deps.deliverAgent(`Scheduled job "${job.name}" fired with this output:\n\n${outcome.text}`),
);
}
}
}
if ("at" in job.schedule) this.jobs = this.jobs.filter((j) => j.id !== job.id);
await this.persist();
} catch (err) {
this.deps.logger.error(`job ${job.id} (${job.name}) crashed`, err);
} finally {
this.running.delete(job.id);
}
}
private async evaluate(job: Job): Promise<ScriptOutcome> {
if (job.source.kind === "text") return { kind: "output", text: job.source.text };
return this.runScript(job.source.command, {
cwd: this.deps.workspaceDir,
timeoutSec: job.source.timeoutSec ?? DEFAULT_SCRIPT_TIMEOUT_SEC,
shellPath: this.deps.shellPath,
});
}
private async safeDeliver(deliver: () => Promise<void>): Promise<void> {
try {
await deliver();
} catch (err) {
this.deps.logger.error("job delivery failed", err);
}
}
private async persist(): Promise<void> {
await this.deps.store.save(this.jobs);
}
}
export function validateJobInput(input: NewJobInput, timezone: string): string | null {
if (!input.name || !input.name.trim()) return "name is required";
if (!input.schedule || !("at" in input.schedule || "cron" in input.schedule)) return "schedule must have at or cron";
const scheduleProblem = validateSchedule(input.schedule, timezone);
if (scheduleProblem) return scheduleProblem;
if (!input.source) return "source is required";
if (input.source.kind === "text" && !input.source.text.trim()) return "text is required";
if (input.source.kind === "script") {
if (!input.source.command.trim()) return "command is required";
if (input.source.timeoutSec !== undefined && !(input.source.timeoutSec > 0)) return "timeoutSec must be a positive number";
}
if (input.deliver !== "agent" && input.deliver !== "direct") return "deliver must be agent or direct";
return null;
}
export function formatFailure(job: Job, outcome: FailedOutcome): string {
const code = outcome.exitCode ?? "?";
const suffix = outcome.timedOut ? ", timed out" : "";
return `⚠️ Job "${job.name}" failed (exit ${code}${suffix}):\n${outcome.stderrTail}`;
}