refactor(schedule): bound fixed-rate reminders

This commit is contained in:
Tianyi Cui
2026-08-09 17:04:48 +08:00
parent 3fa4c012b1
commit 45ff1eab98
40 changed files with 982 additions and 3977 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
/**
* Agent-scoped durable one-shot, fixed-rate, and calendar reminders over the session event log.
* Agent-scoped durable one-shot and fixed-rate reminders over the session event log.
* @module @deepseek-ai/dsh-tool-schedule
*/
@@ -12,15 +12,19 @@ import { registerScheduleTools } from './tools.ts'
export type * from './types.ts'
export {
SCHEDULE_CHANGE_VERSION,
MIN_EVERY_INTERVAL_SECONDS,
ScheduleId,
ScheduleInputError,
ScheduleLogError,
allocateScheduleId,
createAfterScheduleRecord,
createAtScheduleRecord,
createEveryScheduleRecord,
decodeScheduleChange,
foldScheduleEvents,
renderReminderFraming,
renderEveryReminderBatchFraming,
resolveEveryOccurrence,
scheduleView,
} from './domain.ts'
export { registerScheduleTools } from './tools.ts'

View File

@@ -6,7 +6,7 @@
import type { Context } from 'cordis'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import { foldScheduleEvents, ScheduleLogError, validateLiveScheduleChange } from './domain.ts'
import { foldScheduleEvents, ScheduleLogError } from './domain.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-schedule'
@@ -15,22 +15,17 @@ export const name = 'tool-schedule-invariant'
/** Service required before reserving this package's invariant ownership. */
export const inject = ['invariants']
/** Convert an owned Schedule validation failure into the invariant service's failure channel. */
function report(run: () => void, fail: InvariantFailure): void {
/** Validate a complete exact-session stream under its fork suffix policy. */
function validate(events: readonly SessionEvent[], seedLength: number, fail: InvariantFailure): void {
try {
run()
foldScheduleEvents(events, seedLength)
} catch (error: unknown) {
/* v8 ignore next -- owned Schedule validators normalize failures to ScheduleLogError. */
/* v8 ignore next -- foldScheduleEvents normalizes every rejected stream to ScheduleLogError. */
if (!(error instanceof ScheduleLogError)) throw error
fail(error.message)
}
}
/** Validate a complete exact-session stream under its fork suffix policy. */
function validate(events: readonly SessionEvent[], seedLength: number, fail: InvariantFailure): void {
report(() => { foldScheduleEvents(events, seedLength) }, fail)
}
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
/** Install replay and pre-append validation for the owned event stream. */
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
@@ -45,9 +40,6 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
const [session, event] = args as [Session, SessionEvent]
if (event.type !== 'schedule/change') return
validate([...session.events, event], session.header.seedLength ?? 0, fail)
report(() => {
validateLiveScheduleChange(session.events, event.data, session.header.seedLength ?? 0)
}, fail)
}, { global: true })
}, { inject: ['sessions'] })
/* jscpd:ignore-end */

View File

