Переглянути джерело

fix: address review round four

- the cached-identity rung gains a finality gate: the identity value
  carries its descriptor seq and a cached row is served only when that
  seq lands in the child's own suffix, so a fork seed's replayed
  ancestor identity can never outrank the authoritative refold
  (stateVersion bumped for the state-shape change)
- the cold preparation validates the inspected header against the
  enumerated candidate's lifecycle witness; a republished id degrades to
  that child's corrupt diagnostic instead of leaking the new owner's log
- the new projection registration proves HMR disposal; companion notes
  qualify the superseded decision text and record the deliberate
  error-face asymmetry
imccyu 2 місяців тому
батько
коміт
c98a754ccb

+ 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: 0684ec284fbce161e37a9f7c7c57460d91c8d0d0
-2026-08-06-subagent-list-identity-projection.zh.md: ea5fb4329032e42bf394706032e8571d5a975e2e
+2026-08-06-subagent-list-identity-projection.md: 535737f183c4e3a1f12e9e2c9c55c5a25c9e2006
+2026-08-06-subagent-list-identity-projection.zh.md: dccf0c781ddde882a5d1941fac470907f9422b44

Різницю між файлами не показано, бо вона завелика
+ 11 - 9
.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md


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

@@ -14,14 +14,14 @@ Status: implemented
 
 ## 决策
 
-mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠,unit 是折叠规则的唯一权威;`listChildren` 不再依赖 session-query——枚举是 subagent 自管的 live-preferred 合并,取值走三级"算完即止"阶梯:live child 同步读注册表的既有水位缓存(零日志读);cold child 先问可选的 `sessionProjectionCache` checkpoint,取到即定值;否则一次 `persistence.inspect` 整读加 `registry.restore` 折叠。无索引、不自建缓存、无回写。
+mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠,unit 是折叠规则的唯一权威;`listChildren` 不再依赖 session-query——枚举是 subagent 自管的 live-preferred 合并,取值走三级"算完即止"阶梯:live child 同步读注册表的既有水位缓存(零日志读);cold child 先问可选的 `sessionProjectionCache` checkpoint,取到过 seq 门的身份即定值;否则一次 `persistence.inspect` 整读加 `registry.restore` 折叠。无索引、不自建缓存、无回写。
 
 消除逐 child 扫描的出路有三类:把 mode/label 提升进 header(写路承担);为投影建持久派生(checkpoint 阶梯,或随查询索引重建落值、读端对账);读时现算(live 走水位缓存,cold 一次整读)。本记录取第三条。"值随查询索引落库"曾是本记录的定稿方向并一度施工,最终整体退役:查询基础设施被迫认识领域词汇,而唯一消费方读时现算即可满足——live child 的零读由 session-projection 既有水位缓存白拿,cold child 的一次整读被"算完即止"显式接受。前两条与退役理由详见考虑过的替代方案一节。
 
 要点:
 
 - **subagent 列表不依赖 session-query**:枚举由 subagent 自管的 live-preferred 合并完成,mode/label 经 `ctx.sessionProjections` 取值;没有 query backend 的部署照常列表。
-- **取值三级"算完即止"阶梯**:live child 读 `sessionProjections.snapshot()`(注册表既有水位缓存,零日志读);cold child 先读可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含非 null 的 `subagent` 身份即直接用;否则一次 `persistence.inspect` 整读加 `registry.restore({}, events, 0)` 折叠;再没有就没有——不自建缓存、无回写、无索引。
+- **取值三级"算完即止"阶梯**:live child 读 `sessionProjections.snapshot()`(注册表既有水位缓存,零日志读);cold child 先读可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含非 null 且过 seq 门(`seq >= seedLength ?? 0`)的 `subagent` 身份即直接用;否则一次 `persistence.inspect` 整读加 `registry.restore({}, events, 0)` 折叠;再没有就没有——不自建缓存、无回写、无索引。
 - **`subagent` projection unit 是折叠规则唯一权威**:live snapshot、cold restore、GUI history 的 detached 折叠全部经 registry 计算,不存在第二份描述符解释逻辑。
 - **header、描述符(v2)、session-persistence、session-projection(-cache)、session-query(-sqlite) 全部零改动**;存量数据第一次被列表时一次 `inspect` 现算获得精确值,无 unknown 降级态、无迁移。
 
@@ -36,8 +36,8 @@ mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠
 
 ```ts ignore-check
 export type SubagentIdentityProjection =
-  | { mode: 'one-shot'; label?: string }
-  | { mode: 'continuable'; label: string }
+  | { mode: 'one-shot'; label?: string; seq: number }
+  | { mode: 'continuable'; label: string; seq: number }
 
 declare module '@deepseek-ai/dsh-session-projection/types' {
   interface SessionProjectionMap {
@@ -47,7 +47,8 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
 ```
 
 - 投影是纯身份,**projection 体系不做失败通道**:unit 永不抛错;载荷损坏、版本不认识与整日志没有描述符一样,折叠结果是**可序列化的 null 哨兵**——map 条目为 `SubagentIdentityProjection | null`,非可选、非 undefined/缺 key。理由:registry 的 onChanged 推送经 JSON 序列化,undefined 字段被 stringify 丢弃,客户端帧校验拒收,消费方存储的旧身份将永不更新;null 完好过帧,消费方以哨兵替换旧身份。判定纪律:消费面把 null 与 undefined(仅 JSON 边界丢 key 可产生)一律视为无值。"算出来没有"如何呈现是消费方自己的事(见下文 `listChildren` 四态映射)。
