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
@@ -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)
})
})
@@ -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', {