fix(schedule): close fixed-rate review gaps
This commit is contained in:
@@ -14,7 +14,7 @@ Every operation that reads or decides from the Schedule fold first awaits `ctx.s
|
||||
|
||||
## Durable state
|
||||
|
||||
The package owns the strict version-1 `schedule/change` create, delete, and dispatch union. Every create record contains a stable session-local `ScheduleId`, the trimmed prompt, and a four-digit-year RFC 3339 UTC `scheduledAt`. An `after` record also stores `afterSeconds`; an `at` record stores no copy of the submitted offset, local calendar fields, or interpreting zone; an `every` record stores `everySeconds` and its earliest unaccepted target without a separate anchor. Delete and one-shot dispatch carry only the id. Every dispatch adds the shared batch `acceptedAt`; the fold derives its latest due occurrence and first anchor-aligned future target.
|
||||
The package owns the strict version-1 `schedule/change` create, delete, and dispatch union. Every create record contains a stable session-local `ScheduleId`, the trimmed prompt, and a four-digit-year RFC 3339 UTC `scheduledAt`. An `after` record also stores `afterSeconds`; an `at` record stores no copy of the submitted offset, local calendar fields, or interpreting zone; an `every` record stores `everySeconds` and its earliest unaccepted target without a separate anchor. Delete and one-shot dispatch carry only the id. Every dispatch adds the shared batch `acceptedAt`; the fold derives its latest due occurrence and first anchor-aligned future target, or terminates all remaining Every records when the shared gate has no four-digit-year admission left.
|
||||
|
||||
Replay rejects unknown versions, extra fields, reused ids, mismatched dispatch shapes, recurring batches less than 300 seconds apart, and transitions against inactive records. Normal sessions fold the complete log. A fork folds only `session.events.slice(session.header.seedLength ?? 0)`, so it does not inherit its parent's reminders. The package's `./invariant` companion applies the same policy to existing logs and candidate events.
|
||||
|
||||
@@ -42,7 +42,7 @@ The closed v1 domain error codes are `invalid_prompt`, `invalid_selector`, `inva
|
||||
|
||||
The live owner derives targets and the latest recurring batch from the durable fold. It splits waits longer than the Node timer range and rereads the wall clock after every wake, so a rollback cannot fire early and a forward jump makes the record overdue. Fixed-rate progression remains anchored to the first target: a late wake selects only the latest due occurrence and advances to the first strictly future target instead of replaying the missed backlog.
|
||||
|
||||
An overdue reminder first checkpoints persistence. If a turn or another maintenance task already owns the Agent, `runMaintenance()` rejects the idle-phase claim; the record stays active and the owner retries after `whenIdle()`. One-shots bypass the recurring gate and keep their single-message, id-only dispatch path. Recurring batches are at least 300 seconds apart: when the gate opens, one decision sample selects every overdue fixed-rate record in target/create order, constructs the complete JSON batch, queues one `followup()`, and appends an independent `{ id, acceptedAt }` dispatch for each record before releasing the phase. Waking input remains parked until that release, after which the owner checkpoints the batch. Framing or synchronous followup failure writes no dispatch. An append failure faults that owner because the message may already be queued; a barrier rejection leaves dispatches pending for a later ordinary preflight and does not start a private retry timer.
|
||||
An overdue reminder first checkpoints persistence. If a turn or another maintenance task already owns the Agent, `runMaintenance()` rejects the idle-phase claim; the record stays active and the owner retries after `whenIdle()`. One-shots bypass the recurring gate and keep their single-message, id-only dispatch path. While any recurring record is overdue behind a closed gate, the owner wakes at that gate or an earlier one-shot rather than at intervening recurring targets. Recurring batches are at least 300 seconds apart: when the gate opens, one decision sample selects every overdue fixed-rate record in target/create order, constructs the complete JSON batch, queues one `followup()`, and appends an independent `{ id, acceptedAt }` dispatch for each record before releasing the phase. Waking input remains parked until that release, after which the owner checkpoints the batch. Framing or synchronous followup failure writes no dispatch. An append failure faults that owner because the message may already be queued; a barrier rejection leaves dispatches pending for a later ordinary preflight and does not start a private retry timer.
|
||||
|
||||
Agent or plugin disposal cancels timers, stops new work, and awaits in-flight preflights and idle waits. It never appends delete records during teardown.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
## 持久状态
|
||||
|
||||
此包(package)拥有严格的版本 1 `schedule/change` create、delete 与 dispatch 联合。每条 create 记录都包含稳定的会话本地 `ScheduleId`、已 trim 的 prompt,以及使用四位年份的 RFC 3339 UTC `scheduledAt`。`after` 记录还会存储 `afterSeconds`;`at` 记录不会保留所提交的偏移量、本地日历字段或解释该值时所用的时区;`every` 记录会存储 `everySeconds` 和最早尚未接受的目标,而不另存锚点。delete 与一次性 dispatch 只携带 id。Every dispatch 会带上共享 batch 的 `acceptedAt`;折叠过程会派生该记录最近一次到期的 occurrence 和第一个与锚点对齐的未来目标。
|
||||
此包(package)拥有严格的版本 1 `schedule/change` create、delete 与 dispatch 联合。每条 create 记录都包含稳定的会话本地 `ScheduleId`、已 trim 的 prompt,以及使用四位年份的 RFC 3339 UTC `scheduledAt`。`after` 记录还会存储 `afterSeconds`;`at` 记录不会保留所提交的偏移量、本地日历字段或解释该值时所用的时区;`every` 记录会存储 `everySeconds` 和最早尚未接受的目标,而不另存锚点。delete 与一次性 dispatch 只携带 id。Every dispatch 会带上共享 batch 的 `acceptedAt`;折叠过程会派生该记录最近一次到期的 occurrence 和第一个与锚点对齐的未来目标,或在共享门控不再有年份为四位数的准入时点时终结所有剩余的 Every record。
|
||||
|
||||
回放会拒绝未知版本、额外字段、重复使用的 id、不匹配的 dispatch 形状、间隔不足 300 秒的周期性 batch,以及针对非活动记录的转换。普通会话折叠完整日志。fork 只折叠 `session.events.slice(session.header.seedLength ?? 0)`,因此不会继承父会话的提醒。此包的 `./invariant` 配套项会对现有日志和候选事件应用相同策略。
|
||||
|
||||
@@ -42,7 +42,7 @@ Web Host 会在创建 Session 时以及每次提交提示词时校验并规范
|
||||
|
||||
live owner 从持久折叠结果派生各个目标与最近一次周期性 batch。它会拆分超过 Node timer 范围的等待,并在每次唤醒后重新读取墙钟,因此时钟回拨不会提前触发,时钟前跳则会使记录进入 overdue 状态。固定频率推进始终锚定首个目标:延迟唤醒只选择最近一次到期的 occurrence,并推进至第一个严格位于未来的目标,而不会回放错过期间积压的 occurrence。
|
||||
|
||||
overdue 提醒首先为持久化建立检查点。如果 agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝对 idle phase 的认领;记录会保持活动,owner 会在 `whenIdle()` 后重试。一次性提醒会绕过周期性门控,仍走单条消息、只含 id 的 dispatch 路径。周期性 batch 之间至少间隔 300 秒:门控开放时,owner 会采样一次决策时间,按目标/create 顺序选择所有 overdue 固定频率记录,构造完整 JSON batch,同步将一个 `followup()` 入队,并在释放 phase 前为每条记录追加独立的 `{ id, acceptedAt }` dispatch。触发唤醒的 input 会保持 parked,直到该 phase 释放;随后 owner 为整个 batch 建立检查点。framing 构造或同步 followup 失败不会写入 dispatch。追加失败会使该 owner 进入故障状态,因为消息可能已经入队;barrier 拒绝会把这些 dispatch 留给后续普通 preflight 处理,而不会启动私有重试 timer。
|
||||
overdue 提醒首先为持久化建立检查点。如果 agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝对 idle phase 的认领;记录会保持活动,owner 会在 `whenIdle()` 后重试。一次性提醒会绕过周期性门控,仍走单条消息、只含 id 的 dispatch 路径。只要有周期性记录因门控关闭而处于 overdue,owner 就会在该门控时点或更早的一次性提醒到期时唤醒,而不会在其间的周期性目标处唤醒。周期性 batch 之间至少间隔 300 秒:门控开放时,owner 会采样一次决策时间,按目标/create 顺序选择所有 overdue 固定频率记录,构造完整 JSON batch,同步将一个 `followup()` 入队,并在释放 phase 前为每条记录追加独立的 `{ id, acceptedAt }` dispatch。触发唤醒的 input 会保持 parked,直到该 phase 释放;随后 owner 为整个 batch 建立检查点。framing 构造或同步 followup 失败不会写入 dispatch。追加失败会使该 owner 进入故障状态,因为消息可能已经入队;barrier 拒绝会把这些 dispatch 留给后续普通 preflight 处理,而不会启动私有重试 timer。
|
||||
|
||||
agent 或插件执行 dispose(资源释放)时,会取消 timer、停止新工作,并等待进行中的 preflight 和 idle wait。清理期间绝不会追加 delete 记录。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-schedule",
|
||||
"description": "Agent-scoped durable one-shot reminders over the session event log",
|
||||
"description": "Agent-scoped durable one-shot and fixed-rate reminders over the session event log",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -641,6 +641,13 @@ 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) {
|
||||
for (const [id, record] of active) {
|
||||
if (record.kind === 'every') active.delete(id)
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
active: Object.freeze([...active.values()]),
|
||||
seenIds: Object.freeze([...seen]),
|
||||
@@ -801,7 +808,8 @@ export function createEveryScheduleRecord(
|
||||
const interval = everySeconds * 1_000
|
||||
const target = now + interval
|
||||
if (!Number.isSafeInteger(now) || !Number.isSafeInteger(interval)
|
||||
|| !Number.isSafeInteger(target) || target <= now || target > MAX_FOUR_DIGIT_YEAR_MS) {
|
||||
|| !Number.isSafeInteger(target) || target <= now
|
||||
|| target < MIN_FOUR_DIGIT_YEAR_MS || target > MAX_FOUR_DIGIT_YEAR_MS) {
|
||||
throw new ScheduleInputError(
|
||||
'time_out_of_range',
|
||||
'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Agent-scoped durable one-shot 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
|
||||
*/
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ function dueDecision(folded: FoldedSchedules, now: number): DueDecision {
|
||||
}
|
||||
|
||||
const future = folded.active
|
||||
.filter(record => recurring.length === 0 || record.kind !== 'every')
|
||||
.map(record => Date.parse(record.scheduledAt))
|
||||
.filter(target => target > now)
|
||||
if (recurring.length > 0) future.push(gate)
|
||||
|
||||
@@ -120,6 +120,16 @@ export type ScheduleView = ScheduleRecord & {
|
||||
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. */
|
||||
export type SchedulePersistenceOperation = 'create' | 'list' | 'delete'
|
||||
|
||||
|
||||
@@ -316,6 +316,18 @@ describe('fixed-rate records and durable progression', () => {
|
||||
.toThrow(ScheduleInputError)
|
||||
expect(() => createEveryScheduleRecord(ScheduleId('schedule-every'), 'x', 300, Number.NaN))
|
||||
.toThrow(ScheduleInputError)
|
||||
try {
|
||||
createEveryScheduleRecord(
|
||||
ScheduleId('schedule-every'),
|
||||
'x',
|
||||
300,
|
||||
Date.parse('0000-12-31T23:50:00.000Z'),
|
||||
)
|
||||
throw new Error('expected every lower-bound failure')
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(ScheduleInputError)
|
||||
expect((error as ScheduleInputError).code).toBe('time_out_of_range')
|
||||
}
|
||||
})
|
||||
|
||||
it('selects the latest due occurrence and first strictly future anchor point', () => {
|
||||
@@ -412,6 +424,37 @@ describe('fixed-rate records and durable progression', () => {
|
||||
])).toThrow(/at least 300 seconds apart/)
|
||||
})
|
||||
|
||||
it('terminates every record when the shared gate has no four-digit-year admission', () => {
|
||||
const folded = foldScheduleEvents([
|
||||
scheduleEvent(everyCreateData(
|
||||
'schedule-final',
|
||||
'final batch',
|
||||
'9999-12-31T23:55:00.000Z',
|
||||
), 0),
|
||||
scheduleEvent(everyCreateData(
|
||||
'schedule-staggered',
|
||||
'staggered target',
|
||||
'9999-12-31T23:58:00.000Z',
|
||||
), 1),
|
||||
scheduleEvent(createData(
|
||||
'schedule-once',
|
||||
'one shot survives',
|
||||
'9999-12-31T23:59:00.000Z',
|
||||
), 2),
|
||||
scheduleEvent({
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-final',
|
||||
acceptedAt: '9999-12-31T23:57:30.000Z',
|
||||
}, 3),
|
||||
])
|
||||
expect(folded).toEqual({
|
||||
active: [expect.objectContaining({ id: 'schedule-once', kind: 'after' })],
|
||||
seenIds: ['schedule-final', 'schedule-staggered', 'schedule-once'],
|
||||
lastRecurringAcceptedAt: '9999-12-31T23:57:30.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('derives each recurring receipt and renders one escaped batch payload', () => {
|
||||
const events = [
|
||||
scheduleEvent(everyCreateData(), 0),
|
||||
|
||||
@@ -348,6 +348,37 @@ describe('Schedule timer and admission runtime', () => {
|
||||
await owner.dispose()
|
||||
})
|
||||
|
||||
it('waits for the recurring gate instead of staggered recurring targets', async () => {
|
||||
const test = await harness()
|
||||
appendEvery(test, 'schedule-overdue', 300, Date.parse('2026-08-05T11:53:00.000Z'), 'overdue')
|
||||
const owner = ownerFor(test)
|
||||
owner.start()
|
||||
await settle()
|
||||
expect(test.followed).toHaveLength(1)
|
||||
|
||||
appendEvery(test, 'schedule-staggered', 300, Date.parse('2026-08-05T11:59:00.000Z'), 'staggered')
|
||||
owner.requestDrive()
|
||||
await settle()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(180_000)
|
||||
await settle()
|
||||
const flushesAtFirstDue = test.controls.flushCount
|
||||
expect(test.followed).toHaveLength(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
await settle()
|
||||
expect(test.controls.flushCount).toBe(flushesAtFirstDue)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
await settle()
|
||||
expect(test.followed).toHaveLength(2)
|
||||
const batch = test.followed[1]?.content[0]
|
||||
if (batch?.type !== 'text') throw new Error('expected recurring batch text')
|
||||
expect(batch.text).toContain('"schedule_id":"schedule-overdue"')
|
||||
expect(batch.text).toContain('"schedule_id":"schedule-staggered"')
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user