feat(schedule): add durable after reminders

This commit is contained in:
pku-xht
2026-08-06 03:47:37 +08:00
committed by Tianyi Cui
parent f7e7851e3f
commit 8b69252664
41 changed files with 354 additions and 146 deletions

View File

@@ -26,7 +26,7 @@ Question responses are validated against their pending request before the first
`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only `compact/summary` record on the same page as the replacement that cites it.
An optional `SessionEventView` is a non-persistent presentation sidecar. Tool calls/results keep their existing Host presenters. A Schedule dispatch remains raw on append; after an acknowledged `session/flushed(session, throughSeq)`, the gateway advances an exact-Session `WeakMap` cursor with `max`, derives newly covered receipts through the Schedule package, and redelivers the identical event with `{ for: 'event', presentationKey: 'schedule/reminder', view }`. Reversed flush completion cannot move the cursor backward or duplicate a receipt. Attached history adds these views only within a persistence-inspected prefix whose header and every event match the live identity; unavailable, failed, or mismatched inspection serves raw history without the sidecar. Detached history is already a persisted prefix.
An optional `SessionEventView` is a non-persistent presentation sidecar. Tool calls/results keep their existing Host presenters. A Schedule dispatch remains raw on append; after an acknowledged `session/flushed(session, throughSeq)`, the gateway advances an exact-Session `WeakMap` cursor with `max`, derives newly covered receipts through the Schedule package, and redelivers the identical event with `{ for: 'event', view }`. The durable event type selects the client renderer. Reversed flush completion cannot move the cursor backward or duplicate a receipt. Attached history adds these views only within a persistence-inspected prefix whose header and every event match the live identity; unavailable, failed, or mismatched inspection serves raw history without the sidecar. Detached history is already a persisted prefix.
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.

View File

@@ -26,7 +26,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent然后按追加来源的消息边界分页`maxMessages` 统计以追加方式进入 surface 的 `user/message``assistant/message` 事件因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间从而让压缩compaction的仅日志 `compact/summary` 记录与引用它的替换留在同一页。
可选的 `SessionEventView` 是非持久 presentation sidecar。工具 callresult 保留既有 Host presenter。Schedule dispatch 在 append 时保持 raw收到获确认的 `session/flushed(session, throughSeq)` 后,网关才以 `max` 推进按 exact Session 键控的 `WeakMap` cursor通过 Schedule package 派生新覆盖的回执,并用 `{ for: 'event', presentationKey: 'schedule/reminder', view }` 重投完全相同的事件。反序完成的 flush 不能让 cursor 后退或重复回执。已附加 history 只会在 persistence inspect 得到的前缀内添加这些 view而且该前缀的 header 与每个 event 都必须和 live identity 一致inspect 不可用、失败或不匹配时,仍会返回 raw history只省略 sidecar。已分离 history 本身已经是持久前缀。
可选的 `SessionEventView` 是非持久 presentation sidecar。工具 callresult 保留既有 Host presenter。Schedule dispatch 在 append 时保持 raw收到获确认的 `session/flushed(session, throughSeq)` 后,网关才以 `max` 推进按 exact Session 键控的 `WeakMap` cursor通过 Schedule package 派生新覆盖的回执,并用 `{ for: 'event', view }` 重投完全相同的事件。持久事件类型选择客户端 renderer。反序完成的 flush 不能让 cursor 后退或重复回执。已附加 history 只会在 persistence inspect 得到的前缀内添加这些 view而且该前缀的 header 与每个 event 都必须和 live identity 一致inspect 不可用、失败或不匹配时,仍会返回 raw history只省略 sidecar。已分离 history 本身已经是持久前缀。
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections``@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema协议 schema 对 `values`/`value` 保持宽松loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。

View File

@@ -60,10 +60,7 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials'
// Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`.
import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
import {
SCHEDULE_REMINDER_PRESENTATION_KEY,
scheduleReminderPresentation,
} from '@deepseek-ai/dsh-tool-schedule'
import { scheduleReminderPresentation } from '@deepseek-ai/dsh-tool-schedule'
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
// Side-effect type import: resolves the `approval/request` waterfall and
// `ctx.get('approval')` without a value dependency on the seam (optional composition).
@@ -486,7 +483,7 @@ function scheduleViewFor(
const view = scheduleReminderPresentation(events, event.seq, header.seedLength ?? 0)
return view === undefined
? undefined
: { for: 'event', presentationKey: SCHEDULE_REMINDER_PRESENTATION_KEY, view }
: { for: 'event', view }
} catch (error: unknown) {
ctx.logger.warn(`api-proxy: Schedule presentation failed at seq ${event.seq}; serving raw event: ${String(error)}`)
return undefined

View File

@@ -33,14 +33,12 @@ export type ToolEventView =
| { for: 'result'; view: ToolResultView }
/**
* Host-computed presentation for one non-surface Session event. The domain
* owns the presentation key and JSON-compatible view shape; the carrier keeps
* both generic so an opt-in client plugin can render the event without adding
* domain vocabulary to the connection package.
* Host-computed presentation for one non-surface Session event. The durable
* event type selects an optional client renderer; the sidecar carries only the
* JSON-compatible view so the connection package adds no domain vocabulary.
*/
export interface PresentedEventView {
for: 'event'
presentationKey: string
view: unknown
}

View File

@@ -193,10 +193,9 @@ export const toolEventViewSchema = z.discriminatedUnion('for', [
z.object({ for: z.literal('result'), view: z.looseObject({ card: z.string() }) }),
]) as unknown as z.ZodType<ToolEventView>
/** Domain-owned presented-event sidecar with a carrier-validated key and present payload. */
/** Domain-owned presented-event sidecar whose durable event supplies the renderer key. */
const presentedEventViewSchema = z.object({
for: z.literal('event'),
presentationKey: z.string().min(1),
view: z.unknown(),
}).refine(value => Object.hasOwn(value, 'view'), {
message: 'presented event view payload is required',

View File

@@ -104,7 +104,6 @@ describe('commit-aware Schedule live views', () => {
expect(presented.map(frame => frame.view)).toEqual([
{
for: 'event',
presentationKey: 'schedule/reminder',
view: {
scheduleId: 'schedule-1', prompt: 'first',
occurrenceAt: '2026-08-05T12:00:01.000Z', deliveryMode: 'session-local',
@@ -112,7 +111,6 @@ describe('commit-aware Schedule live views', () => {
},
{
for: 'event',
presentationKey: 'schedule/reminder',
view: {
scheduleId: 'schedule-2', prompt: 'second',
occurrenceAt: '2026-08-05T12:00:01.000Z', deliveryMode: 'session-local',
@@ -143,7 +141,7 @@ describe('commit-aware Schedule live views', () => {
const frames = await collected
expect(frames.filter(frame => frame.view?.for === 'event')).toHaveLength(1)
expect(frames.at(-1)?.view).toMatchObject({
for: 'event', presentationKey: 'schedule/reminder',
for: 'event',
})
await ctx.fiber.dispose()
})
@@ -180,7 +178,7 @@ describe('Schedule history views', () => {
events: [...session.events.slice(0, 2)],
})
expect((await history()).find(entry => entry.event.seq === 1)?.view).toMatchObject({
for: 'event', presentationKey: 'schedule/reminder',
for: 'event',
})
inspect = () => Promise.resolve({
meta: { ...session.header, cwd: '/different', delegationDepth: 0 },
@@ -213,7 +211,7 @@ describe('Schedule history views', () => {
})
if (!response.result.ok) throw new Error(response.result.error.message)
expect(response.result.value.events.find(entry => entry.event.seq === 1)?.view).toMatchObject({
for: 'event', presentationKey: 'schedule/reminder',
for: 'event',
})
await ctx.fiber.dispose()
})

View File

@@ -198,16 +198,12 @@ describe('sessions domain schemas', () => {
event: { type: 'schedule/change', seq: 2, time: 3, data: { operation: 'dispatch' } },
view: {
for: 'event',
presentationKey: 'schedule/reminder',
view: { scheduleId: 'schedule-1' },
},
}
const parsedHistory = sessionHistoryValueSchema.parse({ events: [presented], hasMore: false })
expect(parsedHistory.events?.at(0)?.view).toEqual(presented.view)
for (const view of [
{ for: 'event', presentationKey: '', view: {} },
{ for: 'event', presentationKey: 'schedule/reminder' },
]) {
for (const view of [{ for: 'event' }]) {
expect(() => sessionHistoryValueSchema.parse({
events: [{ event: presented.event, view }],
hasMore: false,
@@ -442,7 +438,7 @@ describe('events frame schemas', () => {
{
type: 'session/event', sessionId: 's',
event: { type: 'schedule/change', seq: 1, time: 2, data: { operation: 'dispatch' } },
view: { for: 'event', presentationKey: 'schedule/reminder', view: null },
view: { for: 'event', view: null },
},
{ type: 'session/subscribed', sessionId: 's', lastSeq: -1 },
{ type: 'approval/requested', sessionId: 's', approvalId: 'a', toolName: 'bash', callId: 'c', reason: 'r' },