@@ -3,7 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
33import { tmpdir } from 'node:os'
44import { join } from 'node:path'
55import { test } from 'node:test'
6- import { apply } from '../index.js'
6+ import { createRuntime , provideFlyBot } from '../index.js'
77import { fakeContext } from './fake-ctx.js'
88import { reminderText } from '../src/host/schedules.js'
99
@@ -22,9 +22,11 @@ const TEAM = {
2222}
2323
2424// The record fields DSH stores for each create selector (see dsh-schedule's README).
25+ // `every_seconds` is the one DSH anchors to the create instant (now + interval), so a
26+ // move that went through create would re-anchor it; the others keep a fixed instant.
2527const KINDS = {
2628 at : at => ( { kind : 'at' , scheduledAt : at } ) ,
27- every_seconds : seconds => ( { kind : 'every' , everySeconds : seconds , scheduledAt : '2030-01-01T01:00:00.000Z' } ) ,
29+ every_seconds : seconds => ( { kind : 'every' , everySeconds : seconds , scheduledAt : new Date ( Date . now ( ) + seconds * 1000 ) . toISOString ( ) } ) ,
2830 daily : ( { time, time_zone } ) => ( { kind : 'daily' , time, timeZone : time_zone , scheduledAt : '2030-01-01T01:00:00.000Z' } ) ,
2931 weekly : ( { time, time_zone, weekdays } ) => ( { kind : 'weekly' , time, timeZone : time_zone , weekdays, scheduledAt : '2030-01-01T01:00:00.000Z' } ) ,
3032 cron : ( { expression, time_zone } ) => ( { kind : 'cron' , expression, timeZone : time_zone , scheduledAt : '2030-01-01T01:00:00.000Z' } ) ,
@@ -36,21 +38,45 @@ function scheduleService() {
3638 const tasks = new Map ( )
3739 const updates = [ ]
3840 let next = 1
39- return {
41+ // DSH runs management and delivery through one queue (`serialize`); the fake keeps one too.
42+ let chain = Promise . resolve ( )
43+ const service = {
4044 tasks,
4145 updates,
4246 failCreate : false ,
4347 failDelete : false ,
48+ failPut : false ,
4449 async create ( sessionId , request ) {
4550 if ( this . failCreate ) throw new Error ( 'create failed' )
4651 const id = `schedule-${ next ++ } `
4752 const kind = Object . keys ( request ) . find ( key => ! [ 'title' , 'prompt' ] . includes ( key ) )
4853 const record = { id, title : request . title , prompt : request . prompt , timing : request [ kind ] , ...KINDS [ kind ] ( request [ kind ] ) }
49- tasks . set ( id , { sessionId, record, status : 'active' } )
54+ tasks . set ( id , { sessionId, record, status : 'active' , deliveryHistory : { records : [ ] , earlierRecordsUnavailable : false } } )
5055 return record
5156 } ,
5257 async list ( { sessionId } ) { return [ ...tasks . values ( ) ] . filter ( task => task . sessionId === sessionId && task . status === 'active' ) . map ( task => task . record ) } ,
5358 async catalog ( ) { return [ ...tasks . values ( ) ] . map ( task => ( { ...task . record , sessionId : task . sessionId , status : task . status } ) ) } ,
59+ // DSH's storage domain, as `getDomain()` exposes it: the row itself is what a move rewrites.
60+ async getDomain ( ) {
61+ return {
62+ table ( name ) {
63+ assert . equal ( name , 'tasks' )
64+ return {
65+ entries : ( ) => tasks . entries ( ) ,
66+ get : id => tasks . get ( id ) ,
67+ put ( id , task ) {
68+ if ( service . failPut ) throw new Error ( 'write failed' )
69+ tasks . set ( id , task )
70+ } ,
71+ }
72+ } ,
73+ }
74+ } ,
75+ serialize ( work ) {
76+ const pending = chain . then ( work )
77+ chain = pending . catch ( ( ) => undefined )
78+ return pending
79+ } ,
5480 async update ( request ) {
5581 updates . push ( request )
5682 const task = tasks . get ( request . id )
@@ -68,6 +94,7 @@ function scheduleService() {
6894 return { id, deleted : true }
6995 } ,
7096 }
97+ return service
7198}
7299
73100function setup ( services = { } , team = TEAM ) {
@@ -76,9 +103,10 @@ function setup(services = {}, team = TEAM) {
76103 const ctx = fakeContext ( { services } )
77104 const register = ctx . connection . fetch . register
78105 ctx . connection . fetch . register = ( route ) => { ctx . route = route ; return register ( route ) }
79- apply ( ctx , { home } )
106+ const rt = createRuntime ( ctx , { home } )
107+ provideFlyBot ( ctx , rt . flybot )
80108 const call = async ( endpoint , payload ) => ( await ctx . route . fetch ( new Request ( 'http://localhost/api/bot' , { method : 'POST' , body : JSON . stringify ( { endpoint, payload } ) } ) ) ) . json ( )
81- return { ctx, home, call }
109+ return { ctx, home, call, rt }
82110}
83111
84112test ( 'without the schedule service the page says so and creating is refused' , async ( t ) => {
@@ -212,6 +240,79 @@ test('editing a task onto another chat creates the new one before deleting the o
212240 assert . deepEqual ( again . value . tasks . map ( task => [ task . prompt , task . status ] ) , [ [ 'Again' , 'active' ] ] )
213241} )
214242
243+ // #1245: a handoff is not an edit. The earlier part's task moves to the fresh part with its
244+ // id, its delivery history and the instant it was already due — not re-anchored to the
245+ // handoff, which would slip its next run by up to a full interval and hide it.
246+ test ( '#1245: a handoff moves an “every” task without re-anchoring it' , async ( t ) => {
247+ const schedule = scheduleService ( )
248+ const { ctx, home, rt } = setup ( { schedule } )
249+ t . after ( async ( ) => { await ctx . dispose ( ) ; rmSync ( home , { recursive : true , force : true , maxRetries : 5 } ) } )
250+ // A handoff happens well after the task was created, so a create-based move would visibly
251+ // re-anchor it: the fake's create samples this clock (`now + every_seconds`).
252+ let clock = Date . parse ( '2030-01-01T00:00:00.000Z' )
253+ t . mock . method ( Date , 'now' , ( ) => clock )
254+
255+ const record = await schedule . create ( 'm1' , { title : 'Watch' , prompt : 'Check the board' , every_seconds : 7200 } )
256+ const row = schedule . tasks . get ( record . id )
257+ row . deliveryHistory = { records : [ { scheduledAt : '2030-01-01T01:00:00.000Z' , deliveredAt : '2030-01-01T01:00:01.000Z' , messageId : 'msg-old' } ] , earlierRecordsUnavailable : false }
258+ const anchor = row . record . scheduledAt
259+ const history = structuredClone ( row . deliveryHistory )
260+
261+ clock += 45 * 60_000
262+ await rt . moveSchedules ( 'm1' , 'm1-fresh' )
263+
264+ const moved = [ ...schedule . tasks . values ( ) ] . filter ( task => task . sessionId === 'm1-fresh' )
265+ assert . equal ( moved . length , 1 , 'the task moved to the fresh part' )
266+ assert . equal ( moved [ 0 ] . record . scheduledAt , anchor , 'the instant it was already due is kept' )
267+ assert . equal ( moved [ 0 ] . record . id , record . id , 'the task keeps its id' )
268+ assert . deepEqual ( moved [ 0 ] . deliveryHistory , history , 'the delivery history is kept' )
269+ assert . equal ( moved [ 0 ] . record . everySeconds , 7200 )
270+ assert . equal ( schedule . tasks . size , 1 , 'the row was rewritten, not copied' )
271+
272+ // A failed write leaves the task where it was, with nothing half-deleted (#901).
273+ schedule . failPut = true
274+ await rt . moveSchedules ( 'm1-fresh' , 'm1-newer' )
275+ schedule . failPut = false
276+ const [ still ] = [ ...schedule . tasks . values ( ) ]
277+ assert . equal ( still . sessionId , 'm1-fresh' , 'a failed move changes nothing' )
278+ assert . equal ( still . record . id , record . id )
279+ assert . equal ( still . record . scheduledAt , anchor )
280+
281+ // A one-shot whose time has already passed cannot run on the fresh part, so it stays.
282+ const once = await schedule . create ( 'm1-fresh' , { title : 'Once' , prompt : 'Ping' , at : '2030-01-01T00:00:00.000Z' } )
283+ schedule . tasks . get ( once . id ) . record = { ...schedule . tasks . get ( once . id ) . record , scheduledAt : new Date ( Date . now ( ) - 1000 ) . toISOString ( ) }
284+ await rt . moveSchedules ( 'm1-fresh' , 'm1-newer' )
285+ const byId = id => [ ...schedule . tasks . values ( ) ] . find ( task => task . record . id === id )
286+ assert . equal ( byId ( once . id ) . sessionId , 'm1-fresh' , 'a passed one-shot stays where it is' )
287+ assert . equal ( byId ( record . id ) . sessionId , 'm1-newer' , 'the recurring task still moved' )
288+ } )
289+
290+ // The move only takes active rows: a disabled task and an exhausted one-shot have no next run
291+ // to follow the handoff, so they stay on the earlier part with their status untouched.
292+ test ( '#1245: a handoff leaves a disabled and an exhausted row on the earlier part' , async ( t ) => {
293+ const schedule = scheduleService ( )
294+ const { ctx, home, rt } = setup ( { schedule } )
295+ t . after ( async ( ) => { await ctx . dispose ( ) ; rmSync ( home , { recursive : true , force : true , maxRetries : 5 } ) } )
296+ t . mock . method ( Date , 'now' , ( ) => Date . parse ( '2030-01-01T00:00:00.000Z' ) )
297+
298+ const disabled = await schedule . create ( 'm1' , { title : 'Disabled watch' , prompt : 'Switched off' , every_seconds : 7200 } )
299+ const exhausted = await schedule . create ( 'm1' , { title : 'Exhausted ping' , prompt : 'Ran once' , at : '2030-01-01T00:10:00.000Z' } )
300+ // DSH's only ended state is `inactive`: a disabled task and a finished one-shot both keep it.
301+ schedule . tasks . get ( disabled . id ) . status = 'inactive'
302+ schedule . tasks . get ( exhausted . id ) . status = 'inactive'
303+ const active = await schedule . create ( 'm1' , { title : 'Live watch' , prompt : 'Moves' , every_seconds : 3600 } )
304+
305+ await rt . moveSchedules ( 'm1' , 'm1-fresh' )
306+
307+ const row = id => [ ...schedule . tasks . values ( ) ] . find ( task => task . record . id === id )
308+ assert . equal ( row ( disabled . id ) . sessionId , 'm1' , 'a disabled task stays on the earlier part' )
309+ assert . equal ( row ( disabled . id ) . status , 'inactive' , 'its status is untouched' )
310+ assert . equal ( row ( exhausted . id ) . sessionId , 'm1' , 'an exhausted task stays on the earlier part' )
311+ assert . equal ( row ( exhausted . id ) . status , 'inactive' , 'its status is untouched' )
312+ assert . equal ( row ( active . id ) . sessionId , 'm1-fresh' , 'the active task still moves' )
313+ assert . deepEqual ( [ ...schedule . tasks . values ( ) ] . filter ( task => task . sessionId === 'm1-fresh' ) . map ( task => task . record . id ) , [ active . id ] , 'the fresh part gets neither inactive row' )
314+ } )
315+
215316test ( 'pause keeps the task in FlyBot and removes it from DSH; resume brings it back' , async ( t ) => {
216317 const schedule = scheduleService ( )
217318 const { ctx, home, call } = setup ( { schedule } )
0 commit comments