-- label 强度由描述符 schema 决定:continuable 的 label 解析强制必有,one-shot 的本就可选;该判别式与下文 child 行的 mode/label 强契约完全一致。
+- label 强度由描述符 schema 决定:continuable 的 label 解析强制必有,one-shot 的本就可选;mode/label 判别与下文 child 行的强契约完全一致(行不携带 `seq`——它是投影内部的 own-suffix 证明)。
+- 身份携带 `seq`:折出该身份的 `subagent/descriptor` 事件 seq,两臂必有、null 哨兵无——`seq >= header.seedLength ?? 0` 证明身份折叠自 child 自身后缀,而非 fork 种子回放的祖先描述符。state 增 `seq` 使 unit `stateVersion` 升至 2,既存 checkpoint 行按 registry 契约版本失配失效、落权威重折。
 - 折叠规则:`subagent/descriptor` last-wins,与 `subagentTiming` 同一条 descriptor-reset 纪律——fork 前缀里的祖先描述符被自身描述符覆盖。损坏或版本不认识的载荷同样 last-wins:重置为 null 哨兵而非保留先前身份,健康祖先的 fork 不会继承自身描述符立不住的身份。
 
 ### 枚举:subagent 自管 live-preferred 合并
@@ -68,19 +69,20 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
 | 级 | 读法 | 成本 |
 | --- | --- | --- |
 | 1:live child | `ctx.sessionProjections.snapshot(session).values.subagent` | 零日志读——注册表既有水位缓存,同步取值 |
-| 2:cold child,cache 命中 | 可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含非 null 的 `subagent` 身份即直接用——身份一经追加不可变,读到即定值,无视行水位 | 零日志读 |
+| 2:cold child,cache 命中 | 可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含非 null 的 `subagent` 身份且 `identity.seq >= header.seedLength ?? 0` 才直接用——own descriptor 一经追加不可变,seq 门证明该值折叠自 child 自身后缀,无视行水位 | 零日志读 |
 | 3:cold child,兜底 | `persistence.inspect(id)` 整读 + `registry.restore({}, events, 0).snapshot.values.subagent` | 每次列表一次整读现算 |
 
-- 错误契约:`ctx.sessionProjections` 未挂载是配置错误,`listChildren` 在枚举前无条件检查并以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败——零 children 的部署同样确定失败,不因列表恰好为空而掩盖配置问题。会话存储同理:`ctx.get('sessions')`(严格全局读取,不走调用方作用域的属性代理)缺席以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 失败。`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 已随 session-query 依赖删除。
-- cache 是纯可选加速层:服务缺席判空跳过——无错误码、不进配置校验(与 `sessionProjections` 的响亮契约相对)。第二级任何抛错(包括缓存内任一 unit 行中毒使 `viewCheckpoint` 引爆)静默落第三级——缓存是派生数据,其故障不产生 `corrupt` 判决,终审归权威重折;checkpoint 切面早于描述符的行,`subagent` key 天然缺席,自动落底,无特判;行里的 null 哨兵同样不作数——一律落第三级,由权威重折裁决。
+- 错误契约:`ctx.sessionProjections` 未挂载是配置错误,`listChildren` 在枚举前无条件检查并以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败——零 children 的部署同样确定失败,不因列表恰好为空而掩盖配置问题。会话存储同理:`ctx.get('sessions')`(严格全局读取,不走调用方作用域的属性代理)缺席以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 失败。两码的 wire 映射有别:apiproxy 只为 `PROJECTIONS_UNAVAILABLE` 设专门 wire 脸,`SESSION_STORE_UNAVAILABLE` 走通用 internal 兜底——apiproxy 组合自身就 inject `sessions`,该错误在其部署不可达,专门映射违反 need 原则。`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 已随 session-query 依赖删除。
+- cache 是纯可选加速层:服务缺席判空跳过——无错误码、不进配置校验(与 `sessionProjections` 的响亮契约相对)。第二级任何抛错(包括缓存内任一 unit 行中毒使 `viewCheckpoint` 引爆)静默落第三级——缓存是派生数据,其故障不产生 `corrupt` 判决,终审归权威重折;checkpoint 切面早于描述符的行,`subagent` key 天然缺席,自动落底,无特判;行里的 null 哨兵同样不作数——一律落第三级,由权威重折裁决。创建窗口内的 count/interval checkpoint 可能把 fork 种子回放的祖先身份落进行——祖先 seq 落在 seed 区间,被 seq 门拒绝,同样落第三级裁决。
 - per-child 隔离:单 child 的 cold 整读失败只使该行成为 `unavailable` diagnostic,下次列表自然重试,不影响 sibling(见四态映射)。
