fix(schedule): close recurring gate edge cases
This commit is contained in:
@@ -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 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}))?)?$/
|
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. */
|
/** Error from malformed or transition-invalid durable Schedule data. */
|
||||||
export class ScheduleLogError extends Error {
|
export class ScheduleLogError extends Error {
|
||||||
/** Stable machine-readable error code. */
|
/** 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.
|
// A gate beyond the supported time profile can never admit another Every batch.
|
||||||
if (lastRecurringAcceptedAt !== undefined
|
if (isRecurringGateExhausted(lastRecurringAcceptedAt)) {
|
||||||
&& Date.parse(lastRecurringAcceptedAt) + MIN_RECURRING_INTERVAL_SECONDS * 1_000 > MAX_FOUR_DIGIT_YEAR_MS) {
|
|
||||||
for (const [id, record] of active) {
|
for (const [id, record] of active) {
|
||||||
if (record.kind === 'every') active.delete(id)
|
if (record.kind === 'every') active.delete(id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
createAtScheduleRecord,
|
createAtScheduleRecord,
|
||||||
createEveryScheduleRecord,
|
createEveryScheduleRecord,
|
||||||
foldScheduleEvents,
|
foldScheduleEvents,
|
||||||
|
isRecurringGateExhausted,
|
||||||
MIN_RECURRING_INTERVAL_SECONDS,
|
MIN_RECURRING_INTERVAL_SECONDS,
|
||||||
ScheduleId,
|
ScheduleId,
|
||||||
ScheduleInputError,
|
ScheduleInputError,
|
||||||
@@ -466,6 +467,13 @@ export function registerScheduleTools(
|
|||||||
notifyDurableChange()
|
notifyDurableChange()
|
||||||
const folded = foldForTool(agent)
|
const folded = foldForTool(agent)
|
||||||
if (isToolError(folded)) return folded
|
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)
|
const id = allocateScheduleId(folded)
|
||||||
let record: ScheduleRecord
|
let record: ScheduleRecord
|
||||||
let timeZone: AtTimeZoneContext | undefined
|
let timeZone: AtTimeZoneContext | undefined
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Agent, AgentCancelCause, InboxTarget } from '@deepseek-ai/dsh-agen
|
|||||||
import type { UserMessage } from '@deepseek-ai/dsh-llm'
|
import type { UserMessage } from '@deepseek-ai/dsh-llm'
|
||||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import {
|
import {
|
||||||
|
MIN_RECURRING_INTERVAL_SECONDS,
|
||||||
ScheduleId,
|
ScheduleId,
|
||||||
createAfterScheduleRecord,
|
createAfterScheduleRecord,
|
||||||
createEveryScheduleRecord,
|
createEveryScheduleRecord,
|
||||||
@@ -379,6 +380,38 @@ describe('Schedule timer and admission runtime', () => {
|
|||||||
await owner.dispose()
|
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 () => {
|
it('rechecks the wall clock after claiming maintenance before queuing', async () => {
|
||||||
const test = await harness()
|
const test = await harness()
|
||||||
appendAfter(test, 'schedule-1', 1, Date.now() - 1_000)
|
appendAfter(test, 'schedule-1', 1, Date.now() - 1_000)
|
||||||
|
|||||||
@@ -302,6 +302,37 @@ describe('Schedule tool protocol', () => {
|
|||||||
expect(create?.data).not.toHaveProperty('anchorAt')
|
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 () => {
|
it('fails closed when local at lacks confirmed request-zone context', async () => {
|
||||||
const test = await harness()
|
const test = await harness()
|
||||||
expect(value(await execute(test, 'schedule_create', {
|
expect(value(await execute(test, 'schedule_create', {
|
||||||
|
|||||||
Reference in New Issue
Block a user