diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts index a6adcdaba9108..b883a5e75cce5 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts @@ -7,6 +7,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; @@ -234,3 +235,145 @@ it.effect( }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); + +it.effect("shows a slot missed while the server was off as failed, without counting a run", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-07-01T09:00:00.000Z")); + const serviceLayer = ScheduledTaskService.layer.pipe( + Layer.provide( + Layer.mergeAll( + NodeCrypto.layer, + Scheduler.layer, + ManagedPolicy.layerTest(), + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + ), + ), + ); + const { task } = yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + return yield* service.upsert( + yield* decodeUpsertInput({ + title: "Daily notes", + prompt: "Update the model notes.", + enabled: true, + schedule: { type: "fixed_time", timeOfDay: "08:00" }, + projectId: "project-missed-slot", + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }), + ); + }).pipe(Effect.provide(serviceLayer)); + + yield* TestClock.setTime(Date.parse(task.nextRunAt ?? "") + 60 * 60_000); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + yield* TestClock.adjust("6 seconds"); + const updated = yield* service.subscribeList().pipe( + Stream.map(({ tasks }) => tasks[0]), + Stream.filter((listed) => listed?.nextRunAt !== task.nextRunAt), + Stream.runHead, + ); + expect(updated).toMatchObject({ + value: { + lastRunStatus: "failed", + lastRunError: "Skipped the 08:00 run because it couldn't start on time.", + lastRunAt: null, + runCount: 0, + }, + }); + }).pipe(Effect.provide(serviceLayer)); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); + +it.effect("keeps a manual run's result when it finishes after the missed slot was listed", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-07-01T09:00:00.000Z")); + const dispatching = yield* Deferred.make(); + const releaseDispatch = yield* Deferred.make(); + const passedMissedTask = yield* Deferred.make(); + const launched = { threadId: "thread-manual-run", resumed: false } as never; + const serviceLayer = ScheduledTaskService.layer.pipe( + Layer.provide( + Layer.mergeAll( + NodeCrypto.layer, + Scheduler.layer, + ManagedPolicy.layerTest(), + Layer.mock(ThreadLaunchService.ThreadLaunchService)({ + launch: (input) => { + if (input.title === "Slow dispatch") { + return Deferred.succeed(dispatching, undefined).pipe( + Effect.andThen(Deferred.await(releaseDispatch)), + Effect.as(launched), + ); + } + if (input.title === "After the missed slot") { + return Deferred.succeed(passedMissedTask, undefined).pipe(Effect.as(launched)); + } + return Effect.succeed(launched); + }, + }), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + ), + ), + ); + const missed = yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const save = (title: string, schedule: unknown) => + Effect.flatMap( + decodeUpsertInput({ + title, + prompt: "Update the model notes.", + enabled: true, + schedule, + projectId: "project-missed-slot-race", + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }), + service.upsert, + ); + yield* save("Slow dispatch", { type: "interval", everyMs: 60_000 }); + const { task } = yield* save("Daily notes", { type: "fixed_time", timeOfDay: "08:00" }); + // Due tasks dispatch oldest first, one at a time, so this one marks the end of the missed slot. + yield* save("After the missed slot", { + type: "interval", + everyMs: Date.parse(task.nextRunAt ?? "") - Date.parse("2026-07-01T09:00:00.000Z") + 60_000, + }); + return task; + }).pipe(Effect.provide(serviceLayer)); + + yield* TestClock.setTime(Date.parse(missed.nextRunAt ?? "") + 60 * 60_000); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + yield* TestClock.adjust("6 seconds"); + yield* Deferred.await(dispatching); + + const manual = yield* service.runNow({ id: missed.id }); + expect(manual.task).toMatchObject({ lastRunStatus: "succeeded", lastRunError: null }); + + yield* Deferred.succeed(releaseDispatch, undefined); + yield* Deferred.await(passedMissedTask); + const { tasks } = yield* service.list(); + expect(tasks.find((task) => task.id === missed.id)).toMatchObject({ + lastRunStatus: "succeeded", + lastRunError: null, + nextRunAt: manual.task.nextRunAt, + runCount: 1, + }); + }).pipe(Effect.provide(serviceLayer)); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 546d68c8ebe9b..d3fd9bf16f5cf 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -1067,11 +1067,18 @@ export const layer = Layer.effect( missedRunAt: task.nextRunAt, rescheduledTo: next, }); + const slot = task.schedule.type === "fixed_time" ? `${task.schedule.timeOfDay} ` : ""; + // A run started or finished since this task was listed keeps its own result. yield* sql` UPDATE scheduled_tasks SET next_run_at = ${next}, - updated_at = ${iso(now)} + updated_at = ${iso(now)}, + last_run_error = ${`Skipped the ${slot}run because it couldn't start on time.`}, + last_run_status = 'failed' WHERE task_id = ${task.id} + AND next_run_at = ${task.nextRunAt} + AND last_run_status = ${task.lastRunStatus} + AND last_run_at IS ${task.lastRunAt} `.pipe( Effect.mapError((cause) => taskError("Could not reschedule missed schedule task run.", { taskId: task.id, cause }),