fix(schedule): reconcile latest master contracts
This commit is contained in:
@@ -3,9 +3,8 @@
|
||||
// one, the durable event type and JSON sidecar remain inspectable in the flow.
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { IconSparkle16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { DisclosureRow, IconSparkle16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps, EventRowOwnerProps } from '../contract/slots.ts'
|
||||
import { DisclosureRow } from './DisclosureRow.tsx'
|
||||
import css from './ContextInjectionRow.module.css'
|
||||
|
||||
/** Card props: the event owner payload plus the render site's locale seat. */
|
||||
|
||||
@@ -63,8 +63,6 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ interface ReminderPresentation {
|
||||
scheduleId: string
|
||||
prompt: string
|
||||
occurrenceAt: string
|
||||
deliveryMode: 'session-local'
|
||||
}
|
||||
|
||||
/** Full Schedule row props: event owner/runtime share plus the locale seat. */
|
||||
@@ -20,12 +19,10 @@ function reminderPresentation(value: unknown): ReminderPresentation | null {
|
||||
if (typeof record['scheduleId'] !== 'string' || record['scheduleId'].length === 0) return null
|
||||
if (typeof record['prompt'] !== 'string') return null
|
||||
if (typeof record['occurrenceAt'] !== 'string' || record['occurrenceAt'].length === 0) return null
|
||||
if (record['deliveryMode'] !== 'session-local') return null
|
||||
return {
|
||||
scheduleId: record['scheduleId'],
|
||||
prompt: record['prompt'],
|
||||
occurrenceAt: record['occurrenceAt'],
|
||||
deliveryMode: record['deliveryMode'],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `conversation` is an ordering edge: its service is published after the chat
|
||||
* entry has declared `conversation.chat.eventview`.
|
||||
*/
|
||||
export const inject = ['slots', 'conversation', 'locale']
|
||||
export const inject = ['slots', 'locale']
|
||||
|
||||
/**
|
||||
* Register bilingual copy and the Schedule reminder keyed row.
|
||||
@@ -27,12 +23,12 @@ export const inject = ['slots', 'conversation', 'locale']
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-schedule: dictionaries')
|
||||
ctx.effect(
|
||||
ctx.slots.inject(
|
||||
'conversation.chat.eventview',
|
||||
() => ctx.slots.register({
|
||||
name: 'conversation.chat.eventview',
|
||||
key: 'schedule/change',
|
||||
locale: NS,
|
||||
}, ReminderRow),
|
||||
'ui-schedule: reminder row registration',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { ReminderRow } from '../src/client/ReminderRow.tsx'
|
||||
import { apply as nodeApply } from '../src/index.ts'
|
||||
@@ -10,41 +11,55 @@ import {
|
||||
name as invariantName,
|
||||
} from '../src/invariant.ts'
|
||||
|
||||
interface CapturedEntry {
|
||||
name: string
|
||||
key?: string
|
||||
locale?: string
|
||||
component: unknown
|
||||
}
|
||||
|
||||
function bench() {
|
||||
async function bench(declareBeforeApply = true) {
|
||||
const ctx = new Context()
|
||||
let entry: CapturedEntry | undefined
|
||||
ctx.provide('slots', {
|
||||
register(options: Omit<CapturedEntry, 'component'>, component: unknown) {
|
||||
entry = { ...options, component }
|
||||
return () => { entry = undefined }
|
||||
},
|
||||
})
|
||||
ctx.provide('conversation', {})
|
||||
await ctx.plugin(SlotsService)
|
||||
const slots = ctx.slots as unknown as {
|
||||
register: (options: object, component: unknown) => () => void
|
||||
}
|
||||
const declareHost = () => slots.register({
|
||||
name: 'root',
|
||||
children: { 'conversation.chat.eventview': { kind: 'keyed', scope: 'session' } },
|
||||
}, () => null)
|
||||
const initialHost = declareBeforeApply ? declareHost() : undefined
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
return { ctx, fiber, entry: () => entry }
|
||||
await fiber.await()
|
||||
return {
|
||||
ctx,
|
||||
fiber,
|
||||
declareHost,
|
||||
initialHost,
|
||||
entry: () => ctx.slots.entries('conversation.chat.eventview')[0],
|
||||
}
|
||||
}
|
||||
|
||||
describe('ui-schedule browser plugin', () => {
|
||||
it('registers the keyed reminder renderer and unloads it with the fiber', async () => {
|
||||
const b = bench()
|
||||
await b.fiber.await()
|
||||
expect(b.entry()).toEqual({
|
||||
name: 'conversation.chat.eventview',
|
||||
key: 'schedule/change',
|
||||
locale: 'schedule',
|
||||
component: ReminderRow,
|
||||
})
|
||||
const b = await bench()
|
||||
expect(b.entry()?.options).toEqual({ key: 'schedule/change' })
|
||||
expect(b.entry()?.locale).toBe('schedule')
|
||||
expect(b.entry()?.component).toBe(ReminderRow)
|
||||
|
||||
await b.fiber.dispose()
|
||||
expect(b.entry()).toBeUndefined()
|
||||
b.initialHost?.()
|
||||
})
|
||||
|
||||
it('follows delayed declaration, collapse, and redeclaration until contributor disposal', async () => {
|
||||
const b = await bench(false)
|
||||
expect(b.entry()).toBeUndefined()
|
||||
|
||||
const firstHost = b.declareHost()
|
||||
expect(b.entry()?.component).toBe(ReminderRow)
|
||||
firstHost()
|
||||
expect(b.entry()).toBeUndefined()
|
||||
|
||||
const secondHost = b.declareHost()
|
||||
expect(b.entry()?.component).toBe(ReminderRow)
|
||||
await b.fiber.dispose()
|
||||
expect(b.entry()).toBeUndefined()
|
||||
secondHost()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ const invalidSidecars: ReadonlyArray<{ name: string; view: unknown }> = [
|
||||
scheduleId: null,
|
||||
prompt: 'not trusted',
|
||||
occurrenceAt: '2026-08-05T08:00:00.000Z',
|
||||
deliveryMode: 'session-local',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -28,7 +27,6 @@ const invalidSidecars: ReadonlyArray<{ name: string; view: unknown }> = [
|
||||
scheduleId: '',
|
||||
prompt: 'not trusted',
|
||||
occurrenceAt: '2026-08-05T08:00:00.000Z',
|
||||
deliveryMode: 'session-local',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -37,7 +35,6 @@ const invalidSidecars: ReadonlyArray<{ name: string; view: unknown }> = [
|
||||
scheduleId: 'schedule-7',
|
||||
prompt: 7,
|
||||
occurrenceAt: '2026-08-05T08:00:00.000Z',
|
||||
deliveryMode: 'session-local',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -46,7 +43,6 @@ const invalidSidecars: ReadonlyArray<{ name: string; view: unknown }> = [
|
||||
scheduleId: 'schedule-7',
|
||||
prompt: 'not trusted',
|
||||
occurrenceAt: 7,
|
||||
deliveryMode: 'session-local',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -55,16 +51,6 @@ const invalidSidecars: ReadonlyArray<{ name: string; view: unknown }> = [
|
||||
scheduleId: 'schedule-7',
|
||||
prompt: 'not trusted',
|
||||
occurrenceAt: '',
|
||||
deliveryMode: 'session-local',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'unsupported delivery mode',
|
||||
view: {
|
||||
scheduleId: 'schedule-7',
|
||||
prompt: 'not trusted',
|
||||
occurrenceAt: '2026-08-05T08:00:00.000Z',
|
||||
deliveryMode: 'external',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -88,7 +74,6 @@ describe('ReminderRow', () => {
|
||||
scheduleId: 'schedule-7',
|
||||
prompt: 'Check the deploy',
|
||||
occurrenceAt: '2026-08-05T08:00:00.000Z',
|
||||
deliveryMode: 'session-local',
|
||||
})} />)
|
||||
|
||||
expect(screen.getByRole('note')).toBeTruthy()
|
||||
|
||||
@@ -198,7 +198,7 @@ describe('sessions.flush()', () => {
|
||||
const checkpoints: number[] = []
|
||||
ctx.on('session/flushed', (_session, throughSeq) => { checkpoints.push(throughSeq) })
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
|
||||
const first = ctx.sessions.flush(session)
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
@@ -256,7 +256,7 @@ describe('sessions.flush()', () => {
|
||||
const session = ctx.sessions.create()
|
||||
|
||||
const first = ctx.sessions.flush(session)
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const second = ctx.sessions.flush(session)
|
||||
secondGate.resolve(undefined)
|
||||
await second
|
||||
|
||||
@@ -1115,13 +1115,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
if (persistence !== undefined) {
|
||||
try {
|
||||
const stored = await persistence.inspect(sessionId)
|
||||
const stored = await persistence.readFrom(sessionId, 0)
|
||||
presentedThroughSeq = identityMatchingStoredPrefix(attached, events, stored)
|
||||
} catch (error: unknown) {
|
||||
// Attached history remains available from the live Session. A
|
||||
// failed or not-yet-materialized inspection only withholds
|
||||
// failed or not-yet-materialized physical read only withholds
|
||||
// commit-gated event presentation sidecars.
|
||||
ctx.logger.warn(`session.history: persistence inspection for attached "${sessionId}" failed; serving raw events: ${String(error)}`)
|
||||
ctx.logger.warn(`session.history: physical persistence read for attached "${sessionId}" failed; serving raw events: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -1133,10 +1133,25 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
const inspected = await inspectServable(sessionId)
|
||||
const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined
|
||||
let presentedThroughSeq = 0
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
/* v8 ignore next -- inspectServable already rejects when persistence is absent */
|
||||
if (persistence !== undefined) {
|
||||
try {
|
||||
const stored = await persistence.readFrom(sessionId, 0)
|
||||
presentedThroughSeq = identityMatchingStoredPrefix(
|
||||
{ header: inspected.meta },
|
||||
inspected.events,
|
||||
stored,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`session.history: physical persistence read for detached "${sessionId}" failed; serving raw events: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
return {
|
||||
header: inspected.meta,
|
||||
events: inspected.events,
|
||||
presentedThroughSeq: inspected.events.length,
|
||||
presentedThroughSeq,
|
||||
...projections === undefined ? {} : { projections },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ describe('commit-aware Schedule live views', () => {
|
||||
const ctx = await harness({
|
||||
handler: () => ++calls === 1 ? first.promise : true,
|
||||
})
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const abort = new AbortController()
|
||||
const collected = collectEvents(
|
||||
api.events.mux({ rpcId: RpcId('schedule-live'), payload: {} }, abort.signal),
|
||||
@@ -110,14 +110,14 @@ describe('commit-aware Schedule live views', () => {
|
||||
for: 'event',
|
||||
view: {
|
||||
scheduleId: 'schedule-1', prompt: 'first',
|
||||
occurrenceAt: '2026-08-05T12:00:01.000Z', deliveryMode: 'session-local',
|
||||
occurrenceAt: '2026-08-05T12:00:01.000Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
for: 'event',
|
||||
view: {
|
||||
scheduleId: 'schedule-2', prompt: 'second',
|
||||
occurrenceAt: '2026-08-05T12:00:01.000Z', deliveryMode: 'session-local',
|
||||
occurrenceAt: '2026-08-05T12:00:01.000Z',
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -130,7 +130,7 @@ describe('commit-aware Schedule live views', () => {
|
||||
const ctx = await harness({
|
||||
handler: () => ++calls === 1 ? Promise.reject(new Error('disk unavailable')) : true,
|
||||
})
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const abort = new AbortController()
|
||||
const collected = collectEvents(
|
||||
api.events.mux({ rpcId: RpcId('schedule-retry'), payload: {} }, abort.signal),
|
||||
@@ -171,9 +171,9 @@ describe('Schedule history views', () => {
|
||||
})
|
||||
const child = ctx.sessions.fork(resumed, undefined, SessionId('schedule-fork'))
|
||||
ctx.provide('sessionPersistence', {
|
||||
inspect: () => Promise.resolve({ meta: child.header, events: [...child.events] }),
|
||||
readFrom: () => Promise.resolve({ meta: child.header, events: [...child.events] }),
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
const response = await api.sessions.history({
|
||||
rpcId: RpcId('schedule-resumed-fork'), payload: { sessionId: child.id },
|
||||
@@ -185,7 +185,6 @@ describe('Schedule history views', () => {
|
||||
scheduleId,
|
||||
prompt: 'after restart',
|
||||
occurrenceAt: '2026-08-05T12:00:01.000Z',
|
||||
deliveryMode: 'session-local',
|
||||
},
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
@@ -199,14 +198,14 @@ describe('Schedule history views', () => {
|
||||
seed: [...parent.events],
|
||||
meta: { cwd: '/tmp', parentSession: parent.id, seedLength: 2 },
|
||||
})
|
||||
let inspect = (): Promise<{ meta: SessionHeader; events: SessionEvent[] }> => Promise.resolve({
|
||||
let readFrom = (): Promise<{ meta: SessionHeader; events: SessionEvent[] }> => Promise.resolve({
|
||||
meta: session.header,
|
||||
events: [...session.events.slice(0, 1)],
|
||||
})
|
||||
ctx.provide('sessionPersistence', {
|
||||
inspect: () => inspect(),
|
||||
readFrom: () => readFrom(),
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const history = async () => {
|
||||
const response = await api.sessions.history({
|
||||
rpcId: RpcId('schedule-history'), payload: { sessionId: session.id },
|
||||
@@ -216,19 +215,19 @@ describe('Schedule history views', () => {
|
||||
}
|
||||
|
||||
expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
|
||||
inspect = () => Promise.resolve({
|
||||
readFrom = () => Promise.resolve({
|
||||
meta: { ...session.header, delegationDepth: 0 },
|
||||
events: [...session.events.slice(0, 2)],
|
||||
})
|
||||
expect((await history()).find(entry => entry.event.seq === 1)?.view).toMatchObject({
|
||||
for: 'event',
|
||||
})
|
||||
inspect = () => Promise.resolve({
|
||||
readFrom = () => Promise.resolve({
|
||||
meta: { ...session.header, cwd: '/different', delegationDepth: 0 },
|
||||
events: [...session.events.slice(0, 2)],
|
||||
})
|
||||
expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
|
||||
inspect = () => Promise.reject(new Error('inspect unavailable'))
|
||||
readFrom = () => Promise.reject(new Error('physical read unavailable'))
|
||||
expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -247,8 +246,9 @@ describe('Schedule history views', () => {
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([meta]),
|
||||
inspect: () => Promise.resolve({ meta, events }),
|
||||
readFrom: () => Promise.resolve({ meta, events }),
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const response = await api.sessions.history({
|
||||
rpcId: RpcId('schedule-cold'), payload: { sessionId: meta.id },
|
||||
})
|
||||
@@ -258,4 +258,36 @@ describe('Schedule history views', () => {
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('withholds a detached view that exists only in a logical inspection', async () => {
|
||||
const ctx = await harness()
|
||||
let source: Session | undefined
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
source = inner.sessions.create(SessionId('schedule-logical-only'), { meta: { cwd: '/tmp' } })
|
||||
}, { inject: ['sessions'] }))
|
||||
if (source === undefined) throw new Error('session owner did not publish its session')
|
||||
appendReminder(source, 'schedule-logical', 'not physically committed')
|
||||
const meta = source.header
|
||||
const events = [...source.events]
|
||||
await owner.dispose()
|
||||
let physicalEvents = events.slice(0, 1)
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([meta]),
|
||||
inspect: () => Promise.resolve({ meta, events }),
|
||||
readFrom: () => Promise.resolve({ meta, events: physicalEvents }),
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const history = async () => {
|
||||
const response = await api.sessions.history({
|
||||
rpcId: RpcId('schedule-logical-only-history'), payload: { sessionId: meta.id },
|
||||
})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
return response.result.value.events
|
||||
}
|
||||
|
||||
expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
|
||||
physicalEvents = events
|
||||
expect((await history()).find(entry => entry.event.seq === 1)?.view).toMatchObject({ for: 'event' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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
|
||||
README.md: 8068e649d2116da628af1436e1e3cc71b09dcaa0
|
||||
README.zh.md: 72367b421a8b8dbf5ac866933684740be82157bf
|
||||
|
||||
@@ -32,7 +32,7 @@ The closed v1 domain error codes are `invalid_prompt`, `invalid_selector`, `inva
|
||||
|
||||
The live owner derives the earliest target 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.
|
||||
|
||||
An overdue reminder first checkpoints persistence. If `reserveTurnAdmission()` returns `undefined`, the record stays active and the owner retries after `whenIdle()`. A successful reservation samples one decision time, builds the complete framing, synchronously queues `followup()`, appends an id-only dispatch, releases in `finally`, and then checkpoints the dispatch. 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 the dispatch 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()`. A successful maintenance task samples one decision time, builds the complete framing, synchronously queues `followup()`, and appends an id-only dispatch before releasing the phase. Waking input remains parked until that release, after which the owner checkpoints dispatch. 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 the dispatch 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.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
live owner 从持久折叠结果派生最早的目标。它会拆分超过 Node timer 范围的等待,并在每次唤醒后重新读取墙钟,因此时钟回拨不会提前触发,时钟前跳则会使记录进入 overdue 状态。
|
||||
|
||||
overdue 提醒首先为持久化建立检查点。如果 `reserveTurnAdmission()` 返回 `undefined`,记录会保持活动,并在 `whenIdle()` 后重试。reservation 成功后,owner 会采样一次决策时间,构造完整 framing,同步将 `followup()` 入队,追加只含 id 的 dispatch,在 `finally` 中释放 reservation,随后为 dispatch 建立检查点。framing 构造或同步 `followup` 失败不会写入 dispatch。追加失败会使该 owner 进入故障状态,因为消息可能已经入队;barrier 拒绝会把 dispatch 留给后续普通 preflight 处理,而不会启动私有重试 timer。
|
||||
overdue 提醒首先为持久化建立检查点。如果 agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝对 idle phase 的认领;记录会保持活动,owner 会在 `whenIdle()` 后重试。获准执行的 maintenance task 会采样一次决策时间,构造完整 framing,同步将 `followup()` 入队,并在释放 phase 前追加只含 id 的 dispatch。触发唤醒的 input 会保持 parked,直到该 phase 释放;随后 owner 为 dispatch 建立检查点。framing 构造或同步 `followup` 失败不会写入 dispatch。追加失败会使该 owner 进入故障状态,因为消息可能已经入队;barrier 拒绝会把 dispatch 留给后续普通 preflight 处理,而不会启动私有重试 timer。
|
||||
|
||||
agent 或插件执行 dispose(资源释放)时,会取消 timer、停止新工作,并等待进行中的 preflight 和 idle wait。清理期间绝不会追加 delete 记录。
|
||||
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -328,7 +328,6 @@ export function scheduleReminderPresentation(
|
||||
scheduleId: change.schedule.id,
|
||||
prompt: change.schedule.prompt,
|
||||
occurrenceAt: change.schedule.scheduledAt,
|
||||
deliveryMode: 'session-local',
|
||||
})
|
||||
case 'delete':
|
||||
case 'dispatch':
|
||||
|
||||
@@ -38,13 +38,13 @@ export function apply(ctx: Context): void {
|
||||
let stopping = false
|
||||
|
||||
ctx.effect(() => {
|
||||
const stopCreated = ctx.on('agent/created', (agent) => {
|
||||
const stopCreated = ctx.on('agent/created', ({ agent }) => {
|
||||
if (stopping || owners.has(agent) || !ctx.agents.roots().includes(agent)) return
|
||||
const owner = new ScheduleOwner(ctx, agent)
|
||||
const cleanup: OwnerCleanup = agent.ctx.effect(() => {
|
||||
const disposeTools = registerScheduleTools(ctx, agent.ctx, agent, () => { owner.requestDrive() })
|
||||
const stopStatus = agent.ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') owner.requestDrive()
|
||||
const stopStatus = agent.ctx.on('agent/status', ({ status }) => {
|
||||
if (status === 'idle') owner.requestDrive()
|
||||
})
|
||||
owner.start()
|
||||
return async () => {
|
||||
|
||||
@@ -72,8 +72,6 @@ export interface ScheduleReminderPresentation {
|
||||
readonly prompt: string
|
||||
/** Scheduled one-shot occurrence represented by the dispatch. */
|
||||
readonly occurrenceAt: string
|
||||
/** Fixed delivery boundary rendered by the client plugin. */
|
||||
readonly deliveryMode: ScheduleDeliveryMode
|
||||
}
|
||||
|
||||
/** Management operations whose persistence barrier may be uncertain. */
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -812,7 +812,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'async flush(session: Session): Promise<boolean>',
|
||||
jsDoc: '/**\n * Dispatch the awaited `session/flush` durability checkpoint for `session`,\n * with the carrier captured at {@link enter}. THE flush entry point: the\n * store owns the carrier, so callers (the checkpoint policy\'s per-request\n * barrier, goal-session\'s idle checkpoint, teardown drains, and consumers\n * that flush themselves before reading storage) must come through here\n * rather than dispatch a raw `ctx.parallel(\'session/flush\', …)` — one owner,\n * one spelling, and the scoped-dispatch invariant can pin it.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns whether at least one durability listener participated, after every\n * listener has settled successfully.\n * @throws the first registered listener failure after every listener settles.\n */',
|
||||
jsDoc: '/**\n * Dispatch the awaited `session/flush` durability checkpoint for `session`,\n * with the carrier captured at {@link enter}. THE flush entry point: the\n * store owns the carrier, so callers (the checkpoint policy\'s per-request\n * barrier, goal-session\'s idle checkpoint, teardown drains, and consumers\n * that flush themselves before reading storage) must come through here\n * rather than dispatch a raw `ctx.parallel(\'session/flush\', …)` — one owner,\n * one spelling, and the scoped-dispatch invariant can pin it.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns whether at least one listener acknowledged completed durability,\n * after every listener has settled successfully.\n * @throws the first registered listener failure after every listener settles.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'get(id: SessionId): Session | undefined',
|
||||
@@ -1481,9 +1481,16 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
{
|
||||
name: 'session/flush',
|
||||
mode: 'parallel',
|
||||
signature: '\'session/flush\'(this: Scoped<Session>, session: Session): Promise<void> | void',
|
||||
jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */',
|
||||
summary: 'Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.',
|
||||
signature: '\'session/flush\'(this: Scoped<Session>, session: Session): Promise<true | void> | true | void',
|
||||
jsDoc: '/**\n * Awaited parallel checkpoint: every listener runs and the caller awaits\n * all of them, with no waterfall veto. A listener returns literal `true`\n * only after completing durability work; observe-only listeners return\n * void. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the\n * session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */',
|
||||
summary: 'Awaited parallel checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.',
|
||||
},
|
||||
{
|
||||
name: 'session/flushed',
|
||||
mode: 'emit',
|
||||
signature: '\'session/flushed\'(this: Scoped<Session>, session: Session, throughSeq: number): void',
|
||||
jsDoc: '/**\n * Observe a successful durability checkpoint. `throughSeq` is the exclusive\n * event boundary captured when {@link SessionStore.flush} began; events\n * appended while its listeners run require a later successful checkpoint.\n * Concurrent checkpoints may publish their boundaries out of order, so a\n * consumer retaining progress must advance by the maximum observed value.\n * No notification is published when no durability listener participated or\n * any listener failed. Observer failures are logged and contained.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the session\'s\n * owner scope.\n * @param session - the session whose prefix completed the checkpoint.\n * @param throughSeq - exclusive event sequence boundary proven by the checkpoint.\n * @dshScopeScan unsupported\n * @mode emit\n */',
|
||||
summary: 'Observe a successful durability checkpoint.',
|
||||
},
|
||||
{
|
||||
name: 'settings/document-updated',
|
||||
|
||||
@@ -1125,8 +1125,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
|
||||
/** Build one live controller whose write readiness retries the immutable initial prefix. */
|
||||
private createLiveState(session: Session): LiveSessionState {
|
||||
let live: LiveSessionState
|
||||
live = {
|
||||
const live: LiveSessionState = {
|
||||
init: undefined,
|
||||
writes: this.createWriteBehind(session, () => this.ensureInitialized(session, live)),
|
||||
}
|
||||
|
||||
@@ -399,7 +399,7 @@ describe('PersistenceCoordinator retryable live initialization', () => {
|
||||
const session = ctx.sessions.create(SessionId('retry-new-empty'))
|
||||
await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) })
|
||||
const first = ctx.sessions.flush(session)
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
loadGate.resolve(undefined)
|
||||
await expect(first).rejects.toThrow('transient init read failure')
|
||||
|
||||
Reference in New Issue
Block a user