Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<void>();
const releaseDispatch = yield* Deferred.make<void>();
const passedMissedTask = yield* Deferred.make<void>();
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)),
);
9 changes: 8 additions & 1 deletion apps/server/src/scheduledTasks/ScheduledTaskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Comment thread
binjamshid marked this conversation as resolved.
Resolved
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 }),
Expand Down
Loading