Răsfoiți Sursa

refactor(subagent): extract persistent chunked list utility

Dudu-0223 3 săptămâni în urmă
părinte
comite
9f69a7fb16
34 a modificat fișierele cu 430 adăugiri și 68 ștergeri
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml
  2. 3 1
      .agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md
  3. 3 1
      .agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md
  4. 2 2
      docs/config-catalog.i18n.yaml
  5. 1 0
      docs/config-catalog.md
  6. 1 0
      docs/config-catalog.zh.md
  7. 2 2
      docs/module-graph.i18n.yaml
  8. 2 0
      docs/module-graph.md
  9. 2 0
      docs/module-graph.zh.md
  10. 2 2
      docs/persistence-catalog.i18n.yaml
  11. 1 1
      docs/persistence-catalog.md
  12. 1 1
      docs/persistence-catalog.zh.md
  13. 2 2
      packages/subagent/subagent/README.i18n.yaml
  14. 1 1
      packages/subagent/subagent/README.md
  15. 1 1
      packages/subagent/subagent/README.zh.md
  16. 1 0
      packages/subagent/subagent/package.json
  17. 20 49
      packages/subagent/subagent/src/catalog.ts
  18. 1 1
      packages/subagent/subagent/tests/service.spec.ts
  19. 3 0
      packages/subagent/subagent/tsconfig.json
  20. 2 2
      packages/util/README.i18n.yaml
  21. 1 0
      packages/util/README.md
  22. 1 0
      packages/util/README.zh.md
  23. 6 0
      packages/util/chunked-list/README.i18n.yaml
  24. 93 0
      packages/util/chunked-list/README.md
  25. 93 0
      packages/util/chunked-list/README.zh.md
  26. 38 0
      packages/util/chunked-list/package.json
  27. 58 0
      packages/util/chunked-list/src/index.ts
  28. 59 0
      packages/util/chunked-list/tests/chunked-list.spec.ts
  29. 11 0
      packages/util/chunked-list/tsconfig.json
  30. 13 0
      pnpm-lock.yaml
  31. 1 0
      scripts/doc-standard.spec.ts
  32. 1 0
      scripts/verify-package-readme-model-experience.ts
  33. 1 0
      tsconfig.base.json
  34. 1 0
      tsconfig.host.json

+ 2 - 2
.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: 3cfc513d70becf82cf5971be03aa6499de162a6f
-2026-09-01-parent-owned-subagent-catalog.zh.md: ef3f099156703dd18e57c8e11e28ddbe7c9b5e8e
+2026-09-01-parent-owned-subagent-catalog.md: b0eee83499f8f9f3b3e2be652037dc882e005c94
+2026-09-01-parent-owned-subagent-catalog.zh.md: 8985eb291c5dbfadb53eb3576dcd9306496aa983

+ 3 - 1
.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md

@@ -18,7 +18,9 @@ Creation publishes only successful facts. A one-shot run appends the catalog eve
 
 The child header and `subagent/descriptor` remain authoritative for recovery and composition. An Activation and the exact parent relationship remain authoritative for authorization and delivery. Mode and label are snapshotted once and the same detached values reach the parent catalog fact and child descriptor.
 
-The registered `subagentCatalog` projection materializes the parent facts. It stores facts in a persistent stack of 64-entry chunks, so an append copies at most the head chunk in bounded O(1) work. Materialization visits chunks from oldest to newest and preserves parent catalog event order in O(D) time for D facts. Concurrent creation is ordered by successful catalog append, independent of child timestamps and ids. A projection checkpoint clones the state once in O(D); projection-cache writes remain asynchronous and use the existing mandatory creation, turn-end, and disposal points.
+The registered `subagentCatalog` projection materializes the parent facts. It delegates storage, append, iteration, and checkpoint validation to [`dsh-chunked-list`](../../../../packages/util/chunked-list/README.md), which stores facts in a persistent stack of 64-entry chunks, so an append copies at most the head chunk in bounded O(1) work. Materialization visits chunks from oldest to newest and preserves parent catalog event order in O(D) time for D facts. Concurrent creation is ordered by successful catalog append, independent of child timestamps and ids. A projection checkpoint clones the state once in O(D); projection-cache writes remain asynchronous and use the existing mandatory creation, turn-end, and disposal points.
+
+The utility owns chunk layout and its shared capacity constant; the catalog owns event validation, fork filtering, and row conversion. Catalog projection state version 2 stores generic chunk values, so the projection registry rebuilds incompatible caches from Session events. Session event payloads and public catalog rows retain their formats.
 
 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.
 

+ 3 - 1
.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md

@@ -18,7 +18,9 @@ parent Session 的 required `subagent/catalog` 事件是直接 child discovery 
 
 child header 与 `subagent/descriptor` 继续拥有恢复与 composition 权威。Activation 与精确 parent 关系继续拥有授权与投递权威。mode 与 label 只快照一次,同一份分离值写入 parent catalog fact 与 child descriptor。
 
