feat(schedule): add explicit-time-zone cron reminders

This commit is contained in:
pku-xht
2026-08-06 23:48:57 +08:00
committed by Tianyi Cui
parent 9abecc103d
commit 31c0c6a9f6
23 changed files with 1833 additions and 92 deletions

View File

@@ -0,0 +1,543 @@
import { describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { Cron } from 'croner'
import {
ScheduleId,
ScheduleInputError,
ScheduleLogError,
canonicalizeCronExpression,
createCronScheduleRecord,
decodeScheduleChange,
foldScheduleEvents,
resolveCronOccurrence,
scheduleReminderPresentation,
scheduleView,
} from '../src/domain.ts'
function event(data: unknown, seq: number): SessionEvent {
return { type: 'schedule/change', seq, time: 0, data } as SessionEvent
}
function cronCreate(
id = 'schedule-cron',
scheduledAt = '2026-08-07T01:00:00.000Z',
cron = '0 9 * * 1,2,3,4,5',
timeZone = 'Asia/Shanghai',
) {
return {
version: 1,
operation: 'create',
schedule: { id, kind: 'cron', prompt: 'daily review', cron, timeZone, scheduledAt },
}
}
function expectInputCode(run: () => unknown, code: ScheduleInputError['code']): void {
try {
run()
throw new Error(`expected ${code}`)
} catch (error: unknown) {
expect(error).toBeInstanceOf(ScheduleInputError)
expect((error as ScheduleInputError).code).toBe(code)
}
}
describe('restricted cron grammar and frequency proof', () => {
it.each([
['00 09 * * 1,2,3,4,5', '0 9 * * 1,2,3,4,5'],
['0 0 * */01 *', '0 0 * * *'],
['5-20/05 1-3 * * *', '5-20/5 1-3 * * *'],
['05 01 01,15 01,12 *', '5 1 1,15 1,12 *'],
['0 0 * * 7', '0 0 * * 7'],
])('canonicalizes %s', (input, canonical) => {
expect(canonicalizeCronExpression(input)).toBe(canonical)
})
it.each([
'',
' 0 0 * * *',
'0 0 * * * ',
'0 0 * *',
'0 0 0 * * *',
'@daily',
'0 0 * JAN *',
'0 0 * * MON',
'0 0 ? * *',
'0 0 L * *',
'0 0 W * *',
'0 0 * * 1#2',
'-1 0 * * *',
'1.5 0 * * *',
'60 0 * * *',
'0 24 * * *',
'0 0 0 * *',
'0 0 32 * *',
'0 0 * 13 *',
'0 0 * * 8',
'0,0 0 * * *',
'2,1 0 * * *',
'1,2-3 0 * * *',
'2-2 0 * * *',
'3-2 0 * * *',
'*/0 0 * * *',
'*/61 0 * * *',
'1-5/61 0 * * *',
'1-1/2 0 * * *',
'0 0 * * 0,7',
'0 0 * * 0-7',
'0 0 * * */8',
'0 0 * * 0-6/8',
'0 0 * * 1-7/8',
'0 0 1 * 1',
])('rejects unsupported grammar %s', (input) => {
expectInputCode(() => canonicalizeCronExpression(input), 'invalid_rule')
})
it('proves same-day and cycle-seam frequency while allowing the five-minute boundary', () => {
expectInputCode(() => canonicalizeCronExpression('0,4 * * * *'), 'frequency_too_high')
expectInputCode(() => canonicalizeCronExpression('3,59 0,23 * * *'), 'frequency_too_high')
expect(canonicalizeCronExpression('0,5 * * * *')).toBe('0,5 * * * *')
expect(canonicalizeCronExpression('4,59 0,23 * * *')).toBe('4,59 0,23 * * *')
expect(canonicalizeCronExpression('3,59 0,23 * * 1')).toBe('3,59 0,23 * * 1')
expect(canonicalizeCronExpression('3,59 0,23 29 2 *')).toBe('3,59 0,23 29 2 *')
expectInputCode(() => canonicalizeCronExpression('3,59 0,23 * * 5,6'), 'frequency_too_high')
expect(canonicalizeCronExpression('* * 31 2 *')).toBe('* * 31 2 *')
})
})
describe('Croner calendar adapter', () => {
it('creates a canonical explicit-zone record and crosses from 2999 into 3000', () => {
expect(createCronScheduleRecord(
ScheduleId('schedule-workday'),
' review metrics ',
'00 09 * * 1,2,3,4,5',
'US/Eastern',
Date.parse('2026-08-06T12:00:00.000Z'),
)).toEqual({
id: 'schedule-workday',
kind: 'cron',
prompt: 'review metrics',
cron: '0 9 * * 1,2,3,4,5',
timeZone: 'America/New_York',
scheduledAt: '2026-08-06T13:00:00.000Z',
})
expect(createCronScheduleRecord(
ScheduleId('schedule-3000'),
'new millennium',
'0 0 1 1 *',
'UTC',
Date.parse('2999-12-31T23:59:59.999Z'),
).scheduledAt).toBe('3000-01-01T00:00:00.000Z')
})
it('owns forward and reverse calendar search across years 0001 through 0100', () => {
expect(createCronScheduleRecord(
ScheduleId('schedule-year-1'),
'year one',
'0 0 * * *',
'UTC',
Date.parse('0001-01-01T00:00:00.000Z'),
).scheduledAt).toBe('0001-01-02T00:00:00.000Z')
expect(createCronScheduleRecord(
ScheduleId('schedule-year-100'),
'year one hundred',
'0 0 * * *',
'UTC',
Date.parse('0099-12-31T00:00:00.000Z'),
).scheduledAt).toBe('0100-01-01T00:00:00.000Z')
expect(createCronScheduleRecord(
ScheduleId('schedule-low-leap'),
'low leap',
'0 0 29 2 *',
'UTC',
Date.parse('0001-01-01T00:00:00.000Z'),
).scheduledAt).toBe('0004-02-29T00:00:00.000Z')
const historicalOffset = createCronScheduleRecord(
ScheduleId('schedule-low-offset'),
'low offset',
'0 0 29 2 *',
'Pacific/Kiritimati',
Date.parse('0001-01-01T00:00:00.000Z'),
)
expect(new Date(historicalOffset.scheduledAt).getUTCFullYear()).toBeGreaterThan(109)
const baseline = createCronScheduleRecord(
ScheduleId('schedule-reverse-100'),
'reverse one hundred',
'0 0 * * *',
'UTC',
Date.parse('0099-12-30T00:00:00.000Z'),
)
expect(resolveCronOccurrence(baseline, Date.parse('0100-01-01T00:00:00.000Z'))).toEqual({
occurrenceAt: '0100-01-01T00:00:00.000Z',
nextScheduledAt: '0100-01-02T00:00:00.000Z',
})
})
it('skips a DST gap and chooses the first instant in an overlap', () => {
const gap = createCronScheduleRecord(
ScheduleId('schedule-gap'),
'gap',
'30 2 * * *',
'America/New_York',
Date.parse('2026-03-08T05:00:00.000Z'),
)
expect(gap.scheduledAt).toBe('2026-03-09T06:30:00.000Z')
const gapBaseline = {
...gap,
scheduledAt: '2026-03-07T07:30:00.000Z',
}
expect(resolveCronOccurrence(gapBaseline, Date.parse('2026-03-08T08:00:00.000Z'))).toEqual({
occurrenceAt: gapBaseline.scheduledAt,
nextScheduledAt: '2026-03-09T06:30:00.000Z',
})
const overlap = createCronScheduleRecord(
ScheduleId('schedule-overlap'),
'overlap',
'30 1 * * *',
'America/New_York',
Date.parse('2026-10-31T06:00:00.000Z'),
)
expect(overlap.scheduledAt).toBe('2026-11-01T05:30:00.000Z')
expect(resolveCronOccurrence({
...overlap,
scheduledAt: '2026-10-31T05:30:00.000Z',
}, Date.parse('2026-11-01T06:00:00.000Z'))).toEqual({
occurrenceAt: '2026-11-01T05:30:00.000Z',
nextScheduledAt: '2026-11-02T06:30:00.000Z',
})
expect(resolveCronOccurrence(overlap, Date.parse('2026-11-01T07:00:00.000Z'))).toEqual({
occurrenceAt: '2026-11-01T05:30:00.000Z',
nextScheduledAt: '2026-11-02T06:30:00.000Z',
})
})
it('selects the latest current match after a persisted baseline', () => {
const record = createCronScheduleRecord(
ScheduleId('schedule-latest'),
'latest',
'0 9 * * *',
'Asia/Shanghai',
Date.parse('2026-08-01T00:00:00.000Z'),
)
expect(resolveCronOccurrence(record, Date.parse('2026-08-06T12:34:56.789Z'))).toEqual({
occurrenceAt: '2026-08-06T01:00:00.000Z',
nextScheduledAt: '2026-08-07T01:00:00.000Z',
})
})
it('reports invalid zones, impossible calendars, and four-digit-year exhaustion', () => {
expectInputCode(() => createCronScheduleRecord(
ScheduleId('bad-prompt'), ' ', '0 0 * * *', 'UTC', Date.parse('2026-01-01T00:00:00Z'),
), 'invalid_prompt')
expectInputCode(() => createCronScheduleRecord(
ScheduleId('bad-zone'), 'x', '0 0 * * *', 'CST', Date.parse('2026-01-01T00:00:00Z'),
), 'invalid_time_zone')
expectInputCode(() => createCronScheduleRecord(
ScheduleId('no-date'), 'x', '* * 31 2 *', 'UTC', Date.parse('2026-01-01T00:00:00Z'),
), 'no_future_occurrence')
expectInputCode(() => createCronScheduleRecord(
ScheduleId('no-year'), 'x', '59 23 31 12 *', 'UTC', Date.parse('9999-12-31T23:59:00Z'),
), 'no_future_occurrence')
expectInputCode(() => createCronScheduleRecord(
ScheduleId('bad-now'), 'x', '0 0 * * *', 'UTC', Number.NaN,
), 'time_out_of_range')
expectInputCode(() => createCronScheduleRecord(
ScheduleId('last-now'), 'x', '0 0 * * *', 'UTC', Date.parse('9999-12-31T23:59:59.999Z'),
), 'no_future_occurrence')
})
it('contains dependency cursor failures and preserves the baseline when current search has no match', () => {
const record = createCronScheduleRecord(
ScheduleId('schedule-dependency'),
'dependency',
'30 1 * * *',
'America/New_York',
Date.parse('2026-10-31T06:00:00.000Z'),
)
const noPrevious = vi.spyOn(Cron.prototype, 'previousRuns').mockReturnValue([])
expect(resolveCronOccurrence(record, Date.parse(record.scheduledAt))).toMatchObject({
occurrenceAt: record.scheduledAt,
})
noPrevious.mockRestore()
const repeatedPrevious = vi.spyOn(Cron.prototype, 'previousRuns')
.mockImplementationOnce((_count, reference) => [new Date(reference ?? record.scheduledAt)])
.mockReturnValue([new Date('2026-11-01T05:30:00.000Z')])
expect(resolveCronOccurrence(record, Date.parse('2026-11-01T07:00:00.000Z'))).toMatchObject({
occurrenceAt: '2026-11-01T05:30:00.000Z',
})
repeatedPrevious.mockRestore()
const laterOverlap = vi.spyOn(Cron.prototype, 'previousRuns')
.mockReturnValue([new Date('2026-11-01T06:30:00.000Z')])
expect(resolveCronOccurrence(record, Date.parse('2026-11-01T07:00:00.000Z'))).toMatchObject({
occurrenceAt: '2026-11-01T05:30:00.000Z',
})
laterOverlap.mockRestore()
const gapThenNone = vi.spyOn(Cron.prototype, 'previousRuns')
.mockReturnValueOnce([new Date('2026-03-08T07:30:00.000Z')])
.mockReturnValueOnce([])
const gapBaseline = {
...record,
cron: '30 2 * * *',
scheduledAt: '2026-03-07T07:30:00.000Z',
}
expect(resolveCronOccurrence(gapBaseline, Date.parse('2026-03-08T08:00:00.000Z'))).toMatchObject({
occurrenceAt: gapBaseline.scheduledAt,
})
gapThenNone.mockRestore()
const boundaryThenEnd = vi.spyOn(Cron.prototype, 'previousRuns')
.mockReturnValue([new Date('0001-01-01T00:00:00.000Z')])
const boundaryBaseline = {
...record,
cron: '1 0 * * *',
timeZone: 'UTC',
scheduledAt: '0001-01-01T00:01:00.000Z',
}
expect(resolveCronOccurrence(boundaryBaseline, Date.parse('2026-01-01T00:00:00.000Z'))).toMatchObject({
occurrenceAt: '2025-12-31T00:01:00.000Z',
})
boundaryThenEnd.mockRestore()
const repeatedNext = vi.spyOn(Cron.prototype, 'nextRun')
.mockImplementationOnce(reference =>
reference instanceof Date ? new Date(reference) : new Date('2026-01-01T00:00:00.000Z'))
.mockReturnValue(new Date('2026-01-02T00:00:00.000Z'))
expect(createCronScheduleRecord(
ScheduleId('stuck-next'), 'x', '0 0 * * *', 'UTC', Date.parse('2026-01-01T00:00:00Z'),
).scheduledAt).toBe('2026-01-02T00:00:00.000Z')
repeatedNext.mockRestore()
const neverAdvancingNext = vi.spyOn(Cron.prototype, 'nextRun').mockImplementation(reference =>
reference instanceof Date ? new Date(reference) : new Date('2026-01-01T00:00:00.000Z'))
expect(createCronScheduleRecord(
ScheduleId('fallback-next'), 'x', '0 0 * * *', 'UTC', Date.parse('2026-01-01T00:00:00Z'),
).scheduledAt).toBe('2026-01-02T00:00:00.000Z')
neverAdvancingNext.mockRestore()
const invalidNext = vi.spyOn(Cron.prototype, 'nextRun').mockReturnValue(new Date(Number.NaN))
expectInputCode(() => createCronScheduleRecord(
ScheduleId('invalid-next'), 'x', '0 0 * * *', 'UTC', Date.parse('2026-01-01T00:00:00Z'),
), 'invalid_rule')
invalidNext.mockRestore()
const outOfRangeNext = vi.spyOn(Cron.prototype, 'nextRun')
.mockReturnValue(new Date('+010000-01-01T00:00:00.000Z'))
expectInputCode(() => createCronScheduleRecord(
ScheduleId('large-next'), 'x', '0 0 * * *', 'UTC', Date.parse('2026-01-01T00:00:00Z'),
), 'no_future_occurrence')
outOfRangeNext.mockRestore()
const invalidPrevious = vi.spyOn(Cron.prototype, 'previousRuns')
.mockReturnValue([new Date(Number.NaN)])
expect(() => resolveCronOccurrence(record, Date.parse('2026-11-01T07:00:00.000Z')))
.toThrow(/cron evaluation failed: The cron evaluator did not retreat/)
invalidPrevious.mockRestore()
const repeatedAtBaseline = vi.spyOn(Cron.prototype, 'previousRuns')
.mockImplementation((_count, reference) => [new Date(reference ?? record.scheduledAt)])
expect(resolveCronOccurrence(record, Date.parse(record.scheduledAt))).toMatchObject({
occurrenceAt: record.scheduledAt,
})
repeatedAtBaseline.mockRestore()
const neverRetreating = vi.spyOn(Cron.prototype, 'previousRuns')
.mockImplementation((_count, reference) => [new Date(reference ?? record.scheduledAt)])
expect(resolveCronOccurrence({
...record,
scheduledAt: '2026-10-31T05:30:00.000Z',
}, Date.parse('2026-11-01T07:00:00.000Z'))).toMatchObject({
occurrenceAt: '2026-11-01T05:30:00.000Z',
})
neverRetreating.mockRestore()
const nonMinutePrevious = vi.spyOn(Cron.prototype, 'previousRuns')
.mockReturnValue([new Date('2026-11-01T05:30:30.000Z')])
expect(resolveCronOccurrence(record, Date.parse('2026-11-01T07:00:00.000Z'))).toMatchObject({
occurrenceAt: '2026-11-01T05:30:00.000Z',
})
nonMinutePrevious.mockRestore()
const gapWithOwnedMatch = vi.spyOn(Cron.prototype, 'previousRuns')
.mockReturnValue([new Date('2026-03-08T07:30:00.000Z')])
const gapWithNextDay = {
...record,
cron: '30 2 * * *',
scheduledAt: '2026-03-07T07:30:00.000Z',
}
expect(resolveCronOccurrence(gapWithNextDay, Date.parse('2026-03-09T08:00:00.000Z'))).toMatchObject({
occurrenceAt: '2026-03-09T06:30:00.000Z',
})
gapWithOwnedMatch.mockRestore()
const thrownNext = vi.spyOn(Cron.prototype, 'nextRun').mockImplementation(() => {
throw new Error('dependency failed')
})
expect(() => resolveCronOccurrence(record, Date.parse('2026-11-01T07:00:00.000Z')))
.toThrow(/cron evaluation failed: dependency failed/)
thrownNext.mockRestore()
})
})
describe('durable Cron replay', () => {
it('decodes canonical records and advances only from persisted dispatch facts', () => {
const create = event(cronCreate(), 0)
expect(decodeScheduleChange(create.data)).toEqual(cronCreate())
const dispatch = event({
version: 1,
operation: 'dispatch',
id: 'schedule-cron',
occurrenceAt: '2026-08-08T01:00:00.000Z',
acceptedAt: '2026-08-08T03:00:00.000Z',
nextScheduledAt: '2026-08-11T01:00:00.000Z',
}, 1)
expect(foldScheduleEvents([create, dispatch])).toEqual({
active: [{
...cronCreate().schedule,
scheduledAt: '2026-08-11T01:00:00.000Z',
}],
seenIds: ['schedule-cron'],
lastRecurringAcceptedAt: '2026-08-08T03:00:00.000Z',
})
expect(scheduleReminderPresentation([create, dispatch], 1)).toEqual({
scheduleId: 'schedule-cron',
prompt: 'daily review',
occurrenceAt: '2026-08-08T01:00:00.000Z',
deliveryMode: 'session-local',
})
})
it('terminates at exhaustion and rejects mismatched or non-monotonic dispatches', () => {
const create = event(cronCreate(), 0)
const terminal = event({
version: 1,
operation: 'dispatch',
id: 'schedule-cron',
occurrenceAt: '2026-08-08T01:00:00.000Z',
acceptedAt: '2026-08-08T03:00:00.000Z',
}, 1)
expect(foldScheduleEvents([create, terminal]).active).toEqual([])
expect(() => foldScheduleEvents([
create,
event({ version: 1, operation: 'dispatch', id: 'schedule-cron', acceptedAt: '2026-08-08T03:00:00.000Z' }, 1),
])).toThrow(/cron dispatch must contain occurrenceAt/)
expect(() => foldScheduleEvents([
create,
event({
version: 1,
operation: 'dispatch',
id: 'schedule-cron',
occurrenceAt: '2026-08-07T00:59:00.000Z',
acceptedAt: '2026-08-08T03:00:00.000Z',
}, 1),
])).toThrow(/monotonic progression/)
expect(() => foldScheduleEvents([
create,
event({
version: 1,
operation: 'dispatch',
id: 'schedule-cron',
occurrenceAt: '2026-08-08T01:00:00.000Z',
acceptedAt: '2026-08-08T03:00:00.000Z',
nextScheduledAt: '2026-08-08T03:00:00.000Z',
}, 1),
])).toThrow(/monotonic progression/)
const decoded = decodeScheduleChange(cronCreate())
if (decoded.operation !== 'create') throw new Error('expected decoded create')
const decodedRecord = decoded.schedule
if (decodedRecord.kind !== 'cron') throw new Error('expected decoded Cron record')
expect(() => resolveCronOccurrence(decodedRecord, Number.NaN)).toThrow(/acceptedAt/)
expect(() => resolveCronOccurrence(
decodedRecord,
Date.parse('2026-08-07T00:59:00.000Z'),
)).toThrow(/cannot precede/)
})
it('shares gate projection and exhaustion with Every records', () => {
const gateSource = {
version: 1,
operation: 'create',
schedule: {
id: 'schedule-gate',
kind: 'every',
prompt: 'gate',
everySeconds: 300,
scheduledAt: '2026-08-05T11:55:00.000Z',
},
}
const activeCron = cronCreate('schedule-cron', '2026-08-05T12:03:00.000Z', '3 12 * * *', 'UTC')
const folded = foldScheduleEvents([
event(gateSource, 0),
event({
version: 1,
operation: 'dispatch',
id: 'schedule-gate',
acceptedAt: '2026-08-05T12:00:00.000Z',
}, 1),
event({ version: 1, operation: 'delete', id: 'schedule-gate' }, 2),
event(activeCron, 3),
])
expect(scheduleView(
folded.active[0]!,
Date.parse('2026-08-05T12:03:00.000Z'),
folded.lastRecurringAcceptedAt,
)).toMatchObject({
kind: 'cron',
state: 'overdue',
deliveryNotBefore: '2026-08-05T12:05:00.000Z',
})
const exhausted = foldScheduleEvents([
event({
...gateSource,
schedule: { ...gateSource.schedule, scheduledAt: '9999-12-31T23:55:00.000Z' },
}, 0),
event(cronCreate(
'schedule-staggered-cron',
'9999-12-31T23:58:00.000Z',
'58 23 * * *',
'UTC',
), 1),
event({
version: 1,
operation: 'dispatch',
id: 'schedule-gate',
acceptedAt: '9999-12-31T23:57:30.000Z',
}, 2),
])
expect(exhausted.active).toEqual([])
})
it.each([
{ ...cronCreate(), schedule: { ...cronCreate().schedule, cron: '00 9 * * 1,2,3,4,5' } },
{ ...cronCreate(), schedule: { ...cronCreate().schedule, scheduledAt: '2026-08-07T01:00:01.000Z' } },
{ ...cronCreate(), schedule: { ...cronCreate().schedule, extra: true } },
{ ...cronCreate(), schedule: { ...cronCreate().schedule, prompt: '' } },
{ ...cronCreate(), schedule: { ...cronCreate().schedule, cron: 1 } },
{ ...cronCreate(), schedule: { ...cronCreate().schedule, timeZone: 1 } },
{ ...cronCreate(), schedule: { ...cronCreate().schedule, timeZone: 'CST' } },
{ ...cronCreate(), schedule: { ...cronCreate().schedule, cron: 'not cron' } },
{ ...cronCreate(), schedule: { ...cronCreate().schedule, kind: 'calendar' } },
])('rejects noncanonical durable Cron data %#', (data) => {
expect(() => decodeScheduleChange(data)).toThrow(ScheduleLogError)
})
it('replays structural Cron facts without current frequency or ICU canonicalization', () => {
expect(decodeScheduleChange(cronCreate(
'schedule-legacy-zone',
'2026-08-07T01:00:00.000Z',
'* * 31 2 *',
'Europe/Kyiv',
))).toMatchObject({
operation: 'create',
schedule: {
id: 'schedule-legacy-zone',
cron: '* * 31 2 *',
timeZone: 'Europe/Kyiv',
},
})
})
})

View File

@@ -8,6 +8,7 @@ import {
MIN_RECURRING_INTERVAL_SECONDS,
ScheduleId,
createAfterScheduleRecord,
createCronScheduleRecord,
createEveryScheduleRecord,
} from '../src/domain.ts'
import { MAX_TIMER_DELAY_MS, ScheduleOwner } from '../src/runtime.ts'
@@ -132,6 +133,17 @@ function appendEvery(
test.agent.session.append('schedule/change', { version: 1, operation: 'create', schedule: record })
}
function appendCron(
test: RuntimeHarness,
id: string,
cron: string,
createdAt: number,
prompt = 'calendar review',
): void {
const record = createCronScheduleRecord(ScheduleId(id), prompt, cron, 'UTC', createdAt)
test.agent.session.append('schedule/change', { version: 1, operation: 'create', schedule: record })
}
async function settle(): Promise<void> {
for (let index = 0; index < 8; index += 1) await Promise.resolve()
await vi.advanceTimersByTimeAsync(0)
@@ -313,6 +325,104 @@ describe('Schedule timer and admission runtime', () => {
await owner.dispose()
})
it('batches overdue Every and Cron records with independent durable dispatch shapes', async () => {
const test = await harness()
appendEvery(test, 'schedule-every', 300, Date.parse('2026-08-05T11:53:00.000Z'), 'fixed rate')
appendCron(
test,
'schedule-cron',
'0 12 * * *',
Date.parse('2026-08-04T12:01:00.000Z'),
'calendar rate',
)
const owner = ownerFor(test)
owner.start()
await settle()
expect(test.followed).toHaveLength(1)
const block = test.followed[0]?.content[0]
if (block?.type !== 'text') throw new Error('expected mixed recurring batch text')
expect(block.text).toContain('"schedule_id":"schedule-every"')
expect(block.text).toContain('"schedule_id":"schedule-cron"')
const dispatches = test.agent.session.events.filter(event =>
event.type === 'schedule/change' && event.data.operation === 'dispatch')
expect(dispatches.map(event => event.data)).toEqual([
{
version: 1,
operation: 'dispatch',
id: 'schedule-every',
acceptedAt: '2026-08-05T12:00:00.000Z',
},
{
version: 1,
operation: 'dispatch',
id: 'schedule-cron',
occurrenceAt: '2026-08-05T12:00:00.000Z',
acceptedAt: '2026-08-05T12:00:00.000Z',
nextScheduledAt: '2026-08-06T12:00:00.000Z',
},
])
await owner.dispose()
})
it('waits for the shared gate instead of a staggered future Cron target', async () => {
const test = await harness()
appendEvery(test, 'schedule-overdue', 300, Date.parse('2026-08-05T11:53:00.000Z'), 'overdue')
const owner = ownerFor(test)
owner.start()
await settle()
appendCron(
test,
'schedule-staggered-cron',
'4 12 * * *',
Date.parse('2026-08-05T11:59:00.000Z'),
'staggered cron',
)
owner.requestDrive()
await settle()
await vi.advanceTimersByTimeAsync(180_000)
await settle()
const flushesAtFirstDue = test.controls.flushCount
await vi.advanceTimersByTimeAsync(60_000)
await settle()
expect(test.controls.flushCount).toBe(flushesAtFirstDue)
await vi.advanceTimersByTimeAsync(60_000)
await settle()
expect(test.followed).toHaveLength(2)
const batch = test.followed[1]?.content[0]
if (batch?.type !== 'text') throw new Error('expected mixed gate batch')
expect(batch.text).toContain('"schedule_id":"schedule-overdue"')
expect(batch.text).toContain('"schedule_id":"schedule-staggered-cron"')
await owner.dispose()
})
it('omits Cron nextScheduledAt when the four-digit calendar is exhausted', async () => {
vi.setSystemTime(new Date('9999-12-31T23:59:00.000Z'))
const test = await harness()
appendCron(
test,
'schedule-final-cron',
'59 23 31 12 *',
Date.parse('9999-12-31T23:58:00.000Z'),
'final cron',
)
const owner = ownerFor(test)
owner.start()
await settle()
const dispatch = test.agent.session.events.find(event =>
event.type === 'schedule/change' && event.data.operation === 'dispatch')
expect(dispatch?.data).toEqual({
version: 1,
operation: 'dispatch',
id: 'schedule-final-cron',
occurrenceAt: '9999-12-31T23:59:00.000Z',
acceptedAt: '9999-12-31T23:59:00.000Z',
})
await owner.dispose()
})
it('restores the recurring gate while allowing an overdue one-shot to bypass it', async () => {
const test = await harness()
appendEvery(test, 'schedule-every', 300, Date.parse('2026-08-05T11:43:00.000Z'))

View File

@@ -173,12 +173,22 @@ describe('Schedule tool protocol', () => {
expect(value(await execute(test, 'schedule_create', { prompt: 'x', after_seconds: 1, at: 'later' })))
.toEqual({
code: 'invalid_selector',
message: 'schedule_create accepts exactly one of after_seconds, at, or every_seconds.',
message: 'schedule_create accepts exactly one of after_seconds, at, every_seconds, or cron with time_zone.',
})
expect(value(await execute(test, 'schedule_create', { prompt: 'x', every_seconds: 1.5 })))
.toEqual({ code: 'invalid_rule', message: 'every_seconds must be a safe integer.' })
expect(value(await execute(test, 'schedule_create', { prompt: 'x', every_seconds: 299 })))
.toEqual({ code: 'frequency_too_high', message: 'every_seconds must be at least 300.' })
for (const args of [
{ prompt: 'x', cron: '0 9 * * *' },
{ prompt: 'x', time_zone: 'UTC' },
{ prompt: 'x', every_seconds: 300, cron: '0 9 * * *', time_zone: 'UTC' },
]) {
expect(value(await execute(test, 'schedule_create', args))).toEqual({
code: 'invalid_selector',
message: 'schedule_create accepts exactly one of after_seconds, at, every_seconds, or cron with time_zone.',
})
}
expect(test.flushes.count).toBe(0)
expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([])
})
@@ -302,6 +312,62 @@ describe('Schedule tool protocol', () => {
expect(create?.data).not.toHaveProperty('anchorAt')
})
it('creates and lists a canonical explicit-zone Cron record', async () => {
const test = await harness()
expect(value(await execute(test, 'schedule_create', {
prompt: ' workday review ',
cron: '00 09 * * 1,2,3,4,5',
time_zone: 'US/Eastern',
}))).toEqual({
id: 'schedule-1',
kind: 'cron',
prompt: 'workday review',
cron: '0 9 * * 1,2,3,4,5',
timeZone: 'America/New_York',
scheduledAt: '2026-08-05T13:00:00.000Z',
state: 'scheduled',
deliveryMode: 'session-local',
})
expect(value(await execute(test, 'schedule_list', {}))).toEqual([
expect.objectContaining({
id: 'schedule-1',
kind: 'cron',
cron: '0 9 * * 1,2,3,4,5',
timeZone: 'America/New_York',
}),
])
})
it('rejects Cron creation after the shared gate exhausts despite a wall-clock rollback', async () => {
const test = await harness()
test.agent.session.append('schedule/change', {
version: 1,
operation: 'create',
schedule: {
id: 'schedule-final',
kind: 'every',
prompt: 'final batch',
everySeconds: 300,
scheduledAt: '9999-12-31T23:55:00.000Z',
},
} as never)
test.agent.session.append('schedule/change', {
version: 1,
operation: 'dispatch',
id: 'schedule-final',
acceptedAt: '9999-12-31T23:57:30.000Z',
} as never)
vi.setSystemTime(new Date('9999-12-31T23:50:00.000Z'))
expect(value(await execute(test, 'schedule_create', {
prompt: 'rolled back', cron: '55 23 * * *', time_zone: 'UTC',
}))).toEqual({
code: 'time_out_of_range',
message: 'No compliant recurring delivery time remains representable within the four-digit-year range.',
})
expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toHaveLength(2)
})
it('rejects Every creation after the shared gate exhausts despite a wall-clock rollback', async () => {
const test = await harness()
test.agent.session.append('schedule/change', {
@@ -554,6 +620,47 @@ describe('Schedule tool protocol', () => {
expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([])
})
it('returns stable Cron validation errors after persistence preflight', async () => {
const test = await harness()
expect(value(await execute(test, 'schedule_create', {
prompt: 'too frequent', cron: '*/4 * * * *', time_zone: 'UTC',
}))).toEqual({
code: 'frequency_too_high',
message: 'cron occurrences must be at least five minutes apart.',
})
expect(value(await execute(test, 'schedule_create', {
prompt: 'bad zone', cron: '0 9 * * *', time_zone: 'CST',
}))).toEqual({
code: 'invalid_time_zone',
message: 'time_zone must be UTC or a valid IANA Area/Location name.',
})
expect(value(await execute(test, 'schedule_create', {
prompt: 'bad weekday step', cron: '0 0 * * */8', time_zone: 'UTC',
}))).toEqual({
code: 'invalid_rule',
message: 'cron day-of-week has an unsupported value.',
})
expect(value(await execute(test, 'schedule_create', {
prompt: 'impossible', cron: '* * 31 2 *', time_zone: 'UTC',
}))).toEqual({
code: 'no_future_occurrence',
message: 'The cron rule has no future four-digit-year occurrence.',
})
expect(test.flushes.count).toBe(4)
expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([])
})
it('rejects an empty or padded delete id before persistence', async () => {
const test = await harness()
for (const id of ['', ' schedule-1']) {
expect(value(await execute(test, 'schedule_delete', { id }))).toEqual({
code: 'invalid_rule',
message: 'schedule_delete id must be non-empty without surrounding whitespace.',
})
}
expect(test.flushes.count).toBe(0)
})
it('returns a range error only after the create preflight', async () => {
const test = await harness()
expect(value(await execute(test, 'schedule_create', {