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

@@ -6,6 +6,7 @@
import { randomUUID } from 'node:crypto'
import { mkdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { isDeepStrictEqual } from 'node:util'
import type { Context } from 'cordis'
import { installModelSelection } from '@deepseek-ai/dsh-agent'
import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent'
@@ -30,8 +31,9 @@ import type {} from '@deepseek-ai/dsh-tools'
import type {
ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
ModelCatalogFailure, ModelProviderGroup,
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView,
ModelReasoning, MuxFrame, PresentedEventView, QuestionResponsePayload, SessionEventView,
QueuedInboxItem, SessionProjectionsBlock, SessionSearchItem, SessionSummary, SettingsNamespaceView,
SubagentAddress, ToolEventView,
WorkspaceId, WorkspaceView,
} from './api/index.ts'
import {
@@ -58,6 +60,10 @@ 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 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).
@@ -465,6 +471,28 @@ function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) =>
return undefined
}
/**
* Derive one Schedule-owned event sidecar without allowing corrupt domain data
* to break raw event delivery. `seedLength` selects the parent-prefix or
* child-suffix ownership segment inside the package helper.
*/
function scheduleViewFor(
ctx: Context,
header: SessionHeader,
events: readonly SessionEvent[],
event: SessionEvent,
): PresentedEventView | undefined {
try {
const view = scheduleReminderPresentation(events, event.seq, header.seedLength ?? 0)
return view === undefined
? undefined
: { for: 'event', presentationKey: SCHEDULE_REMINDER_PRESENTATION_KEY, view }
} catch (error: unknown) {
ctx.logger.warn(`api-proxy: Schedule presentation failed at seq ${event.seq}; serving raw event: ${String(error)}`)
return undefined
}
}
/**
* Resolve a tool/result's call pairing by scanning a window of events backwards
* for the matching tool/call. Used by the history path (the page is the
@@ -493,17 +521,49 @@ function historyPage(
events: readonly SessionEvent[],
beforeSeq: number | undefined,
maxMessages: number | undefined,
presentation?: { header: SessionHeader; throughSeq: number },
): { events: HistoryEntry[]; hasMore: boolean } {
const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
return {
events: page.events.map((event) => {
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
const toolView = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
const eventView = presentation !== undefined && event.seq < presentation.throughSeq
? scheduleViewFor(ctx, presentation.header, events, event)
: undefined
const view: SessionEventView | undefined = toolView ?? eventView
return { event, ...view === undefined ? {} : { view } }
}),
hasMore: page.hasMore,
}
}
/**
* Prove the exclusive durable prefix of one attached Session against a
* detached persistence inspection. The header and every stored event must
* match the live identity; absent top-level `delegationDepth` is the persisted
* format's canonical zero. A divergent or impossible suffix proves nothing
* and therefore returns zero.
*/
function identityMatchingStoredPrefix(
session: Pick<Session, 'header'>,
liveEvents: readonly SessionEvent[],
stored: { meta: SessionHeader; events: readonly SessionEvent[] },
): number {
const liveIdentity = {
...session.header,
delegationDepth: session.header.delegationDepth ?? 0,
}
const storedIdentity = {
...stored.meta,
delegationDepth: stored.meta.delegationDepth ?? 0,
}
if (!isDeepStrictEqual(storedIdentity, liveIdentity) || stored.events.length > liveEvents.length) return 0
for (let index = 0; index < stored.events.length; index += 1) {
if (!isDeepStrictEqual(stored.events[index], liveEvents[index])) return 0
}
return stored.events.length
}
/**
* The projection baseline for one history tail page: the registry's
* watermark-cache snapshot — one fully synchronous read (no await between the
@@ -745,6 +805,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const pendingQuestions = new Map<RpcId, PendingQuestion>()
const pendingApprovals = new Map<RpcId, PendingApproval>()
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
/** Commit-aware event presentation cursor keyed by exact live Session identity. */
const presentedThrough = new WeakMap<Session, number>()
/**
* Install or return the session-local model selection that prompt assembly snapshots.
@@ -810,6 +872,25 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
for (const queue of muxQueues) queue.push(envelope)
}
// Raw append delivery remains unchanged. A successful durability checkpoint
// later replays only newly covered Schedule dispatches with their sidecar;
// exact-Session identity and max advancement contain id reuse and reversed
// concurrent flush completion without creating another durable state owner.
ctx.on('session/flushed', (session, throughSeq) => {
const previous = presentedThrough.get(session) ?? 0
if (throughSeq <= previous) return
presentedThrough.set(session, throughSeq)
for (let seq = previous; seq < throughSeq; seq += 1) {
const event = session.events[seq]
if (event === undefined) {
throw new Error(`api-proxy: flushed prefix for "${session.id}" is missing event seq ${seq}`)
}
const view = scheduleViewFor(ctx, session.header, session.events, event)
if (view === undefined) continue
broadcast({ type: 'session/event', sessionId: session.id, event, view })
}
})
// Projection change feed → session/projection push frames. The carrier
// mints the wire frame (the Service Definition package holds no wire vocabulary); the
// child activates only when a projection registry is composed, and the
@@ -1023,17 +1104,42 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
async function historyStateFor(
sessionId: SessionId,
includeProjections: boolean,
): Promise<{ events: SessionEvent[]; projections?: SessionProjectionsBlock }> {
): Promise<{
header: SessionHeader
events: SessionEvent[]
presentedThroughSeq: number
projections?: SessionProjectionsBlock
}> {
const attached = ctx.sessions.get(sessionId)
if (attached !== undefined) {
const events = [...attached.events]
const projections = includeProjections ? projectionsFor(ctx, attached) : undefined
return { events, ...projections === undefined ? {} : { projections } }
let presentedThroughSeq = 0
const persistence = ctx.get('sessionPersistence')
if (persistence !== undefined) {
try {
const stored = await persistence.inspect(sessionId)
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
// commit-gated event presentation sidecars.
ctx.logger.warn(`session.history: persistence inspection for attached "${sessionId}" failed; serving raw events: ${String(error)}`)
}
}
return {
header: attached.header,
events,
presentedThroughSeq,
...projections === undefined ? {} : { projections },
}
}
const inspected = await inspectServable(sessionId)
const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined
return {
header: inspected.meta,
events: inspected.events,
presentedThroughSeq: inspected.events.length,
...projections === undefined ? {} : { projections },
}
}
@@ -1611,7 +1717,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
async history(request) {
const { sessionId, beforeSeq, maxMessages } = request.payload
let state: { events: SessionEvent[]; projections?: SessionProjectionsBlock }
let state: {
header: SessionHeader
events: SessionEvent[]
presentedThroughSeq: number
projections?: SessionProjectionsBlock
}
try {
state = await historyStateFor(sessionId, beforeSeq === undefined)
} catch (error: unknown) {
@@ -1624,7 +1735,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
details: {},
})
}
const page = historyPage(ctx, state.events, beforeSeq, maxMessages)
const page = historyPage(ctx, state.events, beforeSeq, maxMessages, {
header: state.header,
throughSeq: state.presentedThroughSeq,
})
return ok(request, {
events: page.events,
hasMore: page.hasMore,

View File

@@ -11,7 +11,7 @@ import type { Wire } from './rpc.schema.ts'
import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts'
import { approvalRequestIdSchema } from './approvals.schema.ts'
import {
contentBlockSchema, messageIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema,
contentBlockSchema, messageIdSchema, sessionEventSchema, sessionEventViewSchema, sessionIdSchema,
} from './sessions.schema.ts'
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
@@ -40,7 +40,7 @@ const messageSchema = z.object({
/** MuxFrame union (payload slot of a mux-stream ServerRequest). */
export const muxFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: toolEventViewSchema.optional() }),
z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: sessionEventViewSchema.optional() }),
z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }),
z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }),
z.object({ type: z.literal('approval/resolved'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]) }),

