Jelajahi Sumber

feat(subagent): opportunistic projection-cache rung for cold listings

Cold children consult the optional session-projection-cache checkpoint
before paying a preparation recompute: the identity is immutable once
appended, so a cached value is definitive regardless of its watermark.
The cache stays a read-only accelerator (absent service or any rung-two
fault falls through silently; verdicts stay with the authoritative
refold), and the note plus core-data-structures pages describe the
three-rung ladder.
imccyu 1 bulan lalu
induk
melakukan
a25d4331d7

+ 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: d23e068b6dbbc08d62bec18b0b00e9651a502a86
-2026-08-06-subagent-list-identity-projection.zh.md: 45bdd41ba07e48b70e8030fb6e61ada944f80645
+2026-08-06-subagent-list-identity-projection.md: feea6a6634724c59a38bfbdc4c5dc138335899ec
+2026-08-06-subagent-list-identity-projection.zh.md: ceb1f1ee2ea2856d073bf1718a651a162233d686

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

@@ -14,14 +14,14 @@ The root cause is that the [durable-subagent-catalog decision](../feature/2026-0
 
 ## Decision
 
-mode and label are folded by the new `subagent` projection unit (pure identity, two arms), and the unit is the sole authority over the fold rules; `listChildren` no longer depends on session-query — enumeration is a subagent-owned live-preferred merge, and value retrieval walks a two-tier live/cold compute-and-discard ladder: a live child synchronously reads the registry's existing watermark cache (zero log reads), and a cold child pays one full `persistence.inspect` read plus one `registry.restore` fold. No index, no cache, no write-back.
+mode and label are folded by the new `subagent` projection unit (pure identity, two arms), and the unit is the sole authority over the fold rules; `listChildren` no longer depends on session-query — enumeration is a subagent-owned live-preferred merge, and value retrieval walks a three-rung compute-and-discard ladder: a live child synchronously reads the registry's existing watermark cache (zero log reads); a cold child first asks the optional `sessionProjectionCache` checkpoint, and a served value is final; otherwise it pays one full `persistence.inspect` read plus one `registry.restore` fold. No index, no cache of its own, no write-back.
 
 There are three families of escape from the per-child scan: promote mode/label into the header (the write path pays); build a durable derivation for the projection (a checkpoint ladder, or values landed during query-index rebuild with read-side reconciliation); or compute at read time (live from the watermark cache, cold from one full read). This note takes the third. "Values landed with the query index" was once this note's settled direction and was under construction for a time, then retired wholesale: query infrastructure was forced to learn domain vocabulary while the sole consumer is satisfied by read-time computation — the live child's zero reads come for free from session-projection's existing watermark cache, and the cold child's single full read is explicitly accepted as compute-and-discard. The first two routes and the retirement rationale are detailed under Alternatives considered.
 
 Key points:
 
 - **The subagent list does not depend on session-query**: enumeration is completed by a subagent-owned live-preferred merge, and mode/label is retrieved through `ctx.sessionProjections`; deployments without a query backend list as usual.
-- **Value retrieval is a two-tier compute-and-discard ladder**: a live child reads `sessionProjections.snapshot()` (the registry's existing watermark cache, zero log reads); a cold child pays one full `persistence.inspect` read plus one `registry.restore({}, events, 0)` fold; beyond that, absent is absent — no cache, no write-back, no index.
+- **Value retrieval is a three-rung compute-and-discard ladder**: a live child reads `sessionProjections.snapshot()` (the registry's existing watermark cache, zero log reads); a cold child first reads the optional `sessionProjectionCache.cachedSnapshot(header)`, using the value directly when `subagent` is among its values; otherwise it pays one full `persistence.inspect` read plus one `registry.restore({}, events, 0)` fold; beyond that, absent is absent — no cache of its own, no write-back, no index.
 - **The `subagent` projection unit is the sole authority over the fold rules**: the live snapshot, the cold restore, and GUI history's detached fold all compute through the registry; no second copy of descriptor-interpretation logic exists.
 - **The header, the descriptor (v2), session-persistence, session-projection(-cache), and session-query(-sqlite) are all untouched**; pre-existing data acquires exact values through one `inspect` computation the first time it is listed — no degraded unknown state, no migration.
 
@@ -61,24 +61,26 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
 - **Absent persistence degrades to live-only enumeration, not an error**: in a deployment without persistence, a cold child could not be resumed anyway, and listing live children remains meaningful. (Contrast: the old implementation rejected wholesale when sessionQuery was missing.)
 - A persistence listing failure fails the whole enumeration; per-child isolation applies only to the per-child cold reads.
 
-### Value retrieval: the two-tier compute-and-discard ladder
+### Value retrieval: the three-rung compute-and-discard ladder
 
-For each enumerated child, mode/label retrieval walks a two-tier ladder, the same shape as apiproxy `session.history`'s cold read — compute-and-discard, no cache, no write-back:
+For each enumerated child, mode/label retrieval walks a three-rung ladder — compute-and-discard, no cache of its own, no write-back (the third rung is the same shape as apiproxy `session.history`'s cold read):
 
-| Tier | Read | Cost |
+| Rung | Read | Cost |
 | --- | --- | --- |
-| live child | `ctx.sessionProjections.snapshot(session).values.subagent` | Zero log reads — the registry's existing watermark cache, synchronous retrieval |
-| cold child | One full `persistence.inspect(id)` read + `registry.restore({}, events, 0).snapshot.values.subagent` | One full read computed per listing |
+| 1: live child | `ctx.sessionProjections.snapshot(session).values.subagent` | Zero log reads — the registry's existing watermark cache, synchronous retrieval |
+| 2: cold child, cache hit | The optional `sessionProjectionCache.cachedSnapshot(header)`, used directly when `subagent` is among its values — identity is immutable once appended, so a served value is final regardless of the row's watermark | Zero log reads |
+| 3: cold child, fallback | One full `persistence.inspect(id)` read + `registry.restore({}, events, 0).snapshot.values.subagent` | One full read computed per listing |
 
 - Error contract: an unmounted `ctx.sessionProjections` is a configuration error; `listChildren` checks unconditionally before enumerating and fails loudly with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` — a deployment with zero children fails just as deterministically, so an empty listing cannot mask the misconfiguration. `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is deleted along with the session-query dependency.
+- The cache is a purely optional acceleration layer: an absent service is skipped on a null check — no error code, no part in configuration validation (in contrast to `sessionProjections`' loud contract). Anything the second rung throws (including a poisoned unit row in the cache detonating `viewCheckpoint`) silently falls to the third rung — the cache is derived data, so its faults never produce a `corrupt` verdict; the final judgment belongs to the authoritative refold. A row whose checkpoint cut predates the descriptor naturally lacks the `subagent` key and falls through automatically, with no special-casing.
 - Per-child isolation: a single child's failed cold full read only turns that row into an `unavailable` diagnostic, naturally retried on the next listing, without affecting siblings (see the four-state mapping).
 - Cold-read concurrency is bounded by the constant 4 — it constrains a read-only scan of local media, not deployment behavior; when a networked persistence backend appears, it is promoted to a validated `Config` field.
-- The cold-read cost, recorded honestly: a cold child pays one full read per listing, at a cost proportional to its transcript size; the settled stance is compute-and-discard, and no cache is built for it. The full read goes through `inspect()` into the [Session preparation](2026-08-05-session-preparation.md) cold read, so short-term repeated reads of the same id can hit its LRU for reuse, but listing does not depend on this. A live child reads zero log throughout.
+- The cold-read cost, recorded honestly: only with the cache unmounted or missed does a cold child pay one full read per listing, at a cost proportional to its transcript size; the settled stance is compute-and-discard, and no cache of its own is built. The full read goes through `inspect()` into the [Session preparation](2026-08-05-session-preparation.md) cold read, so short-term repeated reads of the same id can hit its LRU for reuse, but listing does not depend on this. A live child reads zero log throughout.
 - Cancellation: the caller's signal is checked before and after each persistence read, and a read that settles only after abort is rejected, normalized to the stable error code `CANCELLED`.
 
 ### Authority model
 
-- The session log is the sole authority; this design adds no derived persistence of any kind — no index values, no checkpoints, no in-process memo. Values are computed on read and discarded, and a value's freshness is exactly the live state or persisted revision at the moment of the read.
+- The session log is the sole authority; this design adds no derived persistence of any kind — no index values, no checkpoints of its own, no in-process memo; the `sessionProjectionCache` checkpoint the second rung reads is an existing composition item's derived data, which this design only reads and never writes. Values are computed on read and discarded, and a value's freshness is exactly the live state or persisted revision at the moment of the read (identity is immutable, so a cached value has no staleness problem).
 - The Session and persistence write paths are entirely unaware of listing and projection consumption: no event-listener write-back, no fold-on-write.
 - Enumeration and value retrieval constitute no second authorization source and make no unpublished child visible — the two sources see only published live records and durably written persisted records, consistent with the rule the durable-subagent-catalog note laid down for derived read surfaces.
 
@@ -132,7 +134,7 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir
 | Area | Files | Change |
 | --- | --- | --- |
 | subagent | projection.ts, projection-types.ts, index.ts | New `subagent` unit and its registration |
-| subagent | list-children.ts and its types | Rewritten as subagent-owned enumeration plus the projection-ladder four-state mapping; the session-query dependency, per-child event reads, and in-place classification machinery deleted; error code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` replaced by `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` |
+| subagent | list-children.ts and its types | Rewritten as subagent-owned enumeration plus the projection-ladder four-state mapping; the session-query dependency, per-child event reads, and in-place classification machinery deleted; error code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` replaced by `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`; new optional dependency dsh-session-projection-cache (pure read acceleration, skipped when absent) |
 | host/apiproxy | api-proxy.ts | `hasSubagentDescriptor` deleted; the owner check looks only at `header.origin` |
 | tool | tool-subagent-control/list-agents.ts | Load requirement narrowed (`sessionQuery` dropped from inject); model-visible schema, description, and rendering unchanged |
 | wire/client | api/subagents.ts, runtime sessions/service.ts, GUI | **Zero changes** — row shape and diagnostic handling unchanged |
@@ -142,7 +144,7 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir
 
 **mode/label into SessionHeader.** The strongest zero-read guarantee — rows form from the header alone. But a header shape change propagates into both persistence backends and the header compatibility check; SQLite rejects pre-existing data outright, and JSONL pre-existing data can only degrade to unknown or be backfilled. Read-time computation's answer for pre-existing data is "one `inspect` computation on first listing", touching no durable format.
 
-**The projection-cache ladder (v3 draft: `cachedSnapshot ?? coldSnapshot` plus fail-soft write-back).** The mechanism works — session-projection-cache's checkpoint ladder is designed for cold reads in the first place. But it hands the subagent domain a `sessionProjectionCache` dependency on top of `sessionProjections`, and checkpoints are a new body of derived-data persistence and invalidation orchestration (floor/identity/putSoft); read-time computation needs no durable derivation at all.
+**The projection-cache ladder (v3 draft: `cachedSnapshot ?? coldSnapshot` plus fail-soft write-back).** The mechanism works — session-projection-cache's checkpoint ladder is designed for cold reads in the first place. But checkpoint write-back is a whole list-driven body of derived-data persistence and invalidation orchestration (floor/identity/putSoft); what was rejected is that orchestration as the primary mechanism. The settled three-rung ladder later reuses this cache opportunistically, read-only, as its second rung — no write-back, no orchestration, skipped when absent.
 
 **A bounded-read primitive on persistence to rescue pre-existing data.** Opens a new seam primitive for a one-time problem; superseded by the read-time `inspect` full read — the full read the first time pre-existing data is listed is itself the value retrieval.
 
@@ -160,13 +162,13 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir
 
 ## Verification
 
-`packages/subagent/subagent/tests/list-children.spec.ts` is rewritten to this contract: live-only listing without persistence, query services, or the continuation runtime; with the registry absent, even zero children loudly report `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`; a live child incurs zero `inspect` throughout while a cold child incurs exactly one per listing; multiple descriptors resolve last-wins to the final one; corrupt payloads and unknown versions fold to `corrupt`; a cold-read failure maps to `unavailable` and retries on the next listing; the ancestor descriptor in a fork seed forms a row under that identity (pinning deviation one); ordinary forks and descendants without a subagent origin neither enter the list nor count toward `hasChildren`; `createdAt`-then-id ordering; an unmounted provider does not affect listing; compacted and uncompacted twins list identically; the three cases of pre-abort, persistence listing, and cold-read cancellation all normalize to `CANCELLED`; the empty list and stable error codes. A hostile-unit dual-path probe (`apply` lazily poisons, `view` detonates) proves that any registered unit's fold/schema throw on this child's log is contained as that child's `corrupt` row on both the live and the cold retrieval paths, with siblings and the listing itself unaffected. The `tool-subagent-control` list-agents tests are updated for the narrowed load requirement; `optional-session-query.spec.ts` is deleted with the dependency it guarded; the keyless ACP snapshots (`subagent-list-agents` among others) are not re-recorded — zero change to the wire and model-visible surfaces is pinned by the existing snapshots.
+`packages/subagent/subagent/tests/list-children.spec.ts` is rewritten to this contract: live-only listing without persistence, query services, or the continuation runtime; with the registry absent, even zero children loudly report `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`; a live child incurs zero `inspect` throughout while a cold child incurs exactly one per listing; multiple descriptors resolve last-wins to the final one; corrupt payloads and unknown versions fold to `corrupt`; a cold-read failure maps to `unavailable` and retries on the next listing; the ancestor descriptor in a fork seed forms a row under that identity (pinning deviation one); ordinary forks and descendants without a subagent origin neither enter the list nor count toward `hasChildren`; `createdAt`-then-id ordering; an unmounted provider does not affect listing; compacted and uncompacted twins list identically; the three cases of pre-abort, persistence listing, and cold-read cancellation all normalize to `CANCELLED`; the empty list and stable error codes. A hostile-unit dual-path probe (`apply` lazily poisons, `view` detonates) proves that any registered unit's fold/schema throw on this child's log is contained as that child's `corrupt` row on both the live and the cold retrieval paths, with siblings and the listing itself unaffected. Four second-rung cases: a real-composition cache hit with zero `inspect`, an absent in-row `subagent` key falling through, an absent cache service falling through, and a poisoned cache row silently falling through to the refold. The `tool-subagent-control` list-agents tests are updated for the narrowed load requirement; `optional-session-query.spec.ts` is deleted with the dependency it guarded; the keyless ACP snapshots (`subagent-list-agents` among others) are not re-recorded — zero change to the wire and model-visible surfaces is pinned by the existing snapshots.
 
 ## Consequences
 
-- Listing a live child reads zero log throughout; a cold child pays one full `inspect` read per listing, at a cost proportional to its transcript size and repeated with listing frequency — compute-and-discard is the settled stance: no cache is built, nothing is written back, and short-term repeated full reads of the same id can hit the preparation-phase LRU, though listing does not depend on it.
+- Listing a live child reads zero log throughout; with the cache unmounted or missed, a cold child pays one full `inspect` read per listing, at a cost proportional to its transcript size and repeated with listing frequency — compute-and-discard is the settled stance: no cache of its own is built, nothing is written back, and short-term repeated full reads of the same id can hit the preparation-phase LRU, though listing does not depend on it.
 - The subagent list no longer requires a query backend: both pure-live and persistence-less deployments can list; `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is gone, and loading the `list_agents` plugin no longer requires `sessionQuery`.
-- Identity interpretation exists only in the single unit registered with the registry: the list's two-tier ladder and GUI history's cold read use the same two reads (snapshot/restore), and no bypass fold exists; if some future consuming surface bypasses the registry with a hand-written fold, values will drift across read faces — a discipline this design requires be maintained, not a mechanical guarantee.
+- Identity interpretation exists only in the single unit registered with the registry: the list's three-rung ladder and GUI history's cold read all use the registry's and the cache's existing reads (snapshot, cachedSnapshot, restore), and no bypass fold exists; if some future consuming surface bypasses the registry with a hand-written fold, values will drift across read faces — a discipline this design requires be maintained, not a mechanical guarantee.
 - Per-child isolation is back: a single child's cold-read failure loses only that row and healthy siblings are unaffected; a persistence listing failure still fails the whole enumeration.
 - The diagnostic and enumeration semantics leaves five boundary deviations (a stillborn fork surfacing under its ancestor's identity, multiple descriptors resolving to the last, header conflicts going unnoticed, damaged-source read failures shifting from `corrupt` to `unavailable`, and an unknown parent yielding an empty list instead of not-found); the full semantics is in the known-boundary-deviations list; the first four are display or classification deviations on debris-grade data with resume authorization unaffected, and the unknown-parent one is a silent query-semantics change, explicitly accepted.
 - Pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and pre-release carries no compatibility promise.

+ 15 - 13
.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/cold 两级"算完即止"阶梯:live child 同步读注册表的既有水位缓存(零日志读),cold child 一次 `persistence.inspect` 整读加 `registry.restore` 折叠。无索引、无缓存、无回写。
+mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠,unit 是折叠规则的唯一权威;`listChildren` 不再依赖 session-query——枚举是 subagent 自管的 live-preferred 合并,取值走三级"算完即止"阶梯:live child 同步读注册表的既有水位缓存(零日志读);cold child 先问可选的 `sessionProjectionCache` checkpoint,取到即定值;否则一次 `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 一次 `persistence.inspect` 整读加 `registry.restore({}, events, 0)` 折叠;再没有就没有——缓存、无回写、无索引。
+- **取值级"算完即止"阶梯**:live child 读 `sessionProjections.snapshot()`(注册表既有水位缓存,零日志读);cold child 先读可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含 `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 降级态、无迁移。
 
@@ -61,24 +61,26 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
 - **persistence 缺席退为 live-only 枚举,不报错**:没有 persistence 的部署,cold child 本就无法 resume,列出 live child 仍然有意义。(对照:旧实现在 sessionQuery 缺失时整体拒绝。)
 - persistence 列表失败使整次枚举失败;per-child 隔离只作用于逐 child 的冷读。
 
-### 取值:级"算完即止"阶梯
+### 取值:级"算完即止"阶梯
 
-对每个枚举出的 child,mode/label 取值走两级阶梯,与 apiproxy `session.history` 的冷读同款——算完即止,无缓存、无回写
+对每个枚举出的 child,mode/label 取值走三级阶梯——算完即止,不自建缓存、无回写(第三级与 apiproxy `session.history` 的冷读同款)
 
 | 级 | 读法 | 成本 |
 | --- | --- | --- |
-| live child | `ctx.sessionProjections.snapshot(session).values.subagent` | 零日志读——注册表既有水位缓存,同步取值 |
-| cold child | `persistence.inspect(id)` 整读 + `registry.restore({}, events, 0).snapshot.values.subagent` | 每次列表一次整读现算 |
+| 1:live child | `ctx.sessionProjections.snapshot(session).values.subagent` | 零日志读——注册表既有水位缓存,同步取值 |
+| 2:cold child,cache 命中 | 可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含 `subagent` 即直接用——身份一经追加不可变,读到即定值,无视行水位 | 零日志读 |
+| 3:cold child,兜底 | `persistence.inspect(id)` 整读 + `registry.restore({}, events, 0).snapshot.values.subagent` | 每次列表一次整读现算 |
 
 - 错误契约:`ctx.sessionProjections` 未挂载是配置错误,`listChildren` 在枚举前无条件检查并以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败——零 children 的部署同样确定失败,不因列表恰好为空而掩盖配置问题。`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 已随 session-query 依赖删除。
+- cache 是纯可选加速层:服务缺席判空跳过——无错误码、不进配置校验(与 `sessionProjections` 的响亮契约相对)。第二级任何抛错(包括缓存内任一 unit 行中毒使 `viewCheckpoint` 引爆)静默落第三级——缓存是派生数据,其故障不产生 `corrupt` 判决,终审归权威重折;checkpoint 切面早于描述符的行,`subagent` key 天然缺席,自动落底,无特判。
 - per-child 隔离:单 child 的 cold 整读失败只使该行成为 `unavailable` diagnostic,下次列表自然重试,不影响 sibling(见四态映射)。
 - 冷读并发以常数 4 有界——它约束的是本地介质的一次只读扫描而非部署行为;出现联网 persistence backend 时提升为验证过的 `Config` 字段。
-- 冷读成本如实记录:cold child 每次列表一次整读,成本与其 transcript 大小成正比;定案"算完即止",不为它建缓存。整读经 `inspect()` 走 [Session 准备阶段](2026-08-05-session-preparation.md)的冷读,同 id 短期重复读取可命中其 LRU 复用,但列表不依赖此。live child 全程零日志读。
+- 冷读成本如实记录:cache 未挂载或未命中时,cold child 每次列表才付一次整读,成本与其 transcript 大小成正比;定案"算完即止",不建缓存。整读经 `inspect()` 走 [Session 准备阶段](2026-08-05-session-preparation.md)的冷读,同 id 短期重复读取可命中其 LRU 复用,但列表不依赖此。live child 全程零日志读。
 - 取消:每次 persistence 读前后检查调用方 signal,abort 之后才结算的读拒绝归一化为稳定错误码 `CANCELLED`。
 
 ### 权威模型
 
-- session log 是唯一权威;本方案不新增任何派生持久化——没有索引值、没有 checkpoint、没有进程 memo,取值现算现弃,值的新鲜度就是读取时点的 live 状态或持久化 revision。
+- session log 是唯一权威;本方案不新增任何派生持久化——没有索引值、没有自己的 checkpoint、没有进程 memo;第二级读取的 `sessionProjectionCache` checkpoint 是既有组合项的派生数据本方案只读不写。取值现算现弃,值的新鲜度就是读取时点的 live 状态或持久化 revision(身份不可变,缓存值无陈旧性问题)
 - Session 与 persistence 写路完全不感知列表与投影消费:没有事件监听回写,没有写时折叠。
 - 枚举与取值不构成第二个鉴权来源,也不让尚未发布的 child 可见——两个来源只见已发布的 live 记录与已落盘的持久化记录,与 durable-subagent-catalog 记录对派生读面立下的规则一致。
 
@@ -132,7 +134,7 @@ export type SubagentListEntry =
 | 区域 | 文件 | 改动 |
 | --- | --- | --- |
 | subagent | projection.ts、projection-types.ts、index.ts | 新 `subagent` unit 与注册 |
-| subagent | list-children.ts 及类型 | 重写为自管枚举 + 投影阶梯四态映射;删 session-query 依赖、逐 child 事件读取与就地分类机器;错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 换 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` |
+| subagent | list-children.ts 及类型 | 重写为自管枚举 + 投影阶梯四态映射;删 session-query 依赖、逐 child 事件读取与就地分类机器;错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 换 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;新增可选依赖 dsh-session-projection-cache(纯加速读取,缺席跳过) |
 | host/apiproxy | api-proxy.ts | 删 `hasSubagentDescriptor`,属主判定只看 `header.origin` |
 | tool | tool-subagent-control/list-agents.ts | 加载要求收窄(inject 去 `sessionQuery`);model-visible schema、描述与渲染零改动 |
 | wire/client | api/subagents.ts、runtime sessions/service.ts、GUI | **零改动**——行形状与 diagnostic 处理不变 |
@@ -142,7 +144,7 @@ export type SubagentListEntry =
 
 **mode/label 进 SessionHeader。** 零读保证最强——列表只看 header 就能成行。但 header 形状变更传导两个 persistence backend 与 header 兼容检查;SQLite 存量直接拒收,JSONL 存量只能 unknown 降级或 backfill。读时现算对存量的答案是"第一次列表一次 `inspect` 现算",不碰持久格式。
 
-**projection-cache 阶梯(v3 稿:`cachedSnapshot ?? coldSnapshot` 加 fail-soft 写回)。** 机制成立——session-projection-cache 的 checkpoint 阶梯本就为冷读设计。但它给 subagent 域在 `sessionProjections` 之外再引入 `sessionProjectionCache` 依赖,且 checkpoint 是一套新增的派生数据持久化与失效编排(floor/identity/putSoft);读时现算不需要任何持久派生
+**projection-cache 阶梯(v3 稿:`cachedSnapshot ?? coldSnapshot` 加 fail-soft 写回)。** 机制成立——session-projection-cache 的 checkpoint 阶梯本就为冷读设计。但 checkpoint 写回是一套由列表驱动的派生数据持久化与失效编排(floor/identity/putSoft);被否的是这套编排作为主机制。定稿的第三级阶梯后来以只读方式机会性复用该缓存作第二级——无写回、无编排、缺席即跳过
 
 **给 persistence 加有界读原语抢救存量。** 为一次性问题新开 seam 原语;被读时 `inspect` 整读取代——存量第一次被列表时的整读就是取值本身。
 
@@ -160,13 +162,13 @@ 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 与列表本身不受影响。`tool-subagent-control` 的 list-agents 测试随加载要求收窄更新;`optional-session-query.spec.ts` 随依赖消失删除;无密钥 ACP 快照(`subagent-list-agents` 等)未重录——wire 与 model-visible 面零改动由既有快照钉住。
+`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`、行内 `subagent` key 缺席落底、cache 服务缺席落底、缓存行中毒静默落底重折。`tool-subagent-control` 的 list-agents 测试随加载要求收窄更新;`optional-session-query.spec.ts` 随依赖消失删除;无密钥 ACP 快照(`subagent-list-agents` 等)未重录——wire 与 model-visible 面零改动由既有快照钉住。
 
 ## 后果
 
-- live child 的列表全程零日志读;cold child 每次列表一次 `inspect` 整读,成本与其 transcript 大小成正比、随列表频率重复——定案"算完即止",不建缓存、不回写,同 id 短期重复整读可命中准备阶段 LRU 但列表不依赖它。
+- live child 的列表全程零日志读;cold child 在 cache 未挂载或未命中时每次列表一次 `inspect` 整读,成本与其 transcript 大小成正比、随列表频率重复——定案"算完即止",不建缓存、不回写,同 id 短期重复整读可命中准备阶段 LRU 但列表不依赖它。
 - subagent 列表不再要求 query backend:纯 live 与无 persistence 的部署都能列表;`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 消失,`list_agents` 插件加载不再要求 `sessionQuery`。
-- 身份解释只存在于 registry 注册的一份 unit:列表两级阶梯与 GUI history 冷读走同两处读法(snapshot/restore),不存在旁路折叠;若未来某消费面绕开 registry 手写折叠,各读面的值将漂移——这是本设计要求维持的纪律,不是机制保证。
+- 身份解释只存在于 registry 注册的一份 unit:列表三级阶梯与 GUI history 冷读走的都是 registry 与 cache 的既有读法(snapshot、cachedSnapshot、restore),不存在旁路折叠;若未来某消费面绕开 registry 手写折叠,各读面的值将漂移——这是本设计要求维持的纪律,不是机制保证。
 - per-child 隔离回归:单 child 冷读失败只损失该行,healthy sibling 不受影响;persistence 列表失败仍使整次枚举失败。
 - 诊断与枚举语义留下五处边界偏差(stillborn fork 祖先身份误现、多描述符取末者、header 冲突不再被察觉、损坏源读失败由 `corrupt` 转 `unavailable`、未知 parent 由 not-found 改为空列表),完整语义见已知边界偏差清单;前四处为残骸级数据的展示或分类偏差,恢复鉴权不受影响,未知 parent 一处是查询语义的静默变化,显式接受。
 - pre-#1569 的无 `origin` 存量不再被认作 subagent 属主;其本就不进目录,pre-release 无兼容承诺。

+ 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: e364572f9ac6a52acb118de0906cb5ec442536cc
-subagent.zh.md: 4440c4f6a4212d0cf8a4d6367389f6973dfc5d17
+subagent.md: e96e3556334e7cee9b1a9386eafc81a2fdbce725
+subagent.zh.md: 4ca3f0707d78adc371708c18f586a9dfcb499347

File diff ditekan karena terlalu besar
+ 0 - 0
docs/core-data-structures/subagent.md


File diff ditekan karena terlalu besar
+ 0 - 0
docs/core-data-structures/subagent.zh.md


+ 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: bfed362d5a70bf946295c04d02ed1c6d031041e3
-README.zh.md: 11121735bd4acdfccf2ef950d30e5913646430a4
+README.md: b89fcb4b4d318c872117078b6c49822d0633fd31
+README.zh.md: 6309a9b8ad4ac9edf5ccf994594638cc9f175684

File diff ditekan karena terlalu besar
+ 0 - 0
packages/subagent/subagent/README.md


File diff ditekan karena terlalu besar
+ 0 - 0
packages/subagent/subagent/README.zh.md


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

@@ -41,6 +41,7 @@
     "@deepseek-ai/dsh-session": "^0.0.1",
     "@deepseek-ai/dsh-session-persistence": "^0.0.1",
     "@deepseek-ai/dsh-session-projection": "^0.0.1",
+    "@deepseek-ai/dsh-session-projection-cache": "^0.0.1",
     "@deepseek-ai/dsh-tasks": "^0.0.1",
     "@deepseek-ai/dsh-tools": "^0.0.1",
     "cordis": "^4.0.0-rc.7"
@@ -52,6 +53,9 @@
     "@deepseek-ai/dsh-session-projection": {
       "optional": true
     },
+    "@deepseek-ai/dsh-session-projection-cache": {
+      "optional": true
+    },
     "@deepseek-ai/dsh-tasks": {
       "optional": true
     }
@@ -65,6 +69,9 @@
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-persistence": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",
+    "@deepseek-ai/dsh-session-projection-cache": "workspace:^",
+    "@deepseek-ai/dsh-storage": "workspace:^",
+    "@deepseek-ai/dsh-storage-domain": "workspace:^",
     "@deepseek-ai/dsh-tasks": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "cordis": "^4.0.0-rc.7"

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

@@ -288,8 +288,11 @@ export class SubagentService extends Service {
    * resuming an Agent and without any query seam: the listing merges the live
    * session store with optional session persistence (live-preferred) and
    * serves each child's durable mode/label from the registered `subagent`
-   * projection unit — the registry's watermark snapshot for a live child, one
-   * persistence inspection folded through the registry for a cold one. The
+   * 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
+   * 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,
    * never a list-time descriptor parse. Absent persistence, enumeration is

+ 46 - 18
packages/subagent/subagent/src/list-children.ts

@@ -3,9 +3,11 @@
  * from the live session store and optional session persistence — no query
  * seam. Candidates are the live-preferred merge of both listings filtered to
  * durable `origin: 'subagent'` under the parent; each child's mode/label is
- * the registered `subagent` projection unit's value, served from the
- * registry's watermark cache for a live child and folded once over one
- * persistence inspection for a cold one. The projection fold is the single
+ * the registered `subagent` projection unit's value, resolved down a
+ * three-rung ladder: the registry's watermark cache for a live child, a
+ * durable projection-cache row when the optional cache already serves the
+ * identity, and one persistence inspection folded through the registry
+ * otherwise. The projection fold is the single
  * classification authority — this module parses no descriptor itself. Absent
  * persistence, enumeration is live-only: a cold child is unreachable for
  * resume anyway, so its absence is capability absence, not an error. The
@@ -19,6 +21,7 @@ import type { Context } from 'cordis'
 import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
 import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection'
+import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache'
 import { SubagentError } from './error.ts'
 import type { SubagentIdentityProjection } from './projection-types.ts'
 
@@ -88,11 +91,13 @@ export type SubagentListEntry =
  * Enumerate one parent's origin-classified direct children from the
  * live-preferred merge of `ctx.sessions` and optional session persistence,
  * serving each identity from the `subagent` projection unit: the registry's
- * watermark snapshot for a live child, one bounded-concurrency persistence
- * inspection folded through the registry for a cold one.
+ * watermark snapshot for a live child; for a cold one, a durable
+ * projection-cache row when the optional cache already serves the identity,
+ * else one bounded-concurrency persistence inspection folded through the
+ * registry.
  * @see SubagentService.listChildren for the public cancellation and failure contract.
  * @param ctx - context carrying the session store, the projection registry,
- *   and optional persistence.
+ *   optional persistence, and the optional projection cache.
  * @param parentSessionId - parent session whose direct children are listed.
  * @param signal - caller-owned cancellation observed around every persistence read.
  * @returns children and per-child diagnostics ordered by `createdAt`, then id.
@@ -126,6 +131,10 @@ export async function listChildren(
   }
   assertListingNotCancelled(signal)
   const persistence = ctx.get('sessionPersistence')
+  // Optional acceleration only: an absent cache service just means every
+  // cold candidate takes the authoritative preparation rung, so it carries
+  // no error code and no configuration check.
+  const cache = ctx.get('sessionProjectionCache')
   let persistedHeaders: readonly SessionHeader[] = []
   if (persistence !== undefined) {
     try {
@@ -158,11 +167,11 @@ export async function listChildren(
       || a.header.id.localeCompare(b.header.id))
 
   const rows: (SubagentListEntry | undefined)[] = Array.from({ length: candidates.length })
-  const coldReads: { index: number; id: SessionId }[] = []
+  const coldReads: { index: number; header: SessionHeader }[] = []
   candidates.forEach((candidate, index) => {
     const childId = candidate.header.id
     if (candidate.live === undefined) {
-      coldReads.push({ index, id: childId })
+      coldReads.push({ index, header: candidate.header })
       return
     }
     // The registry's watermark cache serves the live value with zero log
@@ -191,8 +200,9 @@ export async function listChildren(
       { length: Math.min(COLD_READ_CONCURRENCY, queue.length) },
       async () => {
         for (let job = queue.shift(); job !== undefined; job = queue.shift()) {
-          rows[job.index] = await inspectColdIdentity(
-            persistence, projections, job.id, subagentParents.has(job.id), signal,
+          rows[job.index] = await resolveColdIdentity(
+            persistence, projections, cache, job.header,
+            subagentParents.has(job.header.id), signal,
           )
         }
       },
@@ -203,20 +213,38 @@ export async function listChildren(
 }
 
 /**
- * Resolve one cold candidate: 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`.
+ * 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`.
  */
-async function inspectColdIdentity(
+async function resolveColdIdentity(
   persistence: SessionPersistence,
   projections: SessionProjectionRegistry,
-  childId: SessionId,
+  cache: SessionProjectionCache | undefined,
+  header: SessionHeader,
   hasChildren: boolean,
   signal: AbortSignal | undefined,
 ): Promise<SubagentListEntry> {
+  const childId = header.id
+  if (cache !== undefined) {
+    let cached: SubagentIdentityProjection | undefined
+    try {
+      cached = cache.cachedSnapshot(header)?.values.subagent
+    } catch {
+      // Unlike the preparation fold below, a throwing cache read renders no
+      // verdict: the cache is derived data, so its damage (a poisoned stored
+      // row of ANY unit) silently falls through to the authoritative re-fold.
+      cached = undefined
+    }
+    // The identity is immutable once appended, so a cached value is final
+    // regardless of the row's watermark; an absent key (a checkpoint cut
+    // before the descriptor was appended) falls through to preparation.
+    if (cached !== undefined) return childRow(childId, cached, 'inactive', hasChildren)
+  }
   assertListingNotCancelled(signal)
   let events: readonly SessionEvent[]
   try {

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

@@ -12,6 +12,10 @@ import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
 import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
 import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
+import SessionProjectionCache from '@deepseek-ai/dsh-session-projection-cache'
+import Storage from '@deepseek-ai/dsh-storage'
+import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
+import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
 import SubagentService, {
   SUBAGENT_DESCRIPTOR_VERSION,
   SubagentError,
@@ -28,7 +32,10 @@ afterEach(() => {
 })
 
 /** Boot the continuable stack with real JSONL session persistence. */
-async function setup(script: Script, options: { sessionProjections?: boolean } = {}) {
+async function setup(
+  script: Script,
+  options: { sessionProjections?: boolean; projectionCache?: boolean } = {},
+) {
   const ctx = new Context()
   await mountAgentLoopTestDependencies(ctx)
   const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-list-'))
@@ -36,6 +43,14 @@ async function setup(script: Script, options: { sessionProjections?: boolean } =
   await ctx.plugin(JsonlSessionPersistence, { root })
   await ctx.plugin(AgentLoop, { agents: [] })
   if (options.sessionProjections !== false) await ctx.plugin(SessionProjectionRegistry)
+  if (options.projectionCache === true) {
+    await ctx.plugin(Storage)
+    ctx.storage.backend.register('memory', new MemoryStorageBackend(new MemoryMediaPool()))
+    const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
+    ctx.storage.mount('domain', facility)
+    ctx.provide('storageDomain', facility)
+    await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
+  }
   await ctx.plugin(SubagentService)
   await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
   await ctx.plugin(SubagentFork, { providerName: 'fork' })
@@ -606,6 +621,78 @@ describe('SubagentService.listChildren', () => {
     expect(inspected).not.toContain(liveId)
   })
 
+  it('serves a cold child from the projection cache without any inspection', async () => {
+    const { ctx, parent } = await setup([textResponse('done')], { projectionCache: true })
+    const childId = await startChild(ctx, parent, 'cached child')
+    // The child's turn/end and disposal are the cache's mandatory checkpoint
+    // points; both writes are fail-soft asynchronous, so wait for the row.
+    const header = (await ctx.sessionPersistence.list()).find(meta => meta.id === childId)
+    await vi.waitFor(() => {
+      expect(ctx.sessionProjectionCache.cachedSnapshot(header!)?.values.subagent).toBeDefined()
+    }, { timeout: 5_000 })
+    const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
+    await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
+      kind: 'child', id: childId, label: 'cached child', mode: 'continuable',
+      activity: 'inactive', hasChildren: false,
+    }])
+    expect(inspect).not.toHaveBeenCalled()
+  })
+
+  it('falls back to inspection when the cache serves no identity for the child', async () => {
+    const { ctx, parent } = await setup([], { projectionCache: true })
+    const foreign = await authorChild(ctx, '00000000-0000-4000-8000-00000000ac01', {
+      parentSession: parent.id,
+      origin: 'subagent',
+    }, childEvents(descriptorPayload('uncached child')))
+    const expected = [{
+      kind: 'child', id: foreign, label: 'uncached child', mode: 'continuable',
+      activity: 'inactive', hasChildren: false,
+    }]
+    // No stored row at all for a foreign child this process never ran.
+    const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
+    await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual(expected)
+    expect(inspect).toHaveBeenCalledTimes(1)
+    // A stored row whose cut predates the descriptor: the subagent key is
+    // absent from the served values, and preparation still rules.
+    ctx.sessionProjectionCache.cachedSnapshot = () => ({ asOfSeq: 0, values: {} })
+    await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual(expected)
+    expect(inspect).toHaveBeenCalledTimes(2)
+  })
+
+  it('takes the preparation rung directly when no projection cache is mounted', async () => {
+    const { ctx, parent } = await setup([])
+    expect(ctx.get('sessionProjectionCache')).toBeUndefined()
+    const foreign = await authorChild(ctx, '00000000-0000-4000-8000-00000000ac02', {
+      parentSession: parent.id,
+      origin: 'subagent',
+    }, childEvents(descriptorPayload('uncacheable child')))
+    const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
+    await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
+      kind: 'child', id: foreign, label: 'uncacheable child', mode: 'continuable',
+      activity: 'inactive', hasChildren: false,
+    }])
+    expect(inspect).toHaveBeenCalledTimes(1)
+  })
+
+  it('silently falls through to preparation when the cache read throws', async () => {
+    const { ctx, parent } = await setup([], { projectionCache: true })
+    const recovered = await authorChild(ctx, '00000000-0000-4000-8000-00000000ac03', {
+      parentSession: parent.id,
+      origin: 'subagent',
+    }, childEvents(descriptorPayload('recovered child')))
+    ctx.sessionProjectionCache.cachedSnapshot = () => {
+      // A poisoned stored row (any unit's) detonates at view time; the cache
+      // is derived data, so its failure must not become a verdict.
+      throw new Error('poisoned cache row')
+    }
+    const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
+    await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
+      kind: 'child', id: recovered, label: 'recovered child', mode: 'continuable',
+      activity: 'inactive', hasChildren: false,
+    }])
+    expect(inspect).toHaveBeenCalledTimes(1)
+  })
+
   it('does not count an ordinary grandchild without subagent origin', async () => {
     const { ctx, parent } = await setup([textResponse('done')])
     const childId = await startChild(ctx, parent, 'direct child')

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

@@ -32,6 +32,9 @@
     {
       "path": "../../session-projection/session-projection"
     },
+    {
+      "path": "../../session-projection/session-projection-cache"
+    },
     {
       "path": "../../tasks/tasks"
     },

+ 9 - 0
pnpm-lock.yaml

@@ -5193,6 +5193,15 @@ importers:
       '@deepseek-ai/dsh-session-projection':
         specifier: workspace:^
         version: link:../../session-projection/session-projection
+      '@deepseek-ai/dsh-session-projection-cache':
+        specifier: workspace:^
+        version: link:../../session-projection/session-projection-cache
+      '@deepseek-ai/dsh-storage':
+        specifier: workspace:^
+        version: link:../../storage/storage
+      '@deepseek-ai/dsh-storage-domain':
+        specifier: workspace:^
+        version: link:../../storage/storage-domain
       '@deepseek-ai/dsh-tasks':
         specifier: workspace:^
         version: link:../../tasks/tasks

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini