diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md index cbbd1950c3..31a06a4790 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md @@ -56,7 +56,7 @@ Schedule owns a numeric five-field parser rather than exposing Croner's language The frequency proof enumerates the complete 400-year Gregorian date cycle and combines it with exact times-of-day. It checks same-day neighbors, cross-midnight neighbors, and the cycle seam, rejecting any nominal interval below five minutes without maintaining a quota or sampling a shorter window. -The exact production dependency is `croner@10.0.1`, an MIT-licensed ESM package with no transitive dependencies. Schedule gives it hidden seconds=`0` and year=`1-9999`, constructs it paused without a callback, and retains timer, gate, admission, and persistence ownership. The adapter rejects gap-normalized candidates, chooses the first instant in an overlap, and requires strict forward/backward cursor movement. JavaScript constructors remap years 0–99, so an owned local-calendar walker handles that lower range and its transition before safe-year searches delegate to Croner. Live create and due handling use current Croner and ICU; replay only checks canonical rule/zone shapes, whole-minute four-digit UTC instants, and `currentScheduledAt <= occurrenceAt <= acceptedAt < nextScheduledAt`, so tzdata changes never invalidate a committed history. +The exact production dependency is `croner@10.0.1`, an MIT-licensed ESM package with no transitive dependencies. Schedule gives it hidden seconds=`0` and year=`1-9999`, constructs it paused without a callback, and retains timer, gate, admission, and persistence ownership. The adapter rejects gap-normalized candidates, chooses the first instant in an overlap, and requires strict forward/backward cursor movement. JavaScript constructors remap years 0–99, so an owned local-calendar walker handles that lower range and its transition before safe-year searches delegate to Croner. Live create and due handling, including the pre-append package invariant, use current Croner and ICU; replay only checks canonical rule/zone shapes, whole-minute four-digit UTC instants, and `currentScheduledAt <= occurrenceAt <= acceptedAt < nextScheduledAt`, so tzdata changes never invalidate a committed history. ### Persistence checkpoint and initialization recovery diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md index 5c866af86a..73709865f2 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md @@ -56,7 +56,7 @@ Schedule 拥有自己的数值五字段 parser,而不开放 Croner 语言。 频率证明会枚举完整的 400 年 Gregorian 日期周期,并与精确的一日内时刻组合。它会检查同日相邻时点、跨午夜相邻时点与周期首尾衔接处的相邻时点,拒绝任何短于 5 分钟的名义间隔;整个过程既不维护配额,也不对更短窗口采样。 -生产环境精确锁定的依赖是 `croner@10.0.1`:这是一个采用 MIT 许可证、不含传递依赖的 ESM 包。Schedule 为其提供隐藏的 seconds=`0` 与 year=`1-9999`,以 paused 状态且不带 callback 构造;timer、门控、准入与持久化仍由 Schedule 拥有。适配器会拒绝由夏令时空档规范化产生的候选值,在重叠时段选择第一个时刻,并要求正向与反向 cursor 严格移动。JavaScript 构造器会重映射 0–99 年,因此 Schedule 自有的本地日历搜索会处理这一低年份范围及其向安全年份的过渡;只有安全年份搜索才会委托给 Croner。live create 与到期处理使用当前 Croner 和 ICU;回放只检查规范化的规则/时区 shape、整分钟且年份为四位数的 UTC 时点,以及 `currentScheduledAt <= occurrenceAt <= acceptedAt < nextScheduledAt`,因此 tzdata 变化绝不会使已提交的 history 失效。 +生产环境精确锁定的依赖是 `croner@10.0.1`:这是一个采用 MIT 许可证、不含传递依赖的 ESM 包。Schedule 为其提供隐藏的 seconds=`0` 与 year=`1-9999`,以 paused 状态且不带 callback 构造;timer、门控、准入与持久化仍由 Schedule 拥有。适配器会拒绝由夏令时空档规范化产生的候选值,在重叠时段选择第一个时刻,并要求正向与反向 cursor 严格移动。JavaScript 构造器会重映射 0–99 年,因此 Schedule 自有的本地日历搜索会处理这一低年份范围及其向安全年份的过渡;只有安全年份搜索才会委托给 Croner。live create 与到期处理(包括 append 前的 package invariant)使用当前 Croner 和 ICU;回放只检查规范化的规则/时区 shape、整分钟且年份为四位数的 UTC 时点,以及 `currentScheduledAt <= occurrenceAt <= acceptedAt < nextScheduledAt`,因此 tzdata 变化绝不会使已提交的 history 失效。 ### Persistence checkpoint 与初始化恢复 diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index f32daebfe4..8d9fc8576c 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -27,6 +27,9 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts' const DEVELOPMENT_PROMPT = fileURLToPath(new URL('./snapshots/web-runtime-context/development-prompt.expected.md', import.meta.url)) +const WEB_TIME_ZONE = 'UTC' +const SCHEDULE_OVERLAY = fileURLToPath(new URL('../../../examples/web-schedule/cordis.yml', import.meta.url)) +const REAL_SCHEDULE_PROMPT = 'REAL_MODEL_SCHEDULE_PROBE' function waitForReadyLine(child: ChildProcess): Promise { return new Promise((resolveReady, reject) => { @@ -69,7 +72,7 @@ async function rpc(baseUrl: string, method: string, payload: unknown): Promis } interface HistoryPage { - events: { event: { type: string; data: unknown } }[] + events: { event: { type: string; data: unknown }; view?: unknown }[] hasMore: boolean } @@ -99,8 +102,8 @@ function hasAssistantMarker(page: HistoryPage, marker: string): boolean { }) } -async function history(baseUrl: string, sessionId: string): Promise { - return rpc(baseUrl, 'session.history', { sessionId, maxMessages: 10 }) +async function history(baseUrl: string, sessionId: string, maxMessages = 10): Promise { + return rpc(baseUrl, 'session.history', { sessionId, maxMessages }) } async function waitForProviderTitle(baseUrl: string, sessionId: string): Promise { @@ -241,11 +244,14 @@ describe('dsh web keyless CLI smoke', () => { ) try { const baseUrl = await waitForReadyLine(child) - const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {}) + const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', { + timeZone: WEB_TIME_ZONE, + }) await rpc<{ accepted: true }>(baseUrl, 'session.prompt', { sessionId: created.sessionId, mode: 'queue', content: [{ type: 'text', text: 'go' }], + clientTimeZone: WEB_TIME_ZONE, }) const capturedRequests = await Promise.race([ providerRequests, @@ -353,11 +359,14 @@ describe('dsh web keyless CLI smoke', () => { ) try { const baseUrl = await waitForReadyLine(child) - const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {}) + const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', { + timeZone: WEB_TIME_ZONE, + }) await rpc<{ accepted: true }>(baseUrl, 'session.prompt', { sessionId: created.sessionId, mode: 'queue', content: [{ type: 'text', text: promptMarker }], + clientTimeZone: WEB_TIME_ZONE, }) let page: HistoryPage | undefined await expect.poll(async () => { @@ -437,11 +446,14 @@ describe('dsh web keyless CLI smoke', () => { ) try { const baseUrl = await waitForReadyLine(child) - const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {}) + const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', { + timeZone: WEB_TIME_ZONE, + }) await rpc<{ accepted: true }>(baseUrl, 'session.prompt', { sessionId: created.sessionId, mode: 'queue', content: [{ type: 'text', text: 'go' }], + clientTimeZone: WEB_TIME_ZONE, }) const captured = await Promise.race([ providerRequest, @@ -465,6 +477,85 @@ describe('dsh web keyless CLI smoke', () => { }) }) +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('web Schedule smoke (real model)', () => { + it('creates and dispatches a reminder with durable tool and receipt evidence', async () => { + requireDist() + const sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-schedule-real-')) + const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href + const child = spawn( + process.execPath, + [ + '--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), + 'web', '--port', '0', '--patch', SCHEDULE_OVERLAY, + ], + { + cwd: sessionsDir, + env: { + ...process.env, + DSH_HOME: join(sessionsDir, '.dsh'), + DSH_AGENTS_HOME: join(sessionsDir, '.agents'), + TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + try { + const baseUrl = (await waitForReadyLine(child)).replace('0.0.0.0', '127.0.0.1') + const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', { + timeZone: WEB_TIME_ZONE, + }) + await rpc<{ accepted: true }>(baseUrl, 'session.prompt', { + sessionId: created.sessionId, + mode: 'queue', + content: [{ + type: 'text', + text: `Call schedule_create now with exactly {"prompt":"${REAL_SCHEDULE_PROMPT}","after_seconds":1}. Do not answer without using the tool.`, + }], + clientTimeZone: WEB_TIME_ZONE, + }) + + await expect.poll(async () => { + const page = await history(baseUrl, created.sessionId, 50) + const call = page.events.find(({ event }) => + event.type === 'tool/call' && isRecord(event.data) && event.data.name === 'schedule_create') + const callId = isRecord(call?.event.data) ? call.event.data.callId : undefined + if (typeof callId !== 'string') return false + const result = page.events.find(({ event }) => { + if (event.type !== 'tool/result' || !isRecord(event.data) || !isRecord(event.data.message)) return false + const source = event.data.message.source + return isRecord(source) && source.callId === callId + }) + const create = page.events.find(({ event }) => { + if (event.type !== 'schedule/change' || !isRecord(event.data) + || event.data.operation !== 'create' || !isRecord(event.data.schedule)) return false + return event.data.schedule.prompt === REAL_SCHEDULE_PROMPT + }) + const schedule = isRecord(create?.event.data) && isRecord(create.event.data.schedule) + ? create.event.data.schedule + : undefined + const scheduleId = schedule?.id + if (typeof scheduleId !== 'string' || result === undefined + || !JSON.stringify(result.event.data).includes(scheduleId)) return false + const dispatch = page.events.find(({ event }) => + event.type === 'schedule/change' && isRecord(event.data) + && event.data.operation === 'dispatch' && event.data.id === scheduleId) + if (dispatch === undefined || !isRecord(dispatch.view) || !isRecord(dispatch.view.view)) return false + return dispatch.view.for === 'event' + && dispatch.view.view.scheduleId === scheduleId + && dispatch.view.view.prompt === REAL_SCHEDULE_PROMPT + }, { timeout: 240_000, interval: 1_000 }).toBe(true) + } finally { + const closed = child.exitCode === null + ? new Promise((resolveClose) => { child.once('close', () => { resolveClose() }) }) + : Promise.resolve() + if (child.exitCode === null) child.kill('SIGTERM') + await Promise.race([closed, new Promise(resolve => setTimeout(resolve, 10_000).unref())]) + if (child.exitCode === null) child.kill('SIGKILL') + rmSync(sessionsDir, { recursive: true, force: true }) + } + }, 300_000) +}) + describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => { let child: ChildProcess let sessionsDir: string diff --git a/packages/schedule/tool-schedule/README.md b/packages/schedule/tool-schedule/README.md index a3a2364e14..b41a90e22f 100644 --- a/packages/schedule/tool-schedule/README.md +++ b/packages/schedule/tool-schedule/README.md @@ -34,7 +34,7 @@ The public cron language has exactly five numeric fields: minute, hour, day of m Schedule proves the nominal local interval against the complete 400-year Gregorian cycle, including cross-midnight and cycle-seam neighbors, and rejects any rule that can recur in under five minutes. It canonicalizes the explicit zone through `Intl`; `UTC` and IANA Area/Location names or links are accepted, while local defaults, abbreviations, and numeric offsets are not. -The private `croner@10.0.1` adapter runs paused without a callback or timer. It supplies hidden seconds=`0` and year=`1-9999`, filters daylight-saving gap normalization, chooses the first instant in an overlap, and strictly advances forward and backward cursors. Because JavaScript constructors remap years 0–99, an owned local-calendar search covers that lower range and its transition before the adapter delegates safe years to Croner. Create chooses the first match strictly after admission. A late wake retains the persisted target as its baseline, selects the latest newer current match at or before the shared `acceptedAt`, and finds the first future match. Replay validates only canonical structure, whole-minute UTC values, and monotonic dispatch relations; it never asks current Croner, ICU, or the frequency proof to re-decide a historical occurrence. +The private `croner@10.0.1` adapter runs paused without a callback or timer. It supplies hidden seconds=`0` and year=`1-9999`, filters daylight-saving gap normalization, chooses the first instant in an overlap, and strictly advances forward and backward cursors. Because JavaScript constructors remap years 0–99, an owned local-calendar search covers that lower range and its transition before the adapter delegates safe years to Croner. Create chooses the first match strictly after admission. A late wake retains the persisted target as its baseline, selects the latest newer current match at or before the shared `acceptedAt`, and finds the first future match. The package invariant applies the same current calendar validation only to new live create and dispatch appends. Replay validates only canonical structure, whole-minute UTC values, and monotonic dispatch relations; it never asks current Croner, ICU, or the frequency proof to re-decide a historical occurrence. ## Management tools diff --git a/packages/schedule/tool-schedule/README.zh.md b/packages/schedule/tool-schedule/README.zh.md index 4290bbc3ef..5afd36e73b 100644 --- a/packages/schedule/tool-schedule/README.zh.md +++ b/packages/schedule/tool-schedule/README.zh.md @@ -34,7 +34,7 @@ Web Host 会在创建 Session 时以及每次提交提示词时校验并规范 Schedule 会针对完整的 400 年 Gregorian 历法周期证明名义本地间隔,其中包括跨午夜相邻时点与周期首尾衔接处的相邻时点;任何可能以不足 5 分钟的间隔重复发生的规则都会被拒绝。它通过 `Intl` 规范化显式时区;接受 `UTC`、IANA Area/Location 名称或链接,不接受本地默认值、缩写或数值偏移。 -私有 `croner@10.0.1` 适配器以 paused 状态运行,不创建 callback 或 timer。它补入隐藏的 seconds=`0` 与 year=`1-9999`,过滤由夏令时空档规范化产生的候选值,在重叠时段选择第一个时刻,并严格推进正向与反向 cursor。由于 JavaScript 构造器会重映射 0–99 年,Schedule 自有的本地日历搜索会覆盖这一低年份范围及其向安全年份的过渡;只有进入安全年份后,适配器才会将搜索委托给 Croner。create 选择严格晚于 admission 的第一个 match。延迟唤醒以持久目标为 baseline,选择比 baseline 更新且不晚于共享 `acceptedAt` 的最新 current match,并找到第一个未来 match。回放只校验规范化结构、整分钟的 UTC 值与单调 dispatch 关系;绝不会让当前 Croner、ICU 或频率证明重新裁定历史 occurrence。 +私有 `croner@10.0.1` 适配器以 paused 状态运行,不创建 callback 或 timer。它补入隐藏的 seconds=`0` 与 year=`1-9999`,过滤由夏令时空档规范化产生的候选值,在重叠时段选择第一个时刻,并严格推进正向与反向 cursor。由于 JavaScript 构造器会重映射 0–99 年,Schedule 自有的本地日历搜索会覆盖这一低年份范围及其向安全年份的过渡;只有进入安全年份后,适配器才会将搜索委托给 Croner。create 选择严格晚于 admission 的第一个 match。延迟唤醒以持久目标为 baseline,选择比 baseline 更新且不晚于共享 `acceptedAt` 的最新 current match,并找到第一个未来 match。package invariant 只对新发生的 live create 与 dispatch append 应用同一套当前日历验证。回放只校验规范化结构、整分钟的 UTC 值与单调 dispatch 关系;绝不会让当前 Croner、ICU 或频率证明重新裁定历史 occurrence。 ## 管理工具 diff --git a/packages/schedule/tool-schedule/src/domain.ts b/packages/schedule/tool-schedule/src/domain.ts index b0b0a78ac0..c8b6402ed7 100644 --- a/packages/schedule/tool-schedule/src/domain.ts +++ b/packages/schedule/tool-schedule/src/domain.ts @@ -510,7 +510,7 @@ function parseCronField(raw: string, spec: CronFieldSpec): ParsedCronField { const canonical = step.value === 1 ? '*' : `*/${step.canonical}` return Object.freeze({ canonical, - values: cronValues(cronRange(spec.min, spec.max, step.value), spec, canonical === '*'), + values: cronValues(cronRange(spec.min, spec.max, step.value), spec, true), }) } @@ -704,6 +704,7 @@ function isCanonicalCronCandidate( timeZone: string, epoch: number, ): boolean { + /* v8 ignore next 4 -- pinned Croner emits finite in-range whole-minute candidates for this expression. */ if (!Number.isSafeInteger(epoch) || epoch < MIN_FOUR_DIGIT_YEAR_MS || epoch > MAX_FOUR_DIGIT_YEAR_MS @@ -789,7 +790,6 @@ function nextCronInstant(rule: ParsedCronRule, timeZone: string, after: number): if (new Date(after).getUTCFullYear() <= CRONER_LOW_YEAR_CUTOFF) { const lower = ownedLowYearCronInstant(rule, timeZone, after, 1) if (lower !== undefined) return lower - cursor = Math.max(cursor, Date.parse('0109-12-31T23:59:59.999Z')) } const evaluator = cronEvaluator(rule, timeZone) const formatter = cronLocalFormatter(timeZone) @@ -872,6 +872,26 @@ function previousCronInstant( return latestCronInstantThrough(rule, timeZone, baseline, acceptedAt) } +/** Validate one newly appended Cron record against the current parser, ICU, and calendar adapter. */ +function validateLiveCronRecord(record: CronScheduleRecord): void { + try { + const rule = parseCronRule(record.cron) + const timeZone = canonicalizeTimeZone(record.timeZone) + if (timeZone !== record.timeZone) { + throw new ScheduleLogError('live cron timeZone must use its current canonical IANA name') + } + const target = Date.parse(record.scheduledAt) + if (nextCronInstant(rule, timeZone, target - 60_000) !== target) { + throw new ScheduleLogError('live cron scheduledAt must match its rule in the current time-zone data') + } + } catch (error: unknown) { + if (error instanceof ScheduleLogError) throw error + /* v8 ignore next -- current parser and adapter failures are Error subclasses. */ + const detail = error instanceof Error ? error.message : String(error) + throw new ScheduleLogError(`live cron record is invalid: ${detail}`) + } +} + /** Decode the exact v1 after record shape. */ function decodeAfterRecord(value: unknown): AfterScheduleRecord { if (!isRecord(value) || !hasExactKeys(value, ['id', 'kind', 'prompt', 'afterSeconds', 'scheduledAt'])) { @@ -1270,6 +1290,33 @@ export function foldScheduleEvents( }) } +/** + * Validate a newly appended Cron fact with current calendar data without revalidating replay history. + * @param events - Complete exact-session log before the candidate append. + * @param value - Candidate `schedule/change` payload. + * @param seedLength - Inherited prefix length excluded from child ownership. + */ +export function validateLiveScheduleChange( + events: readonly SessionEvent[], + value: unknown, + seedLength = 0, +): void { + const change = decodeScheduleChange(value) + if (change.operation === 'create') { + if (change.schedule.kind === 'cron') validateLiveCronRecord(change.schedule) + return + } + if (change.operation !== 'dispatch' || !('acceptedAt' in change) || !('occurrenceAt' in change)) return + const record = foldScheduleEvents(events, seedLength).active.find(candidate => candidate.id === change.id) + /* v8 ignore next -- the preceding candidate fold requires calendar fields to target an active Cron record. */ + if (record?.kind !== 'cron') return + const expected = resolveCronOccurrence(record, Date.parse(change.acceptedAt)) + const nextScheduledAt = 'nextScheduledAt' in change ? change.nextScheduledAt : undefined + if (change.occurrenceAt !== expected.occurrenceAt || nextScheduledAt !== expected.nextScheduledAt) { + throw new ScheduleLogError('live cron dispatch must match the current calendar decision') + } +} + /** * Allocate the next readable id without reusing any prior session-local id. * @param folded - Fold containing every previously created id. diff --git a/packages/schedule/tool-schedule/src/invariant.ts b/packages/schedule/tool-schedule/src/invariant.ts index 2e7af53e1b..fae38ec647 100644 --- a/packages/schedule/tool-schedule/src/invariant.ts +++ b/packages/schedule/tool-schedule/src/invariant.ts @@ -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 } from './domain.ts' +import { foldScheduleEvents, ScheduleLogError, validateLiveScheduleChange } from './domain.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-schedule' @@ -15,17 +15,22 @@ export const name = 'tool-schedule-invariant' /** Service required before reserving this package's invariant ownership. */ export const inject = ['invariants'] -/** Validate a complete exact-session stream under its fork suffix policy. */ -function validate(events: readonly SessionEvent[], seedLength: number, fail: InvariantFailure): void { +/** Convert an owned Schedule validation failure into the invariant service's failure channel. */ +function report(run: () => void, fail: InvariantFailure): void { try { - foldScheduleEvents(events, seedLength) + run() } catch (error: unknown) { - /* v8 ignore next -- foldScheduleEvents normalizes every rejected stream to ScheduleLogError. */ + /* v8 ignore next -- owned Schedule validators normalize failures 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) => { @@ -40,6 +45,9 @@ 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 */ diff --git a/packages/schedule/tool-schedule/tests/cron.spec.ts b/packages/schedule/tool-schedule/tests/cron.spec.ts index 98491ae831..bfd7dee4ce 100644 --- a/packages/schedule/tool-schedule/tests/cron.spec.ts +++ b/packages/schedule/tool-schedule/tests/cron.spec.ts @@ -48,6 +48,7 @@ describe('restricted cron grammar and frequency proof', () => { ['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'], + ['0 9 * * */7', '0 9 * * */7'], ])('canonicalizes %s', (input, canonical) => { expect(canonicalizeCronExpression(input)).toBe(canonical) }) @@ -181,6 +182,13 @@ describe('Croner calendar adapter', () => { occurrenceAt: yearOne.scheduledAt, nextScheduledAt: '0001-01-03T00:00:00.000Z', }) + expect(createCronScheduleRecord( + ScheduleId('schedule-low-year-positive-offset-seam'), + 'positive offset seam', + '0 0 1 1 *', + 'Etc/GMT-14', + Date.parse('0108-12-31T23:59:59.999Z'), + ).scheduledAt).toBe('0109-12-31T10:00:00.000Z') }) it('skips a DST gap and chooses the first instant in an overlap', () => { diff --git a/packages/schedule/tool-schedule/tests/invariant.spec.ts b/packages/schedule/tool-schedule/tests/invariant.spec.ts index 505aa88845..fab523e617 100644 --- a/packages/schedule/tool-schedule/tests/invariant.spec.ts +++ b/packages/schedule/tool-schedule/tests/invariant.spec.ts @@ -4,7 +4,7 @@ import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import * as scheduleInvariant from '../src/invariant.ts' -import { ScheduleId } from '../src/domain.ts' +import { createCronScheduleRecord, resolveCronOccurrence, ScheduleId } from '../src/domain.ts' import type { ScheduleChange } from '../src/types.ts' function event(data: unknown, seq: number): SessionEvent { @@ -53,6 +53,100 @@ describe('Schedule package invariant', () => { await ctx.fiber.dispose() }) + it('validates live Cron records and dispatches with current calendar data', async () => { + const { ctx } = await harness() + const session = ctx.sessions.create(SessionId('schedule-live-cron-invariant')) + expect(() => session.append('schedule/change', { + version: 1, + operation: 'create', + schedule: { + id: ScheduleId('schedule-invalid-live-cron'), + kind: 'cron', + prompt: 'invalid current target', + cron: '0 9 * * *', + timeZone: 'UTC', + scheduledAt: '2026-08-06T12:00:00.000Z', + }, + })).toThrow(InvariantError) + expect(() => session.append('schedule/change', { + version: 1, + operation: 'create', + schedule: { + id: ScheduleId('schedule-alias-live-cron'), + kind: 'cron', + prompt: 'noncanonical zone', + cron: '0 9 * * *', + timeZone: 'US/Eastern', + scheduledAt: '2026-08-06T13:00:00.000Z', + }, + })).toThrow(InvariantError) + expect(() => session.append('schedule/change', { + version: 1, + operation: 'create', + schedule: { + id: ScheduleId('schedule-fast-live-cron'), + kind: 'cron', + prompt: 'too frequent', + cron: '* * * * *', + timeZone: 'UTC', + scheduledAt: '2026-08-06T12:00:00.000Z', + }, + })).toThrow(InvariantError) + + const record = createCronScheduleRecord( + ScheduleId('schedule-valid-live-cron'), + 'valid current target', + '0 9 * * *', + 'UTC', + Date.parse('2026-08-06T08:00:00.000Z'), + ) + session.append('schedule/change', { version: 1, operation: 'create', schedule: record }) + const acceptedAt = '2026-08-07T12:00:00.000Z' + const expected = resolveCronOccurrence(record, Date.parse(acceptedAt)) + expect(() => session.append('schedule/change', { + version: 1, + operation: 'dispatch', + id: record.id, + occurrenceAt: record.scheduledAt, + acceptedAt, + nextScheduledAt: expected.nextScheduledAt, + })).toThrow(InvariantError) + expect(session.events).toHaveLength(1) + session.append('schedule/change', { + version: 1, + operation: 'dispatch', + id: record.id, + occurrenceAt: expected.occurrenceAt, + acceptedAt, + nextScheduledAt: expected.nextScheduledAt, + }) + expect(session.events).toHaveLength(2) + await ctx.fiber.dispose() + }) + + it('keeps existing Cron replay structural across time-zone data changes', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService) + ctx.sessions.create(SessionId('schedule-historical-cron-invariant'), { + seed: [event({ + version: 1, + operation: 'create', + schedule: { + id: 'schedule-historical-cron', + kind: 'cron', + prompt: 'historical target', + cron: '0 9 * * *', + timeZone: 'UTC', + scheduledAt: '2026-08-06T12:00:00.000Z', + }, + }, 0)], + }) + const fiber = await ctx.plugin(scheduleInvariant) + await fiber.dispose() + await ctx.fiber.dispose() + }) + it('rejects a malformed existing owned stream during companion setup', async () => { const ctx = new Context() await ctx.plugin(SessionStore)