瀏覽代碼

refactor(subagent): retain identity projection state

_Kerman 1 月之前
父節點
當前提交
85bf796a95

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-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 .agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md
-2026-08-06-subagent-list-identity-projection.md: e53d918afe3415213edb0156a5f2125c73d2ae35
-2026-08-06-subagent-list-identity-projection.zh.md: 80f7583430eb5e7b7909e32e46a347224bc102ec
+2026-08-06-subagent-list-identity-projection.md: 5124d5df9edafe5c11a68aff7a0dd2f7929bd020
+2026-08-06-subagent-list-identity-projection.zh.md: 77bf34790f7dfe93fdd8f725b707ecdc4ab4cf03

+ 5 - 4
.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md

@@ -32,7 +32,7 @@ Relationship to existing notes:
 
 ### `subagent` projection unit
 
-It hangs beside the existing `subagentTiming` ([projection.ts](../../../../packages/subagent/subagent/src/projection.ts), [projection-types.ts](../../../../packages/subagent/subagent/src/projection-types.ts)), under key `subagent`. Both units provide client wire views; the identity view is the validated state itself:
+It hangs beside the existing `subagentTiming` ([projection.ts](../../../../packages/subagent/subagent/src/projection.ts), [projection-types.ts](../../../../packages/subagent/subagent/src/projection-types.ts)), under key `subagent`. Both units provide client wire views; the identity unit keeps an optional wrapped host value and maps its absence to the client sentinel:
 
 ```ts ignore-check
 export type SubagentIdentityProjection =
@@ -41,17 +41,18 @@ export type SubagentIdentityProjection =
 
 declare module '@deepseek-ai/dsh-session-projection/types' {
   interface SessionProjectionStateMap {
-    subagent: SubagentIdentityProjection | null
+    subagent: { identity?: SubagentIdentityProjection }
   }
   interface SessionProjectionMap {
     subagentTiming: SubagentTimingProjection
+    subagent: SubagentIdentityProjection | null
   }
 }
 ```
 
-- The projection is pure identity, and **the projection system has no failure channel**: a unit never throws; a corrupt payload or an unrecognized version folds exactly like a log with no descriptor at all — the result is a **serializable null sentinel**: the map entry is `SubagentIdentityProjection | null`, non-optional, never undefined or an absent key. The reason: the unit's state is persisted as plain JSON, where an undefined field is dropped by stringify and a dropped key is indistinguishable from an absent value on the read side — a stale identity would survive in place; `null` passes JSON losslessly, and consumers replace the old identity with the sentinel. The judging discipline: consuming surfaces treat null and undefined (which only a JSON boundary dropping the key can produce) alike as no value. How "computed to nothing" is presented is the consumer's own business (see the `listChildren` four-state mapping below).
+- The projection is pure identity, and **the projection system has no failure channel**: a unit never throws; a corrupt payload or an unrecognized version folds exactly like a log with no descriptor at all. The host checkpoint state is the serializable wrapper `{ identity?: SubagentIdentityProjection }`; absence is `{}`. Its client view is the non-optional `SubagentIdentityProjection | null` entry. `null` passes JSON losslessly, so a pushed reset replaces a stale identity instead of being dropped by stringify. The judging discipline: consuming surfaces treat null and an absent client key alike as no value. How "computed to nothing" is presented is the consumer's own business (see the `listChildren` four-state mapping below).
 - Label strength is decided by the descriptor schema: a continuable's label is mandatory at parse, a one-shot's was always optional; the mode/label discriminant matches the child row's strong contract below exactly (the row carries no `seq` — it is the projection's internal own-suffix proof).
-- The identity carries `seq`: the seq of the `subagent/descriptor` event it was folded from, mandatory on both arms and absent on the null sentinel — `seq >= header.seedLength ?? 0` proves the identity was folded from the child's own suffix rather than a fork seed's replayed ancestor descriptor. The unit exposes that validated state directly as its client wire view and is checkpointed like every unit (the `persist` opt-in is gone); its `stateVersion` is 3: adding `seq` bumped it to 2, and the state/client-views split changing the fold state to the direct `SubagentIdentityProjection | null` sentinel bumped it to 3. Existing checkpoint rows are invalidated by version mismatch per the registry contract, falling to the authoritative refold.
+- The identity carries `seq`: the seq of the `subagent/descriptor` event it was folded from, mandatory on both arms and absent on the null sentinel — `seq >= header.seedLength ?? 0` proves the identity was folded from the child's own suffix rather than a fork seed's replayed ancestor descriptor. The unit maps the wrapper's validated identity to its client wire view and is checkpointed like every unit (the `persist` opt-in is gone); its `stateVersion` is 2, bumped when `seq` was added. Existing older checkpoint rows are invalidated by version mismatch per the registry contract, falling to the authoritative refold.
 - Fold rule: `subagent/descriptor` is last-wins, under the same descriptor-reset discipline as `subagentTiming` — ancestor descriptors in the fork prefix are overridden by the session's own descriptor. A corrupt or unrecognized-version payload is last-wins all the same: it resets to the null sentinel rather than keeping the prior identity, so a fork of a healthy ancestor does not inherit an identity its own descriptor cannot stand up.
 
 ### Enumeration: subagent-owned live-preferred merge