-注册的 `subagentCatalog` projection 物化 parent fact。它以每块 64 项的持久 stack 保存事实,因此 append 最多复制 head chunk,以有界 O(1) 工作完成。materialization 从旧到新访问 chunk,对 D 条事实以 O(D) 时间保留父目录事件顺序。并发创建按目录成功追加的顺序排列,与 child 时间戳和 id 无关。projection checkpoint 以 O(D) 克隆 state;projection-cache 继续异步写入,并使用既有创建、turn-end 与 disposal 强制点。
+注册的 `subagentCatalog` projection 物化 parent fact。它将存储、追加、迭代和检查点校验交给 [`dsh-chunked-list`](../../../../packages/util/chunked-list/README.zh.md),后者以每块 64 项的持久 stack 保存事实,因此 append 最多复制 head chunk,以有界 O(1) 工作完成。materialization 从旧到新访问 chunk,对 D 条事实以 O(D) 时间保留父目录事件顺序。并发创建按目录成功追加的顺序排列,与 child 时间戳和 id 无关。projection checkpoint 以 O(D) 克隆 state;projection-cache 继续异步写入,并使用既有创建、turn-end 与 disposal 强制点。
+
+工具库拥有分块布局及其共享容量常量;目录拥有事件校验、fork 过滤和目录行转换。目录 projection state 版本 2 保存通用块值,因此 projection registry 从 Session 事件重建不兼容的缓存。Session 事件载荷和公开目录行保持各自格式。
 
 fork 隔离使用 projection 初始化时提供的精确 `Session.inheritedEventCount`。fold 忽略该 offset 之前的 `subagent/catalog` 事件。state 保存 inherited offset,但不保存每条 event seq,因为接受判定已在 fold 时完成。
 

+ 2 - 2
docs/config-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 docs/config-catalog.md
-config-catalog.md: 9099ca894f0cab6fb8030c5ffced74cf7547df77
-config-catalog.zh.md: a9d7502de051857bb947361c1808a48ebcedb2ee
+config-catalog.md: cd6449b29479dabfd9f686c7cd33f30c60d14739
+config-catalog.zh.md: 52e563119240e5fbdbffbb2aa0664d4a6abe3f2b

+ 1 - 0
docs/config-catalog.md

@@ -3539,6 +3539,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
 - `@deepseek-ai/dsh-atomic-write` ([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts))
 - `@deepseek-ai/dsh-base` ([`packages/bundle/base/src/index.ts`](../packages/bundle/base/src/index.ts))
 - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts))
