feat(schedule): add durable after reminders

This commit is contained in:
pku-xht
2026-08-05 19:00:02 +08:00
committed by Tianyi Cui
parent a229b42e24
commit f7e7851e3f
102 changed files with 2619 additions and 122 deletions

View File

@@ -0,0 +1,11 @@
# AGENTS.md — Schedule packages
These rules supplement the repository and package instructions for `packages/schedule/*`.
- The owning Session's versioned `schedule/change` stream is the only durable Schedule state. Folds validate every durable JSON boundary and derive active records; timers, waiters, admission reservations, presentation cursors, and tool values remain disposable projections.
- A normal Session folds its complete log. A fork derives active Schedule state only from events at or after `SessionHeader.seedLength`; it never inherits an active parent reminder.
- Every Schedule management operation that reads or decides from the fold first awaits `ctx.sessions.flush(session)`. Create and an actual delete await a second barrier after append; a failed barrier returns the stable uncertainty result instead of inferring durability from the live log.
- Runtime owners attach only to future live root Agents while the plugin is loaded. They do not scan persisted Sessions, adopt already-published roots, wake cold Sessions, register global tools, or delete durable records during teardown.
- Due handling rechecks the wall clock and exact live owner, reserves turn admission through the public Agent seam, constructs the complete escaped framing before `followup()`, appends dispatch only after synchronous enqueue returns, releases the reservation in `finally`, and then awaits durability. A synchronous framing/enqueue failure appends no dispatch; a later model failure does not roll one back.
- Rule math and durable transition logic stay pure and deterministic. Production uses the platform wall clock and segmented timers; tests supply explicit samples or fake timers without adding a production clock service.
- Host and browser presentation is derived from a durability-proven event prefix. Domain view construction belongs to Schedule, generic transport and keyed fallback belong to the Host/client runtime, and the Schedule card belongs to its separate client plugin.

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/schedule/README.md
README.md: 1f21dd03d71d00e08a167efabd676dc5319f9671
README.zh.md: ab56383cd8b00001db83120d41e4bcd292a10f04

View File

@@ -0,0 +1,11 @@
# schedule/ — durable Session-local reminders
English | [中文](README.zh.md)
The Schedule family owns reminders whose durable state and delivery receipt live in the original Session log. A process-local owner waits only while that Session has a live root Agent; cold Sessions resume overdue work when they become live again and never imply an external notification channel.
| Package | Role | ctx key |
|---|---|---|
| `tool-schedule/` | Versioned Schedule events and fold, model-facing create/list/delete tools, live root-Agent timer owner, and pure reminder presentation | — |
The package deliberately exposes no public Schedule service or mutable database. Tools and runtime append to the Session stream, while Web presentation and the browser renderer consume derived, durability-proven views.

View File

@@ -0,0 +1,11 @@
# schedule/:持久、仅限 Session 内的提醒
[English](README.md) | 中文
Schedule 家族负责把持久状态与交付回执保存在原 Session 日志中的提醒。进程内 owner 只会在该 Session 拥有 live 根 Agent 时等待cold Session 再次 live 后会恢复逾期工作,但不会表示存在外部通知渠道。
| 包 | 职责 | ctx 键 |
|---|---|---|
| `tool-schedule/` | 版本化 Schedule 事件与 fold、面向模型的创建列出删除工具、live 根 Agent timer owner以及纯提醒 presentation | 无 |
本包有意不公开 Schedule service 或可变数据库。工具与 runtime 向 Session stream 追加事件Web presentation 与浏览器 renderer 则消费由已证明持久的前缀派生出的 view。

View File

@@ -1,2 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/schedule/tool-schedule/README.md
README.md: 55842c3cb49c43b5c577835a26ef43e6ad452dfd
README.zh.md: 8738ac6b4516a1933b206b6baee5bb3d7d77d23a

View File

@@ -46,6 +46,7 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"

View File

