diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index c71cf6c9a..4dc0f0660 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -171,6 +171,47 @@ describe("ScheduleService", () => { ); }); + test("advances stale nextRunAt on daemon restart", async () => { + const service1 = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + + const created = await service1.create({ + prompt: "Periodic check", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { provider: "claude", cwd: tempDir }, + }, + }); + + expect(created.nextRunAt).toBe("2026-01-01T00:01:00.000Z"); + await service1.stop(); + + // Simulate daemon restart 10 minutes later + now = new Date("2026-01-01T00:10:00.000Z"); + const service2 = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + await service2.start(); + + const inspected = await service2.inspect(created.id); + expect( + new Date(inspected.nextRunAt!).getTime(), + ).toBeGreaterThan(now.getTime()); + await service2.stop(); + }); + test("keeps schedules paused when an in-flight run finishes after pause", async () => { let releaseRun: (() => void) | null = null; const runStarted = new Promise((resolve) => { diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index 3e874e82b..7fd01cf0e 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -251,23 +251,46 @@ export class ScheduleService { const schedules = await this.store.list(); const now = this.now(); for (const schedule of schedules) { - const runningIndex = schedule.runs.findIndex((run) => run.status === "running"); - if (runningIndex === -1) { - continue; + let updated = { ...schedule }; + let dirty = false; + + // Mark any in-flight runs as failed + const runningIndex = updated.runs.findIndex( + (run) => run.status === "running", + ); + if (runningIndex !== -1) { + const runs = [...updated.runs]; + runs[runningIndex] = { + ...runs[runningIndex], + status: "failed", + endedAt: now.toISOString(), + error: "Daemon restarted before the scheduled run completed", + }; + updated = { ...updated, runs }; + dirty = true; + } + + // Advance stale nextRunAt for active schedules + if ( + updated.status === "active" && + updated.nextRunAt && + new Date(updated.nextRunAt).getTime() <= now.getTime() + ) { + let nextRunAt = computeNextRunAt( + updated.cadence, + new Date(updated.nextRunAt), + ); + while (nextRunAt.getTime() <= now.getTime()) { + nextRunAt = computeNextRunAt(updated.cadence, nextRunAt); + } + updated = { ...updated, nextRunAt: nextRunAt.toISOString() }; + dirty = true; + } + + if (dirty) { + updated = { ...updated, updatedAt: now.toISOString() }; + await this.store.put(updated); } - const runs = [...schedule.runs]; - runs[runningIndex] = { - ...runs[runningIndex], - status: "failed", - endedAt: now.toISOString(), - error: "Daemon restarted before the scheduled run completed", - }; - const nextSchedule = { - ...schedule, - runs, - updatedAt: now.toISOString(), - }; - await this.store.put(nextSchedule); } }