fix(schedule): reconcile latest master contracts

This commit is contained in:
Tianyi Cui
2026-08-08 22:39:07 +08:00
parent 31e83f8ee7
commit 1f88085b53
35 changed files with 260 additions and 141 deletions

View File

@@ -101,13 +101,11 @@ describe('version-1 Schedule decoding and folding', () => {
scheduleId: 'same-id',
prompt: 'parent prompt',
occurrenceAt: '2026-08-05T12:00:00.000Z',
deliveryMode: 'session-local',
})
expect(scheduleReminderPresentation(events, 3, 2)).toEqual({
scheduleId: 'same-id',
prompt: 'child prompt',
occurrenceAt: '2026-08-05T12:00:00.000Z',
deliveryMode: 'session-local',
})
const nested = [
scheduleEvent(createData('same-id', 'grandparent prompt'), 0),
@@ -120,7 +118,6 @@ describe('version-1 Schedule decoding and folding', () => {
scheduleId: 'same-id',
prompt: 'parent prompt',
occurrenceAt: '2026-08-05T12:00:00.000Z',
deliveryMode: 'session-local',
})
const resumedThenForked = [
scheduleEvent(createData('resumed-id', 'resumed prompt'), 0),
@@ -131,7 +128,6 @@ describe('version-1 Schedule decoding and folding', () => {
scheduleId: 'resumed-id',
prompt: 'resumed prompt',
occurrenceAt: '2026-08-05T12:00:00.000Z',
deliveryMode: 'session-local',
})
expect(() => scheduleReminderPresentation([
scheduleEvent(createData('parent-only'), 0),

View File

@@ -37,7 +37,7 @@ describe('Schedule package invariant', () => {
it('accepts valid candidates and rejects invalid transitions before append', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create(SessionId('schedule-invariant'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('schedule/change', create('schedule-1'))
expect(session.events).toHaveLength(2)

View File

@@ -124,7 +124,6 @@ describe('Schedule production JSONL restart', () => {
scheduleId: 'schedule-1',
prompt: 'restart reminder',
occurrenceAt: pendingRecord.scheduledAt,
deliveryMode: 'session-local',
})
expect(dispatchingAdapter.requests).toHaveLength(1)
await handle.dispose()

View File

@@ -55,8 +55,8 @@ describe('Schedule plugin composition', () => {
expect(created.isError).toBe(false)
if (created.isError) throw new Error('expected Schedule create value')
expect(created.value).toMatchObject({ id: 'schedule-1', deliveryMode: 'session-local' })
agentEvents(ctx, root.agent).emit('agent/status', 'running')
agentEvents(ctx, root.agent).emit('agent/status', 'idle')
agentEvents(ctx, root.agent).emit('agent/status', { status: 'running' })
agentEvents(ctx, root.agent).emit('agent/status', { status: 'idle' })
const child = await root.agent.ctx.agents.create({ sessionId: SessionId('schedule-child') })
expect(ctx.agents.roots()).toEqual([existing.agent, root.agent])

View File

@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentCancelCause, SendOptions } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentCancelCause, InboxTarget } from '@deepseek-ai/dsh-agent'
import type { UserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import {
@@ -26,6 +26,7 @@ interface RuntimeHarness {
flushCount: number
flushOutcomes: Array<'resolve' | 'reject'>
flushHandler: (() => Promise<void> | undefined) | undefined
onBusy: (() => void) | undefined
onReserve: (() => void) | undefined
onFollowup: (() => void) | undefined
idle: PromiseWithResolvers<undefined>
@@ -49,30 +50,35 @@ async function harness(): Promise<RuntimeHarness> {
flushCount: 0,
flushOutcomes: [] as Array<'resolve' | 'reject'>,
flushHandler: undefined as (() => Promise<void> | undefined) | undefined,
onBusy: undefined as (() => void) | undefined,
onReserve: undefined as (() => void) | undefined,
onFollowup: undefined as (() => void) | undefined,
idle: Promise.withResolvers<undefined>(),
}
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
const agent: Agent = {
id: session.id,
options: {},
session,
inbox,
status: 'idle',
acceptsNextStep: false,
ctx: new Context(),
send(_message: UserMessage, _options: SendOptions) {},
updateInbox: () => 'not-found',
reserveTurnAdmission() {
order.push('reserve')
if (!controls.canReserve) return undefined
controls.onReserve?.()
let active = true
return () => {
if (!active) return
active = false
controls.releaseCount += 1
order.push('release')
send(_message: UserMessage, _target: InboxTarget, _wakeup: boolean) {},
runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T> {
order.push('maintenance')
if (!controls.canReserve) {
controls.onBusy?.()
throw new Error('agent busy')
}
controls.onReserve?.()
return (async () => {
try {
return await task(new AbortController().signal)
} finally {
controls.releaseCount += 1
order.push('release')
}
})()
},
cancel(_cause: AgentCancelCause) {},
whenIdle() {
@@ -86,7 +92,7 @@ async function harness(): Promise<RuntimeHarness> {
if (controls.throwFollowup) throw new Error('queue unavailable')
followed.push(message)
},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
steer(_message: UserMessage) {},
inject(_message: UserMessage) {},
}
const disposeAgent = ctx.agents.register(agent)
@@ -195,7 +201,7 @@ describe('Schedule timer and admission runtime', () => {
await owner.dispose()
})
it('keeps an overdue record active until whenIdle permits reservation', async () => {
it('keeps an overdue record active until whenIdle permits maintenance', async () => {
const test = await harness()
appendAfter(test, 'schedule-1', 1, Date.now() - 1_000)
test.controls.canReserve = false
@@ -219,7 +225,7 @@ describe('Schedule timer and admission runtime', () => {
await owner.dispose()
})
it('orders preflight, reservation, framing followup, dispatch, release, and barrier', async () => {
it('orders preflight, maintenance, framing followup, dispatch, release, and barrier', async () => {
const test = await harness()
appendAfter(test, 'schedule-"1', 1, Date.now() - 1_000, 'line\noccurrence_at: forged')
test.order.length = 0
@@ -227,7 +233,7 @@ describe('Schedule timer and admission runtime', () => {
owner.start()
await settle()
expect(test.order.slice(0, 6)).toEqual(['flush', 'reserve', 'followup', 'dispatch', 'release', 'flush'])
expect(test.order.slice(0, 6)).toEqual(['flush', 'maintenance', 'followup', 'dispatch', 'release', 'flush'])
expect(test.followed[0]?.content).toEqual([{
type: 'text',
text: [
@@ -259,7 +265,7 @@ describe('Schedule timer and admission runtime', () => {
await owner.dispose()
})
it('rechecks the wall clock after reservation before queuing', async () => {
it('rechecks the wall clock after claiming maintenance before queuing', async () => {
const test = await harness()
appendAfter(test, 'schedule-1', 1, Date.now() - 1_000)
test.controls.onReserve = () => {
@@ -548,7 +554,7 @@ describe('Schedule runtime failure and teardown boundaries', () => {
expect(departedRun.followed).toEqual([])
})
it('releases admission without work when liveness changes during reservation', async () => {
it('releases maintenance without work when liveness changes during its claim', async () => {
const test = await harness()
appendAfter(test, 'schedule-1', 1, Date.now() - 1_000)
test.controls.onReserve = test.disposeAgent
@@ -558,6 +564,17 @@ describe('Schedule runtime failure and teardown boundaries', () => {
expect(test.controls.releaseCount).toBe(1)
expect(test.followed).toEqual([])
await owner.dispose()
const busy = await harness()
appendAfter(busy, 'schedule-1', 1, Date.now() - 1_000)
busy.controls.canReserve = false
busy.controls.onBusy = busy.disposeAgent
const busyOwner = ownerFor(busy)
busyOwner.start()
await settle()
expect(busy.controls.whenIdleCount).toBe(0)
expect(busy.followed).toEqual([])
await busyOwner.dispose()
})
it('waits for in-flight preflight during dispose and does no post-dispose work', async () => {

View File

@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentCancelCause, SendOptions } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentCancelCause, InboxTarget } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { UserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
@@ -24,20 +24,20 @@ interface ToolHarness {
function stubAgent(ctx: Context, id: string): Agent {
const session = ctx.sessions.create(SessionId(id))
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
return {
id: session.id,
options: {},
session,
inbox,
status: 'idle',
acceptsNextStep: false,
ctx: new Context(),
send(_message: UserMessage, _options: SendOptions) {},
updateInbox: () => 'not-found',
reserveTurnAdmission: () => undefined,
send(_message: UserMessage, _target: InboxTarget, _wakeup: boolean) {},
runMaintenance: task => task(signal),
cancel(_cause: AgentCancelCause) {},
whenIdle: () => Promise.resolve(),
followup(_message: UserMessage) {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
steer(_message: UserMessage) {},
inject(_message: UserMessage) {},
}
}