From 2c14986d21955b5784f9889bd69ecdeab46af6ff Mon Sep 17 00:00:00 2001 From: Ajraba04 <143622128+Ajraba04@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:10:24 +0400 Subject: [PATCH 1/2] fix(server): a skipped scheduled run shows as failed with the reason --- .../ScheduledTaskService.schedule.test.ts | 58 +++++++++++++++++++ .../scheduledTasks/ScheduledTaskService.ts | 7 ++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts index a6adcdaba9108..a19dbecfdc14f 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,60 @@ 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 T3 Code wasn't running at that time.", + lastRunAt: null, + runCount: 0, + }, + }); + }).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..18b500453498d 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -1067,10 +1067,15 @@ export const layer = Layer.effect( missedRunAt: task.nextRunAt, rescheduledTo: next, }); + const slot = task.schedule.type === "fixed_time" ? `${task.schedule.timeOfDay} ` : ""; + // A run started since this task was listed keeps its own status. yield* sql` UPDATE scheduled_tasks SET next_run_at = ${next}, - updated_at = ${iso(now)} + updated_at = ${iso(now)}, + last_run_error = CASE WHEN last_run_status = 'running' THEN last_run_error + ELSE ${`Skipped the ${slot}run because T3 Code wasn't running at that time.`} END, + last_run_status = CASE WHEN last_run_status = 'running' THEN 'running' ELSE 'failed' END WHERE task_id = ${task.id} `.pipe( Effect.mapError((cause) => From d2836d351cb907bc7a391ba7941dc46c5fee9931 Mon Sep 17 00:00:00 2001 From: Ajraba04 <143622128+Ajraba04@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:41:56 +0400 Subject: [PATCH 2/2] fix(server): a skipped run leaves a newer run's result alone The missed-slot update matched on task id only, so a manual run that finished after the task was listed lost its status and next run time. It now applies only if the row is unchanged since it was listed. The reason no longer says T3 Code wasn't running, because a slot can also be missed while the server is up and dispatch is delayed. --- .../ScheduledTaskService.schedule.test.ts | 87 ++++++++++++++++++- .../scheduledTasks/ScheduledTaskService.ts | 10 ++- 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts index a19dbecfdc14f..b883a5e75cce5 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts @@ -284,7 +284,7 @@ it.effect("shows a slot missed while the server was off as failed, without count expect(updated).toMatchObject({ value: { lastRunStatus: "failed", - lastRunError: "Skipped the 08:00 run because T3 Code wasn't running at that time.", + lastRunError: "Skipped the 08:00 run because it couldn't start on time.", lastRunAt: null, runCount: 0, }, @@ -292,3 +292,88 @@ it.effect("shows a slot missed while the server was off as failed, without count }).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 18b500453498d..d3fd9bf16f5cf 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -1068,15 +1068,17 @@ export const layer = Layer.effect( rescheduledTo: next, }); const slot = task.schedule.type === "fixed_time" ? `${task.schedule.timeOfDay} ` : ""; - // A run started since this task was listed keeps its own status. + // 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)}, - last_run_error = CASE WHEN last_run_status = 'running' THEN last_run_error - ELSE ${`Skipped the ${slot}run because T3 Code wasn't running at that time.`} END, - last_run_status = CASE WHEN last_run_status = 'running' THEN 'running' ELSE 'failed' END + 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 }),