+ 5 - 4
.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md

@@ -32,7 +32,7 @@ mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠
 
 ### `subagent` projection unit
 
-挂在现有 `subagentTiming` 旁([projection.ts](../../../../packages/subagent/subagent/src/projection.ts)、[projection-types.ts](../../../../packages/subagent/subagent/src/projection-types.ts)),key 为 `subagent`。两个 unit 都提供客户端 wire view;身份 view 就是校验后的状态本身:
+挂在现有 `subagentTiming` 旁([projection.ts](../../../../packages/subagent/subagent/src/projection.ts)、[projection-types.ts](../../../../packages/subagent/subagent/src/projection-types.ts)),key 为 `subagent`。两个 unit 都提供客户端 wire view;身份 unit 在 host 状态中保留可选的包装值,并把缺席映射为客户端哨兵:
 
 ```ts ignore-check
 export type SubagentIdentityProjection =
@@ -41,17 +41,18 @@ export type SubagentIdentityProjection =
 
 declare module '@deepseek-ai/dsh-session-projection/types' {
   interface SessionProjectionStateMap {
-    subagent: SubagentIdentityProjection | null
+    subagent: { identity?: SubagentIdentityProjection }
   }
   interface SessionProjectionMap {
     subagentTiming: SubagentTimingProjection
+    subagent: SubagentIdentityProjection | null
   }
 }
 ```
 
-- 投影是纯身份,**projection 体系不做失败通道**:unit 永不抛错;载荷损坏、版本不认识与整日志没有描述符一样,折叠结果是**可序列化的 null 哨兵**——map 条目为 `SubagentIdentityProjection | null`,非可选、非 undefined/缺 key。理由:unit 的状态以纯 JSON checkpoint 持久化,undefined 字段被 stringify 丢弃,读侧无从区分被丢弃的 key 与缺值——旧身份将原样存活;null 完好过 JSON,消费方以哨兵替换旧身份。判定纪律:消费面把 null 与 undefined(仅 JSON 边界丢 key 可产生)一律视为无值。「算出来没有」如何呈现是消费方自己的事(见下文 `listChildren` 四态映射)。
+- 投影是纯身份,**projection 体系不做失败通道**:unit 永不抛错;载荷损坏、版本不认识与整日志没有描述符一样。host checkpoint 状态使用可序列化的包装 `{ identity?: SubagentIdentityProjection }`,缺席为 `{}`;客户端 view 则是非可选的 `SubagentIdentityProjection | null` 条目。`null` 完好通过 JSON,因此推送 reset 会替换旧身份,而不会被 stringify 丢掉。判定纪律:消费面把 null 与客户端 key 缺席一律视为无值。「算出来没有」如何呈现是消费方自己的事(见下文 `listChildren` 四态映射)。
 - label 强度由描述符 schema 决定:continuable 的 label 解析强制必有,one-shot 的本就可选;mode/label 判别与下文 child 行的强约定完全一致(行不携带 `seq`——它是投影内部的 own-suffix 证明)。
-- 身份携带 `seq`:折出该身份的 `subagent/descriptor` 事件 seq,两臂必有、null 哨兵无——`seq >= header.seedLength ?? 0` 证明身份折叠自 child 自身后缀,而非 fork 种子回放的祖先描述符。unit 将这份校验后的状态直接暴露为客户端 wire view,并与其他 unit 一律检查点化(`persist` 选项已删除);`stateVersion` 现为 3:增 `seq` 升至 2,state/client 视图拆分把折叠状态改为直接的 `SubagentIdentityProjection | null` 哨兵后再升至 3。既存 checkpoint 行按 registry 约定版本失配失效、落权威重折。
+- 身份携带 `seq`:折出该身份的 `subagent/descriptor` 事件 seq,两臂必有、null 哨兵无——`seq >= header.seedLength ?? 0` 证明身份折叠自 child 自身后缀,而非 fork 种子回放的祖先描述符。unit 把包装状态中校验后的身份映射为客户端 wire view,并与其他 unit 一律检查点化(`persist` 选项已删除);`stateVersion` 为 2,在增加 `seq` 时升版。更早的 checkpoint 行按 registry 约定版本失配失效、落权威重折。
 - 折叠规则:`subagent/descriptor` last-wins,与 `subagentTiming` 同一条 descriptor-reset 纪律——fork 前缀里的祖先描述符被自身描述符覆盖。损坏或版本不认识的载荷同样 last-wins:重置为 null 哨兵而非保留先前身份,健康祖先的 fork 不会继承自身描述符立不住的身份。
 
 ### 枚举:subagent 自管 live-preferred 合并

