diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml index d85dec23a0..b6fb66d7e5 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.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 .agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md -2026-09-01-parent-owned-subagent-catalog.md: 7b2082bb6c8e046d3083ab95abffb739895d13b8 -2026-09-01-parent-owned-subagent-catalog.zh.md: 5f8413a3b88a747de6308b1220bb3139543ba83c +2026-09-01-parent-owned-subagent-catalog.md: 6e0547c95db3ad5fc22219163785ae29afe3d707 +2026-09-01-parent-owned-subagent-catalog.zh.md: 0d99f8f7e513953b824da78c7200cdafda0e209e diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md index 7b2082bb6c..6e0547c95d 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md +++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md @@ -22,7 +22,7 @@ The registered host-only `subagentCatalog` projection materializes the parent fa Fork isolation uses the exact `Session.inheritedEventCount` supplied to projection initialization. The fold ignores `subagent/catalog` events below that offset. The state stores the inherited offset but not each event seq because acceptance is decided during folding. -Snapshot normalizers zero `childCreatedAt` because it originates from the process clock. The TypeScript normalizer also sorts adjacent catalog facts by distinct child id because parallel successful creations may append in either order. Non-catalog events remain ordering barriers. +Snapshot normalizers zero `childCreatedAt` because it originates from the process clock. The TypeScript normalizer also sorts adjacent catalog facts by distinct child id because parallel successful creations may append in either order. Non-catalog events remain ordering barriers. Source-event references are remapped to the sorted positions so each citation retains its original fact. ## Alternatives considered @@ -38,6 +38,6 @@ Snapshot normalizers zero `childCreatedAt` because it originates from the proces ## Consequences -A caller can request `projectionStateKeys: ['subagentCatalog']` from `observeSession`. Live observations clone the maintained registry state; cold observations hydrate their prepared Session and detach the same state at the observation cursor. Direct-child and descendant listing still use the Session corpus and child identity projection. +A caller can request `projectionStateKeys: ['subagentCatalog']` from `observeSession`. Live observations clone the maintained registry state; cold observations hydrate their prepared Session and detach the same state at the observation cursor. A host-state-only read passes an empty view selection through checkpoint hydration, including cache reuse and malformed-row recovery; unrelated wire views are neither computed nor validated. Direct-child and descendant listing still use the Session corpus and child identity projection. Backends that do not know the required event refuse the log under the existing Session event mechanism. Pre-release format policy requires no fallback scan for old logs. diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md index 5f8413a3b8..0d99f8f7e5 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md @@ -22,7 +22,7 @@ child header 与 `subagent/descriptor` 继续拥有恢复与 composition 权威 fork 隔离使用 projection 初始化时提供的精确 `Session.inheritedEventCount`。fold 忽略该 offset 之前的 `subagent/catalog` 事件。state 保存 inherited offset,但不保存每条 event seq,因为接受判定已在 fold 时完成。 -snapshot normalizer 会把 `childCreatedAt` 归零,因为它来自 process clock。TypeScript normalizer 还会按不同 child id 排序相邻 catalog fact,因为并行成功创建的 append 顺序可能不同。非 catalog event 继续作为顺序 barrier。 +snapshot normalizer 会把 `childCreatedAt` 归零,因为它来自 process clock。TypeScript normalizer 还会按不同 child id 排序相邻 catalog fact,因为并行成功创建的 append 顺序可能不同。非 catalog event 继续作为顺序 barrier。来源事件引用会重映射到排序后的位置,使每个引用仍指向原来的事实。 ## 考虑过的替代方案 @@ -38,6 +38,6 @@ snapshot normalizer 会把 `childCreatedAt` 归零,因为它来自 process clo ## 后果 -调用方可以向 `observeSession` 请求 `projectionStateKeys: ['subagentCatalog']`。实时观察克隆 registry 维护中的 state;冷观察 hydrate 已准备的 Session,并在观察 cursor 处分离出同一 state。直接子级和后代列表仍使用 Session 语料库与子级身份 projection。 +调用方可以向 `observeSession` 请求 `projectionStateKeys: ['subagentCatalog']`。实时观察克隆 registry 维护中的 state;冷观察 hydrate 已准备的 Session,并在观察 cursor 处分离出同一 state。仅读取 host state 时,空视图选择会传递到检查点 hydration,包括缓存复用和损坏 row 的恢复;无关 wire view 不会被计算或校验。直接子级和后代列表仍使用 Session 语料库与子级身份 projection。 不认识该 required event 的 backend 会按既有 Session event 机制拒绝日志。pre-release format policy 不要求为旧日志保留 fallback scan。 diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 39bbf0016a..03a6f38bbd 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.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-projection.md -session-projection.md: 8568b6df94b1427a341568777c64c82d74176a17 -session-projection.zh.md: 5e50341a6443efde2a3b49b7f5185bc44896efc6 +session-projection.md: 52e99245609872c61853792250a4e0fcf631f279 +session-projection.zh.md: 372c530ba36e83240c431c2c07edc2d39fb7d21b diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 8568b6df94..52e9924560 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -163,9 +163,10 @@ cachedPredecessorTitle( meta: SessionHeader, inheritedEventCount: SessionLogOffs * because the logical observation may contain recovery events not yet durable. * @param session - exact unpublished Session retained by persistence. * @param events - exact logical event prefix represented by the observation. - * @returns all projection values at the event cut. + * @param keys - optional client-visible outputs; an empty list hydrates states without computing or validating views. + * @returns selected projection values at the event cut, defaulting to all views. */ -hydratePrepared( session: Session, events: readonly SessionEvent[], ): ProjectionSnapshot +hydratePrepared( session: Session, events: readonly SessionEvent[], keys?: readonly Extract[], ): ProjectionSnapshot /** * Durably checkpoint one live session NOW (all mandatory points call @@ -329,11 +330,12 @@ viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: readonly Extract[], ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } /** * Restore an exact cut and install its states on the supplied prepared Session. @@ -343,9 +345,10 @@ restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], base * @param checkpoint - persisted rows for this Session lifecycle. * @param events - exact events at the observation cut. * @param baseSeq - first supplied event sequence. - * @returns all projection values at the supplied cut. + * @param keys - optional client-visible outputs; an empty list installs every state without computing or validating views. + * @returns selected projection values at the supplied cut, defaulting to all views. */ -hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, ): ProjectionSnapshot +hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, keys?: readonly Extract[], ): ProjectionSnapshot ``` Types: [Session](session.md) · [SessionEvent](session.md) · [SessionHeader](persistence.md) · [SessionLogOffset](session.md) diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 5e50341a64..372c530ba3 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -163,9 +163,10 @@ cachedPredecessorTitle( meta: SessionHeader, inheritedEventCount: SessionLogOffs * because the logical observation may contain recovery events not yet durable. * @param session - exact unpublished Session retained by persistence. * @param events - exact logical event prefix represented by the observation. - * @returns all projection values at the event cut. + * @param keys - optional client-visible outputs; an empty list hydrates states without computing or validating views. + * @returns selected projection values at the event cut, defaulting to all views. */ -hydratePrepared( session: Session, events: readonly SessionEvent[], ): ProjectionSnapshot +hydratePrepared( session: Session, events: readonly SessionEvent[], keys?: readonly Extract[], ): ProjectionSnapshot /** * Durably checkpoint one live session NOW (all mandatory points call @@ -329,11 +330,12 @@ viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: readonly Extract[], ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } /** * Restore an exact cut and install its states on the supplied prepared Session. @@ -343,9 +345,10 @@ restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], base * @param checkpoint - persisted rows for this Session lifecycle. * @param events - exact events at the observation cut. * @param baseSeq - first supplied event sequence. - * @returns all projection values at the supplied cut. + * @param keys - optional client-visible outputs; an empty list installs every state without computing or validating views. + * @returns selected projection values at the supplied cut, defaulting to all views. */ -hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, ): ProjectionSnapshot +hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, keys?: readonly Extract[], ): ProjectionSnapshot ``` Types: [Session](session.zh.md) · [SessionEvent](session.zh.md) · [SessionHeader](persistence.zh.md) · [SessionLogOffset](session.zh.md) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index de254c190b..dba22a4046 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1627,10 +1627,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'a title-only checkpoint view with `asOfSeq: -1`, or `undefined` when the record is current, newer, unrelated, missing, or incompatible with the title unit. The sentinel avoids reusing a sequence that a cardinality-changing Session migration may have remapped.', }, { - signature: 'hydratePrepared( session: Session, events: readonly SessionEvent[], ): ProjectionSnapshot', + signature: 'hydratePrepared( session: Session, events: readonly SessionEvent[], keys?: readonly Extract[], ): ProjectionSnapshot', description: 'Hydrate projection cells for an already-prepared Session without another persistence read. The cache seeds matching rows; the supplied exact log advances every unit to the observation cut. No checkpoint is written because the logical observation may contain recovery events not yet durable.', - parameters: [{ name: 'session', description: 'exact unpublished Session retained by persistence.' }, { name: 'events', description: 'exact logical event prefix represented by the observation.' }], - returns: 'all projection values at the event cut.', + parameters: [{ name: 'session', description: 'exact unpublished Session retained by persistence.' }, { name: 'events', description: 'exact logical event prefix represented by the observation.' }, { name: 'keys', description: 'optional client-visible outputs; an empty list hydrates states without computing or validating views.' }], + returns: 'selected projection values at the event cut, defaulting to all views.', }, { signature: 'async write(session: Session): Promise', @@ -1706,16 +1706,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'whole values per key with a usable row; empty when none.', }, { - signature: 'restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, header: SessionHeader, inheritedEventCount: SessionLogOffset, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }', + signature: 'restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, header: SessionHeader, inheritedEventCount: SessionLogOffset, keys?: readonly Extract[], ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }', description: 'Cold read: fold every persisted unit over a stored log suffix, seeding each from its checkpoint row when usable — the one read recipe (cached state + forward tail replay + `view`) applied without a live `Session`. Call with the stored events at or past `restoreFloor(checkpoint)` (a `SessionHandle.read` slice) and that same floor as `baseSeq`; the floor\'s one-below anchor makes the supplied end honest, so a shrunk log is detected here. A row is usable iff its `ver` matches the live unit\'s `stateVersion`, it does not predate `baseSeq` (`seq >= baseSeq - 1`), and it does not claim events past the supplied end (`seq <= endSeq`); an unusable row is discarded and its key refolds from `init` — which is only sound over the full log, so a discarded row with `baseSeq > 0` throws (the caller re-reads from seq 0, e.g. after a crash-repair truncation shrank the log below a row\'s watermark).', - parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }, { name: 'events', description: 'the stored events with `seq >= baseSeq`, in seq order.' }, { name: 'baseSeq', description: 'the seq `events` starts at (its first event\'s seq when non-empty).' }, { name: 'header', description: 'immutable metadata for the Session being restored.' }, { name: 'inheritedEventCount', description: 'exact fork-inherited prefix length supplied to unit initialization.' }], + parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }, { name: 'events', description: 'the stored events with `seq >= baseSeq`, in seq order.' }, { name: 'baseSeq', description: 'the seq `events` starts at (its first event\'s seq when non-empty).' }, { name: 'header', description: 'immutable metadata for the Session being restored.' }, { name: 'inheritedEventCount', description: 'exact fork-inherited prefix length supplied to unit initialization.' }, { name: 'keys', description: 'optional client-visible outputs; an empty list restores every state without computing or validating views.' }], returns: 'the snapshot cut at the supplied log end (`asOfSeq` is the last supplied event\'s seq, `baseSeq - 1` for an empty tail) plus the refreshed checkpoint rows at that cut, ready for a durable write-back.', }, { - signature: 'hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, ): ProjectionSnapshot', + signature: 'hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, keys?: readonly Extract[], ): ProjectionSnapshot', description: 'Restore an exact cut and install its states on the supplied prepared Session. A later publication reuses these cells; ordinary live reads and event drive advance any constructor-owned suffix exactly once.', - parameters: [{ name: 'session', description: 'exact prepared Session that owns the restored log prefix.' }, { name: 'checkpoint', description: 'persisted rows for this Session lifecycle.' }, { name: 'events', description: 'exact events at the observation cut.' }, { name: 'baseSeq', description: 'first supplied event sequence.' }], - returns: 'all projection values at the supplied cut.', + parameters: [{ name: 'session', description: 'exact prepared Session that owns the restored log prefix.' }, { name: 'checkpoint', description: 'persisted rows for this Session lifecycle.' }, { name: 'events', description: 'exact events at the observation cut.' }, { name: 'baseSeq', description: 'first supplied event sequence.' }, { name: 'keys', description: 'optional client-visible outputs; an empty list installs every state without computing or validating views.' }], + returns: 'selected projection values at the supplied cut, defaulting to all views.', }, ], }, diff --git a/packages/session-query/session-query/README.i18n.yaml b/packages/session-query/session-query/README.i18n.yaml index e5ac8d1d9b..7857b37bc6 100644 --- a/packages/session-query/session-query/README.i18n.yaml +++ b/packages/session-query/session-query/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-query/session-query/README.md -README.md: 52c6870fed7f39c2e2c5a49c1aa43c7e498488ea -README.zh.md: e062d0a2a37252378ae32431d8ae8d1a264ec200 +README.md: b754f342e4614137b92aa749c3b6822a23e2e4bf +README.zh.md: 9c41cc0ce97a99d8b64c206af26799527e97d102 diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 52c6870fed..b754f342e4 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -108,7 +108,7 @@ The decision history lives in the [unified service decision](../../../.agents/no ### Observation cache -`observeSession` builds point observations without a listing preflight. A live observation fixes its cut as the current log length and materializes `events` on first read, so header-, cursor-, or projection-only consumers never copy the log; the log only appends, so a late first read still yields exactly that prefix. The cold path stats the stored session first and consults an own bounded cache keyed by the persistence instance and the `stat` revision: an unchanged revision reuses the restored unpublished Session without re-reading the log; a changed revision, or a replaced persistence instance, reloads through the handle seam and replaces the entry. The cache holds `preparedSessionCacheSize` entries with least-recently-used eviction, entries pinned by active observation leases are never evicted, and a session that goes live mid-read retries the live path. `projectionStateKeys` detaches named host projection states at the same cut independently of `projectionMode`: a live observation clones the registry's maintained cells, and a cold observation hydrates its prepared Session's cells once so states and any views share one cut; unregistered keys are omitted and an absent registry leaves `projectionStates` undefined. +`observeSession` builds point observations without a listing preflight. A live observation fixes its cut as the current log length and materializes `events` on first read, so header-, cursor-, or projection-only consumers never copy the log; the log only appends, so a late first read still yields exactly that prefix. The cold path stats the stored session first and consults an own bounded cache keyed by the persistence instance and the `stat` revision: an unchanged revision reuses the restored unpublished Session without re-reading the log; a changed revision, or a replaced persistence instance, reloads through the handle seam and replaces the entry. The cache holds `preparedSessionCacheSize` entries with least-recently-used eviction, entries pinned by active observation leases are never evicted, and a session that goes live mid-read retries the live path. `projectionStateKeys` detaches named host projection states at the same cut independently of `projectionMode`: a live observation clones the registry's maintained cells, and a cold observation hydrates its prepared Session's cells once so states and any views share one cut; unregistered keys are omitted and an absent registry leaves `projectionStates` undefined. With `projectionMode: 'none'`, both live and cold observations omit client view computation and validation even when host states are requested. ### Reads and traces diff --git a/packages/session-query/session-query/README.zh.md b/packages/session-query/session-query/README.zh.md index e062d0a2a3..9c41cc0ce9 100644 --- a/packages/session-query/session-query/README.zh.md +++ b/packages/session-query/session-query/README.zh.md @@ -108,7 +108,7 @@ kind: "package-reference" ### 观察缓存 -`observeSession` 不经过列表预检直接构建定点观察。实时观察以当前日志长度固定 cut,并在首次读取时才物化 `events`,因此只需要 header、cursor 或 projection 的消费者永远不会复制日志;日志只会追加,所以延后的首次读取得到的仍然正好是该前缀。冷路径先对存储会话执行 `stat`,再查询自有的有界缓存,缓存键为持久化实例加 `stat` 修订:修订未变则复用已恢复的未发布 Session,不再重读日志;修订变化或持久化实例被替换则经 handle 缝重新加载并替换条目。缓存保留 `preparedSessionCacheSize` 个条目并按最久未用淘汰,被活跃观察租约钉住的条目从不被淘汰;读取中途转为实时的会话会重试实时路径。 `projectionStateKeys` 在同一 cut 上分离出指定的 host projection state,与 `projectionMode` 无关:实时观察克隆 registry 维护中的 cell,冷观察则一次性 hydrate 其已准备 Session 的 cell,使 state 与任何 view 共享同一 cut;未注册的 key 被省略,registry 缺失时 `projectionStates` 为 undefined。 +`observeSession` 不经过列表预检直接构建定点观察。实时观察以当前日志长度固定 cut,并在首次读取时才物化 `events`,因此只需要 header、cursor 或 projection 的消费者永远不会复制日志;日志只会追加,所以延后的首次读取得到的仍然正好是该前缀。冷路径先对存储会话执行 `stat`,再查询自有的有界缓存,缓存键为持久化实例加 `stat` 修订:修订未变则复用已恢复的未发布 Session,不再重读日志;修订变化或持久化实例被替换则经 handle 缝重新加载并替换条目。缓存保留 `preparedSessionCacheSize` 个条目并按最久未用淘汰,被活跃观察租约钉住的条目从不被淘汰;读取中途转为实时的会话会重试实时路径。 `projectionStateKeys` 在同一 cut 上分离出指定的 host projection state,与 `projectionMode` 无关:实时观察克隆 registry 维护中的 cell,冷观察则一次性 hydrate 其已准备 Session 的 cell,使 state 与任何 view 共享同一 cut;未注册的 key 被省略,registry 缺失时 `projectionStates` 为 undefined。使用 `projectionMode: 'none'` 时,即使请求了 host state,实时和冷观察也都不会计算或校验客户端视图。 ### 读取与追踪 diff --git a/packages/session-query/session-query/src/observation.ts b/packages/session-query/session-query/src/observation.ts index 7ba5951710..eb1a886243 100644 --- a/packages/session-query/session-query/src/observation.ts +++ b/packages/session-query/session-query/src/observation.ts @@ -50,7 +50,7 @@ export interface SessionObservation extends Disposable { export interface SessionObservationOptions { /** Optional cancellation while resolving a cold source. */ readonly signal?: AbortSignal - /** Whether to compute every projection or leave projection state untouched. */ + /** Whether to compute client views; requested host states are still materialized. */ readonly projectionMode?: 'all' | 'none' /** * Host projection states to detach from the same exact cut, independent of @@ -103,7 +103,7 @@ export class SessionObservationReader { /** * Observe one live-preferred Session and retain a cold preparation until disposal. * @param sessionId - logical Session identity. - * @param options - cancellation and all-or-none projection computation for this read. + * @param options - cancellation, client views, and requested host states for this read. * @returns one exact immutable observation. */ async read( @@ -167,7 +167,7 @@ export class SessionObservationReader { if (projectionMode === 'all' || projectionStateKeys !== undefined) { // Hydration installs every unit's cell on the prepared Session, so a // requested host state reads the same cut the views were taken from. - const snapshot = this.preparedProjections(entry) + const snapshot = this.preparedProjections(entry, projectionMode) projections = projectionMode === 'all' ? snapshot : undefined projectionStates = this.projectionStates(entry.session, projectionStateKeys) } @@ -330,13 +330,17 @@ export class SessionObservationReader { return lease() } - private preparedProjections(entry: PreparedEntry): ProjectionSnapshot | undefined { + private preparedProjections( + entry: PreparedEntry, + projectionMode: NonNullable, + ): ProjectionSnapshot | undefined { const registry = this.ctx.get('sessionProjections') if (registry === undefined) return undefined const cache = this.ctx.get('sessionProjectionCache') + const keys = projectionMode === 'none' ? [] : undefined return cache === undefined - ? registry.hydrate(entry.session, {}, entry.events, SessionLogOffset(0)) - : cache.hydratePrepared(entry.session, entry.events) + ? registry.hydrate(entry.session, {}, entry.events, SessionLogOffset(0), keys) + : cache.hydratePrepared(entry.session, entry.events, keys) } /** diff --git a/packages/session-query/session-query/tests/observation.spec.ts b/packages/session-query/session-query/tests/observation.spec.ts index 106f3453e1..93a0e4c191 100644 --- a/packages/session-query/session-query/tests/observation.spec.ts +++ b/packages/session-query/session-query/tests/observation.spec.ts @@ -762,7 +762,7 @@ describe('SessionObservationReader cold projections', () => { await ctx.fiber.dispose() }) - it('hydrates prepared projections through a mounted projection cache', async () => { + it.each(['all', 'none'] as const)('hydrates prepared projections through a mounted projection cache in %s mode', async (projectionMode) => { const ctx = await readerContext() await ctx.plugin(SessionProjectionRegistry) const meta = header('cold-cache') @@ -772,10 +772,14 @@ describe('SessionObservationReader cold projections', () => { const hydratePrepared = vi.fn().mockReturnValue(snapshot) ctx.provide('sessionProjectionCache', { hydratePrepared } as never) - using observed = await new SessionObservationReader(ctx).read(meta.id) + using observed = await new SessionObservationReader(ctx).read(meta.id, { + projectionMode, + projectionStateKeys: ['title'], + }) - expect(observed.projections).toBe(snapshot) + expect(observed.projections).toBe(projectionMode === 'all' ? snapshot : undefined) expect(hydratePrepared).toHaveBeenCalledOnce() + expect(hydratePrepared.mock.calls[0]?.[2]).toEqual(projectionMode === 'all' ? undefined : []) await ctx.fiber.dispose() }) @@ -837,6 +841,33 @@ describe('SessionObservationReader host projection states', () => { await ctx.fiber.dispose() }) + it('omits wire views on fresh and reused cold host-state reads', async () => { + const ctx = await readerContext() + try { + await ctx.plugin(SessionProjectionRegistry) + const view = vi.fn(titleProjectionDefinition.wire.view) + ctx.sessionProjections.register({ + ...titleProjectionDefinition, + wire: { ...titleProjectionDefinition.wire, view }, + }) + const meta = header('cold-host-only') + const store = new Map([[meta.id, { header: meta, events: [titleEvent(0, 'Cold title')], revision: 'r1' }]]) + ctx.provide('sessionPersistence', stubPersistence(store, { stat: 0, open: 0, read: 0 })) + const reader = new SessionObservationReader(ctx) + for (let read = 0; read < 2; read++) { + using observed = await reader.read(meta.id, { projectionMode: 'none', projectionStateKeys: ['title'] }) + expect(observed.projections).toBeUndefined() + expect(observed.projectionStates).toEqual({ title: 'Cold title' }) + expect(view).not.toHaveBeenCalled() + } + using full = await reader.read(meta.id) + expect(full.projections?.values).toEqual({ title: 'Cold title' }) + expect(view).toHaveBeenCalledOnce() + } finally { + await ctx.fiber.dispose() + } + }) + it('omits unregistered keys and the whole map when no registry is mounted', async () => { const withRegistry = await readerContext() await withRegistry.plugin(SessionProjectionRegistry) diff --git a/packages/session/session-projection-cache/README.i18n.yaml b/packages/session/session-projection-cache/README.i18n.yaml index c738058bea..549dd6c0d8 100644 --- a/packages/session/session-projection-cache/README.i18n.yaml +++ b/packages/session/session-projection-cache/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-projection-cache/README.md -README.md: dcbe57af1f9c13f1f43572ff188dfdaaf9287160 -README.zh.md: 367d893c56a23e0965eb1e10f42fb6705284af6b +README.md: ab9c4f0ebb8f68d53ebdb952997805f2b52f59af +README.zh.md: c6ac28788ea4000327e0232d45fb0e1344e500da diff --git a/packages/session/session-projection-cache/README.md b/packages/session/session-projection-cache/README.md index dcbe57af1f..ab9c4f0ebb 100644 --- a/packages/session/session-projection-cache/README.md +++ b/packages/session/session-projection-cache/README.md @@ -52,6 +52,8 @@ The cache opens its domain through the storage stack, so base mounts `storage`, The plugin injects `storageDomain`, `sessionProjections`, and `sessions`. The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-session-projection-cache) is the exhaustive source for every accepted field and its JSDoc. +`hydratePrepared` accepts optional view keys and restores every state from the checkpoint and event tail. An empty key list suppresses client view computation and validation, including when a malformed checkpoint requires a full refold. + ### How checkpoints are written Three mandatory points always write: session creation persists the seed-derived cut, `turn/end` persists the value that listing reads want, and session disposal persists the final live cut. Between them, the configured count and interval throttles write as events accumulate. Every write atomically replaces the session's complete record through the domain write chain; a failure logs a warning and keeps the cache stale, and the next write self-heals. diff --git a/packages/session/session-projection-cache/README.zh.md b/packages/session/session-projection-cache/README.zh.md index 367d893c56..c6ac28788e 100644 --- a/packages/session/session-projection-cache/README.zh.md +++ b/packages/session/session-projection-cache/README.zh.md @@ -52,6 +52,8 @@ kind: "package-reference" 本插件注入 `storageDomain`、`sessionProjections` 与 `sessions`。生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-session-projection-cache)是每个受支持字段及其 JSDoc 的穷尽式真源。 +`hydratePrepared` 接受可选的视图 key,并从检查点与事件尾部恢复所有状态。空 key 列表会跳过客户端视图的计算与校验,即使检查点损坏、需要重新折叠完整日志也如此。 + ### 检查点如何写入 三个必写点总是写入:会话创建保存由种子派生的切面,`turn/end` 保存列表读取所需的轮次终值,会话释放保存最终实时切面。其间,配置的条数与间隔节流随事件累积写入。每次写入通过领域写入链以原子方式替换该会话的完整记录;失败会记录警告并让缓存保持陈旧,后续写入会自行修复。 diff --git a/packages/session/session-projection-cache/src/index.ts b/packages/session/session-projection-cache/src/index.ts index 639dfcc978..af5636c68c 100644 --- a/packages/session/session-projection-cache/src/index.ts +++ b/packages/session/session-projection-cache/src/index.ts @@ -207,18 +207,20 @@ export class SessionProjectionCache extends Service { * because the logical observation may contain recovery events not yet durable. * @param session - exact unpublished Session retained by persistence. * @param events - exact logical event prefix represented by the observation. - * @returns all projection values at the event cut. + * @param keys - optional client-visible outputs; an empty list hydrates states without computing or validating views. + * @returns selected projection values at the event cut, defaulting to all views. */ hydratePrepared( session: Session, events: readonly SessionEvent[], + keys?: readonly Extract[], ): ProjectionSnapshot { const record = this.recordFor( session.id, identityOf(session.header, session.inheritedEventCount), ) if (record === undefined) { - return this.ctx.sessionProjections.hydrate(session, {}, events, SessionLogOffset(0)) + return this.ctx.sessionProjections.hydrate(session, {}, events, SessionLogOffset(0), keys) } try { return this.ctx.sessionProjections.hydrate( @@ -226,11 +228,12 @@ export class SessionProjectionCache extends Service { record.rows, events, SessionLogOffset(0), + keys, ) } catch { // Cached rows are disposable derived data. Retry from the exact log so a // stale schema cannot make a valid Session unreadable. - return this.ctx.sessionProjections.hydrate(session, {}, events, SessionLogOffset(0)) + return this.ctx.sessionProjections.hydrate(session, {}, events, SessionLogOffset(0), keys) } } diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index b38f49eec1..1774adb6ad 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -651,6 +651,46 @@ describe('SessionProjectionCache cold-read seeding', () => { }) }) + it.each(['matching', 'malformed', 'absent'])('hydrates every state with only selected views from a %s checkpoint', async (record) => { + const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) + roots.push(root) + const id = SessionId('selected-views') + if (record !== 'absent') { + await seedRecord(root, id, { + 'cache-test/marks': { ver: 1, seq: SessionSeq(1), val: { marks: record === 'matching' ? ['cached'] : 'malformed' } }, + }) + } + const { cache, ctx } = await harness({ root }) + const view = vi.fn(secondaryMarksUnit.wire.view) + const schema = secondaryMarksUnit.wire.viewSchema.clone() + const parseView = vi.spyOn(schema, 'parse') + ctx.sessionProjections.register({ + ...secondaryMarksUnit, + wire: { viewSchema: schema, view }, + }) + const events = storedLog([['fresh']]) + const session = Session.create(id, events, headerOf(id)) + const marks = { marks: [record === 'matching' ? 'cached' : 'fresh'] } + + for (let read = 0; read < 2; read++) { + expect(cache.hydratePrepared(session, events, [])).toEqual({ asOfSeq: 2, values: {} }) + expect(ctx.sessionProjections.stateOf(session, 'cache-test/marks')).toEqual(marks) + expect(ctx.sessionProjections.stateOf(session, 'cache-test/secondary-marks')).toEqual({ marks: ['fresh'] }) + expect(view).not.toHaveBeenCalled() + expect(parseView).not.toHaveBeenCalled() + } + expect(cache.hydratePrepared(session, events, ['cache-test/marks'])).toEqual({ + asOfSeq: 2, + values: { 'cache-test/marks': marks }, + }) + expect(view).not.toHaveBeenCalled() + expect(cache.hydratePrepared(session, events).values).toEqual({ + 'cache-test/marks': marks, + 'cache-test/secondary-marks': { marks: ['fresh'] }, + }) + expect(view).toHaveBeenCalledOnce() + }) + it('coldSnapshot traverses the full log but applies only the events after each cached watermark', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) roots.push(root) diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index 1b5c91d061..2d8e377de7 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/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-projection/README.md -README.md: d1aee8aed891fe04585781772757a8a1ea307590 -README.zh.md: e97617a2006f9a944e7fce3aed26cd0e922cce0b +README.md: 9cbe4ea8f065787ee8ba568d4598a2a1cf69812a +README.zh.md: 16fa8202c4fe13823062bc3396f63e19c26713cc diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index d1aee8aed8..9cbe4ea8f0 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -64,6 +64,8 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) A domain that requires projected state declares `sessionProjections` as a Cordis service dependency; optional contributors may register under `ctx.inject(['sessionProjections'], …)`. Carriers use `ctx.get('sessionProjections')` and omit their block or frames when the registry is absent. +`restore` and `hydrate` accept optional view keys. Omitting them returns all client views; an empty list restores every host state without computing or validating any client view. + ### Persisted checkpoints Every unit's state is checkpointed — client-visible and host-only alike — through `checkpoint(session)`, and the sibling [session-projection-cache](../session-projection-cache/README.md) persists those checkpoints so cold reads skip full log loads. Checkpoint watermarks use `SessionSeqCursor` (`-1` for an empty log), while replay starts use `SessionLogOffset`; `restoreFloor` and `restore` implement the read recipe without conflating an existing event with a log gap. diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index e97617a200..16fa8202c4 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -64,6 +64,8 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) 必须使用投影状态的领域把 `sessionProjections` 声明为 Cordis 服务依赖;可选贡献方可以在 `ctx.inject(['sessionProjections'], …)` 下注册。载体使用 `ctx.get('sessionProjections')`,注册表缺席时省略自己的块或帧。 +`restore` 和 `hydrate` 接受可选的视图 key。省略时返回全部客户端视图;传入空列表时恢复所有 host state,但不计算或校验任何客户端视图。 + ### 持久检查点 每个单元的状态都会被检查点化——client-visible 与 host-only 一视同仁——通过 `checkpoint(session)`,同级包 [session-projection-cache](../session-projection-cache/README.zh.md) 持久化这些检查点,使冷读跳过全量日志加载。检查点水位使用 `SessionSeqCursor`(空日志为 `-1`),回放起点使用 `SessionLogOffset`;`restoreFloor` 与 `restore` 在无活动会话的情况下实现读取配方,且不会混淆已有事件与日志间隙。 diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index d29c5bb021..8488d12e62 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -488,6 +488,7 @@ export class SessionProjectionRegistry extends Service { * @param baseSeq - the seq `events` starts at (its first event's seq when non-empty). * @param header - immutable metadata for the Session being restored. * @param inheritedEventCount - exact fork-inherited prefix length supplied to unit initialization. + * @param keys - optional client-visible outputs; an empty list restores every state without computing or validating views. * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last * supplied event's seq, `baseSeq - 1` for an empty tail) plus the * refreshed checkpoint rows at that cut, ready for a durable write-back. @@ -498,12 +499,14 @@ export class SessionProjectionRegistry extends Service { baseSeq: SessionLogOffset, header: SessionHeader, inheritedEventCount: SessionLogOffset, + keys?: readonly Extract[], ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } { const endSeq: SessionSeqCursor = events.at(-1)?.seq ?? cursorBefore(baseSeq) const beforeBase = cursorBefore(baseSeq) const values: Record = {} const refreshed: ProjectionCheckpoint = {} + const selected = keys === undefined ? undefined : new Set(keys) for (const registration of this.registrations.values()) { const def = registration.def const row = checkpoint[def.key] @@ -530,7 +533,9 @@ export class SessionProjectionRegistry extends Service { } state = def.apply(state, event) } - if (def.wire !== undefined) values[def.key] = def.wire.viewSchema.parse(def.wire.view(state)) + if (def.wire !== undefined && (selected === undefined || selected.has(def.key))) { + values[def.key] = def.wire.viewSchema.parse(def.wire.view(state)) + } refreshed[def.key] = { ver: def.stateVersion, seq: endSeq, val: state } } return { @@ -547,13 +552,15 @@ export class SessionProjectionRegistry extends Service { * @param checkpoint - persisted rows for this Session lifecycle. * @param events - exact events at the observation cut. * @param baseSeq - first supplied event sequence. - * @returns all projection values at the supplied cut. + * @param keys - optional client-visible outputs; an empty list installs every state without computing or validating views. + * @returns selected projection values at the supplied cut, defaulting to all views. */ hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, + keys?: readonly Extract[], ): ProjectionSnapshot { const endSeq: SessionSeqCursor = events.at(-1)?.seq ?? cursorBefore(baseSeq) let complete = true @@ -566,8 +573,10 @@ export class SessionProjectionRegistry extends Service { } if (complete) { const values: Record = {} + const selected = keys === undefined ? undefined : new Set(keys) for (const registration of this.registrations.values()) { if (registration.def.wire === undefined) continue + if (selected !== undefined && !selected.has(registration.def.key)) continue const current = registration.cells.get(session) as UnitCell values[registration.def.key] = this.viewCell(registration, current) } @@ -579,6 +588,7 @@ export class SessionProjectionRegistry extends Service { baseSeq, session.header, session.inheritedEventCount, + keys, ) for (const registration of this.registrations.values()) { const row = restored.checkpoint[registration.def.key] diff --git a/packages/test-support/session-snapshot/src/normalize.ts b/packages/test-support/session-snapshot/src/normalize.ts index 2a4145fe80..fe4469e1c9 100644 --- a/packages/test-support/session-snapshot/src/normalize.ts +++ b/packages/test-support/session-snapshot/src/normalize.ts @@ -427,11 +427,17 @@ function projectSessionSnapshot(rawLog: string): string { const lines = rawLog.split('\n').filter(line => line.trim().length > 0) const header = lines.shift() as string - const records = canonicalizeCatalogRuns(lines.map((line) => { + const original = lines.map((line) => { return JSON.parse(line) as Record & { type: string } - })) + }) + const records = canonicalizeCatalogRuns(original) + const positions = new Map(records.map((record, seq) => [record, seq])) + const sourcePositions = original.map(record => positions.get(record) as number) const body = records.map((record) => { const projected = { ...record } + if (Array.isArray(record.sourceEventSeqs)) { + projected.sourceEventSeqs = record.sourceEventSeqs.map((seq: number) => sourcePositions[seq] ?? seq) + } omitFixtureEnvelope(projected) return JSON.stringify(projected) }) diff --git a/packages/test-support/session-snapshot/tests/normalize.spec.ts b/packages/test-support/session-snapshot/tests/normalize.spec.ts index e6594ea603..339e190411 100644 --- a/packages/test-support/session-snapshot/tests/normalize.spec.ts +++ b/packages/test-support/session-snapshot/tests/normalize.spec.ts @@ -657,6 +657,28 @@ describe('normalizeSessionSnapshot', () => { expect(normalized).toContain('"childId":3') }) + it.each([ + { sources: [0, 1], remapped: [0, 2] }, + { sources: [0, 2], remapped: [0, 1] }, + ])('preserves cited children when adjacent catalog facts change position: $sources', ({ sources, remapped }) => { + const records = [ + { type: 'session', version: 2 }, + { type: 'tool/call', data: { callId: 'parallel' } }, + { type: 'subagent/catalog', data: { childId: 'child-z', childCreatedAt: 1, version: 0, mode: 'one-shot' } }, + { type: 'subagent/catalog', data: { childId: 'child-a', childCreatedAt: 2, version: 0, mode: 'one-shot' } }, + { type: 'tool/result', data: { callId: 'parallel' }, sourceEventSeqs: sources, surfaceOp: 'append' }, + ] + const normalized = normalizeSessionSnapshot(records.map(record => JSON.stringify(record)).join('\n'), ctx) + expect(normalized).toBe([ + records[0], + records[1], + { ...records[3], data: { ...records[3]?.data, childCreatedAt: 0 } }, + { ...records[2], data: { ...records[2]?.data, childCreatedAt: 0 } }, + { ...records[4], sourceEventSeqs: remapped }, + ].map(record => JSON.stringify(record)).join('\n') + '\n') + expect(normalizeSessionSnapshot(normalized, ctx)).toBe(normalized) + }) + it('omits current catalog facts and rebases source references for historical comparison', () => { const normalized = [ JSON.stringify({ type: 'session', id: '{{session:1}}' }),