Ver código fonte

fix(schedule): close recurring gate edge cases

pku-xht 2 meses atrás
pai
commit
b96a9f2268

+ 7 - 2
packages/schedule/tool-schedule/src/domain.ts

@@ -38,6 +38,12 @@ const LOCAL_TIME = /^(?<hour>\d{2}):(?<minute>\d{2}):(?<second>\d{2})(?:\.(?<fra
 const IANA_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/
 const OFFSET_NAME = /^GMT(?:(?<sign>[+-])(?<hour>\d{2}):(?<minute>\d{2})(?::(?<second>\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)
     }

+ 8 - 0
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

+ 33 - 0
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)

+ 31 - 0
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', {