@@ -392,3 +392,76 @@ it.effect(
392392 } ) . pipe ( Effect . provide ( ScheduledTaskService . layer . pipe ( Layer . provide ( layerDependencies ) ) ) ) ;
393393 } ) . pipe ( Effect . provide ( SqlitePersistence . layerMemory ) ) ,
394394) ;
395+
396+ it . 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+ ) ;
0 commit comments