From b96a9f226829b84b680dcfe188c17d8d7b695565 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 6 Aug 2026 22:23:42 +0800 Subject: [PATCH] fix(schedule): close recurring gate edge cases --- packages/schedule/tool-schedule/src/domain.ts | 9 +++-- packages/schedule/tool-schedule/src/tools.ts | 8 +++++ .../tool-schedule/tests/runtime.spec.ts | 33 +++++++++++++++++++ .../tool-schedule/tests/tools.spec.ts | 31 +++++++++++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/packages/schedule/tool-schedule/src/domain.ts b/packages/schedule/tool-schedule/src/domain.ts index 2a83f57025..a36dd6a220 100644 --- a/packages/schedule/tool-schedule/src/domain.ts +++ b/packages/schedule/tool-schedule/src/domain.ts @@ -38,6 +38,12 @@ const LOCAL_TIME = /^(?\d{2}):(?\d{2}):(?\d{2})(?:\.(?[+-])(?\d{2}):(?\d{2})(?::(?\d{2}))?)?$/ +/** Whether the durable recurring gate has no four-digit-year admission left. */ +export function isRecurringGateExhausted(lastAcceptedAt: string | undefined): boolean { + return lastAcceptedAt !== undefined + && Date.parse(lastAcceptedAt) + MIN_RECURRING_INTERVAL_SECONDS * 1_000 > MAX_FOUR_DIGIT_YEAR_MS +} + /** Error from malformed or transition-invalid durable Schedule data. */ export class ScheduleLogError extends Error { /** Stable machine-readable error code. */ @@ -642,8 +648,7 @@ export function foldScheduleEvents( } } // A gate beyond the supported time profile can never admit another Every batch. - if (lastRecurringAcceptedAt !== undefined - && Date.parse(lastRecurringAcceptedAt) + MIN_RECURRING_INTERVAL_SECONDS * 1_000 > MAX_FOUR_DIGIT_YEAR_MS) { + if (isRecurringGateExhausted(lastRecurringAcceptedAt)) { for (const [id, record] of active) { if (record.kind === 'every') active.delete(id) } diff --git a/packages/schedule/tool-schedule/src/tools.ts b/packages/schedule/tool-schedule/src/tools.ts index 58b52e84e4..29eab877cd 100644 --- a/packages/schedule/tool-schedule/src/tools.ts +++ b/packages/schedule/tool-schedule/src/tools.ts @@ -16,6 +16,7 @@ import { createAtScheduleRecord, createEveryScheduleRecord, foldScheduleEvents, + isRecurringGateExhausted, MIN_RECURRING_INTERVAL_SECONDS, ScheduleId, ScheduleInputError, @@ -466,6 +467,13 @@ export function registerScheduleTools( notifyDurableChange() const folded = foldForTool(agent) if (isToolError(folded)) return folded + if (args.every_seconds !== undefined + && isRecurringGateExhausted(folded.lastRecurringAcceptedAt)) { + return { + code: 'time_out_of_range', + message: 'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.', + } + } const id = allocateScheduleId(folded) let record: ScheduleRecord let timeZone: AtTimeZoneContext | undefined diff --git a/packages/schedule/tool-schedule/tests/runtime.spec.ts b/packages/schedule/tool-schedule/tests/runtime.spec.ts index 1598b8e569..a666c3db89 100644 --- a/packages/schedule/tool-schedule/tests/runtime.spec.ts +++ b/packages/schedule/tool-schedule/tests/runtime.spec.ts @@ -5,6 +5,7 @@ import type { Agent, AgentCancelCause, InboxTarget } from '@deepseek-ai/dsh-agen import type { UserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { + MIN_RECURRING_INTERVAL_SECONDS, ScheduleId, createAfterScheduleRecord, createEveryScheduleRecord, @@ -379,6 +380,38 @@ describe('Schedule timer and admission runtime', () => { await owner.dispose() }) + it('derives the 288-batch half-open-day bound from production gate spacing', async () => { + const test = await harness() + appendEvery( + test, + 'schedule-budget', + MIN_RECURRING_INTERVAL_SECONDS, + Date.now() - MIN_RECURRING_INTERVAL_SECONDS * 1_000, + 'budget', + ) + const owner = ownerFor(test) + owner.start() + await settle() + + const spacing = MIN_RECURRING_INTERVAL_SECONDS * 1_000 + for (let index = 1; index <= 288; index += 1) { + await vi.advanceTimersByTimeAsync(spacing) + await settle() + } + const accepted = test.agent.session.events.flatMap((event) => { + if (event.type !== 'schedule/change' || event.data.operation !== 'dispatch' + || !('acceptedAt' in event.data)) return [] + return [Date.parse(event.data.acceptedAt)] + }) + expect(accepted).toHaveLength(289) + const windowStart = accepted[0]! + const windowEnd = windowStart + 86_400_000 + expect(accepted.slice(0, 288).every(value => value >= windowStart && value < windowEnd)).toBe(true) + expect(accepted[288]).toBe(windowEnd) + expect(accepted.every((value, index) => index === 0 || value - accepted[index - 1]! === spacing)).toBe(true) + await owner.dispose() + }) + it('rechecks the wall clock after claiming maintenance before queuing', async () => { const test = await harness() appendAfter(test, 'schedule-1', 1, Date.now() - 1_000) diff --git a/packages/schedule/tool-schedule/tests/tools.spec.ts b/packages/schedule/tool-schedule/tests/tools.spec.ts index eb65aa07de..61608f0da0 100644 --- a/packages/schedule/tool-schedule/tests/tools.spec.ts +++ b/packages/schedule/tool-schedule/tests/tools.spec.ts @@ -302,6 +302,37 @@ describe('Schedule tool protocol', () => { expect(create?.data).not.toHaveProperty('anchorAt') }) + 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', { + 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', every_seconds: 300, + }))).toEqual({ + code: 'time_out_of_range', + message: 'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.', + }) + expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toHaveLength(2) + expect(value(await execute(test, 'schedule_list', {}))).toEqual([]) + }) + it('fails closed when local at lacks confirmed request-zone context', async () => { const test = await harness() expect(value(await execute(test, 'schedule_create', {