feat(schedule): add durable after reminders
This commit is contained in:
@@ -26,6 +26,7 @@ const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | nu
|
||||
'session/disposed': null,
|
||||
'session/event': null,
|
||||
'session/flush': null,
|
||||
'session/flushed': null,
|
||||
'subagent/end': null,
|
||||
'subagent/start': null,
|
||||
'system-prompt/assemble': args => (args[1] as Record<string, unknown>)['scope'],
|
||||
|
||||
@@ -12,8 +12,9 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`.
|
||||
- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject.
|
||||
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, `origin`, and `delegationDepth`.
|
||||
- `ctx.sessions.flush(session)` dispatches an awaited parallel checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; observe-only listeners return void, while a persistence listener returns literal `true` only after completing durability work. A fully successful checkpoint with at least one such acknowledgement returns `true` and emits contained `session/flushed(session, throughSeq)` with the exclusive event boundary captured at entry; no durability acknowledgement returns `false`, and unpublished, detached, or stale objects reject. A caller that requires durable storage rejects `false` at its own policy boundary.
|
||||
- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest log event because between-turn records and non-message turns have no prompt outcome.
|
||||
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that prefix to end outside an open turn, and create a live child session with lineage metadata.
|
||||
- `ctx.sessions.get(id: SessionId): Session | undefined`
|
||||
- `ctx.sessions.list(): Session[]`
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
|
||||
### 公共 API
|
||||
|
||||
- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength` 和 `delegationDepth`。
|
||||
- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝。
|
||||
- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength`、`origin` 和 `delegationDepth`。
|
||||
- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行检查点。每个监听器都会启动,调用会等待全部结算后才报告失败;仅观察的监听器返回 void,持久化监听器只有在完成持久化工作后才返回字面量 `true`。全部成功且至少有一个此类确认时,调用返回 `true`,并发布受包含的 `session/flushed(session, throughSeq)`,其中 `throughSeq` 是入口处捕获的事件排他边界;没有持久化确认时返回 `false`,未发布、已脱离或陈旧对象会被拒绝。要求持久化存储的调用方应在自己的策略边界拒绝 `false`。
|
||||
- `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取日志中最近的事件,因为轮次间记录和非消息轮次没有提示词结果。
|
||||
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id,选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。
|
||||
- `ctx.sessions.get(id: SessionId): Session | undefined`
|
||||
- `ctx.sessions.list(): Session[]`
|
||||
|
||||
@@ -95,14 +95,30 @@ declare module 'cordis' {
|
||||
*/
|
||||
'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
|
||||
/**
|
||||
* Awaited parallel durability checkpoint: every listener runs and the
|
||||
* caller awaits all of them, with no waterfall veto. Scope-filtered dispatch
|
||||
* (`@deepseek-ai/dsh-scope`) reuses the session's owner scope.
|
||||
* Awaited parallel checkpoint: every listener runs and the caller awaits
|
||||
* all of them, with no waterfall veto. A listener returns literal `true`
|
||||
* only after completing durability work; observe-only listeners return
|
||||
* void. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the
|
||||
* session's owner scope.
|
||||
* @param session - the session whose buffered events must reach durable storage.
|
||||
* @dshScopeScan unsupported
|
||||
* @mode parallel
|
||||
*/
|
||||
'session/flush'(this: Scoped<Session>, session: Session): Promise<void> | void
|
||||
'session/flush'(this: Scoped<Session>, session: Session): Promise<true | void> | true | void
|
||||
/**
|
||||
* Observe a successful durability checkpoint. `throughSeq` is the exclusive
|
||||
* event boundary captured when {@link SessionStore.flush} began; events
|
||||
* appended while its listeners run require a later successful checkpoint.
|
||||
* No notification is published when no durability listener participated or
|
||||
* any listener failed. Observer failures are logged and contained.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the session's
|
||||
* owner scope.
|
||||
* @param session - the session whose prefix completed the checkpoint.
|
||||
* @param throughSeq - exclusive event sequence boundary proven by the checkpoint.
|
||||
* @dshScopeScan unsupported
|
||||
* @mode emit
|
||||
*/
|
||||
'session/flushed'(this: Scoped<Session>, session: Session, throughSeq: number): void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,7 +412,7 @@ function collectSessionCallbacks(ctx: Context, args: unknown[]): SessionCallback
|
||||
/** Invoke one resolved observe-only listener snapshot with per-listener containment. */
|
||||
function invokeContainedSessionObservers(
|
||||
ctx: Context,
|
||||
name: 'session/event' | 'session/disposed',
|
||||
name: 'session/event' | 'session/disposed' | 'session/flushed',
|
||||
id: SessionId,
|
||||
args: unknown[],
|
||||
callbacks: SessionCallback[],
|
||||
@@ -1029,12 +1045,13 @@ export class SessionStore extends Service {
|
||||
* rather than dispatch a raw `ctx.parallel('session/flush', …)` — one owner,
|
||||
* one spelling, and the scoped-dispatch invariant can pin it.
|
||||
* @param session - the session whose buffered events must reach durable storage.
|
||||
* @returns whether at least one durability listener participated, after every
|
||||
* listener has settled successfully.
|
||||
* @returns whether at least one listener acknowledged completed durability,
|
||||
* after every listener has settled successfully.
|
||||
* @throws the first registered listener failure after every listener settles.
|
||||
*/
|
||||
async flush(session: Session): Promise<boolean> {
|
||||
const { carrier } = this.liveEntryFor(session)
|
||||
const throughSeq = session.seq
|
||||
const callbackArgs: unknown[] = [session]
|
||||
const callbacks = collectSessionCallbacks(this.ctx, [carrier, 'session/flush', session])
|
||||
const results = await Promise.allSettled(callbacks.map((callback) => {
|
||||
@@ -1049,7 +1066,23 @@ export class SessionStore extends Service {
|
||||
}))
|
||||
const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
if (failure !== undefined) throw failure.reason
|
||||
return callbacks.length > 0
|
||||
const durable = results.some(result => result.status === 'fulfilled' && result.value === true)
|
||||
if (durable) {
|
||||
const flushedArgs: unknown[] = [session, throughSeq]
|
||||
const observers = collectSessionCallbacks(this.ctx, [
|
||||
carrier,
|
||||
'session/flushed',
|
||||
...flushedArgs,
|
||||
])
|
||||
invokeContainedSessionObservers(
|
||||
this.ctx,
|
||||
'session/flushed',
|
||||
session.id,
|
||||
flushedArgs,
|
||||
observers,
|
||||
)
|
||||
}
|
||||
return durable
|
||||
}
|
||||
|
||||
/** Return the exact live entry; detached/prepared objects reject. */
|
||||
|
||||
@@ -83,19 +83,41 @@ describe('sessions.flush()', () => {
|
||||
it('allows an ordinary flush with no listeners', async () => {
|
||||
const ctx = await mount()
|
||||
const session = ctx.sessions.create()
|
||||
const flushed: number[] = []
|
||||
ctx.on('session/flushed', (_current, throughSeq) => { flushed.push(throughSeq) })
|
||||
|
||||
await expect(ctx.sessions.flush(session)).resolves.toBe(false)
|
||||
expect(flushed).toEqual([])
|
||||
})
|
||||
|
||||
it('reports a participating listener after it succeeds', async () => {
|
||||
it('reports a durability listener after it acknowledges success', async () => {
|
||||
const ctx = await mount()
|
||||
const session = ctx.sessions.create()
|
||||
const flushed: Session[] = []
|
||||
ctx.on('session/flush', current => void flushed.push(current))
|
||||
const checkpoints: number[] = []
|
||||
ctx.on('session/flush', (current) => {
|
||||
flushed.push(current)
|
||||
return true as const
|
||||
})
|
||||
ctx.on('session/flushed', (_current, throughSeq) => { checkpoints.push(throughSeq) })
|
||||
|
||||
await expect(ctx.sessions.flush(session)).resolves.toBe(true)
|
||||
|
||||
expect(flushed).toEqual([session])
|
||||
expect(checkpoints).toEqual([0])
|
||||
})
|
||||
|
||||
it('does not treat an observe-only flush listener as durability', async () => {
|
||||
const ctx = await mount()
|
||||
const session = ctx.sessions.create()
|
||||
const observed: Session[] = []
|
||||
const checkpoints: number[] = []
|
||||
ctx.on('session/flush', current => void observed.push(current))
|
||||
ctx.on('session/flushed', (_current, throughSeq) => { checkpoints.push(throughSeq) })
|
||||
|
||||
await expect(ctx.sessions.flush(session)).resolves.toBe(false)
|
||||
expect(observed).toEqual([session])
|
||||
expect(checkpoints).toEqual([])
|
||||
})
|
||||
|
||||
it('dispatches session/flush with the owning carrier and awaits all listeners', async () => {
|
||||
@@ -121,9 +143,13 @@ describe('sessions.flush()', () => {
|
||||
|
||||
it('propagates a rejecting flush listener (the caller owns the failure policy)', async () => {
|
||||
const ctx = await mount()
|
||||
const checkpoints: number[] = []
|
||||
ctx.on('session/flush', () => Promise.reject(new Error('disk full')))
|
||||
ctx.on('session/flush', () => true)
|
||||
ctx.on('session/flushed', (_session, throughSeq) => { checkpoints.push(throughSeq) })
|
||||
const session = ctx.sessions.create()
|
||||
await expect(ctx.sessions.flush(session)).rejects.toThrow('disk full')
|
||||
expect(checkpoints).toEqual([])
|
||||
})
|
||||
|
||||
it('does not let a synchronous flush failure starve later listeners', async () => {
|
||||
@@ -160,6 +186,67 @@ describe('sessions.flush()', () => {
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
|
||||
it('publishes the entry prefix while a concurrent suffix waits for a later checkpoint', async () => {
|
||||
const ctx = await mount()
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
let attempts = 0
|
||||
ctx.on('session/flush', async () => {
|
||||
attempts += 1
|
||||
if (attempts === 1) await gate.promise
|
||||
return true as const
|
||||
})
|
||||
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' } } })
|
||||
|
||||
const first = ctx.sessions.flush(session)
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
gate.resolve(undefined)
|
||||
await first
|
||||
await ctx.sessions.flush(session)
|
||||
|
||||
expect(checkpoints).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('contains successful-checkpoint observers without reversing the barrier', async () => {
|
||||
const ctx = await mount()
|
||||
const checkpoints: number[] = []
|
||||
ctx.on('session/flush', () => true)
|
||||
ctx.on('session/flushed', () => { throw new Error('observer failed') })
|
||||
ctx.on('session/flushed', (_session, throughSeq) => { checkpoints.push(throughSeq) })
|
||||
const session = ctx.sessions.create()
|
||||
|
||||
await expect(ctx.sessions.flush(session)).resolves.toBe(true)
|
||||
expect(checkpoints).toEqual([0])
|
||||
})
|
||||
|
||||
it('may publish overlapping checkpoints out of order without widening either boundary', async () => {
|
||||
const ctx = await mount()
|
||||
const firstGate = Promise.withResolvers<undefined>()
|
||||
const secondGate = Promise.withResolvers<undefined>()
|
||||
const gates = [firstGate, secondGate]
|
||||
ctx.on('session/flush', async () => {
|
||||
const gate = gates.shift()
|
||||
if (gate === undefined) throw new Error('unexpected checkpoint attempt')
|
||||
await gate.promise
|
||||
return true as const
|
||||
})
|
||||
const checkpoints: number[] = []
|
||||
ctx.on('session/flushed', (_session, throughSeq) => { checkpoints.push(throughSeq) })
|
||||
const session = ctx.sessions.create()
|
||||
|
||||
const first = ctx.sessions.flush(session)
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const second = ctx.sessions.flush(session)
|
||||
secondGate.resolve(undefined)
|
||||
await second
|
||||
firstGate.resolve(undefined)
|
||||
await first
|
||||
|
||||
expect(checkpoints).toEqual([1, 0])
|
||||
})
|
||||
|
||||
it('rejects a never-entered session instead of inventing a carrier', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'owner')
|
||||
|
||||
Reference in New Issue
Block a user