fix(schedule): harden calendar transition handling
This commit is contained in:
@@ -228,6 +228,14 @@ describe('Croner calendar adapter', () => {
|
||||
occurrenceAt: '2026-11-01T05:30:00.000Z',
|
||||
nextScheduledAt: '2026-11-02T06:30:00.000Z',
|
||||
})
|
||||
expect(resolveCronOccurrence({
|
||||
...overlap,
|
||||
cron: '0,30 1 * * *',
|
||||
scheduledAt: '2026-10-31T05:30:00.000Z',
|
||||
}, Date.parse('2026-11-01T07:00:00.000Z'))).toEqual({
|
||||
occurrenceAt: '2026-11-01T05:30:00.000Z',
|
||||
nextScheduledAt: '2026-11-02T06:00:00.000Z',
|
||||
})
|
||||
expect(createCronScheduleRecord(
|
||||
ScheduleId('schedule-overlap-after-first'),
|
||||
'after first overlap instant',
|
||||
@@ -237,6 +245,18 @@ describe('Croner calendar adapter', () => {
|
||||
).scheduledAt).toBe('2026-11-02T06:30:00.000Z')
|
||||
})
|
||||
|
||||
it('skips a sub-minute local-mean-time era before iterating dense safe-year matches', () => {
|
||||
const record = createCronScheduleRecord(
|
||||
ScheduleId('schedule-sub-minute-offset'),
|
||||
'standard-time handoff',
|
||||
'*/5 * * * *',
|
||||
'Europe/Amsterdam',
|
||||
Date.parse('0100-01-01T00:00:00.000Z'),
|
||||
)
|
||||
expect(new Date(record.scheduledAt).getUTCFullYear()).toBeGreaterThan(109)
|
||||
expect(Math.abs(Date.parse(record.scheduledAt) % 60_000)).toBe(0)
|
||||
}, 1_000)
|
||||
|
||||
it('selects the latest current match after a persisted baseline', () => {
|
||||
const record = createCronScheduleRecord(
|
||||
ScheduleId('schedule-latest'),
|
||||
|
||||
@@ -143,6 +143,28 @@ describe('Schedule package invariant', () => {
|
||||
}, 0)],
|
||||
})
|
||||
const fiber = await ctx.plugin(scheduleInvariant)
|
||||
const alias = ctx.sessions.create(SessionId('schedule-historical-zone-alias'), {
|
||||
seed: [event({
|
||||
version: 1,
|
||||
operation: 'create',
|
||||
schedule: {
|
||||
id: 'schedule-historical-zone-alias',
|
||||
kind: 'cron',
|
||||
prompt: 'historical zone alias',
|
||||
cron: '0 9 * * *',
|
||||
timeZone: 'US/Eastern',
|
||||
scheduledAt: '2026-08-06T13:00:00.000Z',
|
||||
},
|
||||
}, 0)],
|
||||
})
|
||||
expect(() => alias.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: ScheduleId('schedule-historical-zone-alias'),
|
||||
occurrenceAt: '2026-08-07T13:00:00.000Z',
|
||||
acceptedAt: '2026-08-07T14:00:00.000Z',
|
||||
nextScheduledAt: '2026-08-08T13:00:00.000Z',
|
||||
})).not.toThrow()
|
||||
const invalidLiveRules = [
|
||||
{
|
||||
id: 'schedule-historical-fast-cron',
|
||||
|
||||
@@ -4,6 +4,7 @@ import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentCancelCause, InboxTarget } from '@deepseek-ai/dsh-agent'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { Cron } from 'croner'
|
||||
import {
|
||||
MIN_RECURRING_INTERVAL_SECONDS,
|
||||
ScheduleId,
|
||||
@@ -168,6 +169,43 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('Schedule timer and admission runtime', () => {
|
||||
it('contains calendar resolution failure without permanently faulting the owner', async () => {
|
||||
const test = await harness()
|
||||
const invalidId = ScheduleId('schedule-invalid-zone')
|
||||
appendCron(test, invalidId, '0 0 * * *', Date.now() - 86_400_000)
|
||||
const wakeFailure = vi.spyOn(Cron.prototype, 'previousRuns').mockImplementation(() => {
|
||||
throw new Error('calendar unavailable')
|
||||
})
|
||||
const owner = ownerFor(test)
|
||||
owner.start()
|
||||
await settle()
|
||||
expect(test.followed).toEqual([])
|
||||
wakeFailure.mockRestore()
|
||||
|
||||
let restoreCalendarFailure: (() => void) | undefined
|
||||
test.controls.onReserve = () => {
|
||||
const calendarFailure = vi.spyOn(Cron.prototype, 'previousRuns').mockImplementation(() => {
|
||||
throw new Error('calendar unavailable')
|
||||
})
|
||||
restoreCalendarFailure = () => { calendarFailure.mockRestore() }
|
||||
}
|
||||
owner.requestDrive()
|
||||
await settle()
|
||||
expect(test.followed).toEqual([])
|
||||
|
||||
restoreCalendarFailure?.()
|
||||
test.controls.onReserve = undefined
|
||||
test.agent.session.append('schedule/change', { version: 1, operation: 'delete', id: invalidId })
|
||||
appendAfter(test, 'schedule-healthy-after', 1, Date.now() - 2_000)
|
||||
owner.requestDrive()
|
||||
await settle()
|
||||
expect(test.followed).toHaveLength(1)
|
||||
expect(test.agent.session.events.some(event =>
|
||||
event.type === 'schedule/change'
|
||||
&& event.data.operation === 'dispatch'
|
||||
&& event.data.id === 'schedule-healthy-after')).toBe(true)
|
||||
})
|
||||
|
||||
it('segments waits beyond the Node timer limit and rechecks the wall clock', async () => {
|
||||
const test = await harness()
|
||||
const delaySeconds = Math.ceil((MAX_TIMER_DELAY_MS + 1_500) / 1_000)
|
||||
|
||||
Reference in New Issue
Block a user