+- 冷路径的生命周期见证:preparation 的结果必须仍指向枚举时的那个生命周期——见证字段集与旧 SOURCE_CONFLICT 检查同款七字段(version、id、createdAt、cwd、parentSession、seedLength、delegationDepth);同 id 删除后重新发布的会话对旧 parent 的目录降级为 `corrupt` 行,不外漏新 owner 的 child。
 - 冷读并发以常数 4 有界——它约束的是本地介质的一次只读扫描而非部署行为;出现联网 persistence backend 时提升为验证过的 `Config` 字段。
 - 冷读成本如实记录:cache 未挂载或未命中时,cold child 每次列表才付一次整读,成本与其 transcript 大小成正比;定案"算完即止",不自建缓存。整读经 `inspect()` 走 [Session 准备阶段](2026-08-05-session-preparation.md)的冷读,同 id 短期重复读取可命中其 LRU 复用,但列表不依赖此。live child 全程零日志读。
 - 取消:每次 persistence 读前后检查调用方 signal,abort 之后才结算的读拒绝归一化为稳定错误码 `CANCELLED`。
 
 ### 权威模型
 
-- session log 是唯一权威;本方案不新增任何派生持久化——没有索引值、没有自己的 checkpoint、没有进程 memo;第二级读取的 `sessionProjectionCache` checkpoint 是既有组合项的派生数据,本方案只读不写。取值现算现弃,值的新鲜度就是读取时点的 live 状态或持久化 revision(身份不可变,缓存值无陈旧性问题)。
+- session log 是唯一权威;本方案不新增任何派生持久化——没有索引值、没有自己的 checkpoint、没有进程 memo;第二级读取的 `sessionProjectionCache` checkpoint 是既有组合项的派生数据,本方案只读不写。取值现算现弃,值的新鲜度就是读取时点的 live 状态或持久化 revision(own descriptor 一经追加不可变——缓存身份过 seq 门后无陈旧性问题,门防的是种子回放的祖先身份)。
 - Session 与 persistence 写路完全不感知列表与投影消费:没有事件监听回写,没有写时折叠。
 - 枚举与取值不构成第二个鉴权来源,也不让尚未发布的 child 可见——两个来源只见已发布的 live 记录与已落盘的持久化记录,与 durable-subagent-catalog 记录对派生读面立下的规则一致。
 
@@ -162,7 +164,7 @@ export type SubagentListEntry =
 
 ## 验证
 
-`packages/subagent/subagent/tests/list-children.spec.ts` 重写为本契约:无 persistence、query 服务与继续运行时的 live-only 列表;registry 缺席时零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;live child 全程零 `inspect`、cold child 每次列表恰一次;多描述符 last-wins 取末者;损坏载荷与未知版本折为 `corrupt`;冷读失败映射 `unavailable` 且下次列表重试;fork seed 里的祖先描述符按该身份成行(偏差一钉住);普通 fork 与无 subagent origin 的后代不入列也不计入 `hasChildren`;`createdAt`→id 排序;provider 未挂载不影响列表;压缩与未压缩孪生一致;预中止、持久化列表与冷读取消三例归一 `CANCELLED`;空列表与稳定错误码。敌意 unit 双路探针(`apply` 惰性置毒、`view` 引爆)证明任一注册 unit 在该 child 日志上的 fold/schema 抛错,在 live 与 cold 两条取值路径上都收纳为该 child 的 `corrupt` 行,sibling 与列表本身不受影响。第二级四例:真组合 cache 命中零 `inspect`、行内无身份(null 哨兵或 key 缺席)落底、cache 服务缺席落底、缓存行中毒静默落底重折。`tool-subagent-control` 的 list-agents 测试随加载要求收窄更新;`optional-session-query.spec.ts` 随依赖消失删除;既有无密钥快照(`subagent-list-agents` 等)零变化,钉住健康路径的 wire 与 model-visible 面不变;新增无密钥快照 `subagent-diagnostic`(examples/headless-agent)钉住四态映射的诊断分类——descriptor-less 定局残骸成 `corrupt` 行等模型可见变化。
+`packages/subagent/subagent/tests/list-children.spec.ts` 重写为本契约:无 persistence、query 服务与继续运行时的 live-only 列表;registry 缺席时零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;live child 全程零 `inspect`、cold child 每次列表恰一次;多描述符 last-wins 取末者;损坏载荷与未知版本折为 `corrupt`;冷读失败映射 `unavailable` 且下次列表重试;fork seed 里的祖先描述符按该身份成行(偏差一钉住);普通 fork 与无 subagent origin 的后代不入列也不计入 `hasChildren`;`createdAt`→id 排序;provider 未挂载不影响列表;压缩与未压缩孪生一致;预中止、持久化列表与冷读取消三例归一 `CANCELLED`;空列表与稳定错误码。敌意 unit 双路探针(`apply` 惰性置毒、`view` 引爆)证明任一注册 unit 在该 child 日志上的 fold/schema 抛错,在 live 与 cold 两条取值路径上都收纳为该 child 的 `corrupt` 行,sibling 与列表本身不受影响。第二级例:own-seq 身份直用零 `inspect`、fork 种子祖先身份(seq 落在 seed 区间)被门拒绝落底、行内无身份(null 哨兵或 key 缺席)落底、cache 服务缺席落底、缓存行中毒静默落底重折;冷路径 lifecycle 篡改按见证七字段逐一(`it.each`)降级为 `corrupt`。`tool-subagent-control` 的 list-agents 测试随加载要求收窄更新;`optional-session-query.spec.ts` 随依赖消失删除;既有无密钥快照(`subagent-list-agents` 等)零变化,钉住健康路径的 wire 与 model-visible 面不变;新增无密钥快照 `subagent-diagnostic`(examples/headless-agent)钉住四态映射的诊断分类——descriptor-less 定局残骸成 `corrupt` 行等模型可见变化。
 
 ## 后果
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.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/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md
-2026-07-22-durable-subagent-catalog-and-list-agents.md: 9515cf744706765dc2a5f34311198d2432d22924
-2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 25b019713c143c268cb10bd3a3dd0d774f97ad8f
+2026-07-22-durable-subagent-catalog-and-list-agents.md: b96d6e1dd36c58af67c8e93e62515672790ad009
+2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 856bac615db84bfe2898ec0838094c6bc29f77b2

