From 0fc6bf0c94b934f609588cb6af8b6484c278d599 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:55:19 +0200 Subject: [PATCH] fix(schedule): keep firing after a run that could not be recorded (#6334) * fix(schedule): keep firing after a run that could not be recorded A schedule whose running-run write failed stayed marked as running in memory, so every later tick skipped it and run-once reported it as already running until the daemon restarted. The running mark is now cleared whenever the run ends, including when it could not be recorded, and the next tick retries the due run. * test(schedule): skip the unwritable-directory test as root --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/schedule/service.test.ts | 40 ++++++++++- .../server/src/server/schedule/service.ts | 68 ++++++++++--------- 2 files changed, 74 insertions(+), 34 deletions(-) diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index 14e0221913..8e37d77e8a 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -2867,6 +2867,44 @@ describe("ScheduleService", () => { expect(inspected.runs[0]?.error).toBe("network blip"); }); + // chmod cannot make a directory unwritable on Windows or for root. + test.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "fires again after a tick whose run could not be recorded", + async () => { + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "ran" }), + }); + + const created = await service.create({ + prompt: "ping target", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId: "88888888-8888-4888-8888-888888888888" }, + }); + + const schedulesDir = join(tempDir, "schedules"); + now = new Date("2026-01-01T00:01:00.000Z"); + await chmod(schedulesDir, 0o555); + try { + await expect(service.tick()).rejects.toThrow(); + } finally { + await chmod(schedulesDir, 0o755); + } + + await service.tick(); + + const inspected = await service.inspect(created.id); + expect(inspected.runs).toHaveLength(1); + expect(inspected.runs[0]?.status).toBe("succeeded"); + expect(inspected.nextRunAt).toBe("2026-01-01T00:02:00.000Z"); + }, + ); + test("completes the schedule when a scheduled run targets an archived agent", async () => { const service = createScheduleService({ paseoHome: tempDir, diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index 53d2432f88..9dcf27407a 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -694,42 +694,44 @@ export class ScheduleService { ): Promise { const manual = options?.manual === true; this.runningScheduleIds.add(schedule.id); - const runId = randomUUID(); - const runningRun: ScheduleRun = { - id: runId, - scheduledFor: manual ? now.toISOString() : (schedule.nextRunAt ?? now.toISOString()), - startedAt: now.toISOString(), - endedAt: null, - status: "running", - agentId: null, - output: null, - error: null, - }; - const scheduleWithRun = await this.appendRunningRun(schedule.id, runningRun); - try { - const result = await this.runner(scheduleWithRun, runId); - await this.finishRun({ - scheduleId: schedule.id, - runId, - status: "succeeded", - agentId: result.agentId, - output: result.output, - error: null, - targetGone: false, - manual, - }); - } catch (error) { - await this.finishRun({ - scheduleId: schedule.id, - runId, - status: "failed", + const runId = randomUUID(); + const runningRun: ScheduleRun = { + id: runId, + scheduledFor: manual ? now.toISOString() : (schedule.nextRunAt ?? now.toISOString()), + startedAt: now.toISOString(), + endedAt: null, + status: "running", agentId: null, output: null, - error: error instanceof Error ? error.message : String(error), - targetGone: error instanceof ScheduleTargetGoneError, - manual, - }); + error: null, + }; + const scheduleWithRun = await this.appendRunningRun(schedule.id, runningRun); + + try { + const result = await this.runner(scheduleWithRun, runId); + await this.finishRun({ + scheduleId: schedule.id, + runId, + status: "succeeded", + agentId: result.agentId, + output: result.output, + error: null, + targetGone: false, + manual, + }); + } catch (error) { + await this.finishRun({ + scheduleId: schedule.id, + runId, + status: "failed", + agentId: null, + output: null, + error: error instanceof Error ? error.message : String(error), + targetGone: error instanceof ScheduleTargetGoneError, + manual, + }); + } } finally { this.runningScheduleIds.delete(schedule.id); }