Просмотр исходного кода

refactor(session-title): keep title input as an O(1) projection

The titleInput unit no longer retains the full eligible message history
in bounded reverse-linked chunks. It folds only {first, last, count} —
the values scheduling and fallback reads need — and the full eligible
prefix for one provider generation is scanned from the session log at
execution time. The projection state is O(1) per session instead of
growing with every user message.

The README description updates accordingly and drops the inaccurate
"latest request route" claim; the projection test now asserts the
bounded aggregate and its checkpoint row.
_Kerman 3 недель назад
Родитель
Сommit
b0c2e2bf01

+ 2 - 2
docs/subsystems/session-title.i18n.yaml

@@ -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 docs/subsystems/session-title.md
-session-title.md: d872086500cf477cfa88f8eb756fe9b7be5fc571
-session-title.zh.md: 01aa62a24320e9c9329641603529c1ced9da4360
+session-title.md: 07629a83fa70fa52429e9ca57d75fac6565f5a3e
+session-title.zh.md: ea7581f74434204377f7f5e02611e0feb3da144b

+ 1 - 1
docs/subsystems/session-title.md

@@ -200,5 +200,5 @@ register(provider: SessionTitleProvider): () => Promise<void>
 
 Types: [Session](session.md)
 
-Source: [`packages/session/session-title/src/index.ts:282`](../../packages/session/session-title/src/index.ts)
+Source: [`packages/session/session-title/src/index.ts:289`](../../packages/session/session-title/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 1 - 1
docs/subsystems/session-title.zh.md

@@ -200,5 +200,5 @@ register(provider: SessionTitleProvider): () => Promise<void>
 
 Types: [Session](session.md)
 
-Source: [`packages/session/session-title/src/index.ts:282`](../../packages/session/session-title/src/index.ts)
+Source: [`packages/session/session-title/src/index.ts:289`](../../packages/session/session-title/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 2 - 2
packages/session/session-title/README.i18n.yaml

@@ -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/session/session-title/README.md
-README.md: 7949dbdb73d33c6c7ba1cccd469a6b22732aa95d
-README.zh.md: 24014485050be9e6aa736ac6c686416c3778ac10
+README.md: f7e18b1163790e65b5f0644909098bf5c55dd6b0
+README.zh.md: 74aee2727eb05a2c57b56f491fdfe598840a696d

+ 1 - 1
packages/session/session-title/README.md

@@ -13,7 +13,7 @@ Only text blocks from human `user/message` events are eligible. The first eligib
 - `rename(session, title)` accepts an explicit user title synchronously: it normalizes the text, supersedes in-flight automatic work, and appends a `session/title` event with the `user` source. A user-sourced latest title pins the session — later user messages schedule no automatic revision; an explicit `refresh` remains the deliberate unpin.
 - `register(provider)` installs the sole optional provider and returns its awaitable Cordis effect disposer. A second registration throws immediately; disposal aborts pending and active calls, waits for their settlement, and only then permits another provider to register.
 
-The service also registers the host-only `titleInput` projection. It incrementally retains eligible human text and the latest request route, so provider scheduling reads typed state through `stateOf()` instead of rescanning the session log.
+The service also registers the host-only `titleInput` projection. It incrementally retains the first and latest eligible messages plus their count, so scheduling and fallback reads are O(1) through `stateOf()`; the full eligible prefix for one provider generation is scanned from the session log at execution time.
 
 Automatic work never delays the main agent response. A provider starts only after a marked loop-built request's exact route matches the current logged `request/header`, including when the unchanged header needs no new snapshot. Its late completion appends a standalone log-only event directly through `Session` without opening a turn. Persistence observes that event eagerly and drains on ordinary lifecycle checkpoints; title publication itself does not force a flush. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append. Concurrent explicit refreshes reserve their revision before provider work, while overlapping automatic and explicit fallback requests share one session-local in-flight append. The service and bundled model provider each append their own literal event type, so no generic title-write marker, cast, or settlement queue is needed. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes.
 

+ 1 - 1
packages/session/session-title/README.zh.md

@@ -13,7 +13,7 @@
 - `rename(session, title)` 同步接受用户显式标题:规范化文本、取代在途自动工作,并追加一条 `user` 来源的 `session/title` 事件。用户来源的最新标题会钉住该会话——后续用户消息不再安排自动修订;显式 `refresh` 仍是有意的解钉手段。
 - `register(provider)` 安装唯一可选提供方,并返回可等待的 Cordis effect disposer。第二次注册会立即抛出;对提供方执行 dispose(资源释放)会中止待处理和活跃调用,等待其结算,之后才允许注册另一个提供方。
 
-服务还会注册仅供 host 使用的 `titleInput` 投影。该投影增量保留符合条件的人类文本和最近一次请求 route,使提供方调度可以通过 `stateOf()` 读取类型化状态,无需重新扫描会话日志
+服务还会注册仅供 host 使用的 `titleInput` 投影。该投影增量保留第一条与最新一条合格消息及其计数,使调度与回退读取通过 `stateOf()` 达到 O(1);某次提供方生成所需的完整合格前缀,则会在执行时从会话日志中扫描取得
 
 自动工作绝不会延迟主 agent(智能体)响应。只有当带标记、由循环构建的请求,其确切路由与当前已记录的 `request/header` 匹配时,提供方才会启动;即使请求头未变而无需新快照,也适用此规则。延迟完成会直接通过 `Session` 追加一个独立的纯日志事件,而不打开轮次。持久化会立即观察到该事件,并在常规生命周期检查点完成刷写;标题发布本身不会强制刷写。自动失败会发出警告并保留最新标题。新的全消息修订、提供方 dispose、会话 dispose 和显式刷新都会中止旧工作,陈旧的完成结果无法追加。并发显式刷新会在提供方工作之前预留修订号;重叠的自动/显式回退请求共享一个会话本地正在进行的追加操作。服务与内置模型提供方各自追加自己的字面事件类型,因此不需要通用标题写入标记、类型断言或结算队列。服务拆卸会取消排队工作,并在卸载完成前等待不响应取消的调用结算完成。
 

+ 22 - 19
packages/session/session-title/src/index.ts

@@ -31,7 +31,6 @@ import type {
   SessionTitleSnapshot,
   SessionTitleSource,
   SessionTitleUserMessage,
-  TitleInputChunk,
   TitleInputState,
   TitleProjection,
 } from './types.ts'
@@ -211,20 +210,28 @@ export function titleSnapshotFromState(state: TitleUnitState): SessionTitleSnaps
   })
 }
 