+ 1 - 1
.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md

@@ -35,7 +35,7 @@ Session lineage is broader than subagent identity: an ordinary `ctx.sessions.for
 
 The published logical record is also the activity source: `SessionRecord.live` means `running`, while `live: false, persisted: true` means `inactive`. Activity comes directly from the trace and causes no additional child-log load. `inactive` encodes neither successful completion nor resumability: it may describe settled one-shot history or a continuable child for which `send_message` can materialize another Activation. Conversely, `running` says only that the session is live: a live continuable Agent outside the continuation manager's matching Activation still appears as `running`, but `send_message` rejects it as an ownership conflict. A child is not visible before its session is published, and no process-local Activation entry is added as a second candidate or activity source. Listing is a snapshot that may race publication, disposal, or a later message; `send_message` remains the authoritative delivery-time operation.
 
-The subagent service keeps `sessionQuery` optional so start and follow-up remain available without it. Its public `listChildren(parentSessionId: SessionId)` method resolves the optional service and dynamically loads the optional session-query runtime only when called; ordinary subagent imports, start, and follow-up therefore do not evaluate that package. Listing belongs directly to `SubagentService`: it interprets the query's lineage, events, and live state without resolving the Activation-based continuation manager or consulting Agent registrations, Activations, or providers, so a deployment with sessions, `subagents`, and `sessionQuery` can list even when `agents` is absent. The method throws `SubagentError` with stable code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` before loading the runtime or doing query work when the query service is absent. `@deepseek-ai/dsh-tool-subagent-control` exports separately loadable tool plugins: the `send_message` adapter requires only `subagents`, while the `list_agents` adapter requires both `subagents` and `sessionQuery` at load. A deployment may therefore use `send_message` without installing or loading session query; the list-tool fiber remains inactive until the required service is available, while another direct service consumer receives the same explicit call-time contract.
+The subagent service keeps `sessionQuery` optional so start and follow-up remain available without it. Its public `listChildren(parentSessionId: SessionId)` method resolves the optional service and dynamically loads the optional session-query runtime only when called; ordinary subagent imports, start, and follow-up therefore do not evaluate that package. Listing belongs directly to `SubagentService`: it interprets the query's lineage, events, and live state without resolving the Activation-based continuation manager or consulting Agent registrations, Activations, or providers, so a deployment with sessions, `subagents`, and `sessionQuery` can list even when `agents` is absent. The method throws `SubagentError` with stable code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` before loading the runtime or doing query work when the query service is absent. `@deepseek-ai/dsh-tool-subagent-control` exports separately loadable tool plugins: the `send_message` adapter requires only `subagents`, while the `list_agents` adapter requires both `subagents` and `sessionQuery` at load. A deployment may therefore use `send_message` without installing or loading session query; the list-tool fiber remains inactive until the required service is available, while another direct service consumer receives the same explicit call-time contract. This dependency posture — the optional `sessionQuery`, its error code, and the list tool's load requirement — is part of the superseded read path: the current codes (`SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`) and the narrowed load requirement live in the [superseding note](../architecture/2026-08-06-subagent-list-identity-projection.md).
 
 `listChildren(parentSessionId, signal?)` forwards the caller's signal to `traceSession()` and the conditional exact `readEvent()` operation. `listEvents()` has no cancellation parameter, so the listing path checks the signal before and after that await and after each candidate settles. If any query operation rejects after the signal aborts, the service normalizes the result to `SubagentError` with stable code `CANCELLED`; a backend abort error or a diagnostic-mapped query error cannot escape or become a successful partial listing.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md

