1import * as Scheduler from "../scheduling/Scheduler.ts";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import { expect, it } from "@effect/vitest";
4import {
5 type OrchestrationV2ConversationMessage,
6 type OrchestrationV2Run,
7 MessageId,
8 RunId,
9 ScheduledTaskUpsertInput,
10} from "@t3tools/contracts";
11import * as DateTime from "effect/DateTime";
12import * as Deferred from "effect/Deferred";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import * as Ref from "effect/Ref";
16import * as Schema from "effect/Schema";
17import * as TestClock from "effect/testing/TestClock";
18
19import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
20import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
21import * as SecretRequests from "../secrets/SecretRequests.ts";
22import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts";
23import * as SqlitePersistence from "../persistence/Sqlite.ts";
24import * as ScheduledTaskService from "./ScheduledTaskService.ts";
25import * as ManagedPolicy from "../policy/ManagedPolicy.ts";
26
27const decodeUpsertInput = Schema.decodeUnknownEffect(ScheduledTaskUpsertInput);
28
29it.effect("rejects a stale form save after deletion while preserving explicit-id creates", () =>
30 Effect.gen(function* () {
31 const layerDependencies = Layer.mergeAll(
32 NodeCrypto.layer,
33 Scheduler.layer,
34 ManagedPolicy.layerTest(),
35 Layer.mock(ThreadLaunchService.ThreadLaunchService)({}),
36 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({
37 namedProjectsRoot: "/projects",
38 isScratchProject: () => Effect.succeed(false),
39 }),
40 Layer.mock(ThreadManagementService.ThreadManagementService)({}),
41 Layer.mock(SecretRequests.SecretRequests)({}),
42 );
43 yield* Effect.gen(function* () {
44 const service = yield* ScheduledTaskService.ScheduledTaskService;
45 const input = yield* decodeUpsertInput({
46 id: "scheduled-task:edit-after-delete",
47 title: "Review",
48 prompt: "Review the open pull requests.",
49 enabled: true,
50 schedule: { type: "interval", everyMs: 60_000 },
51 projectId: "project-stale-schedule",
52 workspaceStrategy: { type: "root" },
53 modelSelection: { instanceId: "codex", model: "gpt-5.4" },
54 runtimeMode: "full-access",
55 interactionMode: "default",
56 });
57 const created = yield* service.upsert(input);
58 const edit = yield* decodeUpsertInput({ ...input, requireExisting: true, title: "Edited" });
59 expect((yield* service.upsert(edit)).task.title).toBe("Edited");
60 yield* service.delete({ id: created.task.id });
61
62 const failure = yield* service.upsert(edit).pipe(Effect.flip);
63 expect(failure.message).toBe("Schedule task not found.");
64 expect((yield* service.list()).tasks).toEqual([]);
65
66 expect((yield* service.upsert(input)).task.id).toBe(created.task.id);
67 }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies))));
68 }).pipe(Effect.provide(SqlitePersistence.layerMemory)),
69);
70
71it.effect("preserves a due run when a save only pads the scheduled hour", () =>
72 Effect.gen(function* () {
73 const dueAt = DateTime.makeZonedUnsafe(
74 { year: 2026, month: 7, day: 1, hour: 9, minute: 0, second: 0, millisecond: 0 },
75 { timeZone: DateTime.zoneMakeLocal(), adjustForTimeZone: true },
76 );
77 yield* TestClock.setTime(DateTime.toEpochMillis(dueAt) - 1_000);
78
79 const layerDependencies = Layer.mergeAll(
80 NodeCrypto.layer,
81 Scheduler.layer,
82 ManagedPolicy.layerTest(),
83 Layer.mock(ThreadLaunchService.ThreadLaunchService)({}),
84 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({
85 namedProjectsRoot: "/projects",
86 isScratchProject: () => Effect.succeed(false),
87 }),
88 Layer.mock(ThreadManagementService.ThreadManagementService)({}),
89 Layer.mock(SecretRequests.SecretRequests)({}),
90 );
91 yield* Effect.gen(function* () {
92 const service = yield* ScheduledTaskService.ScheduledTaskService;
93 const input = yield* decodeUpsertInput({
94 commandId: "schedule-time-format",
95 title: "Morning review",
96 prompt: "Review the open pull requests.",
97 enabled: true,
98 schedule: { type: "fixed_time", timeOfDay: "9:00" },
99 projectId: "project-schedule-time-format",
100 workspaceStrategy: { type: "root" },
101 modelSelection: { instanceId: "codex", model: "gpt-5.4" },
102 runtimeMode: "full-access",
103 interactionMode: "default",
104 creationSource: "mcp",
105 });
106 const created = yield* service.upsert(input);
107 const expectedDueAt = DateTime.formatIso(DateTime.toUtc(dueAt));
108 expect(created.task.nextRunAt).toBe(expectedDueAt);
109
110 yield* TestClock.setTime(DateTime.toEpochMillis(dueAt) + 1_000);
111 const update = yield* decodeUpsertInput({
112 ...input,
113 id: created.task.id,
114 schedule: { type: "fixed_time", timeOfDay: "09:00" },
115 });
116 const updated = yield* service.upsert(update);
117 expect(updated.task.nextRunAt).toBe(expectedDueAt);
118 expect((yield* service.list()).tasks[0]?.nextRunAt).toBe(expectedDueAt);
119
120 const rescheduled = yield* service.upsert({
121 ...update,
122 schedule: { type: "fixed_time", timeOfDay: "09:30" },
123 });
124 expect(rescheduled.task.nextRunAt).toBe(
125 DateTime.formatIso(DateTime.toUtc(DateTime.add(dueAt, { minutes: 30 }))),
126 );
127 }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies))));
128 }).pipe(Effect.provide(SqlitePersistence.layerMemory)),
129);
130
131it.effect("keeps the task list order when a toggle or edit rewrites updated_at", () =>
132 Effect.gen(function* () {
133 yield* TestClock.setTime(Date.parse("2026-07-01T09:00:00.000Z"));
134 const layerDependencies = Layer.mergeAll(
135 NodeCrypto.layer,
136 Scheduler.layer,
137 ManagedPolicy.layerTest(),
138 Layer.mock(ThreadLaunchService.ThreadLaunchService)({}),
139 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({
140 namedProjectsRoot: "/projects",
141 isScratchProject: () => Effect.succeed(false),
142 }),
143 Layer.mock(ThreadManagementService.ThreadManagementService)({}),
144 Layer.mock(SecretRequests.SecretRequests)({}),
145 );
146 yield* Effect.gen(function* () {
147 const service = yield* ScheduledTaskService.ScheduledTaskService;
148 const input = (id: string) => ({
149 id,
150 title: id,
151 prompt: "Review the open pull requests.",
152 enabled: true,
153 schedule: { type: "interval", everyMs: 60_000 },
154 projectId: "project-stable-order",
155 workspaceStrategy: { type: "root" },
156 modelSelection: { instanceId: "codex", model: "gpt-5.4" },
157 runtimeMode: "full-access",
158 interactionMode: "default",
159 });
160 const listedIds = service
161 .list()
162 .pipe(Effect.map(({ tasks }) => tasks.map((task) => String(task.id))));
163
164 const older = yield* service.upsert(yield* decodeUpsertInput(input("scheduled-task:older")));
165 yield* TestClock.adjust("1 second");
166 yield* service.upsert(yield* decodeUpsertInput(input("scheduled-task:newer")));
167 const order = ["scheduled-task:newer", "scheduled-task:older"];
168 expect(yield* listedIds).toEqual(order);
169
170 yield* TestClock.adjust("1 second");
171 const toggled = yield* service.setEnabled({ id: older.task.id, enabled: false });
172 expect(toggled.task.updatedAt > older.task.updatedAt).toBe(true);
173 expect(yield* listedIds).toEqual(order);
174
175 yield* TestClock.adjust("1 second");
176 const edit = { ...input(older.task.id), requireExisting: true, title: "Edited" };
177 yield* service.upsert(yield* decodeUpsertInput({ ...edit, enabled: false }));
178 expect(yield* listedIds).toEqual(order);
179 }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies))));
180 }).pipe(Effect.provide(SqlitePersistence.layerMemory)),
181);
182
183it.effect("keeps one queued occurrence of a recurring task while its bound thread is busy", () =>
184 Effect.gen(function* () {
185 yield* TestClock.setTime(Date.parse("2026-07-01T09:00:00.000Z"));
186 // The bound thread's own run never finishes, so every scheduled send lands
187 // in its queue and stays there.
188 const activeRun = {
189 id: RunId.make("run:busy"),
190 status: "running",
191 userMessageId: MessageId.make("message:busy"),
192 } as OrchestrationV2Run;
193 const thread = yield* Ref.make<{
194 readonly runs: ReadonlyArray<OrchestrationV2Run>;
195 readonly messages: ReadonlyArray<OrchestrationV2ConversationMessage>;
196 }>({ runs: [activeRun], messages: [] });
197 const runDueWork = yield* Ref.make<Effect.Effect<void>>(Effect.void);
198 const layerDependencies = Layer.mergeAll(
199 NodeCrypto.layer,
200 ManagedPolicy.layerTest(),
201 // Capture the poll pass so the test drives each due interval directly.
202 Layer.mock(Scheduler.Scheduler)({
203 register: (name, work) =>
204 name !== "scheduled-tasks"
205 ? Effect.void
206 : Effect.context<Effect.Services<typeof work>>().pipe(
207 Effect.flatMap((context) =>
208 Ref.set(runDueWork, work.pipe(Effect.provideContext(context), Effect.ignore)),
209 ),
210 ),
211 }),
212 Layer.mock(ThreadLaunchService.ThreadLaunchService)({}),
213 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({
214 namedProjectsRoot: "/projects",
215 isScratchProject: () => Effect.succeed(false),
216 }),
217 Layer.mock(ThreadManagementService.ThreadManagementService)({
218 sendToThread: (input) =>
219 Ref.modify(thread, (current) => {
220 const run = {
221 id: RunId.make(`run:${input.messageId}`),
222 status: "queued",
223 userMessageId: input.messageId,
224 } as OrchestrationV2Run;
225 const message = {
226 id: input.messageId,
227 threadId: input.threadId,
228 runId: run.id,
229 role: "user",
230 text: input.text,
231 ...(input.scheduledTaskId === undefined
232 ? {}
233 : { scheduledTaskId: input.scheduledTaskId }),
234 } as OrchestrationV2ConversationMessage;
235 return [
236 {
237 delivery: "queued",
238 run,
239 message,
240 } as ThreadManagementService.ThreadManagementSendResult,
241 { runs: [...current.runs, run], messages: [...current.messages, message] },
242 ];
243 }),
244 getThreadRecords: () => Ref.get(thread).pipe(Effect.map((records) => records as never)),
245 getThreadShell: () =>
246 Effect.succeed({
247 projectId: "project-busy-thread",
248 deletedAt: null,
249 archivedAt: null,
250 } as never),
251 }),
252 Layer.mock(SecretRequests.SecretRequests)({}),
253 );
254 yield* Effect.gen(function* () {
255 const service = yield* ScheduledTaskService.ScheduledTaskService;
256 const { task } = yield* service.upsert(
257 yield* decodeUpsertInput({
258 id: "scheduled-task:busy-thread",
259 title: "Check progress",
260 prompt: "Check progress",
261 enabled: true,
262 schedule: { type: "interval", everyMs: 60_000 },
263 projectId: "project-busy-thread",
264 threadId: "thread:busy",
265 workspaceStrategy: { type: "root" },
266 modelSelection: { instanceId: "codex", model: "gpt-5.4" },
267 runtimeMode: "full-access",
268 interactionMode: "default",
269 }),
270 );
271
272 for (let interval = 0; interval < 3; interval++) {
273 yield* TestClock.adjust("61 seconds");
274 yield* Effect.flatten(Ref.get(runDueWork));
275 }
276
277 const queuedOccurrencesOf = (taskId: typeof task.id) =>
278 Ref.get(thread).pipe(
279 Effect.map(
280 ({ runs, messages }) =>
281 runs
282 .filter((run) => run.status === "queued")
283 .map((run) => messages.find((message) => message.id === run.userMessageId))
284 .filter((message) => message?.scheduledTaskId === taskId).length,
285 ),
286 );
287 const queuedOccurrences = queuedOccurrencesOf(task.id);
288 expect(yield* queuedOccurrences).toBe(1);
289 // Skipped intervals still move the schedule forward.
290 const [listed] = (yield* service.list()).tasks;
291 expect(Date.parse(listed?.nextRunAt ?? "")).toBeGreaterThan(
292 Date.parse("2026-07-01T09:03:03.000Z"),
293 );
294
295 // Run now is an explicit request, so it still queues behind the held one.
296 yield* service.runNow({ id: task.id });
297 expect(yield* queuedOccurrences).toBe(2);
298
299 // Once the queued copies start, the next interval sends again.
300 yield* Ref.update(thread, (current) => ({
301 ...current,
302 runs: current.runs.map((run) =>
303 run.status === "queued" ? ({ ...run, status: "running" } as OrchestrationV2Run) : run,
304 ),
305 }));
306 yield* TestClock.adjust("61 seconds");
307 yield* Effect.flatten(Ref.get(runDueWork));
308 expect(yield* queuedOccurrences).toBe(1);
309
310 // A one-time task's only scheduled occurrence still sends behind a queued Run now.
311 const { task: once } = yield* service.upsert(
312 yield* decodeUpsertInput({
313 id: "scheduled-task:busy-thread-once",
314 title: "Check once",
315 prompt: "Check once",
316 enabled: true,
317 schedule: { type: "once", runAt: "2026-07-01T09:05:00.000Z" },
318 projectId: "project-busy-thread",
319 threadId: "thread:busy",
320 workspaceStrategy: { type: "root" },
321 modelSelection: { instanceId: "codex", model: "gpt-5.4" },
322 runtimeMode: "full-access",
323 interactionMode: "default",
324 }),
325 );
326 yield* service.runNow({ id: once.id });
327 yield* TestClock.adjust("61 seconds");
328 yield* Effect.flatten(Ref.get(runDueWork));
329 expect(yield* queuedOccurrencesOf(once.id)).toBe(2);
330 }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies))));
331 }).pipe(Effect.provide(SqlitePersistence.layerMemory)),
332);
333
334it.effect(
335 "idle polls still wake for tasks saved later, recorded due times, and beside far-future tasks",
336 () =>
337 Effect.gen(function* () {
338 yield* TestClock.setTime(Date.parse("2026-07-01T09:00:00.000Z"));
339 const launched = yield* Deferred.make<string>();
340 const layerDependencies = Layer.mergeAll(
341 NodeCrypto.layer,
342 Scheduler.layer,
343 ManagedPolicy.layerTest(),
344 Layer.mock(ThreadLaunchService.ThreadLaunchService)({
345 launch: (input) =>
346 Deferred.succeed(launched, input.title).pipe(
347 Effect.as({ threadId: "thread-idle-poll", resumed: false } as never),
348 ),
349 }),
350 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({
351 namedProjectsRoot: "/projects",
352 isScratchProject: () => Effect.succeed(false),
353 }),
354 Layer.mock(ThreadManagementService.ThreadManagementService)({}),
355 Layer.mock(SecretRequests.SecretRequests)({}),
356 );
357 yield* Effect.gen(function* () {
358 const service = yield* ScheduledTaskService.ScheduledTaskService;
359 // The first poll finds no tasks and remembers that nothing is due.
360 yield* TestClock.adjust("6 seconds");
361 // Its year-10000+ next run sorts first as text; it must not hide the task below.
362 yield* service.upsert(
363 yield* decodeUpsertInput({
364 title: "Far future",
365 prompt: "Review the open pull requests.",
366 enabled: true,
367 schedule: { type: "interval", everyMs: 3e14 },
368 projectId: "project-idle-poll",
369 workspaceStrategy: { type: "root" },
370 modelSelection: { instanceId: "codex", model: "gpt-5.4" },
371 runtimeMode: "full-access",
372 interactionMode: "default",
373 }),
374 );
375 const created = yield* service.upsert(
376 yield* decodeUpsertInput({
377 title: "Idle poll",
378 prompt: "Review the open pull requests.",
379 enabled: true,
380 schedule: { type: "interval", everyMs: 60_000 },
381 projectId: "project-idle-poll",
382 workspaceStrategy: { type: "root" },
383 modelSelection: { instanceId: "codex", model: "gpt-5.4" },
384 runtimeMode: "full-access",
385 interactionMode: "default",
386 }),
387 );
388 // This poll records the new task's due time; the later ones must still fire it.
389 yield* TestClock.adjust("6 seconds");
390 yield* TestClock.adjust("60 seconds");
391 expect(yield* Deferred.await(launched)).toBe(created.task.title);
392 }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies))));
393 }).pipe(Effect.provide(SqlitePersistence.layerMemory)),
394);
395
396it.effect("a fixed-time slot missed while the server was off shows on the task", () =>
397 Effect.gen(function* () {
398 const at = (hour: number, minute = 0, day = 1) =>
399 DateTime.makeZonedUnsafe(
400 { year: 2026, month: 7, day, hour, minute, second: 0, millisecond: 0 },
401 { timeZone: DateTime.zoneMakeLocal(), adjustForTimeZone: true },
402 );
403 yield* TestClock.setTime(DateTime.toEpochMillis(at(8)));
404 const launches = yield* Ref.make(0);
405 const layerDependencies = Layer.mergeAll(
406 NodeCrypto.layer,
407 Scheduler.layer,
408 ManagedPolicy.layerTest(),
409 Layer.mock(ThreadLaunchService.ThreadLaunchService)({
410 launch: () =>
411 Ref.update(launches, (count) => count + 1).pipe(
412 Effect.as({ threadId: "thread-missed-slot", resumed: false } as never),
413 ),
414 }),
415 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({
416 namedProjectsRoot: "/projects",
417 isScratchProject: () => Effect.succeed(false),
418 }),
419 Layer.mock(ThreadManagementService.ThreadManagementService)({}),
420 Layer.mock(SecretRequests.SecretRequests)({}),
421 );
422 const withServer = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
423 effect.pipe(
424 Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies))),
425 );
426
427 const taskId = yield* withServer(
428 Effect.gen(function* () {
429 const service = yield* ScheduledTaskService.ScheduledTaskService;
430 const { task } = yield* service.upsert(
431 yield* decodeUpsertInput({
432 title: "Morning bookkeeping",
433 prompt: "Update yesterday's notes.",
434 enabled: true,
435 schedule: { type: "fixed_time", timeOfDay: "09:00" },
436 projectId: "project-missed-slot",
437 workspaceStrategy: { type: "root" },
438 modelSelection: { instanceId: "codex", model: "gpt-5.4" },
439 runtimeMode: "full-access",
440 interactionMode: "default",
441 }),
442 );
443 yield* service.runNow({ id: task.id });
444 return task.id;
445 }),
446 );
447
448 // The server is off across the 09:00 slot and comes back at 09:30.
449 yield* TestClock.setTime(DateTime.toEpochMillis(at(9, 30)));
450 const [listed] = yield* withServer(
451 Effect.gen(function* () {
452 const service = yield* ScheduledTaskService.ScheduledTaskService;
453 yield* TestClock.adjust("6 seconds");
454 return (yield* service.list()).tasks;
455 }),
456 );
457
458 expect(listed?.id).toBe(taskId);
459 expect(yield* Ref.get(launches)).toBe(1);
460 expect(listed?.nextRunAt).toBe(DateTime.formatIso(DateTime.toUtc(at(9, 0, 2))));
461 // The skipped slot must not read as the earlier manual run's clean success.
462 expect({ status: listed?.lastRunStatus, error: listed?.lastRunError }).not.toEqual({
463 status: "succeeded",
464 error: null,
465 });
466 }).pipe(Effect.provide(SqlitePersistence.layerMemory)),
467);
468
469it.effect(
470 "a run made while the scheduler was busy is not overwritten by its stale missed slot",
471 () =>
472 Effect.gen(function* () {
473 const at = (hour: number, minute = 0, day = 1) =>
474 DateTime.makeZonedUnsafe(
475 { year: 2026, month: 7, day, hour, minute, second: 0, millisecond: 0 },
476 { timeZone: DateTime.zoneMakeLocal(), adjustForTimeZone: true },
477 );
478 yield* TestClock.setTime(DateTime.toEpochMillis(at(6)));
479 const slowStarted = yield* Deferred.make<void>();
480 const releaseSlow = yield* Deferred.make<void>();
481 const lastProcessed = yield* Deferred.make<void>();
482 const layerDependencies = Layer.mergeAll(
483 NodeCrypto.layer,
484 Scheduler.layer,
485 ManagedPolicy.layerTest(),
486 Layer.mock(ThreadLaunchService.ThreadLaunchService)({
487 launch: (input) =>
488 (input.title === "Slow"
489 ? Deferred.succeed(slowStarted, undefined).pipe(
490 Effect.andThen(Deferred.await(releaseSlow)),
491 )
492 : input.title === "Last"
493 ? Deferred.succeed(lastProcessed, undefined)
494 : Effect.void
495 ).pipe(Effect.as({ threadId: `thread-${input.title}`, resumed: false } as never)),
496 }),
497 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({
498 namedProjectsRoot: "/projects",
499 isScratchProject: () => Effect.succeed(false),
500 }),
501 Layer.mock(ThreadManagementService.ThreadManagementService)({}),
502 Layer.mock(SecretRequests.SecretRequests)({}),
503 );
504 const withServer = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
505 effect.pipe(
506 Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies))),
507 );
508 const upsert = (
509 title: string,
510 schedule: { type: "interval"; everyMs: number } | { type: "fixed_time"; timeOfDay: string },
511 ) =>
512 Effect.gen(function* () {
513 const service = yield* ScheduledTaskService.ScheduledTaskService;
514 const { task } = yield* service.upsert(
515 yield* decodeUpsertInput({
516 title,
517 prompt: "Update the notes.",
518 enabled: true,
519 schedule,
520 projectId: "project-stale-slot",
521 workspaceStrategy: { type: "root" },
522 modelSelection: { instanceId: "codex", model: "gpt-5.4" },
523 runtimeMode: "full-access",
524 interactionMode: "default",
525 }),
526 );
527 return task.id;
528 });
529
530 // Due at 07:00, 08:00 and 08:30; the server is off until 09:30.
531 const morningId = yield* withServer(
532 Effect.gen(function* () {
533 yield* upsert("Slow", { type: "interval", everyMs: 60 * 60 * 1000 });
534 const id = yield* upsert("Morning", { type: "fixed_time", timeOfDay: "08:00" });
535 yield* upsert("Last", { type: "interval", everyMs: 150 * 60 * 1000 });
536 return id;
537 }),
538 );
539 yield* TestClock.setTime(DateTime.toEpochMillis(at(9, 30)));
540 const morning = yield* withServer(
541 Effect.gen(function* () {
542 const service = yield* ScheduledTaskService.ScheduledTaskService;
543 yield* TestClock.adjust("6 seconds");
544 yield* Deferred.await(slowStarted);
545 yield* service.runNow({ id: morningId });
546 yield* Deferred.succeed(releaseSlow, undefined);
547 yield* Deferred.await(lastProcessed);
548 return (yield* service.list()).tasks.find((task) => task.id === morningId);
549 }),
550 );
551
552 expect({ status: morning?.lastRunStatus, error: morning?.lastRunError }).toEqual({
553 status: "succeeded",
554 error: null,
555 });
556 expect(morning?.nextRunAt).toBe(DateTime.formatIso(DateTime.toUtc(at(8, 0, 2))));
557 }).pipe(Effect.provide(SqlitePersistence.layerMemory)),
558);