View File

@@ -32,6 +32,21 @@ export type ToolEventView =
| { for: 'call'; view: ToolCallView }
| { 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.
*/
export interface PresentedEventView {
for: 'event'
presentationKey: string
view: unknown
}
/** Optional non-persistent presentation sidecar for one Session event. */
export type SessionEventView = ToolEventView | PresentedEventView
/** One pending inbox occurrence in the authoritative `session/queue` snapshot. */
export interface QueuedInboxItem {
/** Message identity used by inbox mutations. */
@@ -66,7 +81,7 @@ export interface EventsApi {
* approval/question frames (requested = answerable server-request, the rest are pure pushes).
*/
export type MuxFrame =
| { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: ToolEventView }
| { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: SessionEventView }
| { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number }
| { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string }
| { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome }

View File

@@ -48,7 +48,10 @@ export type {
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor } from './commands.ts'
export type { SkillsApi, SkillEntry } from './skills.ts'
export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
export type {
EventsApi, HostFrame, MuxFrame, PresentedEventView, QueuedInboxItem,
SessionEventView, ToolCallView, ToolEventView, ToolResultView,
} from './events.ts'
export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'
export type { CredentialsApi, CredentialView } from './credentials.ts'

View File

@@ -14,7 +14,7 @@ import type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelSelection, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
} from './sessions.ts'
import type { ToolEventView } from './events.ts'
import type { SessionEventView, ToolEventView } from './events.ts'
import type { WorkspaceId } from './workspace.ts'
import {
SESSION_SEARCH_RESULT_LIMIT,
@@ -193,10 +193,26 @@ export const toolEventViewSchema = z.discriminatedUnion('for', [
z.object({ for: z.literal('result'), view: z.looseObject({ card: z.string() }) }),
]) as unknown as z.ZodType<ToolEventView>
/** One session.history item: the session event plus its optional host-computed tool view. */
/** Domain-owned presented-event sidecar with a carrier-validated key and present payload. */
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',
path: ['view'],
})
/** Any optional host-computed sidecar carried with a Session event. */
export const sessionEventViewSchema = z.union([
toolEventViewSchema,
presentedEventViewSchema,
]) as unknown as z.ZodType<SessionEventView>
/** One session.history item: the session event plus its optional host-computed view. */
export const historyEntrySchema: z.ZodType<Wire<HistoryEntry>> = z.object({
event: sessionEventSchema,
view: toolEventViewSchema.optional(),
view: sessionEventViewSchema.optional(),
}) as unknown as z.ZodType<Wire<HistoryEntry>>
/**

View File

@@ -11,7 +11,7 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
// cordis Context merge (via dsh-agent) must not enter client aggregates.
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { RpcId, RpcRequest, RpcResponse } from './rpc.ts'
import type { ToolEventView } from './events.ts'
import type { SessionEventView } from './events.ts'
import type { WorkspaceId } from './workspace.ts'
declare module '@deepseek-ai/dsh-llm' {
@@ -33,7 +33,7 @@ declare module '@deepseek-ai/dsh-llm' {
*/
export interface HistoryEntry {
event: SessionEvent
view?: ToolEventView
view?: SessionEventView
}
/**