@@ -35,7 +35,7 @@ parent 到 child 的枚举是一项带消费方专用投影的服务功能。`Su
 
 已发布的逻辑记录同时也是活动状态来源:`SessionRecord.live` 表示 `running`,而 `live: false, persisted: true` 表示 `inactive`。活动状态直接来自追踪结果,不会导致额外加载 child 日志。`inactive` 既不表示执行成功,也不表示可恢复:它可能表示已结算的一次性历史,也可能表示 `send_message` 可以为其物化另一次 Activation 的可继续 child。反过来,`running` 只表示会话存活:位于继续执行管理器对应 Activation 之外的存活可继续 Agent 仍会显示为 `running`,但 `send_message` 会将其作为所有权冲突拒绝。child 会话发布前不可见,也不会添加进程内 Activation 条目作为第二个候选来源或活动状态来源。列表查询是一份快照,可能与发布、dispose 或后续消息发生竞态;`send_message` 仍是消息送达时的权威操作。
 
-subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务时仍可执行 start 和 follow-up。其公开的 `listChildren(parentSessionId: SessionId)` 方法只在被调用时才会解析这个可选服务,并动态加载可选的会话查询运行时;因此,普通 subagent 导入、start 和 follow-up 都不会触发该包求值。列表查询直接由 `SubagentService` 负责:它解释查询返回的谱系、事件和存活状态,无需解析基于 Activation 的继续执行管理器,也不会查询 Agent 注册信息、Activation 或提供方;因此,仅包含会话、`subagents` 和 `sessionQuery` 的部署即使缺少 `agents` 也能执行列表查询。如果查询服务缺失,该方法会在加载运行时或执行查询工作前抛出 `SubagentError`,并携带稳定错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`。`@deepseek-ai/dsh-tool-subagent-control` 导出可分别加载的工具插件:`send_message` 适配器只要求 `subagents`,而 `list_agents` 适配器在加载时同时要求 `subagents` 和 `sessionQuery`。因此,部署可以在既不安装也不加载会话查询的情况下使用 `send_message`;列表工具 fiber 会在必需服务可用前保持未激活状态,而其他直接服务消费方会收到同一项明确的调用时契约。
+subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务时仍可执行 start 和 follow-up。其公开的 `listChildren(parentSessionId: SessionId)` 方法只在被调用时才会解析这个可选服务,并动态加载可选的会话查询运行时;因此,普通 subagent 导入、start 和 follow-up 都不会触发该包求值。列表查询直接由 `SubagentService` 负责:它解释查询返回的谱系、事件和存活状态,无需解析基于 Activation 的继续执行管理器,也不会查询 Agent 注册信息、Activation 或提供方;因此,仅包含会话、`subagents` 和 `sessionQuery` 的部署即使缺少 `agents` 也能执行列表查询。如果查询服务缺失,该方法会在加载运行时或执行查询工作前抛出 `SubagentError`,并携带稳定错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`。`@deepseek-ai/dsh-tool-subagent-control` 导出可分别加载的工具插件:`send_message` 适配器只要求 `subagents`,而 `list_agents` 适配器在加载时同时要求 `subagents` 和 `sessionQuery`。因此,部署可以在既不安装也不加载会话查询的情况下使用 `send_message`;列表工具 fiber 会在必需服务可用前保持未激活状态,而其他直接服务消费方会收到同一项明确的调用时契约。这一段的依赖姿态——可选 `sessionQuery`、其错误码与列表工具的加载要求——同属被取代的读路径:现行错误码(`SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`、`SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`)与收窄后的加载要求以[取代记录](../architecture/2026-08-06-subagent-list-identity-projection.md)为准。
 
 `listChildren(parentSessionId, signal?)` 会把调用方的取消信号转发给 `traceSession()` 和条件性精确 `readEvent()` 操作。`listEvents()` 不接受取消参数,因此列表查询路径会在等待该操作的前后,以及每个候选处理完成后检查信号。如果取消信号触发后有查询操作以拒绝结算,服务会将结果归一化为 `SubagentError`,并携带稳定错误码 `CANCELLED`;后端中止错误或可映射为 diagnostic 的查询错误均不会逃逸,也不会使调用以成功的部分列表返回。
 

+ 3 - 2
docs/cordis-catalog/services.md

@@ -2105,8 +2105,9 @@ async drainContinuableDescendants(parents: readonly Agent[]): Promise<void>
  * serves each child's durable mode/label from the registered `subagent`
  * projection unit down a three-rung ladder — the registry's watermark
  * snapshot for a live child; for a cold one, a durable projection-cache
- * row when the optional cache already serves the identity (the value is
- * immutable, so staleness cannot matter), else one persistence inspection
+ * row when the optional cache serves an own-suffix identity (its `seq`
+ * gate proves the value postdates the fork seed, where a child's own
+ * descriptor is immutable once appended), else one persistence inspection
  * folded through the registry. The
  * projection fold is the single classification authority; per-child
  * diagnostics relay a fold that served no identity or a failed inspection,

+ 2 - 2
docs/core-data-structures/subagent.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/core-data-structures/subagent.md
-subagent.md: 514963706dedbf88c519c8c83aaf2bd6954fe492
-subagent.zh.md: 9835e8881841a0fbe682a2645731344265bfa762
+subagent.md: 4d7552eb5749284d90e31e62d8ac02e3d7a21b1d
+subagent.zh.md: ebcb1ce445c164352109d6613028c7a36c10d6d4

Різницю між файлами не показано, бо вона завелика
+ 0 - 0
docs/core-data-structures/subagent.md


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
docs/core-data-structures/subagent.zh.md


+ 1 - 1
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -938,7 +938,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
       {
         signature: 'listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]>',
-        jsDoc: '/**\n * Enumerate the parent\'s direct session-backed subagents without loading or\n * resuming an Agent and without any query seam: the listing merges the live\n * session store with optional session persistence (live-preferred) and\n * serves each child\'s durable mode/label from the registered `subagent`\n * projection unit down a three-rung ladder — the registry\'s watermark\n * snapshot for a live child; for a cold one, a durable projection-cache\n * row when the optional cache already serves the identity (the value is\n * immutable, so staleness cannot matter), else one persistence inspection\n * folded through the registry. The\n * projection fold is the single classification authority; per-child\n * diagnostics relay a fold that served no identity or a failed inspection,\n * never a list-time descriptor parse. Absent persistence, enumeration is\n * live-only (a cold child cannot be resumed then either, so its absence is\n * capability absence, not an error). This service consults no Agent\n * registrations, Activations, or providers.\n *\n * Every persistence read receives `signal`, and the listing rechecks\n * cancellation around each of those awaits. Read rejections that settle\n * after an abort become a stable `SubagentError` with code `CANCELLED`.\n * @param parentSessionId - parent session whose direct children are listed.\n * @param signal - caller-owned cancellation forwarded to persistence reads\n *   and observed around every read await.\n * @returns children and per-child diagnostics ordered by `createdAt`, then id.\n * @throws {@link SubagentError} when the projection registry or the session\n *   store is not mounted, or the caller cancels the listing.\n */',
+        jsDoc: '/**\n * Enumerate the parent\'s direct session-backed subagents without loading or\n * resuming an Agent and without any query seam: the listing merges the live\n * session store with optional session persistence (live-preferred) and\n * serves each child\'s durable mode/label from the registered `subagent`\n * projection unit down a three-rung ladder — the registry\'s watermark\n * snapshot for a live child; for a cold one, a durable projection-cache\n * row when the optional cache serves an own-suffix identity (its `seq`\n * gate proves the value postdates the fork seed, where a child\'s own\n * descriptor is immutable once appended), else one persistence inspection\n * folded through the registry. The\n * projection fold is the single classification authority; per-child\n * diagnostics relay a fold that served no identity or a failed inspection,\n * never a list-time descriptor parse. Absent persistence, enumeration is\n * live-only (a cold child cannot be resumed then either, so its absence is\n * capability absence, not an error). This service consults no Agent\n * registrations, Activations, or providers.\n *\n * Every persistence read receives `signal`, and the listing rechecks\n * cancellation around each of those awaits. Read rejections that settle\n * after an abort become a stable `SubagentError` with code `CANCELLED`.\n * @param parentSessionId - parent session whose direct children are listed.\n * @param signal - caller-owned cancellation forwarded to persistence reads\n *   and observed around every read await.\n * @returns children and per-child diagnostics ordered by `createdAt`, then id.\n * @throws {@link SubagentError} when the projection registry or the session\n *   store is not mounted, or the caller cancels the listing.\n */',
       },
       {
         signature: 'registerProvider(provider: SubagentProvider): () => void',

+ 2 - 2
packages/subagent/subagent/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/subagent/subagent/README.md
-README.md: 92c8222381338f71c7d444da80c23098aba247b3
-README.zh.md: 8f61379bb789de6c1b97b22db034698009d0619c
+README.md: 9d2e38c8730f7b7f26e690aa878a4466fa7c2829
+README.zh.md: 341c18617af4d040ec44814fac1ec4502d9b8902

Різницю між файлами не показано, бо вона завелика
+ 0 - 0
packages/subagent/subagent/README.md


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
packages/subagent/subagent/README.zh.md


+ 3 - 2
packages/subagent/subagent/src/index.ts

@@ -290,8 +290,9 @@ export class SubagentService extends Service {
    * serves each child's durable mode/label from the registered `subagent`
    * projection unit down a three-rung ladder — the registry's watermark
    * snapshot for a live child; for a cold one, a durable projection-cache
-   * row when the optional cache already serves the identity (the value is
-   * immutable, so staleness cannot matter), else one persistence inspection
+   * row when the optional cache serves an own-suffix identity (its `seq`
+   * gate proves the value postdates the fork seed, where a child's own
+   * descriptor is immutable once appended), else one persistence inspection
    * folded through the registry. The
    * projection fold is the single classification authority; per-child
    * diagnostics relay a fold that served no identity or a failed inspection,

+ 38 - 15
packages/subagent/subagent/src/list-children.ts

@@ -216,12 +216,13 @@ export async function listChildren(
 
 /**
  * Resolve one cold candidate down the remaining ladder: a durable
- * projection-cache row when it already serves the identity, otherwise one
- * persistence inspection folded through the projection registry (the same
- * detached recipe the API proxy uses for detached session projections). A
- * failed inspection is one transient `unavailable` row retried on the next
- * listing; a settled log the fold cannot identify — or that makes any
- * registered unit throw — is final, so it reports `corrupt`.
+ * projection-cache row when it serves an own-suffix identity (the seq gate),
+ * otherwise one persistence inspection folded through the projection
+ * registry (the same detached recipe the API proxy uses for detached session
+ * projections). A failed inspection is one transient `unavailable` row
+ * retried on the next listing; an inspection naming another lifecycle, and a
+ * settled log the fold cannot identify — or that makes any registered unit
+ * throw — are final, so they report `corrupt`.
  */
 async function resolveColdIdentity(
   persistence: SessionPersistence,
@@ -242,19 +243,22 @@ async function resolveColdIdentity(
       // row of ANY unit) silently falls through to the authoritative re-fold.
       cached = undefined
     }
-    // A served identity is immutable once appended, so a cached one is final
-    // regardless of the row's watermark. Both no-value forms fall through to
-    // preparation: an absent key (a checkpoint cut before the descriptor was
-    // appended) and the `null` sentinel, whose verdict belongs to the
-    // authoritative re-fold, not to a derived row.
-    if (cached !== undefined && cached !== null) {
+    // A child's OWN descriptor is immutable once appended, so a cached
+    // identity is final only when the seq gate proves it was folded from the
+    // own suffix: a creation-window checkpoint may instead carry a fork
+    // seed's replayed ANCESTOR descriptor (seq below `seedLength`), which
+    // must not outrank the re-fold. Everything else also falls through to
+    // preparation: an absent key (a cut before any descriptor) and the
+    // `null` sentinel, whose verdict belongs to the authoritative re-fold,
+    // not to a derived row.
+    if (cached !== undefined && cached !== null && cached.seq >= (header.seedLength ?? 0)) {
       return childRow(childId, cached, 'inactive', hasChildren)
     }
   }
   assertListingNotCancelled(signal)
-  let events: readonly SessionEvent[]
+  let inspected: { meta: SessionHeader; events: readonly SessionEvent[] }
   try {
-    events = (await persistence.inspect(childId, signal)).events
+    inspected = await persistence.inspect(childId, signal)
   } catch {
     // Per-child isolation: the child vanished or its backend read failed —
     // one diagnostic row, and the listing itself still succeeds.
@@ -262,9 +266,15 @@ async function resolveColdIdentity(
     return { kind: 'diagnostic', id: childId, reason: 'unavailable' }
   }
   assertListingNotCancelled(signal)
+  // A session id names a slot, not a lifecycle: a child deleted and
+  // re-published under another owner between the enumeration and this read
+  // must not leak into the old parent's listing.
+  if (!sameLifecycle(inspected.meta, header)) {
+    return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
+  }
   let identity: SubagentIdentityProjection | null | undefined
   try {
-    identity = projections.restore({}, events, 0).snapshot.values.subagent
+    identity = projections.restore({}, inspected.events, 0).snapshot.values.subagent
   } catch {
     // The restore folds EVERY registered unit over this child's log, so any
     // unit's fold or schema can reject damaged payloads — deterministic data
@@ -303,6 +313,19 @@ function childRow(
     }
 }
 
+/** Immutable header fields that distinguish one session lifecycle from another under the same id. */
+const LIFECYCLE_WITNESS_KEYS = [
+  'version', 'id', 'createdAt', 'cwd', 'parentSession', 'seedLength', 'delegationDepth',
+] as const
+
+/**
+ * Whether an inspected log still belongs to the enumerated lifecycle,
+ * mirroring the retired query-source compatibility check's field set.
+ */
+function sameLifecycle(meta: SessionHeader, expected: SessionHeader): boolean {
+  return LIFECYCLE_WITNESS_KEYS.every(key => meta[key] === expected[key])
+}
+
 /** Stop a listing at its next cancellation checkpoint. */
 function assertListingNotCancelled(signal: AbortSignal | undefined): void {
   if (signal?.aborted) {

+ 9 - 0
packages/subagent/subagent/src/projection-types.ts

@@ -29,12 +29,21 @@ export type SubagentIdentityProjection =
     mode: 'one-shot'
     /** Optional durable creation label from the child's descriptor. */
     label?: string
+    /**
+     * Seq of the `subagent/descriptor` event this identity was folded from.
+     * `seq >= header.seedLength` proves the identity comes from the child's
+     * OWN log suffix — where a descriptor is immutable once appended — and
+     * not from a fork seed's replayed ancestor descriptor.
+     */
+    seq: number
   }
   | {
     /** A resumable conversation. */
     mode: 'continuable'
     /** Durable creation label from the child's descriptor. */
     label: string
+    /** Seq of the folded descriptor event; see the one-shot arm for the own-suffix proof. */
+    seq: number
   }
 
 declare module '@deepseek-ai/dsh-session-projection/types' {

+ 11 - 3
packages/subagent/subagent/src/projection.ts

@@ -99,10 +99,12 @@ const identitySchema = z.discriminatedUnion('mode', [
   z.object({
     mode: z.literal('one-shot'),
     label: z.string().optional(),
+    seq: z.number().int().nonnegative(),
   }).strict(),
   z.object({
     mode: z.literal('continuable'),
     label: z.string(),
+    seq: z.number().int().nonnegative(),
   }).strict(),
 ]).nullable() as unknown as z.ZodType<SubagentIdentityProjection | null>
 
@@ -118,8 +120,12 @@ function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | u
   }
   if (descriptor === undefined) return undefined
   return descriptor.mode === 'one-shot'
-    ? { mode: 'one-shot', ...descriptor.label !== undefined ? { label: descriptor.label } : {} }
-    : { mode: 'continuable', label: descriptor.label }
+    ? {
+      mode: 'one-shot',
+      ...descriptor.label !== undefined ? { label: descriptor.label } : {},
+      seq: event.seq,
+    }
+    : { mode: 'continuable', label: descriptor.label, seq: event.seq }
 }
 
 /**
@@ -144,5 +150,7 @@ ProjectionDefinition<'subagent', IdentityState> = {
     return identity === undefined ? {} : { identity }
   },
   view: state => state.identity ?? null,
-  stateVersion: 1,
+  // 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,
 }

+ 77 - 1
packages/subagent/subagent/tests/list-children.spec.ts

@@ -373,7 +373,7 @@ describe('SubagentService.listChildren', () => {
     live.append('turn/start', { turn: 1 })
     live.append('subagent/descriptor', descriptorPayload('was valid'))
     expect(ctx.sessionProjections.snapshot(live).values.subagent)
-      .toEqual({ mode: 'continuable', label: 'was valid' })
+      .toEqual({ mode: 'continuable', label: 'was valid', seq: 1 })
     // Last-wins: the malformed follow-up resets the identity to the sentinel.
     live.append(
       'subagent/descriptor',
@@ -409,6 +409,82 @@ describe('SubagentService.listChildren', () => {
     ])
   })
 
+  it('serves a cached own-suffix identity directly without inspection', async () => {
+    const { ctx, parent } = await setup([], { projectionCache: true })
+    const child = await authorChild(ctx, '00000000-0000-4000-8000-00000000ae01', {
+      parentSession: parent.id,
+      origin: 'subagent',
+    }, childEvents(descriptorPayload('disk label')))
+    // seq 2 >= seedLength 0: the cached identity provably comes from the
+    // child's own suffix, so it is final and the log is never re-read — the
+    // divergent label proves the row, not the log, produced the entry.
+    ctx.sessionProjectionCache.cachedSnapshot = () => ({
+      asOfSeq: 2,
+      values: { subagent: { mode: 'continuable', label: 'cached own', seq: 2 } },
+    })
+    const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
+    await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
+      kind: 'child', id: child, label: 'cached own', mode: 'continuable',
+      activity: 'inactive', hasChildren: false,
+    }])
+    expect(inspect).not.toHaveBeenCalled()
+  })
+
+  it('refuses a cached ancestor identity from the fork seed and lets preparation rule', async () => {
+    const { ctx, parent } = await setup([], { projectionCache: true })
+    // A fork child: the seed replays the ancestor's descriptor (seq 2), and
+    // the child's own descriptor arrives in its first own turn (seq 5).
+    const seed = childEvents(descriptorPayload('ancestor label'))
+    const events = [
+      ...seed,
+      { type: 'turn/start', seq: 4, time: 5, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
+      { type: 'subagent/descriptor', seq: 5, time: 6, data: descriptorPayload('own label') },
+      { type: 'turn/end', seq: 6, time: 7, data: { turn: 2, reason: { kind: 'completed' } } },
+    ] as SessionEvent[]
+    const forkChild = await authorChild(ctx, '00000000-0000-4000-8000-00000000ae02', {
+      parentSession: parent.id,
+      seedLength: seed.length,
+      origin: 'subagent',
+    }, events)
+    // A creation-window checkpoint carried the ANCESTOR identity: its seq 2
+    // fails the own-suffix gate (< seedLength 4), so preparation rules.
+    ctx.sessionProjectionCache.cachedSnapshot = () => ({
+      asOfSeq: 2,
+      values: { subagent: { mode: 'continuable', label: 'ancestor label', seq: 2 } },
+    })
+    const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
+    await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
+      kind: 'child', id: forkChild, label: 'own label', mode: 'continuable',
+      activity: 'inactive', hasChildren: false,
+    }])
+    expect(inspect).toHaveBeenCalledTimes(1)
+  })
+
+  it.each([
+    ['createdAt', (meta: SessionHeader): SessionHeader => ({ ...meta, createdAt: meta.createdAt + 1 })],
+    ['delegationDepth', (meta: SessionHeader): SessionHeader => ({ ...meta, delegationDepth: (meta.delegationDepth ?? 0) + 1 })],
+  ] as const)('diagnoses an inspection returning another lifecycle (%s) as corrupt', async (_field, mutate) => {
+    const { ctx, parent } = await setup([textResponse('done')])
+    const healthy = await startChild(ctx, parent, 'healthy sibling')
+    const reborn = await authorChild(ctx, '00000000-0000-4000-8000-00000000ae03', {
+      parentSession: parent.id,
+      origin: 'subagent',
+    }, childEvents(descriptorPayload('reborn child')))
+    const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
+    ctx.sessionPersistence.inspect = async (sessionId, signal) => {
+      const result = await original(sessionId, signal)
+      if (sessionId !== reborn) return result
+      // The id was re-published as a different lifecycle after enumeration.
+      return { ...result, meta: mutate(result.meta) }
+    }
+    const entries = await ctx.subagents.listChildren(parent.id)
+    expect(entries).toContainEqual({ kind: 'diagnostic', id: reborn, reason: 'corrupt' })
+    expect(entries).toContainEqual({
+      kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable',
+      activity: 'inactive', hasChildren: false,
+    })
+  })
+
   it('lets preparation rule when the cache serves the null sentinel', async () => {
     const { ctx, parent } = await setup([], { projectionCache: true })
     const healthy = await authorChild(ctx, '00000000-0000-4000-8000-00000000ad02', {

+ 8 - 4
packages/subagent/subagent/tests/timing-projection.spec.ts

@@ -23,11 +23,15 @@ describe('subagent timing projection', () => {
     await ctx.plugin(SessionProjectionRegistry)
     const serviceFiber = await ctx.plugin(SubagentService)
 
-    expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming)
-      .toEqual({ settledMs: 0 })
+    const before = ctx.sessionProjections.snapshot(ctx.sessions.create()).values
+    expect(before.subagentTiming).toEqual({ settledMs: 0 })
+    // The identity unit registers alongside timing; an empty log serves its
+    // serializable null sentinel.
+    expect(before.subagent).toBeNull()
     await serviceFiber.dispose()
-    expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming)
-      .toBeUndefined()
+    const after = ctx.sessionProjections.snapshot(ctx.sessions.create()).values
+    expect(after.subagentTiming).toBeUndefined()
+    expect(after.subagent).toBeUndefined()
   })
 
   it('resets inherited seed timing at the child descriptor and sums later completed turns', () => {

Деякі файли не було показано, через те що забагато файлів було змінено