+ 3 - 7
packages/subagent/subagent/src/projection-types.ts

@@ -47,7 +47,9 @@ export type SubagentIdentityProjection =
   }
 
 declare module '@deepseek-ai/dsh-session-projection/types' {
-  interface SessionProjectionStateMap {
+  interface SessionProjectionMap {
+    /** Active-turn duration for a descriptor-backed subagent session. */
+    subagentTiming: SubagentTimingProjection
     /**
      * Identity of a descriptor-backed subagent session. `null` ⟺ no valid
      * descriptor (missing, malformed, or unrecognized-version — deliberately
@@ -59,10 +61,4 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
      */
     subagent: SubagentIdentityProjection | null
   }
-  interface SessionProjectionMap {
-    /** Durable mode and label for a descriptor-backed subagent session. */
-    subagent: SubagentIdentityProjection | null
-    /** Active-turn duration for a descriptor-backed subagent session. */
-    subagentTiming: SubagentTimingProjection
-  }
 }

+ 19 - 7
packages/subagent/subagent/src/projection.ts

@@ -47,6 +47,7 @@ const timingStateSchema: z.ZodType<TimingState> = z.object({
 declare module '@deepseek-ai/dsh-session-projection/types' {
   interface SessionProjectionStateMap {
     subagentTiming: TimingState
+    subagent: IdentityState
   }
 }
 
@@ -60,7 +61,6 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
  */
 export const subagentTimingProjectionDefinition = {
   key: 'subagentTiming',
-  stateVersion: 2,
   stateSchema: timingStateSchema,
   init: () => ({ descriptorSeen: false, settledMs: 0 }),
   apply: (state, event) => {
@@ -102,8 +102,14 @@ export const subagentTimingProjectionDefinition = {
       ...(state.active === undefined ? {} : { active: state.active }),
     }),
   },
+  stateVersion: 2,
 } satisfies ProjectionDefinition<'subagentTiming', TimingState>
 
+interface IdentityState {
+  /** Identity from the last valid descriptor; absent before one, and after an invalid one. */
+  identity?: SubagentIdentityProjection | undefined
+}
+
 // The cast bridges only the optional-label arm: Zod's optional output
 // includes explicit `undefined`, which exactOptionalPropertyTypes excludes
 // from the public interface. The no-value state itself is the serializable
@@ -124,6 +130,10 @@ const identityValueSchema = z.discriminatedUnion('mode', [
 
 const identitySchema = identityValueSchema.nullable()
 
+const identityStateSchema: z.ZodType<IdentityState> = z.object({
+  identity: identityValueSchema.optional(),
+}).strict()
+
 /** Interpret one `subagent/descriptor` event's identity; no value when the payload cannot be trusted. */
 function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | undefined {
   let descriptor: SubagentDescriptorData | undefined
@@ -157,13 +167,15 @@ function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | u
  */
 export const subagentIdentityProjectionDefinition = {
   key: 'subagent',
-  stateVersion: 3,
-  stateSchema: identitySchema,
-  init: () => null,
+  stateSchema: identityStateSchema,
+  init: () => ({}),
   apply: (state, event) => {
     if (event.type !== 'subagent/descriptor') return state
     const identity = descriptorIdentity(event)
-    return identity === undefined ? null : identity
+    return identity === undefined ? {} : { identity }
   },
-  wire: { viewSchema: identitySchema, view: state => state },
-} satisfies ProjectionDefinition<'subagent', SubagentIdentityProjection | null>
+  wire: { viewSchema: identitySchema, view: state => state.identity ?? null },
+  // Bumped when the identity gained its `seq` field: an older checkpoint row
+  // would replay into a value the schema rejects, so it must refold instead.
+  stateVersion: 2,
+} satisfies ProjectionDefinition<'subagent', IdentityState>