@@ -6,16 +6,11 @@
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type {
OneShotScheduleRecord,
RecurringScheduleRecord,
} from './types.ts'
import type { EveryScheduleRecord, OneShotScheduleRecord } from './types.ts'
import {
foldScheduleEvents,
MIN_RECURRING_INTERVAL_SECONDS,
renderReminderBatchFraming,
renderEveryReminderBatchFraming,
renderReminderFraming,
resolveCronOccurrence,
resolveEveryOccurrence,
ScheduleLogError,
} from './domain.ts'
@@ -26,68 +21,50 @@ import { runScheduleTransaction } from './transaction.ts'
/** Largest delay that Node timers represent without clamping. */
export const MAX_TIMER_DELAY_MS = 2_147_483_647
interface RecurringDue {
readonly record: RecurringScheduleRecord
interface EveryDue {
readonly record: EveryScheduleRecord
readonly occurrenceAt: string
readonly nextScheduledAt?: string
}
type DueDecision =
| { readonly kind: 'one-shot'; readonly record: OneShotScheduleRecord }
| { readonly kind: 'recurring'; readonly reminders: readonly RecurringDue[]; readonly acceptedAt: string }
| { readonly kind: 'every'; readonly reminders: readonly EveryDue[]; readonly acceptedAt: string }
| { readonly kind: 'wait'; readonly target?: number }
/** Select one unblocked one-shot, one complete recurring batch, or the next wake. */
/** Select one due one-shot, one complete fixed-rate batch, or the next wake. */
function dueDecision(folded: FoldedSchedules, now: number): DueDecision {
const indexed = folded.active.map((record, index) => ({ record, index }))
const dueOneShots = indexed
const byTargetThenCreate = (
left: { readonly record: { readonly scheduledAt: string }; readonly index: number },
right: { readonly record: { readonly scheduledAt: string }; readonly index: number },
): number => Date.parse(left.record.scheduledAt) - Date.parse(right.record.scheduledAt)
|| left.index - right.index
const oneShot = indexed
.filter((entry): entry is { record: OneShotScheduleRecord; index: number } =>
entry.record.kind !== 'every' && entry.record.kind !== 'cron'
&& Date.parse(entry.record.scheduledAt) <= now)
.sort((left, right) =>
Date.parse(left.record.scheduledAt) - Date.parse(right.record.scheduledAt)
|| left.index - right.index)
const oneShot = dueOneShots[0]?.record
entry.record.kind !== 'every' && Date.parse(entry.record.scheduledAt) <= now)
.sort(byTargetThenCreate)[0]?.record
if (oneShot !== undefined) return { kind: 'one-shot', record: oneShot }
const recurring = indexed
.filter((entry): entry is { record: RecurringScheduleRecord; index: number } =>
(entry.record.kind === 'every' || entry.record.kind === 'cron')
&& Date.parse(entry.record.scheduledAt) <= now)
.sort((left, right) =>
Date.parse(left.record.scheduledAt) - Date.parse(right.record.scheduledAt)
|| left.index - right.index)
const gate = folded.lastRecurringAcceptedAt === undefined
? Number.NEGATIVE_INFINITY
: Date.parse(folded.lastRecurringAcceptedAt) + MIN_RECURRING_INTERVAL_SECONDS * 1_000
if (recurring.length > 0 && now >= gate) {
const every = indexed
.filter((entry): entry is { record: EveryScheduleRecord; index: number } =>
entry.record.kind === 'every' && Date.parse(entry.record.scheduledAt) <= now)
.sort(byTargetThenCreate)
if (every.length > 0) {
return {
kind: 'recurring',
kind: 'every',
acceptedAt: new Date(now).toISOString(),
reminders: recurring.map(({ record }) => {
const occurrence = record.kind === 'every'
? resolveEveryOccurrence(record, now)
: resolveCronOccurrence(record, now)
return {
record,
occurrenceAt: occurrence.occurrenceAt,
...(occurrence.nextScheduledAt === undefined
? {}
: { nextScheduledAt: occurrence.nextScheduledAt }),
}
}),
reminders: every.map(({ record }) => ({
record,
occurrenceAt: resolveEveryOccurrence(record, now).occurrenceAt,
})),
}
}
const future = folded.active
.filter(record => recurring.length === 0 || (record.kind !== 'every' && record.kind !== 'cron'))
.map(record => Date.parse(record.scheduledAt))
.filter(target => target > now)
if (recurring.length > 0) future.push(gate)
const target = future.reduce<number | undefined>(
(selected, candidate) => selected === undefined || candidate < selected ? candidate : selected,
undefined,
)
const target = folded.active.reduce<number | undefined>((selected, record) => {
const candidate = Date.parse(record.scheduledAt)
return candidate > now && (selected === undefined || candidate < selected) ? candidate : selected
}, undefined)
return { kind: 'wait', ...(target === undefined ? {} : { target }) }
}
@@ -185,6 +162,11 @@ export class ScheduleOwner {
&& this.ctx.agents.roots().includes(this.agent)
}
/** Whether this owner may start or continue Schedule work. */
private isRunnable(): boolean {
return !this.stopping && this.isLive()
}
/** Cancel the currently armed timer, if any. */
private clearTimer(): void {
if (this.timer === undefined) return
@@ -235,20 +217,20 @@ export class ScheduleOwner {
}
}
/** Contain a current calendar-resolution failure without permanently faulting this owner. */
/** Contain an invalid wall-clock decision without permanently faulting this owner. */
private decide(folded: FoldedSchedules, now: number): DueDecision | undefined {
try {
return dueDecision(folded, now)
} catch (error: unknown) {
this.ctx.logger.warn(`tool-schedule: calendar decision failed for agent "${this.agent.id}": ${renderThrown(error)}`)
this.ctx.logger.warn(`tool-schedule: fixed-rate decision failed for agent "${this.agent.id}": ${renderThrown(error)}`)
return undefined
}
}
/** Preflight, fold, arm, or dispatch the next one-shot or recurring batch. */
/** Preflight, fold, arm, or dispatch the next one-shot or fixed-rate batch. */
private async driveOnce(): Promise<void> {
this.clearTimer()
if (this.stopping || !this.isLive()) return
if (!this.isRunnable()) return
try {
await flushSchedulePersistence(this.ctx, this.agent.session)
} catch (error: unknown) {
@@ -257,8 +239,7 @@ export class ScheduleOwner {
}
return
}
// oxlint-disable-next-line typescript/no-unnecessary-condition -- disposal or replacement can win while persistence is awaited.
if (this.stopping || !this.isLive()) return
if (!this.isRunnable()) return
const folded = this.readFolded()
if (folded === undefined) return
@@ -273,7 +254,7 @@ export class ScheduleOwner {
let maintenance: Promise<boolean>
try {
maintenance = this.agent.runMaintenance(() => {
if (this.stopping || !this.isLive()) return Promise.resolve(false)
if (!this.isRunnable()) return Promise.resolve(false)
const claimed = this.readFolded()
if (claimed === undefined) return Promise.resolve(false)
const decisionNow = Date.now()
@@ -286,7 +267,7 @@ export class ScheduleOwner {
try {
const text = decision.kind === 'one-shot'
? renderReminderFraming(decision.record)
: renderReminderBatchFraming(decision.reminders)
: renderEveryReminderBatchFraming(decision.reminders)
const message = createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'tool-schedule' },
@@ -307,25 +288,12 @@ export class ScheduleOwner {
})
} else {
for (const reminder of decision.reminders) {
if (reminder.record.kind === 'every') {
this.agent.session.append('schedule/change', {
version: 1,
operation: 'dispatch',
id: reminder.record.id,
acceptedAt: decision.acceptedAt,
})
} else {
this.agent.session.append('schedule/change', {
version: 1,
operation: 'dispatch',
id: reminder.record.id,
occurrenceAt: reminder.occurrenceAt,
acceptedAt: decision.acceptedAt,
...(reminder.nextScheduledAt === undefined
? {}
: { nextScheduledAt: reminder.nextScheduledAt }),
})
}
this.agent.session.append('schedule/change', {
version: 1,
operation: 'dispatch',
id: reminder.record.id,
acceptedAt: decision.acceptedAt,
})
}
}
} catch (error: unknown) {
@@ -351,7 +319,6 @@ export class ScheduleOwner {
}
return
}
// oxlint-disable-next-line typescript/no-unnecessary-condition -- disposal can win while the barrier is awaited.
if (!this.stopping && this.isLive()) this.requestDrive()
if (this.isRunnable()) this.requestDrive()
}
}

View File

@@ -6,19 +6,15 @@
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { deriveClientTimeZoneContext } from '@deepseek-ai/dsh-time-context'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import {
allocateScheduleId,
createAfterScheduleRecord,
createAtScheduleRecord,
createCronScheduleRecord,
createEveryScheduleRecord,
foldScheduleEvents,
isRecurringGateExhausted,
MIN_RECURRING_INTERVAL_SECONDS,
MIN_EVERY_INTERVAL_SECONDS,
ScheduleId,
ScheduleInputError,
ScheduleLogError,
@@ -73,25 +69,10 @@ const EVERY_VIEW_SCHEMA = {
...SHARED_VIEW_PROPERTIES,
kind: { type: 'string', required: true, const: 'every' },
everySeconds: { type: 'integer', required: true },
deliveryNotBefore: { type: 'string' },
},
} as const
const CRON_VIEW_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
...SHARED_VIEW_PROPERTIES,
kind: { type: 'string', required: true, const: 'cron' },
cron: { type: 'string', required: true },
timeZone: { type: 'string', required: true },
deliveryNotBefore: { type: 'string' },
},
} as const
const VIEW_SCHEMA = {
oneOf: [AFTER_VIEW_SCHEMA, AT_VIEW_SCHEMA, EVERY_VIEW_SCHEMA, CRON_VIEW_SCHEMA],
} as const
const VIEW_SCHEMA = { oneOf: [AFTER_VIEW_SCHEMA, AT_VIEW_SCHEMA, EVERY_VIEW_SCHEMA] } as const
/** Build one exact two-field error schema while preserving its literal code. */
function basicErrorSchema<const C extends string>(code: C) {
@@ -113,22 +94,10 @@ const BASIC_ERROR_SCHEMAS = [
basicErrorSchema('not_future'),
basicErrorSchema('time_out_of_range'),
basicErrorSchema('frequency_too_high'),
basicErrorSchema('no_future_occurrence'),
basicErrorSchema('corrupt_schedule_log'),
basicErrorSchema('internal_error'),
] as const
const TIME_ZONE_CONFIRMATION_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
code: { type: 'string', required: true, const: 'timezone_confirmation_required' },
message: { type: 'string', required: true },
sessionTimeZone: { type: 'string', required: true },
clientTimeZones: { type: 'array', required: true, items: { type: 'string' } },
},
} as const
const PERSISTENCE_ERROR_SCHEMA = {
type: 'object',
additionalProperties: false,
@@ -142,7 +111,6 @@ const PERSISTENCE_ERROR_SCHEMA = {
const ERROR_SCHEMAS = [
...BASIC_ERROR_SCHEMAS,
TIME_ZONE_CONFIRMATION_SCHEMA,
PERSISTENCE_ERROR_SCHEMA,
] as const
@@ -179,8 +147,9 @@ const DELETE_OUTPUT_SCHEMA = {
const CREATE_DESCRIPTION =
'Create one reminder in the current session. Supply a non-empty prompt and exactly one selector: '
+ 'a positive safe-integer after_seconds delay, at as a strict offset date-time or local '
+ `date/time object, safe-integer every_seconds of at least ${MIN_RECURRING_INTERVAL_SECONDS}, `
+ 'or a restricted five-field cron paired with an explicit IANA time_zone. '
+ `date/time object, or safe-integer every_seconds of at least ${MIN_EVERY_INTERVAL_SECONDS}. `
+ 'Fixed-rate reminders stay creation-aligned, skip missed occurrences, and batch one latest '
+ 'occurrence per overdue rule. '
+ 'Delivery is session-local: the reminder runs on time only while this session '
+ 'is live and otherwise becomes overdue until the session is resumed.'
@@ -192,14 +161,6 @@ const DELETE_DESCRIPTION =
'Delete one active reminder in the current session by the exact id returned by schedule_create '
+ 'or schedule_list. Unknown or already-finished ids return deleted false.'
const CRON_DESCRIPTION =
'Five numeric fields in order: minute 0-59, hour 0-23, day-of-month 1-31, month 1-12, '
+ 'day-of-week 0-7 (0 and 7 are Sunday). Each field is *, one integer, a strictly increasing '
+ 'integer list, an increasing a-b range, */s, or a-b/s. Day-of-month or day-of-week must be *. '
+ 'Steps are positive and at most the field cardinality (7 for day-of-week). Names, macros, '
+ 'seconds, years, ?, L, W, and # are unsupported; nominal matches must be at '
+ 'least five minutes apart. Requires time_zone.'
/** Deterministic model content for every canonical Schedule value. */
function renderValue(_args: unknown, value: unknown): ContentBlock[] {
// The ToolRegistry has already validated the value against the lossless-JSON output schema.
@@ -252,103 +213,8 @@ function persistenceError(
}
}
/** Request-local zone evidence returned with an implicit-local confirmation failure. */
interface AtTimeZoneContext {
readonly implicitTimeZone?: string
readonly sessionTimeZone: string
readonly clientTimeZones: string[]
}
/** Whether one durable message is the exact time-context snapshot marker. */
function isTimeContextReading(event: SessionEvent): boolean {
if (event.type !== 'user/message') return false
const source = event.data.source
if (source.kind !== 'plugin'
|| source.plugin !== 'time-context'
|| Object.keys(source).length !== 4
|| source.form !== 'snapshot') return false
const blockValue: unknown = event.data.content[0]
const block = typeof blockValue === 'object' && blockValue !== null
? blockValue as Record<string, unknown>
: undefined
const sections: unknown = source.sections
const sectionValue: unknown = Array.isArray(sections) ? sections[0] : undefined
const section = typeof sectionValue === 'object' && sectionValue !== null
? sectionValue as Record<string, unknown>
: undefined
return event.data.content.length === 1
&& block !== undefined
&& Object.keys(block).length === 2
&& block.type === 'text'
&& typeof block.text === 'string'
&& Array.isArray(sections)
&& sections.length === 1
&& section !== undefined
&& Object.keys(section).length === 2
&& section.name === 'time-context'
&& section.text === block.text
}
/** Derive request zones only while the current open turn contains a time-context reading. */
function currentClientTimeZoneContext(agent: Agent): ReturnType<typeof deriveClientTimeZoneContext> | undefined {
const events = agent.session.events
let stepStart = -1
let turn = 0
for (let index = events.length - 1; index >= 0; index--) {
const event = events[index]
/* v8 ignore next -- the loop bounds index to the dense Session event array. */
if (event === undefined) continue
if (event.type === 'step/end' || event.type === 'turn/end') return undefined
if (event.type === 'step/start') {
stepStart = index
turn = event.data.turn
break
}
}
if (stepStart < 0) return undefined
const turnStart = events.findLastIndex(event => event.type === 'turn/start' && event.data.turn === turn)
if (turnStart < 0) return undefined
const hasReading = events.slice(turnStart + 1).some(isTimeContextReading)
if (!hasReading) return undefined
const messages = events.slice(turnStart + 1)
.flatMap(event => event.type === 'user/message' ? [event.data] : [])
return deriveClientTimeZoneContext(messages)
}
/** Resolve the only request state that may supply an omitted local time zone. */
function atTimeZoneContext(agent: Agent): AtTimeZoneContext {
const sessionTimeZone = agent.session.header.timeZone ?? 'unavailable'
const client = currentClientTimeZoneContext(agent)
const clientTimeZones = client === undefined || client.kind === 'missing'
? []
: client.kind === 'resolved'
? [client.timeZone]
: [...client.timeZones]
const implicitTimeZone = sessionTimeZone !== 'unavailable'
&& client?.kind === 'resolved'
&& client.timeZone === sessionTimeZone
? sessionTimeZone
: undefined
return {
...(implicitTimeZone === undefined ? {} : { implicitTimeZone }),
sessionTimeZone,
clientTimeZones,
}
}
/** Translate one contained input failure to the closed tool union. */
function inputError(error: ScheduleInputError, timeZone?: AtTimeZoneContext): ScheduleToolError {
if (error.code === 'timezone_confirmation_required') {
// The domain emits this code only for the omitted-zone local-at arm,
// whose request context is computed immediately before decoding.
const requestTimeZone = timeZone as AtTimeZoneContext
return {
code: error.code,
message: error.message,
sessionTimeZone: requestTimeZone.sessionTimeZone,
clientTimeZones: requestTimeZone.clientTimeZones,
}
}
function inputError(error: ScheduleInputError): ScheduleToolError {
return { code: error.code, message: error.message }
}
@@ -389,25 +255,18 @@ function validateCreateArgs(args: {
after_seconds?: number
at?: AtInput
every_seconds?: number
cron?: string
time_zone?: string
}): ScheduleToolError | undefined {
const keys = Object.keys(args as unknown as Record<string, unknown>)
const hasCronSelector = args.cron !== undefined || args.time_zone !== undefined
if (keys.some(key => key !== 'prompt'
&& key !== 'after_seconds'
&& key !== 'at'
&& key !== 'every_seconds'
&& key !== 'cron'
&& key !== 'time_zone')
&& key !== 'every_seconds')
|| Number(args.after_seconds !== undefined)
+ Number(args.at !== undefined)
+ Number(args.every_seconds !== undefined)
+ Number(hasCronSelector) !== 1
|| (hasCronSelector && (args.cron === undefined || args.time_zone === undefined))) {
+ Number(args.every_seconds !== undefined) !== 1) {
return {
code: 'invalid_selector',
message: 'schedule_create accepts exactly one of after_seconds, at, every_seconds, or cron with time_zone.',
message: 'schedule_create accepts exactly one of after_seconds, at, or every_seconds.',
}
}
if (args.prompt.trim().length === 0) {
@@ -420,10 +279,10 @@ function validateCreateArgs(args: {
if (args.every_seconds !== undefined && !Number.isSafeInteger(args.every_seconds)) {
return { code: 'invalid_rule', message: 'every_seconds must be a safe integer.' }
}
if (args.every_seconds !== undefined && args.every_seconds < MIN_RECURRING_INTERVAL_SECONDS) {
if (args.every_seconds !== undefined && args.every_seconds < MIN_EVERY_INTERVAL_SECONDS) {
return {
code: 'frequency_too_high',
message: `every_seconds must be at least ${MIN_RECURRING_INTERVAL_SECONDS}.`,
message: `every_seconds must be at least ${MIN_EVERY_INTERVAL_SECONDS}.`,
}
}
return undefined
@@ -470,18 +329,10 @@ export function registerScheduleTools(
},
every_seconds: {
type: 'number',
description: `Fixed-rate safe-integer interval in seconds, at least ${MIN_RECURRING_INTERVAL_SECONDS}.`,
},
cron: {
type: 'string',
description: CRON_DESCRIPTION,
},
time_zone: {
type: 'string',
description: 'Explicit UTC or IANA Area/Location for cron evaluation.',
description: `Fixed-rate safe-integer interval in seconds, at least ${MIN_EVERY_INTERVAL_SECONDS}.`,
},
at: {
description: 'Absolute target as strict offset RFC 3339 or local date/time with optional IANA zone.',
description: 'Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone.',
oneOf: [
{ type: 'string' },
{
@@ -490,7 +341,7 @@ export function registerScheduleTools(
properties: {
date: { type: 'string', required: true },
time: { type: 'string', required: true },
time_zone: { type: 'string' },
time_zone: { type: 'string', required: true },
},
},
],
@@ -507,49 +358,23 @@ export function registerScheduleTools(
notifyDurableChange()
const folded = foldForTool(agent)
if (isToolError(folded)) return folded
if ((args.every_seconds !== undefined || args.cron !== undefined)
&& isRecurringGateExhausted(folded.lastRecurringAcceptedAt)) {
return {
code: 'time_out_of_range',
message: 'No compliant recurring delivery time remains representable within the four-digit-year range.',
}
}
const id = allocateScheduleId(folded)
let record: ScheduleRecord
let timeZone: AtTimeZoneContext | undefined
try {
if (args.at !== undefined) {
const at = args.at
timeZone = typeof at === 'string' || at.time_zone !== undefined
? undefined
: atTimeZoneContext(agent)
record = createAtScheduleRecord(
id,
args.prompt,
at,
Date.now(),
timeZone?.implicitTimeZone,
)
record = createAtScheduleRecord(id, args.prompt, args.at, Date.now())
} else if (args.after_seconds !== undefined) {
record = createAfterScheduleRecord(id, args.prompt, args.after_seconds, Date.now())
} else if (args.every_seconds !== undefined) {
} else {
record = createEveryScheduleRecord(
id,
args.prompt,
args.every_seconds,
Date.now(),
)
} else {
record = createCronScheduleRecord(
id,
args.prompt,
args.cron as string,
args.time_zone as string,
args.every_seconds as number,
Date.now(),
)
}
} catch (error: unknown) {
return error instanceof ScheduleInputError ? inputError(error, timeZone) : internalError()
return error instanceof ScheduleInputError ? inputError(error) : internalError()
}
const cancelledBeforeAppend = cancellationPlaceholder(exec.signal)
if (cancelledBeforeAppend !== undefined) return cancelledBeforeAppend
@@ -565,7 +390,7 @@ export function registerScheduleTools(
const barrier = await preflight(rootCtx, agent, 'create', id)
if (barrier !== undefined) return barrier
notifyDurableChange()
return scheduleView(record, Date.now(), folded.lastRecurringAcceptedAt)
return scheduleView(record, Date.now())
})
},
presentCall: args => present('Create reminder', 'other', args.prompt),
@@ -585,7 +410,7 @@ export function registerScheduleTools(
const folded = foldForTool(agent)
if (isToolError(folded)) return folded
const now = Date.now()
return folded.active.map(record => scheduleView(record, now, folded.lastRecurringAcceptedAt))
return folded.active.map(record => scheduleView(record, now))
})
},
presentCall: () => present('List reminders', 'read'),

View File

@@ -35,7 +35,7 @@ export interface AtScheduleRecord {
readonly scheduledAt: string
}
/** Durable fixed-rate reminder whose next target remains anchor-aligned. */
/** Durable fixed-rate reminder whose next target remains creation-anchor-aligned. */
export interface EveryScheduleRecord {
/** Session-local stable identity. */
readonly id: ScheduleId
@@ -45,23 +45,7 @@ export interface EveryScheduleRecord {
readonly prompt: string
/** Fixed safe-integer interval, never below five minutes. */
readonly everySeconds: number
/** Earliest anchor-aligned occurrence not yet accepted. */
readonly scheduledAt: string
}
/** Durable calendar reminder evaluated in one explicit IANA time zone. */
export interface CronScheduleRecord {
/** Session-local stable identity. */
readonly id: ScheduleId
/** Rule discriminator for a calendar recurring reminder. */
readonly kind: 'cron'
/** Trimmed user-authored reminder content. */
readonly prompt: string
/** Canonical restricted five-field cron expression. */
readonly cron: string
/** Canonical IANA time-zone name used for future evaluation. */
readonly timeZone: string
/** Earliest calendar occurrence not yet accepted. */
/** Earliest anchor-aligned occurrence not yet dispatched. */
readonly scheduledAt: string
}
@@ -81,11 +65,8 @@ export type AtInput = string | LocalAtInput
/** One-shot record variants that terminate on an id-only dispatch. */
export type OneShotScheduleRecord = AfterScheduleRecord | AtScheduleRecord
/** Recurring record variants that share one model-turn gate. */
export type RecurringScheduleRecord = EveryScheduleRecord | CronScheduleRecord
/** The v1 durable reminder record union. */
export type ScheduleRecord = OneShotScheduleRecord | RecurringScheduleRecord
export type ScheduleRecord = OneShotScheduleRecord | EveryScheduleRecord
/** Creates one durable reminder record. */
export interface ScheduleCreateChange {
@@ -108,33 +89,17 @@ export interface OneShotScheduleDispatchChange {
readonly id: ScheduleId
}
/** Records one fixed-rate batch decision without copying its derived occurrence or next target. */
/** Records one fixed-rate decision and advances directly past missed occurrences. */
export interface EveryScheduleDispatchChange {
readonly version: 1
readonly operation: 'dispatch'
readonly id: ScheduleId
/** Shared recurring-batch decision time as canonical UTC. */
/** Wall-clock decision time used to select the latest due occurrence. */
readonly acceptedAt: string
}
/** Freezes one calendar decision against the live evaluator and tzdata. */
export interface CronScheduleDispatchChange {
readonly version: 1
readonly operation: 'dispatch'
readonly id: ScheduleId
/** Latest accepted calendar occurrence as canonical UTC. */
readonly occurrenceAt: string
/** Shared recurring-batch decision time as canonical UTC. */
readonly acceptedAt: string
/** First future calendar occurrence, omitted only at four-digit-year exhaustion. */
readonly nextScheduledAt?: string
}
/** Durable dispatch shapes supported by the current rule set. */
export type ScheduleDispatchChange =
| OneShotScheduleDispatchChange
| EveryScheduleDispatchChange
| CronScheduleDispatchChange
export type ScheduleDispatchChange = OneShotScheduleDispatchChange | EveryScheduleDispatchChange
/** Strict version-1 durable Schedule mutation union. */
export type ScheduleChange = ScheduleCreateChange | ScheduleDeleteChange | ScheduleDispatchChange
@@ -151,18 +116,6 @@ export type ScheduleView = ScheduleRecord & {
readonly state: ScheduleState
/** Reminder delivery never leaves the owning session. */
readonly deliveryMode: ScheduleDeliveryMode
/** Earliest recurring batch admission while an overdue record is gate-blocked. */
readonly deliveryNotBefore?: string
}
/** JSON-compatible Web receipt derived from one durable dispatch. */
export interface ScheduleReminderPresentation {
/** Session-local reminder identity. */
readonly scheduleId: ScheduleId
/** Original user-authored reminder content. */
readonly prompt: string
/** Scheduled occurrence represented by the dispatch. */
readonly occurrenceAt: string
}
/** Management operations whose persistence barrier may be uncertain. */
@@ -204,18 +157,12 @@ export interface TimeOutOfRangeError {
readonly message: string
}
/** Stable error returned when a recurring rule exceeds the fixed model-turn frequency. */
/** Stable error returned when a fixed-rate rule runs more often than supported. */
export interface FrequencyTooHighError {
readonly code: 'frequency_too_high'
readonly message: string
}
/** Stable error returned when a recurring rule has no representable future occurrence. */
export interface NoFutureOccurrenceError {
readonly code: 'no_future_occurrence'
readonly message: string
}
/** Stable error returned when the durable Schedule stream is malformed. */
export interface CorruptScheduleLogError {
readonly code: 'corrupt_schedule_log'
@@ -245,7 +192,6 @@ export type ScheduleToolError =
| NotFutureError
| TimeOutOfRangeError
| FrequencyTooHighError
| NoFutureOccurrenceError
| CorruptScheduleLogError
| PersistenceUncertainError
| InternalScheduleError