-const TITLE_INPUT_CHUNK_SIZE = 64
+const EMPTY_TITLE_INPUT: TitleInputState = { first: null, last: null, count: 0 }
 
-const EMPTY_TITLE_INPUT: TitleInputState = { first: null, last: null, count: 0, tail: null }
-
-function titleInputPrefix(state: TitleInputState, throughSeq: number): SessionTitleUserMessage[] {
-  const chunks: TitleInputChunk[] = []
-  for (let chunk = state.tail; chunk !== null; chunk = chunk.previous) chunks.push(chunk)
-  const prefix: SessionTitleUserMessage[] = []
-  for (const chunk of chunks.reverse()) {
-    for (const message of chunk.messages) {
-      if (message.seq <= throughSeq) prefix.push(message)
-    }
+/**
+ * Collect eligible human text messages from a session log, in seq order.
+ * The full eligible prefix is only materialized for one provider generation,
+ * so it is scanned from the log at execution time rather than retained by
+ * the O(1) `titleInput` projection.
+ * @param events - the session event log.
+ * @param throughSeq - optional inclusive upper seq bound.
+ * @returns eligible messages with exact source seqs.
+ */
+function collectSessionTitleMessages(
+  events: readonly SessionEvent[],
+  throughSeq?: number,
+): SessionTitleUserMessage[] {
+  const messages: SessionTitleUserMessage[] = []
+  for (const event of events) {
+    if (throughSeq !== undefined && event.seq > throughSeq) break
+    const message = sessionTitleUserMessageOf(event)
+    if (message !== undefined) messages.push(message)
   }
-  return prefix
+  return messages
 }
 
 // Zod cannot express the branded provider id without a runtime transform.
@@ -326,20 +333,16 @@ export class SessionTitleService extends Service {
 
     ctx.sessionProjections.register<'titleInput', TitleInputState>({
       key: 'titleInput',
-      stateVersion: 1,
+      stateVersion: 2,
       stateSchema: zod.custom<TitleInputState>(),
       init: () => EMPTY_TITLE_INPUT,
       apply: (state, event) => {
         const message = sessionTitleUserMessageOf(event)
         if (message === undefined) return state
-        const tail = state.tail === null || state.tail.messages.length >= TITLE_INPUT_CHUNK_SIZE
-          ? { messages: [message], previous: state.tail }
-          : { messages: [...state.tail.messages, message], previous: state.tail.previous }
         return {
           first: state.first ?? message,
           last: message,
           count: state.count + 1,
-          tail,
         }
       },
     })
@@ -586,7 +589,7 @@ export class SessionTitleService extends Service {
       this.assertCurrent(session, work)
       await this.ensureFallback(session)
       this.assertCurrent(session, work)
-      const messages = titleInputPrefix(this.titleInputOf(session), work.throughSeq)
+      const messages = collectSessionTitleMessages(session.events, work.throughSeq)
       const result = await work.registration.provider.generate({
         session,
         messages,

+ 1 - 9
packages/session/session-title/src/types.ts

@@ -65,13 +65,7 @@ export interface SessionTitleUserMessage {
   readonly text: string
 }
 
-/** One bounded reverse-linked group of eligible title-input messages. */
-export interface TitleInputChunk {
-  readonly messages: readonly SessionTitleUserMessage[]
-  readonly previous: TitleInputChunk | null
-}
-
-/** Eligible title input stored in bounded reverse-linked chunks. */
+/** Eligible title input stored as a bounded aggregate. */
 export interface TitleInputState {
   /** The oldest eligible message, or null before any. */
   readonly first: SessionTitleUserMessage | null
@@ -79,8 +73,6 @@ export interface TitleInputState {
   readonly count: number
   /** Newest eligible message, or null before any. */
   readonly last: SessionTitleUserMessage | null
-  /** Newest chunk in the reverse-linked list. */
-  readonly tail: TitleInputChunk | null
 }
 
 declare module '@deepseek-ai/dsh-session-projection/types' {

+ 4 - 5
packages/session/session-title/tests/projection.spec.ts

@@ -62,7 +62,7 @@ describe('title projection unit', () => {
     expect('title' in ctx.sessionProjections.snapshot(session).values).toBe(false)
   })
 
-  it('keeps thousands of title inputs in bounded reverse-linked chunks without persisting them', async () => {
+  it('keeps thousands of title inputs as a bounded aggregate and checkpoints it', async () => {
     const { ctx, session } = await harness(false)
     session.append('turn/start', { turn: 1 })
     for (let index = 0; index < 5_000; index++) {
@@ -75,9 +75,8 @@ describe('title projection unit', () => {
 
     const state = ctx.sessionProjections.stateOf(session, 'titleInput')
     expect(state?.count).toBe(5_000)
-    let chunks = 0
-    for (let chunk = state?.tail ?? null; chunk !== null; chunk = chunk.previous) chunks += 1
-    expect(chunks).toBe(Math.ceil(5_000 / 64))
-    expect(ctx.sessionProjections.checkpoint(session).titleInput).toBeUndefined()
+    expect(state?.first?.text).toBe('message 0')
+    expect(state?.last?.text).toBe('message 4999')
+    expect(ctx.sessionProjections.checkpoint(session).titleInput).toBeDefined()
   })
 })