+- `@deepseek-ai/dsh-chunked-list` ([`packages/util/chunked-list/src/index.ts`](../packages/util/chunked-list/src/index.ts))
 - `@deepseek-ai/dsh-client-store` ([`packages/client/store/src/index.ts`](../packages/client/store/src/index.ts))
 - `@deepseek-ai/dsh-client-test-runtime` ([`packages/test-support/client-runtime/src/index.ts`](../packages/test-support/client-runtime/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-dockkit` ([`packages/client/ui-dockkit/src/index.ts`](../packages/client/ui-dockkit/src/index.ts))

+ 1 - 0
docs/config-catalog.zh.md

@@ -3540,6 +3540,7 @@ export interface Config {
 - `@deepseek-ai/dsh-atomic-write`([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts))
 - `@deepseek-ai/dsh-base`([`packages/bundle/base/src/index.ts`](../packages/bundle/base/src/index.ts))
 - `@deepseek-ai/dsh-brand`([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts))
+- `@deepseek-ai/dsh-chunked-list`([`packages/util/chunked-list/src/index.ts`](../packages/util/chunked-list/src/index.ts))
 - `@deepseek-ai/dsh-client-store`([`packages/client/store/src/index.ts`](../packages/client/store/src/index.ts))
 - `@deepseek-ai/dsh-client-test-runtime`([`packages/test-support/client-runtime/src/index.ts`](../packages/test-support/client-runtime/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-dockkit`([`packages/client/ui-dockkit/src/index.ts`](../packages/client/ui-dockkit/src/index.ts))

+ 2 - 2
docs/module-graph.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/module-graph.md
-module-graph.md: 233ae6b3fb49b07a2f7237aef2674c782df07720
-module-graph.zh.md: 64c5aa749aa76631c308d4b724ed1cd356068019
+module-graph.md: 9d229ba5d25fbf9f4763a96667150dd2103d0231
+module-graph.zh.md: 830f777dde991098ad2f4279a879ecf9cb144900

+ 2 - 0
docs/module-graph.md

@@ -10,6 +10,7 @@ flowchart TD
   subgraph group_util["packages/util"]
     pkg_atomic_write["atomic-write"]
     pkg_brand["brand"]
+    pkg_chunked_list["chunked-list"]
     pkg_deque["deque"]
     pkg_home_paths["home-paths"]
     pkg_http_proxy["http-proxy"]
@@ -1172,6 +1173,7 @@ flowchart TD
 | --- | --- | --- |
 | [`atomic-write`](../packages/util/atomic-write) | `util` | — |
 | [`brand`](../packages/util/brand) | `util` | — |
+| [`chunked-list`](../packages/util/chunked-list) | `util` | — |
 | [`deque`](../packages/util/deque) | `util` | — |
 | [`home-paths`](../packages/util/home-paths) | `util` | — |
 | [`http-proxy`](../packages/util/http-proxy) | `util` | — |

+ 2 - 0
docs/module-graph.zh.md

@@ -12,6 +12,7 @@ flowchart TD
   subgraph group_util["packages/util"]
     pkg_atomic_write["atomic-write"]
     pkg_brand["brand"]
+    pkg_chunked_list["chunked-list"]
     pkg_deque["deque"]
     pkg_home_paths["home-paths"]
     pkg_http_proxy["http-proxy"]
@@ -1174,6 +1175,7 @@ flowchart TD
 | --- | --- | --- |
 | [`atomic-write`](../packages/util/atomic-write) | `util` | — |
 | [`brand`](../packages/util/brand) | `util` | — |
+| [`chunked-list`](../packages/util/chunked-list) | `util` | — |
 | [`deque`](../packages/util/deque) | `util` | — |
 | [`home-paths`](../packages/util/home-paths) | `util` | — |
 | [`http-proxy`](../packages/util/http-proxy) | `util` | — |

+ 2 - 2
docs/persistence-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 docs/persistence-catalog.md
-persistence-catalog.md: d9a9449a4369988d4031d921c4124d8cb74f8791
-persistence-catalog.zh.md: 538b20e430c8d061e7f443d9a412443e81851415
+persistence-catalog.md: 06242185a988d6908be0c66e13a45af4e4974e1c
+persistence-catalog.zh.md: bc6f4f623e60962df7adfd3771d4bf95b1c1ff3b

+ 1 - 1
docs/persistence-catalog.md

@@ -773,7 +773,7 @@ Source: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/
 'subagent/catalog': SubagentCatalogEvent
 ```
 
-Source: [`packages/subagent/subagent/src/catalog.ts:38`](../packages/subagent/subagent/src/catalog.ts)
+Source: [`packages/subagent/subagent/src/catalog.ts:40`](../packages/subagent/subagent/src/catalog.ts)
 
 <a id="subagentdescriptor--log-only"></a>
 

+ 1 - 1
docs/persistence-catalog.zh.md

@@ -775,7 +775,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'subagent/catalog': SubagentCatalogEvent
 ```
 
-来源:[`packages/subagent/subagent/src/catalog.ts:38`](../packages/subagent/subagent/src/catalog.ts)
+来源:[`packages/subagent/subagent/src/catalog.ts:40`](../packages/subagent/subagent/src/catalog.ts)
 
 <a id="subagentdescriptor--log-only"></a>
 

+ 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: 96bfe3ffcf097f4b8e8326478c9840632801233d
-README.zh.md: b692806732fc6c479877881f32a904f663fa81b5
+README.md: 5f9668fd7a14cdfca33ab72a217c07372a24502f
+README.zh.md: 563ed06237088c4bc250ca3c4f5a863b6ffe8239

+ 1 - 1
packages/subagent/subagent/README.md

@@ -96,7 +96,7 @@ A request is validated against the provider's advertised capabilities, a durable
 
 The manager reserves a child identity, resolves the durable descriptor, creates (or cold-resumes) the child Agent, installs it in an Activation, and submits the prompt. Model-authored messages cross one parent/child edge through fixed Steer scheduling; browser human prompts choose Queue or best-effort Steer through an internal adapter, while other host protocols may retain Queue for distinct turns. A Session queue command admits a live subagent-owned Agent only from its own continuable descriptor. Settlement waits for Agent activity to finish, an empty Inbox, and no owned children, then flushes final Session state with admission open. Under the child lock, the manager revalidates the wake generation, Session sequence, Inbox, and owned children; the synchronous task entry of `Agent.runMaintenance()` claims the idle phase and closes the private subagent Inbox in the same JavaScript turn before handle disposal. An absent direct-child Activation cold-resumes from the persisted session. When a resident Activation settles, the manager tells the child's direct parent in the parent's own turn stream.
 
-Successful local child creation appends a `subagent/catalog` fact to the parent Session. One-shot creation records it after the provider returns; continuable creation records it after initial inbox admission and before returning the child id. Failure releases the child without publishing a compensating catalog event. A one-shot catalog append failure handles the run’s result rejection and preserves the catalog error; disposal failures are logged separately. The `subagentCatalog` projection excludes fork-inherited facts and exposes a direct-child list through `projections.values.subagentCatalog` in Session observations and client snapshots. Invalid own catalog payloads, including unsupported versions, reject projection restoration. Its view preserves parent catalog event order in O(D) time for D facts. [The parent-catalog decision](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns ordering, persistence costs, and alternatives.
+Successful local child creation appends a `subagent/catalog` fact to the parent Session. One-shot creation records it after the provider returns; continuable creation records it after initial inbox admission and before returning the child id. Failure releases the child without publishing a compensating catalog event. A one-shot catalog append failure handles the run’s result rejection and preserves the catalog error; disposal failures are logged separately. The `subagentCatalog` projection excludes fork-inherited facts and exposes a direct-child list through `projections.values.subagentCatalog` in Session observations and client snapshots. Invalid own catalog payloads, including unsupported versions, reject projection restoration. Its immutable storage and checkpoint validation use [`dsh-chunked-list`](../../util/chunked-list/README.md). Its view preserves parent catalog event order in O(D) time for D facts. [The parent-catalog decision](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns ordering, persistence costs, and alternatives.
 
 ### Ownership and invariants
 

+ 1 - 1
packages/subagent/subagent/README.zh.md

@@ -96,7 +96,7 @@ kind: "package-reference"
 
 管理器预留 child 身份、解析持久化描述符、创建(或冷恢复)child、把它安装进 Activation 并提交提示词。模型编写的消息通过固定 Steer 调度跨一条 parent/child 边;浏览器人类 prompt 通过内部适配器选择 Queue 或 best-effort Steer,其他 host 协议仍可保留 Queue 以创建独立轮次。Session queue command 仅根据 child 自身的 continuable descriptor 准入在线 subagent-owned Agent。Settlement 会等待 Agent 活动结束、Inbox 为空且没有所拥有子级,再在准入开放时 flush 最终 Session 状态。管理器随后在 child lock 内重新验证 wake generation、Session 序号、Inbox 与所拥有子级;`Agent.runMaintenance()` 的同步 task 入口会占用 idle 阶段,并在同一个 JavaScript turn 内关闭私有 subagent Inbox,然后才 dispose handle。直接 child 不存在 Activation 时会从持久化会话冷恢复。当驻留 Activation 结算时,管理器会在 parent 自身的轮次流中告知该 child 的直接 parent。
 
-本地子级创建成功时,父 Session 追加一条 `subagent/catalog` 事实。一次性创建在 provider 返回后记录;可继续创建在初始 inbox 准入后、返回子级 id 前记录。失败会释放子级,不发布补偿性目录事件。一次性目录追加失败时会处理 run 的结果拒绝,并保留目录错误;资源释放失败会单独记录。`subagentCatalog` projection 排除 fork 继承的事实,通过 Session 观察和客户端快照中的 `projections.values.subagentCatalog` 暴露直接子级列表。无效的自身 catalog payload(包括不支持的版本)会使 projection 恢复失败。其视图对 D 条事实以 O(D) 时间保留父目录事件顺序。[父目录决策](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明排序、持久化成本和替代方案。
+本地子级创建成功时,父 Session 追加一条 `subagent/catalog` 事实。一次性创建在 provider 返回后记录;可继续创建在初始 inbox 准入后、返回子级 id 前记录。失败会释放子级,不发布补偿性目录事件。一次性目录追加失败时会处理 run 的结果拒绝,并保留目录错误;资源释放失败会单独记录。`subagentCatalog` projection 排除 fork 继承的事实,通过 Session 观察和客户端快照中的 `projections.values.subagentCatalog` 暴露直接子级列表。无效的自身 catalog payload(包括不支持的版本)会使 projection 恢复失败。其不可变存储和检查点校验使用 [`dsh-chunked-list`](../../util/chunked-list/README.zh.md)。其视图对 D 条事实以 O(D) 时间保留父目录事件顺序。[父目录决策](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明排序、持久化成本和替代方案。
 
 ### 所有权与不变式
 

+ 1 - 0
packages/subagent/subagent/package.json

@@ -54,6 +54,7 @@
   "license": "MIT",
   "dependencies": {
     "@deepseek-ai/dsh-brand": "workspace:^",
+    "@deepseek-ai/dsh-chunked-list": "workspace:^",
     "@deepseek-ai/dsh-util-values": "workspace:^",
     "zod": "^4.4.3"
   },

+ 20 - 49
packages/subagent/subagent/src/catalog.ts

@@ -5,6 +5,8 @@
  */
 
 import { z } from 'zod'
+import { appendChunkedList, chunkedListSchema, iterateChunkedList } from '@deepseek-ai/dsh-chunked-list'
+import type { ChunkedList } from '@deepseek-ai/dsh-chunked-list'
 import type {
   Session,
   SessionEvent,
@@ -39,20 +41,12 @@ declare module '@deepseek-ai/dsh-session/types' {
   }
 }
 
-/** A fixed-size persistent stack node; newest facts occupy the head chunk. */
-interface CatalogChunk {
-  readonly facts: readonly SubagentCatalogEvent[]
-  readonly previous?: CatalogChunk | undefined
-}
-
 /** Host fold state for one parent catalog. */
 export interface SubagentCatalogState {
   readonly inheritedEventCount: SessionLogOffset
-  readonly head?: CatalogChunk | undefined
+  readonly head?: ChunkedList<SubagentCatalogEvent> | undefined
 }
 
-const CATALOG_CHUNK_CAPACITY = 64
-
 const sessionIdSchema = z.string() as unknown as z.ZodType<SessionId>
 const oneShotCatalogSchema = z.object({
   version: z.literal(SUBAGENT_CATALOG_VERSION),
@@ -82,13 +76,9 @@ const viewSchema = z.array(z.union([
     createdAt: continuableCatalogSchema.shape.childCreatedAt,
   }),
 ])) as unknown as z.ZodType<SubagentCatalogEntry[]>
-const chunkSchema: z.ZodType<CatalogChunk> = z.lazy(() => z.object({
-  facts: z.array(eventDataSchema).min(1).max(CATALOG_CHUNK_CAPACITY),
-  previous: chunkSchema.optional(),
-}).strict())
 const stateSchema: z.ZodType<SubagentCatalogState> = z.object({
   inheritedEventCount: z.number().int().nonnegative() as unknown as z.ZodType<SessionLogOffset>,
-  head: chunkSchema.optional(),
+  head: chunkedListSchema(eventDataSchema).optional(),
 }).strict()
 
 declare module '@deepseek-ai/dsh-session-projection/types' {
@@ -97,46 +87,27 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
   }
 }
 
-/** Append one fact to the persistent chunk stack in constant bounded work. */
-function appendFact(state: SubagentCatalogState, fact: SubagentCatalogEvent): SubagentCatalogState {
-  const head = state.head
-  if (head === undefined || head.facts.length === CATALOG_CHUNK_CAPACITY) {
-    return { ...state, head: { facts: [fact], ...head === undefined ? {} : { previous: head } } }
-  }
-  return {
-    ...state,
-    head: {
-      facts: [...head.facts, fact],
-      ...head.previous === undefined ? {} : { previous: head.previous },
-    },
-  }
-}
-
 /**
  * Materialize direct children from their parent's successful creation facts.
  * @param state - parent catalog fold state.
  * @returns current direct-child rows in parent catalog event order.
  */
 function subagentCatalogEntries(state: SubagentCatalogState): SubagentCatalogEntry[] {
-  const chunks: CatalogChunk[] = []
-  for (let chunk = state.head; chunk !== undefined; chunk = chunk.previous) chunks.push(chunk)
   const entries: SubagentCatalogEntry[] = []
-  for (const chunk of chunks.reverse()) {
-    for (const data of chunk.facts) {
-      entries.push(data.mode === 'one-shot'
-        ? {
-          id: data.childId,
-          createdAt: data.childCreatedAt,
-          mode: data.mode,
-          ...data.label === undefined ? {} : { label: data.label },
-        }
-        : {
-          id: data.childId,
-          createdAt: data.childCreatedAt,
-          mode: data.mode,
-          label: data.label,
-        })
-    }
+  for (const data of iterateChunkedList(state.head)) {
+    entries.push(data.mode === 'one-shot'
+      ? {
+        id: data.childId,
+        createdAt: data.childCreatedAt,
+        mode: data.mode,
+        ...data.label === undefined ? {} : { label: data.label },
+      }
+      : {
+        id: data.childId,
+        createdAt: data.childCreatedAt,
+        mode: data.mode,
+        label: data.label,
+      })
   }
   return entries
 }
@@ -148,9 +119,9 @@ export const subagentCatalogProjectionDefinition = {
   init: (_header: SessionHeader, inheritedEventCount: SessionLogOffset) => ({ inheritedEventCount }),
   apply: (state, event: SessionEvent) => {
     if (event.type !== 'subagent/catalog' || event.seq < state.inheritedEventCount) return state
-    return appendFact(state, eventDataSchema.parse(event.data))
+    return { ...state, head: appendChunkedList(state.head, eventDataSchema.parse(event.data)) }
   },
-  stateVersion: 1,
+  stateVersion: 2,
   wire: { viewSchema, view: subagentCatalogEntries },
 } satisfies ProjectionDefinition<'subagentCatalog', SubagentCatalogState>
 

+ 1 - 1
packages/subagent/subagent/tests/service.spec.ts

@@ -84,7 +84,7 @@ describe('SubagentRuntime', () => {
       childCreatedAt: 1,
       mode: 'one-shot',
     })
-    expect(ctx.sessionProjections.stateOf(parent, 'subagentCatalog')?.head?.facts).toHaveLength(1)
+    expect(ctx.sessionProjections.snapshot(parent).values.subagentCatalog).toHaveLength(1)
 
     await fiber.dispose()
 

+ 3 - 0
packages/subagent/subagent/tsconfig.json

@@ -67,6 +67,9 @@
     },
     {
       "path": "../../runtime-diagnostics/invariants"
+    },
+    {
+      "path": "../../util/chunked-list"
     }
   ]
 }

+ 2 - 2
packages/util/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/util/README.md
-README.md: 2e46d9d4573b31afa14d7f1380af106c391d19d6
-README.zh.md: c7d8b63dc3ee7e748e8a07a3d65315ff81d5476f
+README.md: c6fd72b85fe302392c41818263396c316f987422
+README.zh.md: 879671b642e4872084926e510a84ee7dc1a3b39b

+ 1 - 0
packages/util/README.md

@@ -30,6 +30,7 @@ Each package provides one primitive; open a package page for how to use it.
 | [`package-manifest/`](package-manifest/README.md) | Shared TypeScript declarations for package manifests |
 | [`crypto/`](crypto/README.md) | Mints RFC 9562 v4 UUIDs from the cross-runtime `crypto.getRandomValues` primitive |
 | [`deque/`](deque/README.md) | Provides amortized constant-time queue operations with bounded vacant storage |
+| [`chunked-list/`](chunked-list/README.md) | Retains immutable list versions with bounded append copying and checkpoint validation |
 | [`values/`](values/README.md) | Validates, snapshots, compares, and freezes lossless JSON-compatible values |
 | [`home-paths/`](home-paths/README.md) | Resolves the single Harness home and joins shared user-data paths |
 | [`http-proxy/`](http-proxy/README.md) | Resolves one outbound proxy policy and installs it for `fetch`, SDK agents, and spawned children |

+ 1 - 0
packages/util/README.zh.md

@@ -30,6 +30,7 @@ kind: "package-group"
 | [`package-manifest/`](package-manifest/README.zh.md) | Package manifest 的共享 TypeScript 声明 |
 | [`crypto/`](crypto/README.zh.md) | 基于跨运行时 `crypto.getRandomValues` 原语生成 RFC 9562 v4 UUID |
 | [`deque/`](deque/README.zh.md) | 提供摊销常数时间的队列操作和有界空闲存储 |
+| [`chunked-list/`](chunked-list/README.zh.md) | 通过有界追加复制和检查点校验保留不可变列表版本 |
 | [`values/`](values/README.zh.md) | 校验、创建快照、比较和冻结无损 JSON 兼容值 |
 | [`home-paths/`](home-paths/README.zh.md) | 解析统一的 Harness 主目录并拼接共享的用户数据路径 |
 | [`http-proxy/`](http-proxy/README.zh.md) | 解析出唯一的出站代理策略,并为 `fetch`、SDK agent 与派生子进程安装它 |

+ 6 - 0
packages/util/chunked-list/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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/util/chunked-list/README.md
+README.md: f0af89e5dd6cfdc6388fe90aaeaf2fb70b149827
+README.zh.md: 9f28884ad7414ceeae1d7e624cb20efca9033b25

+ 93 - 0
packages/util/chunked-list/README.md

@@ -0,0 +1,93 @@
+---
+description: "Immutable append-only lists for projection state, with bounded append copying, insertion-order iteration, and Zod checkpoint validation."
+kind: "package-library"
+---
+
+# @deepseek-ai/dsh-chunked-list
+
+English | [中文](README.zh.md)
+
+## Summary
+
+`dsh-chunked-list` lets callers append values while retaining earlier list versions without copying the whole collection. Callers can iterate every value in insertion order and validate JSON checkpoints with their own value schema. The subagent catalog uses it for immutable projection state.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## Use this package
+
+Use this list when an append-only collection needs immutable versions and JSON-compatible storage. An empty list is `undefined`; appending returns a new head without modifying existing nodes. The list shares stored values by reference, so callers must treat them as immutable.
+
+```ts
+import { appendChunkedList, iterateChunkedList } from '@deepseek-ai/dsh-chunked-list'
+
+const first = appendChunkedList(undefined, 'first')
+const second = appendChunkedList(first, 'second')
+console.log([...iterateChunkedList(second)])
+```
+
+The example produces `['first', 'second']`; `first` still contains only its original value. `chunkedListSchema(valueSchema)` validates JSON checkpoints and rejects unknown fields, invalid values, and empty or oversized chunks. Use `.optional()` on the schema when the containing field also permits an empty list. See the [source contracts](src/index.ts) for the operations.
+
+-----
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<details>
+<summary>Implementation internals — click to expand</summary>
+
+The newest chunk stores up to 64 values. Appends copy at most that chunk and share older nodes, taking bounded O(1) work. The capacity controls storage layout, not total list length. Iteration visits all N values in O(N) time and uses O(N / 64) scratch space to visit chunks from oldest to newest. A single capacity constant governs append rollover and recursive Zod validation.
+
+| File | Role |
+|---|---|
+| [`src/index.ts`](src/index.ts) | Persistent list operations and checkpoint validation |
+| [`tests/chunked-list.spec.ts`](tests/chunked-list.spec.ts) | Version isolation, ordering, structural sharing, and checkpoint acceptance |
+
+No runtime invariant companion is published because this library has no independently changing observations; its operations return caller-owned immutable values.
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## Further Exploration
+
+- [Utility package map](../README.md) — shared primitives.
+- [Subagent catalog decision](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) — why projection state uses chunks.
+
+-----
+
+<a id="model-experience"></a>
+## Model Experience
+
+None, as this collection registers nothing model-facing.
+
+#### KV Cache effect
+
+Nothing here enters a model request, so provider cache reuse is unaffected.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- **Append-only access** — callers needing removal or random access need another collection.
+- **Recursive checkpoints** — JSON serialization and schema validation remain subject to runtime nesting limits. Stored values must themselves support the caller's serialization format.
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>

+ 93 - 0
packages/util/chunked-list/README.zh.md

@@ -0,0 +1,93 @@
+---
+description: "用于 projection state 的不可变追加列表,提供有界追加复制、按插入顺序迭代和 Zod 检查点校验。"
+kind: "package-library"
+---
+
+# @deepseek-ai/dsh-chunked-list
+
+[English](README.md) | 中文
+
+## 概述
+
+`dsh-chunked-list` 让调用方追加值并保留早期列表版本,无需复制整个集合。调用方可以按插入顺序迭代所有值,并使用自己的值 schema 校验 JSON 检查点。subagent 目录用它保存不可变的 projection state。
+
+## 目录
+
+- [使用此包](#use-this-package)
+- [理解实现](#understand-the-implementation)
+- [进一步探索](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与延后工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## 使用此包
+
+当仅追加集合需要不可变版本和兼容 JSON 的存储时,使用此列表。空列表用 `undefined` 表示;追加返回新的头节点,不修改已有节点。列表按引用共享所存的值,因此调用方必须将这些值视为不可变。
+
+```ts
+import { appendChunkedList, iterateChunkedList } from '@deepseek-ai/dsh-chunked-list'
+
+const first = appendChunkedList(undefined, 'first')
+const second = appendChunkedList(first, 'second')
+console.log([...iterateChunkedList(second)])
+```
+
+示例输出 `['first', 'second']`;`first` 仍只包含原来的值。`chunkedListSchema(valueSchema)` 校验 JSON 检查点并拒绝未知字段、无效值和空块或超大块。当外层字段也允许空列表时,在 schema 上使用 `.optional()`。各操作详见[源码约定](src/index.ts)。
+
+-----
+
+<a id="understand-the-implementation"></a>
+## 理解实现
+
+<details>
+<summary>实现内部机制——点击展开</summary>
+
+最新的块最多存储 64 个值。追加最多复制该块并共享较旧的节点,工作量为有界 O(1)。容量控制存储布局,不限制列表总长度。迭代以 O(N) 时间访问全部 N 个值,并使用 O(N / 64) 临时空间按从旧到新的顺序访问各块。追加换块与递归 Zod 校验共用一个容量常量。
+
+| 文件 | 职责 |
+|---|---|
+| [`src/index.ts`](src/index.ts) | 持久化列表操作与检查点校验 |
+| [`tests/chunked-list.spec.ts`](tests/chunked-list.spec.ts) | 版本隔离、排序、结构共享与检查点接受条件 |
+
+此库没有独立变化的观测值,因此不发布运行时不变式伴随模块;其操作返回调用方拥有的不可变值。
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## 进一步探索
+
+- [工具包映射](../README.zh.md)——共享原语。
+- [Subagent 目录决策](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md)——projection state 使用分块的原因。
+
+-----
+
+<a id="model-experience"></a>
+## 模型体验
+
+无,因为此集合不注册任何面向模型的内容。
+
+#### KV Cache 影响
+
+本包没有内容进入模型请求,因此不影响提供方缓存复用。
+
+## 已知限制与延后工作
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- **仅追加访问**——需要删除或随机访问的调用方应使用其他集合。
+- **递归检查点**——JSON 序列化与 schema 校验仍受运行时嵌套深度限制。所存的值本身必须支持调用方的序列化格式。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护者的工作上下文——点击展开</summary>
+
+无。
+
+</details>

+ 38 - 0
packages/util/chunked-list/package.json

@@ -0,0 +1,38 @@
+{
+  "name": "@deepseek-ai/dsh-chunked-list",
+  "description": "Persistent append-only chunked lists with bounded copying and JSON checkpoint validation",
+  "version": "0.1.3-alpha.2",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/util/chunked-list"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.d.ts"
+  ],
+  "license": "MIT",
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^"
+  },
+  "dependencies": {
+    "zod": "^4.4.3"
+  }
+}

+ 58 - 0
packages/util/chunked-list/src/index.ts

@@ -0,0 +1,58 @@
+/**
+ * Persistent append-only lists with bounded copying and JSON checkpoint validation.
+ * @module @deepseek-ai/dsh-chunked-list
+ */
+
+import { z } from 'zod'
+
+const CHUNK_CAPACITY = 64
+
+/**
+ * Newest chunk of an immutable list; `undefined` represents the empty list.
+ * Values within each chunk follow insertion order. Callers treat nodes, arrays,
+ * and stored values as immutable; operations share values and older chunks.
+ */
+export interface ChunkedList<T> {
+  readonly values: readonly T[]
+  readonly previous?: ChunkedList<T> | undefined
+}
+
+/**
+ * Append without modifying the input, copying at most one 64-value chunk.
+ * @param head - current list, or `undefined` for an empty list.
+ * @param value - value to retain by reference.
+ * @returns new list sharing the unchanged older chunks.
+ */
+export function appendChunkedList<T>(head: ChunkedList<T> | undefined, value: T): ChunkedList<T> {
+  if (head === undefined || head.values.length === CHUNK_CAPACITY) {
+    return { values: [value], ...head === undefined ? {} : { previous: head } }
+  }
+  return {
+    values: [...head.values, value],
+    ...head.previous === undefined ? {} : { previous: head.previous },
+  }
+}
+
+/**
+ * Visit all values in insertion order, with O(N) time and O(N / 64) scratch space.
+ * @param head - current list, or `undefined` for an empty list.
+ * @returns iterator yielding the stored values by reference, without truncation.
+ */
+export function* iterateChunkedList<T>(head: ChunkedList<T> | undefined): Generator<T> {
+  const chunks: ChunkedList<T>[] = []
+  for (let chunk = head; chunk !== undefined; chunk = chunk.previous) chunks.push(chunk)
+  for (const chunk of chunks.reverse()) yield* chunk.values
+}
+
+/**
+ * Validate nonempty list checkpoints, including every stored value and chunk size.
+ * @param valueSchema - caller-owned validation for each stored value.
+ * @returns recursive Zod schema rejecting empty or oversized chunks and unknown fields.
+ */
+export function chunkedListSchema<T>(valueSchema: z.ZodType<T>): z.ZodType<ChunkedList<T>> {
+  const schema: z.ZodType<ChunkedList<T>> = z.lazy(() => z.object({
+    values: z.array(valueSchema).min(1).max(CHUNK_CAPACITY),
+    previous: schema.optional(),
+  }).strict())
+  return schema
+}

+ 59 - 0
packages/util/chunked-list/tests/chunked-list.spec.ts

@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest'
+import { z } from 'zod'
+import { appendChunkedList, chunkedListSchema, iterateChunkedList } from '../src/index.ts'
+import type { ChunkedList } from '../src/index.ts'
+
+describe('persistent chunked list', () => {
+  it('iterates an empty list and retains undefined values', () => {
+    expect([...iterateChunkedList(undefined)]).toEqual([])
+    const head = appendChunkedList(undefined, undefined)
+    expect([...iterateChunkedList(head)]).toEqual([undefined])
+  })
+
+  it('keeps every prefix unchanged across chunk rollovers and divergent appends', () => {
+    let head: ChunkedList<number> | undefined
+    const prefixes: ChunkedList<number>[] = []
+    for (let value = 0; value < 200; value += 1) {
+      head = appendChunkedList(head, value)
+      Object.freeze(head.values)
+      Object.freeze(head)
+      prefixes.push(head)
+    }
+    for (const [index, prefix] of prefixes.entries()) {
+      expect([...iterateChunkedList(prefix)]).toEqual(Array.from({ length: index + 1 }, (_, i) => i))
+    }
+    expect(prefixes[64]?.previous).toBe(prefixes[63])
+    expect(prefixes[65]?.previous).toBe(prefixes[63])
+    expect([...iterateChunkedList(appendChunkedList(prefixes[64], -1))])
+      .toEqual([...Array.from({ length: 65 }, (_, i) => i), -1])
+    expect([...iterateChunkedList(prefixes[65])]).toEqual(Array.from({ length: 66 }, (_, i) => i))
+  })
+
+  it('retains stored objects by reference', () => {
+    const value = Object.freeze({ id: 'first' })
+    expect([...iterateChunkedList(appendChunkedList(undefined, value))][0]).toBe(value)
+  })
+
+  it('restores JSON checkpoints and appends at and after rollover', () => {
+    const schema = chunkedListSchema(z.number())
+    let head: ChunkedList<number> | undefined
+    for (let value = 0; value < 130; value += 1) {
+      head = appendChunkedList(head, value)
+      head = schema.parse(JSON.parse(JSON.stringify(head)))
+    }
+    expect([...iterateChunkedList(head)]).toEqual(Array.from({ length: 130 }, (_, i) => i))
+  })
+
+  it.each([
+    { values: [] },
+    { values: Array.from({ length: 65 }, () => 0) },
+    { values: ['invalid'] },
+    { values: [0], extra: true },
+    { values: [0], previous: { values: [] } },
+    { values: [0], previous: { values: Array.from({ length: 65 }, () => 0) } },
+    { values: [0], previous: { values: ['invalid'] } },
+    { values: [0], previous: { values: [1], extra: true } },
+  ])('rejects an invalid checkpoint: %j', (checkpoint) => {
+    expect(() => chunkedListSchema(z.number()).parse(checkpoint)).toThrow(z.ZodError)
+  })
+})

+ 11 - 0
packages/util/chunked-list/tsconfig.json

@@ -0,0 +1,11 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": []
+}

+ 13 - 0
pnpm-lock.yaml

@@ -9036,6 +9036,9 @@ importers:
       '@deepseek-ai/dsh-brand':
         specifier: workspace:^
         version: link:../../util/brand
+      '@deepseek-ai/dsh-chunked-list':
+        specifier: workspace:^
+        version: link:../../util/chunked-list
       '@deepseek-ai/dsh-util-values':
         specifier: workspace:^
         version: link:../../util/values
@@ -10169,6 +10172,16 @@ importers:
         specifier: workspace:^
         version: link:../../../vendor/cordis
 
+  packages/util/chunked-list:
+    dependencies:
+      zod:
+        specifier: ^4.4.3
+        version: 4.4.3
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
+
   packages/util/crypto:
     devDependencies:
       '@deepseek-ai/cordis':

+ 1 - 0
scripts/doc-standard.spec.ts

@@ -82,6 +82,7 @@ const PACKAGE_LIBRARIES: Readonly<Record<string, string>> = {
   'packages/util/brand': 'Stateless nominal-string and canonical-key constructors.',
   'packages/util/crypto': 'Zero-dependency identifier minting utility.',
   'packages/util/deque': 'Zero-dependency circular deque utility.',
+  'packages/util/chunked-list': 'Persistent collection operations and checkpoint validation without a plugin surface.',
   'packages/util/home-paths': 'Zero-dependency harness-home path resolver.',
   'packages/util/launch-environment': 'Zero-dependency environment resolver.',
   'packages/util/native-command': 'Host-side subprocess runner utility.',

+ 1 - 0
scripts/verify-package-readme-model-experience.ts

@@ -57,6 +57,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/client/ui-agent-preset': { kind: 'indirect', reason: 'Browser-side settings row; the preset it selects owns every model-facing effect.' },
   'packages/util/crypto': { kind: 'indirect', reason: 'Pure identifier minting; the ids consumers mint with it never enter prompts as semantic content.' },
   'packages/util/deque': { kind: 'none', reason: 'In-process collection primitive; registers nothing model-facing.' },
+  'packages/util/chunked-list': { kind: 'none', reason: 'Immutable collection primitive; registers nothing model-facing.' },
   'packages/util/package-manifest': { kind: 'none', reason: 'Type declarations only; registers nothing model-facing.' },
   'packages/util/time': { kind: 'indirect', reason: 'Pure zone validation; the consumer that records a canonical zone owns the model-visible line derived from it.' },
   'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' },

+ 1 - 0
tsconfig.base.json

@@ -269,6 +269,7 @@
       "@deepseek-ai/dsh-bash-local": ["./packages/shell/bash-local/src"],
       "@deepseek-ai/dsh-bash-sandbox": ["./packages/shell/bash-sandbox/src"],
       "@deepseek-ai/dsh-brand": ["./packages/util/brand/src"],
+      "@deepseek-ai/dsh-chunked-list": ["./packages/util/chunked-list/src"],
       "@deepseek-ai/dsh-cmdline": ["./packages/boot/cmdline/src"],
       "@deepseek-ai/dsh-code-runtime": ["./packages/code-runtime/code-runtime/src"],
       "@deepseek-ai/dsh-code-runtime-worker-thread": ["./packages/code-runtime/code-runtime-worker-thread/src"],

+ 1 - 0
tsconfig.host.json

@@ -148,6 +148,7 @@
     { "path": "./packages/util/timeout" },
     { "path": "./packages/util/crypto" },
     { "path": "./packages/util/deque" },
+    { "path": "./packages/util/chunked-list" },
     { "path": "./packages/util/values" },
     { "path": "./packages/util/workspace-path" },
     { "path": "./packages/util/output-retention" },