@@ -34,6 +34,7 @@ function renderThrown(value: unknown): string {
/** One process-local, disposable projection of an exact agent's durable schedules. */
export class ScheduleOwner {
private readonly stop = Promise.withResolvers<void>()
private timer: ReturnType<typeof setTimeout> | undefined
private idleWait: Promise<void> | undefined
private run: Promise<void> | undefined
@@ -91,6 +92,7 @@ export class ScheduleOwner {
this.stopping = true
this.requested = false
this.clearTimer()
this.stop.resolve()
const pending = [this.run, this.idleWait].filter((value): value is Promise<void> => value !== undefined)
await Promise.allSettled(pending)
})())
@@ -138,7 +140,7 @@ export class ScheduleOwner {
/** Await one public idle boundary without holding admission or creating a retry timer. */
private waitForIdle(): void {
if (this.idleWait !== undefined) return
const wait = this.agent.whenIdle()
const wait = Promise.race([this.agent.whenIdle(), this.stop.promise])
this.idleWait = wait
void wait.then(
() => {

View File

@@ -0,0 +1,153 @@
/** Production JSONL restart evidence through the real Agent resume lifecycle. */
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as toolSchedule from '../src/index.ts'
import {
ScheduleId,
createAfterScheduleRecord,
foldScheduleEvents,
scheduleReminderPresentation,
} from '../src/domain.ts'
const roots: string[] = []
const contexts: Context[] = []
afterEach(async () => {
await Promise.allSettled(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
})
class RecordingAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
const response: StreamChunk[] = [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'block-end', index: 0, block: { type: 'text', text: 'Reminder acknowledged.' } },
{ type: 'finish', reason: { kind: 'stop' } },
]
for (const chunk of response) yield chunk
}
}
async function mountPersistence(root: string): Promise<Context> {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
return ctx
}
async function mountRuntime(root: string, adapter: RecordingAdapter): Promise<Context> {
const ctx = new Context()
contexts.push(ctx)
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
ctx.llm.registerAdapter(['mock'], adapter)
await ctx.plugin(toolSchedule)
return ctx
}
async function disposeContext(ctx: Context): Promise<void> {
const index = contexts.indexOf(ctx)
if (index >= 0) contexts.splice(index, 1)
await ctx.fiber.dispose()
}
function waitForDispatch(ctx: Context, sessionId: SessionId): Promise<void> {
return new Promise((resolve) => {
const stop = ctx.on('session/event', (session, event) => {
if (session.id !== sessionId
|| event.type !== 'schedule/change'
|| event.data.operation !== 'dispatch') return
stop()
resolve()
})
})
}
async function settleCurrentTasks(): Promise<void> {
await new Promise<void>(resolve => setImmediate(resolve))
}
describe('Schedule production JSONL restart', () => {
it('resumes one overdue reminder exactly once across fresh runtime mounts', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-schedule-jsonl-'))
roots.push(root)
const sessionId = SessionId('schedule-jsonl-restart')
const first = await mountPersistence(root)
const pending = first.sessions.create(sessionId, { meta: { cwd: '/tmp' } })
const pendingRecord = createAfterScheduleRecord(
ScheduleId('schedule-1'), 'restart reminder', 1, Date.now() - 60_000,
)
pending.append('schedule/change', { version: 1, operation: 'create', schedule: pendingRecord })
await expect(first.sessions.flush(pending)).resolves.toBe(true)
await disposeContext(first)
const dispatchingAdapter = new RecordingAdapter()
const restarted = await mountRuntime(root, dispatchingAdapter)
const dispatched = waitForDispatch(restarted, sessionId)
const handle = await restarted.agents.resume({
resumeSessionId: sessionId,
agentOptions: { provider: 'mock', model: 'mock' },
})
await dispatched
await handle.agent.whenIdle()
await expect(restarted.sessions.flush(handle.agent.session)).resolves.toBe(true)
const dispatchedStored = await restarted.sessionPersistence.inspect(sessionId)
expect(foldScheduleEvents(dispatchedStored.events, dispatchedStored.meta.seedLength ?? 0).active)
.toEqual([])
const dispatches = dispatchedStored.events.filter(event =>
event.type === 'schedule/change' && event.data.operation === 'dispatch')
expect(dispatches).toHaveLength(1)
const dispatch = dispatches[0]
if (dispatch?.type !== 'schedule/change' || dispatch.data.operation !== 'dispatch') {
throw new Error('missing durable Schedule dispatch')
}
expect(scheduleReminderPresentation(
dispatchedStored.events,
dispatch.seq,
dispatchedStored.meta.seedLength ?? 0,
)).toEqual({
scheduleId: 'schedule-1',
prompt: 'restart reminder',
occurrenceAt: pendingRecord.scheduledAt,
deliveryMode: 'session-local',
})
expect(dispatchingAdapter.requests).toHaveLength(1)
await handle.dispose()
await disposeContext(restarted)
const replayAdapter = new RecordingAdapter()
const replayed = await mountRuntime(root, replayAdapter)
const replayHandle = await replayed.agents.resume({
resumeSessionId: sessionId,
agentOptions: { provider: 'mock', model: 'mock' },
})
await replayed.sessions.flush(replayHandle.agent.session)
await replayHandle.agent.whenIdle()
await settleCurrentTasks()
await replayed.sessions.flush(replayHandle.agent.session)
expect(replayAdapter.requests).toEqual([])
expect(replayHandle.agent.session.events.filter(event =>
event.type === 'schedule/change' && event.data.operation === 'dispatch')).toHaveLength(1)
const replayedStored = await replayed.sessionPersistence.inspect(sessionId)
expect(replayedStored.events.filter(event =>
event.type === 'schedule/change' && event.data.operation === 'dispatch')).toHaveLength(1)
await replayHandle.dispose()
await disposeContext(replayed)
})
})

View File

@@ -410,6 +410,30 @@ describe('Schedule runtime failure and teardown boundaries', () => {
await departedOwner.dispose()
})
it('stops an idle wait during dispose even if the agent never becomes idle', async () => {
const test = await harness()
appendAfter(test, 'schedule-1', 1, Date.now() - 1_000)
test.controls.canReserve = false
const owner = ownerFor(test)
owner.start()
await settle()
expect(test.controls.whenIdleCount).toBe(1)
let disposed = false
const disposal = owner.dispose().then(() => { disposed = true })
await settle()
try {
expect(disposed).toBe(true)
} finally {
test.controls.idle.resolve(undefined)
await disposal
}
await settle()
expect(test.followed).toEqual([])
expect(test.agent.session.events.filter(event =>
event.type === 'schedule/change' && event.data.operation === 'dispatch')).toEqual([])
})
it('faults on corrupt or unreadable durable state after preflight', async () => {
const corrupt = await harness()
Object.defineProperty(corrupt.agent.session, 'events', {

View File

@@ -32,6 +32,9 @@
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../session-persistence/session-persistence-jsonl"
},
{
"path": "../../support/invariants"
}