/** * schedule.test.ts — SubagentScheduler engine. * * Tests: * - Static format parsers (cron / relative / interval / detection) * - Job lifecycle (add / update / remove / cleanup) * - Fire path (interval, one-shot) with mocked AgentManager + fake timers * - Past-timestamp rejection * - One-shot auto-disable * - Concurrency-bypass option flows through to manager.spawn */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { NO_FALLBACK, registerAgents, setFallbackSubagent } from "../src/agent-types.js"; import { SubagentScheduler } from "../src/schedule.js"; import { ScheduleStore } from "../src/schedule-store.js"; function makeMockManager() { const spawnFn = vi.fn(() => "agent-" + Math.random().toString(36).slice(2, 10)); return { spawn: spawnFn, getRecord: vi.fn(() => ({ promise: Promise.resolve("done") })), } as any; } function makeMockPi() { return { events: { emit: vi.fn() }, } as any; } function makeMockCtx() { return { cwd: "/tmp", modelRegistry: { find: vi.fn(), getAll: () => [], getAvailable: () => [] }, sessionManager: { getSessionId: () => "sess-1" }, } as any; } describe("SubagentScheduler — static format parsers", () => { it("parseRelativeTime accepts +Ns/Nm/Nh/Nd and rejects bare numbers", () => { const before = Date.now(); const iso = SubagentScheduler.parseRelativeTime("+10s"); expect(iso).not.toBeNull(); const t = new Date(iso!).getTime(); expect(t - before).toBeGreaterThanOrEqual(9_000); expect(t - before).toBeLessThanOrEqual(11_000); expect(SubagentScheduler.parseRelativeTime("+5m")).not.toBeNull(); expect(SubagentScheduler.parseRelativeTime("+1h")).not.toBeNull(); expect(SubagentScheduler.parseRelativeTime("+2d")).not.toBeNull(); // Bare digits / wrong unit / no plus → null expect(SubagentScheduler.parseRelativeTime("10s")).toBeNull(); expect(SubagentScheduler.parseRelativeTime("+5x")).toBeNull(); expect(SubagentScheduler.parseRelativeTime("hello")).toBeNull(); }); it("parseInterval converts unit-suffixed strings to milliseconds", () => { expect(SubagentScheduler.parseInterval("10s")).toBe(10_000); expect(SubagentScheduler.parseInterval("5m")).toBe(300_000); expect(SubagentScheduler.parseInterval("1h")).toBe(3_600_000); expect(SubagentScheduler.parseInterval("2d")).toBe(172_800_000); expect(SubagentScheduler.parseInterval("+5m")).toBeNull(); // relative isn't an interval expect(SubagentScheduler.parseInterval("5x")).toBeNull(); expect(SubagentScheduler.parseInterval("five-minutes")).toBeNull(); }); it("validateCronExpression rejects non-6-field expressions", () => { expect(SubagentScheduler.validateCronExpression("* * * * *").valid).toBe(false); // 5 fields expect(SubagentScheduler.validateCronExpression("0 0 9 * * 1").valid).toBe(true); expect(SubagentScheduler.validateCronExpression("0 0 9 * * *").valid).toBe(true); expect(SubagentScheduler.validateCronExpression("not-a-cron").valid).toBe(false); }); it("detectSchedule tags type and normalizes input", () => { expect(SubagentScheduler.detectSchedule("+10m").type).toBe("once"); expect(SubagentScheduler.detectSchedule("5m").type).toBe("interval"); expect(SubagentScheduler.detectSchedule("5m").intervalMs).toBe(300_000); expect(SubagentScheduler.detectSchedule("0 0 9 * * 1").type).toBe("cron"); const iso = "2099-01-01T00:00:00.000Z"; const r = SubagentScheduler.detectSchedule(iso); expect(r.type).toBe("once"); expect(r.normalized).toBe(iso); expect(() => SubagentScheduler.detectSchedule("garbage")).toThrow(/Invalid schedule/); }); }); describe("SubagentScheduler — lifecycle", () => { let tmp: string; let store: ScheduleStore; let scheduler: SubagentScheduler; let manager: any; let pi: any; let ctx: any; beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), "scheduler-test-")); store = new ScheduleStore(join(tmp, "s.json")); scheduler = new SubagentScheduler(); manager = makeMockManager(); pi = makeMockPi(); ctx = makeMockCtx(); scheduler.start(pi, ctx, manager, store); }); afterEach(() => { scheduler.stop(); rmSync(tmp, { recursive: true, force: true }); }); it("isActive() reports start/stop state", () => { expect(scheduler.isActive()).toBe(true); scheduler.stop(); expect(scheduler.isActive()).toBe(false); }); it("addJob persists, arms, and emits added event", () => { const job = scheduler.addJob({ name: "j1", description: "test", schedule: "1h", subagent_type: "general-purpose", prompt: "hi", }); expect(job.scheduleType).toBe("interval"); expect(scheduler.list()).toHaveLength(1); expect(pi.events.emit).toHaveBeenCalledWith("subagents:scheduled", expect.objectContaining({ type: "added" })); }); it("addJob rejects duplicate names", () => { scheduler.addJob({ name: "j1", description: "x", schedule: "1h", subagent_type: "general-purpose", prompt: "p" }); expect(() => scheduler.addJob({ name: "j1", description: "y", schedule: "2h", subagent_type: "general-purpose", prompt: "p2", })).toThrow(/already exists/); }); it("removeJob clears the job and emits removed", () => { const job = scheduler.addJob({ name: "j1", description: "x", schedule: "1h", subagent_type: "general-purpose", prompt: "p" }); expect(scheduler.removeJob(job.id)).toBe(true); expect(scheduler.list()).toEqual([]); expect(pi.events.emit).toHaveBeenCalledWith("subagents:scheduled", expect.objectContaining({ type: "removed", jobId: job.id })); }); it("updateJob({enabled: false}) unschedules but keeps the record", () => { const job = scheduler.addJob({ name: "j1", description: "x", schedule: "1h", subagent_type: "general-purpose", prompt: "p" }); scheduler.updateJob(job.id, { enabled: false }); expect(scheduler.list()[0].enabled).toBe(false); expect(scheduler.getNextRun(job.id)).toBeUndefined(); }); // Regression: getNextRun on a freshly-created interval used to return undefined // (the lastRun-based branch needs lastRun, which is undefined before first fire), // surfacing as "Next run: (unknown)" in the agent's create-response. it("getNextRun returns an approximate future time for a fresh interval (no lastRun yet)", () => { const before = Date.now(); const job = scheduler.addJob({ name: "fresh-interval", description: "x", schedule: "1h", subagent_type: "general-purpose", prompt: "p", }); const next = scheduler.getNextRun(job.id); expect(next).toBeDefined(); const t = new Date(next!).getTime(); // Should be ~now + 1h, with a small tolerance for the time spent in the call expect(t - before).toBeGreaterThanOrEqual(3_600_000 - 1_000); expect(t - before).toBeLessThanOrEqual(3_600_000 + 1_000); }); // Once a fire happens and `lastRun` is set, getNextRun should pivot to it. it("getNextRun uses lastRun when present for interval jobs", () => { const job = scheduler.addJob({ name: "ran-once", description: "x", schedule: "1h", subagent_type: "general-purpose", prompt: "p", }); const lastRun = new Date(Date.now() - 30 * 60_000).toISOString(); // 30m ago scheduler.updateJob(job.id, { lastRun }); const next = scheduler.getNextRun(job.id); expect(next).toBe(new Date(new Date(lastRun).getTime() + 3_600_000).toISOString()); }); it("rejects past one-shot timestamps upfront — no record created", () => { const past = new Date(Date.now() - 60_000).toISOString(); expect(() => scheduler.addJob({ name: "past", description: "x", schedule: past, subagent_type: "general-purpose", prompt: "p", })).toThrow(/in the past/); // No dead-on-arrival record left behind expect(scheduler.list()).toEqual([]); }); // The safety net in scheduleJob's past-branch only fires on store reload — // a once-job persisted with a future ISO whose time has now passed (process // restart after the trigger window). detectSchedule rejects past timestamps // at create time, so this is the only remaining production path. it("disables a previously-enabled one-shot reloaded from disk past its time", () => { const past = new Date(Date.now() - 60_000).toISOString(); // Direct store insert bypasses addJob's upfront validation, mimicking a // record that was valid when written but is now stale on reload. store.add({ id: "reload-test", name: "reload", description: "reload", schedule: past, scheduleType: "once", subagent_type: "general-purpose", prompt: "x", enabled: true, createdAt: past, runCount: 0, }); // Re-arm: stop drops timers, start re-reads store.list() and calls scheduleJob // for every enabled job → the past-branch fires for our seeded record. scheduler.stop(); scheduler.start(pi, ctx, manager, store); const reloaded = scheduler.list().find(j => j.id === "reload-test"); expect(reloaded?.enabled).toBe(false); expect(reloaded?.lastStatus).toBe("error"); expect(pi.events.emit).toHaveBeenCalledWith("subagents:scheduled", expect.objectContaining({ type: "error", jobId: "reload-test", error: expect.stringMatching(/in the past/), })); }); }); describe("SubagentScheduler — fire path", () => { let tmp: string; let store: ScheduleStore; let scheduler: SubagentScheduler; let manager: any; let pi: any; let ctx: any; beforeEach(() => { vi.useFakeTimers(); tmp = mkdtempSync(join(tmpdir(), "scheduler-fire-")); store = new ScheduleStore(join(tmp, "s.json")); scheduler = new SubagentScheduler(); manager = makeMockManager(); pi = makeMockPi(); ctx = makeMockCtx(); scheduler.start(pi, ctx, manager, store); }); afterEach(() => { scheduler.stop(); vi.useRealTimers(); // Module-global: restore here, not at the end of a test body, so a failing // assertion can't leak strict dispatch into every test that follows. setFallbackSubagent(undefined); registerAgents(new Map()); rmSync(tmp, { recursive: true, force: true }); }); it("interval jobs fire repeatedly via setInterval", () => { scheduler.addJob({ name: "every-10s", description: "tick", schedule: "10s", subagent_type: "general-purpose", prompt: "tick", }); expect(manager.spawn).toHaveBeenCalledTimes(0); vi.advanceTimersByTime(10_000); expect(manager.spawn).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(20_000); expect(manager.spawn).toHaveBeenCalledTimes(3); }); it("refuses at fire time when the job's agent type no longer resolves", () => { // The registry is what production populates at activation; a job outliving // its agent must not silently run something else (#183). registerAgents(new Map()); setFallbackSubagent(NO_FALLBACK); const job = scheduler.addJob({ name: "gone", description: "vanished agent", schedule: "+1s", subagent_type: "deleted-since", prompt: "run", }); vi.advanceTimersByTime(2_000); expect(manager.spawn).not.toHaveBeenCalled(); const stored = store.get(job.id); expect(stored?.lastStatus).toBe("error"); // Pin WHY it failed — "didn't spawn" alone would be satisfied by any // unrelated pre-spawn throw. expect(pi.events.emit).toHaveBeenCalledWith( "subagents:scheduled", expect.objectContaining({ type: "error", error: expect.stringContaining("Unknown or disabled agent type"), }), ); }); it("one-shot fires once and auto-disables", async () => { const job = scheduler.addJob({ name: "soon", description: "once", schedule: "+1s", subagent_type: "general-purpose", prompt: "once", }); vi.advanceTimersByTime(2_000); expect(manager.spawn).toHaveBeenCalledTimes(1); // The auto-disable update happens synchronously inside the timer callback expect(scheduler.list().find(j => j.id === job.id)?.enabled).toBe(false); // Subsequent ticks shouldn't fire again vi.advanceTimersByTime(60_000); expect(manager.spawn).toHaveBeenCalledTimes(1); }); it("fire passes bypassQueue: true to manager.spawn", () => { scheduler.addJob({ name: "every-1s", description: "x", schedule: "1s", subagent_type: "general-purpose", prompt: "x", }); vi.advanceTimersByTime(1_000); expect(manager.spawn).toHaveBeenCalledTimes(1); const optsArg = manager.spawn.mock.calls[0][4]; expect(optsArg.bypassQueue).toBe(true); expect(optsArg.isBackground).toBe(true); }); it("disabled jobs do not fire", () => { const job = scheduler.addJob({ name: "off", description: "x", schedule: "1s", subagent_type: "general-purpose", prompt: "x", }); scheduler.updateJob(job.id, { enabled: false }); vi.advanceTimersByTime(5_000); expect(manager.spawn).toHaveBeenCalledTimes(0); }); it("emits fired event with agentId on successful spawn", () => { scheduler.addJob({ name: "fire-once", description: "x", schedule: "+1s", subagent_type: "general-purpose", prompt: "x", }); vi.advanceTimersByTime(2_000); expect(pi.events.emit).toHaveBeenCalledWith("subagents:scheduled", expect.objectContaining({ type: "fired", name: "fire-once", agentId: expect.stringMatching(/^agent-/), })); }); it("records lastStatus error and emits when manager.spawn throws", async () => { manager.spawn.mockImplementationOnce(() => { throw new Error("no slots"); }); const job = scheduler.addJob({ name: "boom", description: "x", schedule: "+1s", subagent_type: "general-purpose", prompt: "x", }); vi.advanceTimersByTime(2_000); // Update is synchronous in the spawn-throw path expect(scheduler.list().find(j => j.id === job.id)?.lastStatus).toBe("error"); expect(pi.events.emit).toHaveBeenCalledWith("subagents:scheduled", expect.objectContaining({ type: "error", jobId: job.id, error: "no slots", })); }); // ── Status reflection from record.status (regression for bug #1) ──── // The real AgentManager's promise *always* resolves (its .catch returns ""), // so the schedule's success/error must be inferred from `record.status`, // not from promise resolution. These two tests model that contract. describe("infers success vs error from record.status, not promise resolution", () => { type FakeRecord = { status: string; promise: Promise; resolve: () => void }; function installFaithfulMock(): Map { const records = new Map(); manager.spawn.mockImplementation(() => { const id = "agent-" + Math.random().toString(36).slice(2, 10); let resolve!: () => void; const promise = new Promise(r => { resolve = () => r(""); }); records.set(id, { status: "running", promise, resolve }); return id; }); manager.getRecord.mockImplementation((id: string) => records.get(id)); return records; } it("records lastStatus 'error' when the agent terminates with status='error'", async () => { const records = installFaithfulMock(); const job = scheduler.addJob({ name: "fail-job", description: "x", schedule: "+1s", subagent_type: "general-purpose", prompt: "x", }); vi.advanceTimersByTime(2_000); expect(manager.spawn).toHaveBeenCalledTimes(1); // The agent ran and ended in error — same shape the real AgentManager produces. const r = [...records.values()][0]; r.status = "error"; r.resolve(); // Flush microtasks so .then(finalize) runs. await vi.advanceTimersByTimeAsync(0); expect(scheduler.list().find(j => j.id === job.id)?.lastStatus).toBe("error"); }); it("records lastStatus 'success' when the agent terminates with status='completed'", async () => { const records = installFaithfulMock(); const job = scheduler.addJob({ name: "ok-job", description: "x", schedule: "+1s", subagent_type: "general-purpose", prompt: "x", }); vi.advanceTimersByTime(2_000); const r = [...records.values()][0]; r.status = "completed"; r.resolve(); await vi.advanceTimersByTimeAsync(0); expect(scheduler.list().find(j => j.id === job.id)?.lastStatus).toBe("success"); }); it("treats aborted and stopped as errors (terminal failure states)", async () => { const records = installFaithfulMock(); const a = scheduler.addJob({ name: "abort-job", description: "x", schedule: "+1s", subagent_type: "general-purpose", prompt: "x", }); const b = scheduler.addJob({ name: "stop-job", description: "x", schedule: "+2s", subagent_type: "general-purpose", prompt: "x", }); vi.advanceTimersByTime(3_000); const recs = [...records.values()]; recs[0].status = "aborted"; recs[0].resolve(); recs[1].status = "stopped"; recs[1].resolve(); await vi.advanceTimersByTimeAsync(0); expect(scheduler.list().find(j => j.id === a.id)?.lastStatus).toBe("error"); expect(scheduler.list().find(j => j.id === b.id)?.lastStatus).toBe("error"); }); }); }); describe("SubagentScheduler — stopped state", () => { it("throws on mutation when not started", () => { const scheduler = new SubagentScheduler(); expect(() => scheduler.addJob({ name: "x", description: "x", schedule: "1h", subagent_type: "general-purpose", prompt: "p", })).toThrow(/not started/); }); it("list() returns empty array when not started", () => { const scheduler = new SubagentScheduler(); expect(scheduler.list()).toEqual([]); }); });