fix: advance stale schedule nextRunAt on daemon restart

On restart, persisted nextRunAt could be in the past, showing stale
dates in `schedule ls`. Now recoverInterruptedRuns() advances any
past-due nextRunAt forward to the next future tick.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2026-04-11 04:44:36 +00:00
parent b268c17fe2
commit 96fc4cee47
2 changed files with 80 additions and 16 deletions

View File

@@ -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<void>((resolve) => {

View File

@@ -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);
}
}