From a328fd34d58df0aa3c9ccf7036a849db05144a07 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:45:03 +0800 Subject: [PATCH 01/12] feat: subagent list use preparation + projection --- ...06-subagent-list-identity-projection.zh.md | 199 +++++++++ packages/host/apiproxy/src/api-proxy.ts | 41 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 6 +- packages/subagent/subagent/README.zh.md | 6 +- packages/subagent/subagent/package.json | 5 - packages/subagent/subagent/src/client.ts | 2 +- packages/subagent/subagent/src/index.ts | 43 +- .../subagent/subagent/src/list-children.ts | 352 ++++++++-------- .../subagent/subagent/src/projection-types.ts | 27 ++ packages/subagent/subagent/src/projection.ts | 69 +++- .../subagent/tests/list-children.spec.ts | 386 +++++++----------- .../tests/optional-session-query.spec.ts | 13 - packages/subagent/subagent/tsconfig.json | 3 - .../tool-subagent-control/README.i18n.yaml | 4 +- .../subagent/tool-subagent-control/README.md | 2 +- .../tool-subagent-control/README.zh.md | 2 +- .../tool-subagent-control/package.json | 7 - .../tool-subagent-control/src/list-agents.ts | 13 +- .../tests/list-agents.spec.ts | 7 +- .../tool-subagent-control/tsconfig.json | 3 - pnpm-lock.yaml | 6 - 22 files changed, 696 insertions(+), 504 deletions(-) create mode 100644 .agents/notes/proposed/architecture/2026-08-06-subagent-list-identity-projection.zh.md delete mode 100644 packages/subagent/subagent/tests/optional-session-query.spec.ts diff --git a/.agents/notes/proposed/architecture/2026-08-06-subagent-list-identity-projection.zh.md b/.agents/notes/proposed/architecture/2026-08-06-subagent-list-identity-projection.zh.md new file mode 100644 index 0000000000..c4239ca903 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-06-subagent-list-identity-projection.zh.md @@ -0,0 +1,199 @@ +# Agent Note: subagent 列表经投影单元读取身份 + +Status: proposed + +[English](2026-08-06-subagent-list-identity-projection.md) | 中文 + +## 问题 + +`SubagentService.listChildren`([list-children.ts](../../../../packages/subagent/subagent/src/list-children.ts))对每个 `header.origin === 'subagent'` 的直接 child,每次列表都执行 `listEvents` 加 `readEvent` 两次整日志物化,且每次物化都伴随整日志 structuredClone,只为从描述符事件里折出 mode 与 label 两个字段。描述符在日志中的位置不固定——fork 前缀任意长,zstd 压缩帧没有 seq 索引——因此定位没有捷径;这条路径没有任何缓存,代价随 transcript 长度 × child 数量 × 列表频率放大。它还把 session-query 拉成列表的硬依赖:没有 query backend 的部署,`list_agents` 以 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 整体拒绝,尽管枚举所需只是 header 事实。 + +同一根因还有第二个症状:host 侧的 `hasSubagentDescriptor()`([api-proxy.ts](../../../../packages/host/apiproxy/src/api-proxy.ts))在每次 Agent 绑定 RPC 的属主判定上扫描目标会话的 own suffix,即便 `SessionHeader.origin` 已经回答了同一个问题的绝大部分。 + +根因在于 [durable-subagent-catalog 决策](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)把描述符事件(`subagent/descriptor`)定为目录的唯一持久权威,却没有为描述符读取配任何缓存层,并把逐 child 双读明确接受为"无索引的正确性基线"。[web subagent conversations](../../implemented/feature/2026-07-27-web-subagent-conversations.md)(#1569)已把"是不是 subagent"放进了 header(`SessionHeader.origin`),身份判定不再读日志;mode 与 label 仍然要扫。 + +## 提案 + +mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠,unit 是折叠规则的唯一权威;`listChildren` 摘除 session-query 依赖——枚举由 subagent 自管的 live-preferred 合并完成,取值走 live/cold 两级"算完即止"阶梯:live child 同步读注册表的既有水位缓存(零日志读),cold child 一次 `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)` 折叠;再没有就没有——无缓存、无回写、无索引。 +- **`subagent` projection unit 是折叠规则唯一权威**:live snapshot、cold restore、GUI history 的 detached 折叠全部经 registry 计算,不存在第二份描述符解释逻辑。 +- **session-query 的净变化只剩读路径去 clone 加浅 readonly 借用视图**(附带工作项;DeepReadonly 被实证否决,见替代方案)。 +- **header、描述符(v2)、session-persistence、session-projection(-cache)、session-query-sqlite 全部零改动**;存量数据第一次被列表时一次 `inspect` 现算获得精确值,无 unknown 降级态、无迁移。 + +与既有记录的关系: + +- 本记录取代 [durable-subagent-catalog](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) 中列表读路径的两项设计:经 `sessionQuery.traceSession` 枚举,与逐 child 读取描述符事件(`listEvents` 加精确 `readEvent` 双读、就地诊断分类)。diagnostic 行语义保留,分类改由列表按投影值缺席与 activity 派生;描述符事件仍是 mode/label 的唯一持久权威与折叠输入,恢复鉴权与激活契约不动。属部分取代,两记录保持交叉链接。 +- [session-projection RFC](2026-07-27-session-projection-and-command-log.md) 的 registry 契约(`ProjectionDefinition`、`snapshot`、`restore`)零改动,本记录只为其新增 `subagent` 身份 unit 一个注册项,并成为 snapshot(live)与 restore(cold)两处既有读法的又一消费实例——GUI history 的冷读已是同款。折叠规则只在 registry 注册一份;任何消费面都经 registry 计算,不存在第二份折叠逻辑。 + +### `subagent` projection unit + +挂在现有 `subagentTiming` 旁([projection.ts](../../../../packages/subagent/subagent/src/projection.ts)、[projection-types.ts](../../../../packages/subagent/subagent/src/projection-types.ts)),key 为 `subagent`: + +```ts ignore-check +export type SubagentIdentityProjection = + | { mode: 'one-shot'; label?: string } + | { mode: 'continuable'; label: string } + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + subagent: SubagentIdentityProjection + } +} +``` + +- 投影是纯身份,**projection 体系不做失败通道**:unit 永不抛错;载荷损坏、版本不认识与整日志没有描述符一样,折叠结果就是"无值",该 key 在这个 session 上缺席。"算出来没有"如何呈现是消费方自己的事(见下文 `listChildren` 四态映射)。 +- label 强度由描述符 schema 决定:continuable 的 label 解析强制必有,one-shot 的本就可选;该判别式与下文 child 行的 mode/label 强契约完全一致。 +- 折叠规则:`subagent/descriptor` last-wins,与 `subagentTiming` 同一条 descriptor-reset 纪律——fork 前缀里的祖先描述符被自身描述符覆盖。 + +### 枚举:subagent 自管 live-preferred 合并 + +`listChildren` 的枚举不再经任何查询服务:`ctx.sessions.list()` 与 `ctx.get('sessionPersistence')?.list()` 两个来源按 id 合并,live 优先、不做一致性校验。枚举所需全部是 header 事实: + +- 过滤:`header.origin === 'subagent' && header.parentSession === parentSessionId`。 +- `hasChildren`:同一份合并材料向下看一层——存在 `origin === 'subagent'` 且 `parentSession` 为该 child 的直接后代。 +- `activity`:live 记录为 `running`,仅存在于持久化的为 `inactive`。 +- 排序:`createdAt` 升序、再按 child id 升序(与旧契约一致)。 +- **persistence 缺席退为 live-only 枚举,不报错**:没有 persistence 的部署,cold child 本就无法 resume,列出 live child 仍然有意义。(对照:旧实现在 sessionQuery 缺失时整体拒绝。) + +### 取值:两级"算完即止"阶梯 + +对每个枚举出的 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` | 每次列表一次整读现算 | + +- 错误契约:`ctx.sessionProjections` 未挂载是配置错误,`listChildren` 在枚举前无条件检查并以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败——零 children 的部署同样确定失败,不因列表恰好为空而掩盖配置问题。`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 随 session-query 依赖一并删除。 +- per-child 隔离:单 child 的 cold 整读失败只使该行成为 `unavailable` diagnostic,不影响 sibling(见四态映射)。 +- 冷读成本如实记录:cold child 每次列表一次整读,成本与其 transcript 大小成正比;定案"算完即止",不为它建缓存。整读经 `inspect()` 走 [Session 准备阶段](../../implemented/architecture/2026-08-05-session-preparation.md)的冷读,同 id 短期重复读取可命中其 LRU 复用,但列表不依赖此。live child 全程零日志读。 + +### 权威模型 + +- session log 是唯一权威;本方案不新增任何派生持久化——没有索引值、没有 checkpoint、没有进程 memo,取值现算现弃,值的新鲜度就是读取时点的 live 状态或持久化 revision。 +- Session 与 persistence 写路完全不感知列表与投影消费:没有事件监听回写,没有写时折叠。 +- 枚举与取值不构成第二个鉴权来源,也不让尚未发布的 child 可见——两个来源只见已发布的 live 记录与已落盘的持久化记录,与 durable-subagent-catalog 记录对派生读面立下的规则一致。 + +### `listChildren` 行形状与消费面 + +`SubagentListEntry` **数据结构与今天完全一致**——child 与 diagnostic 两臂、`kind` 判别、reason 三值、child 臂的 mode/label 强契约全部保留;变化只在诊断的信息来源:投影体系没有失败通道,diagnostic 由列表按投影值缺席与 activity 派生,列表本身仍零事件读取。"没有就等待硬读取"继续保证阶梯对健康数据必然算得出 mode/label。 + +```ts ignore-check +export type SubagentListEntry = + | ({ + readonly kind: 'child' + readonly id: SessionId + readonly activity: 'running' | 'inactive' + readonly hasChildren: boolean + } & ( + | { readonly mode: 'one-shot'; readonly label?: string } + | { readonly mode: 'continuable'; readonly label: string } + )) + | { + readonly kind: 'diagnostic' + readonly id: SessionId + readonly reason: 'corrupt' | 'unsupported' | 'unavailable' + } +``` + +实现形态:`listChildren` = 自管枚举(id、activity、hasChildren、`origin` 过滤,全部来自 header 事实)+ 投影阶梯(mode/label)。逐 child 的 `listEvents`、精确 `readEvent`、描述符定位与就地分类机器整体删除。 + +对每个枚举出的 child,阶梯取值结果按四态映射成行: + +| 阶梯取值结果 | 行 | +| --- | --- | +| 快照含 `subagent` 值 | child 行 | +| 快照在、值缺席,且 child **inactive** | diagnostic 行,reason `corrupt`(定局残骸:无、损坏或版本不认识的描述符,不再细分) | +| 快照在、值缺席,且 child **running** | 行不出现(创建窗口:描述符尚未追加,与旧实现同窗口 omit) | +| cold 整读失败 | diagnostic 行,reason `unavailable` | + +- `unsupported` 不再被产出:类型与 wire 枚举按"数据结构保持现状"留存该成员,本记录留档其为不再产出。 +- descriptor-less 定局残骸从旧实现的 omit 归入 `corrupt` diagnostic——库里的坏、死子会话可见,不静默消失,这正是保留 diagnostic 的原始动机。 + +已知边界偏差(有意接受,随本记录留档): + +- 死于发布窗口的 fork child,seed 里若有祖先描述符,last-wins 会给出祖先身份,误现为 child 行;恢复仍按 own-suffix 折叠权威失败(`NOT_RESUMABLE`)。旧实现靠 `seedLength` 过滤将其 omit;projection unit 看不到 header,接受此残骸级偏差(`subagentTiming` 有同类既有暴露)。 +- own suffix 出现多个描述符,旧实现判 corrupt,现 last-wins 取末者(provider 契约本就保证恰一)。 +- live/persisted header 冲突,旧实现是 per-child corrupt;现枚举 live 优先、不做一致性校验,冲突不再被察觉,以 live 记录成行。 +- 损坏存储的源读失败(如坏 surface 被冷读整读拒收),旧实现映射 per-child `corrupt`,现统一成 `unavailable` 行(读侧无从区分成因)。 + +消费面:wire、tool、GUI 的 diagnostic 处理**全部保持现状零改动**(`list_agents` 的 description 与 output schema 亦不动;该插件仅加载要求收窄——inject 去掉 `sessionQuery`)。唯一动行为的是 apiproxy 路由段:删 `hasSubagentDescriptor()` 扫描,`hasSubagentOwner` 只看 `header.origin`——pre-#1569 的无 `origin` 存量不再被认作 subagent 属主,其本就不进目录,pre-release 立场接受。 + +### 附带工作项:session-query 读路去 clone 与浅 readonly + +- `SessionCorpus.load()`、`snapshotLive`、`listSessions` 等移除 structuredClone:live Session 的事件快照数组与事件载荷已深冻结(core/session 的 `deepFreeze` 加 `Object.freeze`),持久化读出的对象图为独占新建,克隆纯属浪费。 +- 公开查询输出标注**浅 readonly**(顶层属性与数组位);深只读化被实证否决(见替代方案),深层不可变由 core/session 的运行时深冻结事实保证,类型层面不再表达,`DeepReadonly` 不进任何公共包。 +- 契约措辞与 `projectMany` 的借用契约("borrowed only for that call")对齐:整个 corpus 面向消费方统一为"只读视图,不得留存可变引用"的不可变借用视图;需要留存的自行克隆。 + +### 改动面清单 + +| 区域 | 文件 | 改动 | +| --- | --- | --- | +| 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` | +| session-query | index.ts、corpus.ts | 读路径去 clone,公开输出浅 readonly 借用视图(净变化仅此) | +| 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 处理不变 | +| core/session、session-persistence、session-projection(-cache)、session-query-sqlite | — | **零改动** | +| 测试/快照 | 相关 spec 与 snapshot | 随行为更新,提 PR 前统一处理 | + +### 推进节奏 + +1. `subagent` projection unit 与注册(纯增量)。 +2. session-query:corpus 去 clone 与浅 readonly 借用视图。 +3. `listChildren` 重写(自管枚举 + 投影阶梯);tool 加载要求收窄;apiproxy 路由段 `hasSubagentDescriptor` 删除。 +4. 测试与快照统一更新,整体 diff 评审后再拆 commit。 + +配套文档随实现 PR 处理:[session-projection RFC](2026-07-27-session-projection-and-command-log.md) 增补一节,记录 `subagent` 身份 unit 与 snapshot/restore 两处既有读法的消费实例(registry 契约零改动);[durable-subagent-catalog 记录](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)的列表读路径段落随实现更新并与本记录交叉链接。 + +## 考虑过的替代方案 + +**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);读时现算不需要任何持久派生。 + +**给 persistence 加有界读原语抢救存量。** 为一次性问题新开 seam 原语;被读时 `inspect` 整读取代——存量第一次被列表时的整读就是取值本身。 + +**list 行 mode/label 可选化(v4 一稿)。** 健康数据必然可算;可选化只是把垃圾数据的处理复杂度外溢给全部消费方——每个消费面都要长出过滤分支和 unknown 展示态。强契约加算不出即 omit 更干净。 + +**彻底删除 diagnostic 行(v5 一稿)。** 删除把库损坏的可见性外溢为行静默消失,wire/tool/GUI 反要各自承担契约与快照变更;而保留只需列表侧按投影值缺席与 activity 派生分类,零成本。库里的坏、死子会话必须可见是 diagnostic 存在的原始动机,保留后消费面整体零改动。 + +**registry 计算失败通道(per-unit 容错加 `failures` 附加字段)。** 为把损坏、版本不认识报告给消费方,曾考虑让 registry 捕获 unit 异常并在 snapshot 旁附 per-key 失败态。被否:failure 不是值,也不必是通道——unit 永不抛错,缺席本身就是信号,"大不了算出来没有",如何呈现是消费方要考虑的事。该路线讨论顺带留下一个独立观察:vendor cordis 的 `emit`([vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts))对 listener 抛错零捕获,投影驱动挂在 `session/event` 上时 unit 异常会沿 emit 逃逸——这加重了"unit 永不抛错"纪律的分量,但 emit 容错的修复不属于本记录范围。 + +**值随 query 索引 preparation 落库(v4/v5 定稿,一度施工)。** 投影值在 sqlite backend 的对账重建里折叠落进 session 索引行,读稳态零日志;`projectionsFor` 批量读面、行值随 `(key → stateVersion)` 注册集存储的失效对账与 SCHEMA bump 均已施工过。整体退役:方向反了——查询基础设施被迫认识领域词汇(投影列、注册集对账),而唯一消费方 subagent 列表读时现算即可满足;消费方归零后,这套派生持久化没有存在理由。`SESSION_QUERY_PROJECTIONS_UNAVAILABLE` 随读面一并删除。 + +**subagent 手工 parse 加进程 memo 加创建播种(v6 稿)。** 为摘除 session-query 依赖,曾考虑 subagent 自己解析描述符事件、以进程内 memo 避免重复整读、创建时播种初值。被 v7 阶梯取代:live 走 `sessionProjections` 水位缓存、cold 走 `registry.restore`,复用 registry 这一份折叠权威,不再出现第二份描述符解释逻辑,也不引入进程态缓存与播种时序。 + +**session-query 输出面 DeepReadonly(去 clone 一稿)。** 公开查询输出深只读化,以在类型层面钉死不可变借用。实证否决:3 处 TS2589(类型实例化过深)加 17 处数组位传染(消费方数组方法与展开处被迫跟改);退回浅 readonly,深层不可变由 core/session 的运行时深冻结保证。 + +## 验收标准 + +- 稳态列表读代价:live child 全程零 events 读取(仅注册表水位缓存);cold child 每次 `listChildren` 恰一次 `persistence.inspect` 整读;由 subagent 测试断言。 +- 行为等价:同一语料下,新实现产出与旧实现相同的行集合(child 行的 id、mode、label、activity、hasChildren 与 diagnostic 行的 id、reason),例外仅限本记录留档的语义变化——descriptor-less 定局残骸由 omit 改为 `corrupt` 行、`unsupported` 归并入 `corrupt`、四条边界偏差(stillborn fork 祖先身份、多描述符 last-wins、header 冲突不再察觉、损坏源读失败由 `corrupt` 转 `unavailable`)——且每处变化有测试钉住新行为。 +- 四态映射成立:快照有值成 child 行;inactive 缺值产生 `corrupt` 行(含 descriptor-less 定局残骸);running 缺值缺席(创建窗口);cold 整读失败映射 `unavailable`;`unsupported` 不再产出。 +- 错误契约:`ctx.sessionProjections` 未挂载时 `listChildren` 于枚举前以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 失败(零 children 部署同样确定失败);`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 从代码与文档中消失。 +- persistence 缺席退为 live-only 枚举,不报错,live child 照常成行。 +- per-child 隔离:单 child 整读失败只产生该行 `unavailable`,sibling 不受影响。 +- `hasSubagentDescriptor` 删除后属主判定只认 `header.origin`;`list_agents` 的 description、output schema 与既有无密钥快照零变化,钉住 wire/tool/GUI 零改动。 +- corpus 去 clone 后公开输出为浅 readonly 借用视图,既有 session-query 行为测试全数通过。 + +## 风险 + +- **折叠规则分叉。** "折叠只在 registry 一份"是本设计的承诺;若未来某消费面绕开 registry 手写折叠,各读面的值可能漂移。缓解:列表两级阶梯与 GUI history 冷读走的都是 registry 的同两处读法(snapshot/restore),不存在旁路折叠。 +- **cold child 的每次列表整读成本。** cold child 每次 `listChildren` 都做一次 `inspect` 整读现算,成本与其 transcript 大小成正比、随列表频率重复;定案"算完即止",不建缓存、不回写。同 id 短期重复整读可命中持久化协调器准备阶段的 LRU 复用,但列表不依赖它;live child 全程零读。显式接受。 +- **诊断语义的四处边界偏差。** stillborn fork 的祖先身份误现为 child 行、多描述符改取末者、header 冲突不再被察觉、损坏源读失败由 `corrupt` 转 `unavailable`——完整语义与接受理由见提案的已知边界偏差清单。均为残骸级数据的展示或分类偏差,恢复鉴权不受影响。 +- **pre-#1569 存量属主判定收窄。** 无 `origin` 的旧 child 不再被认作 subagent 属主。其本就不进目录,pre-release 无兼容承诺,接受。 + +## 相关 + +- [durable-subagent-catalog 与 list_agents](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)——被本记录部分取代:描述符仍是 mode/label 的持久权威与折叠输入,列表的枚举与取值改为自管合并加投影阶梯。 +- [session projections 与命令生命周期日志](2026-07-27-session-projection-and-command-log.md)——registry 契约的权威;本记录为其新增 `subagent` 身份 unit,并成为 snapshot/restore 两处既有读法的消费实例。 +- [web subagent conversations](../../implemented/feature/2026-07-27-web-subagent-conversations.md)——`SessionHeader.origin` 的出处(#1569),身份判定去日志化的前半步;其 history 冷读(inspect 前缀加 registry 折叠)是本记录取值阶梯的同款先例。 +- [发布前可复用的 Session 准备阶段](../../implemented/architecture/2026-08-05-session-preparation.md)——`inspect()` 冷读与 LRU 复用;cold child 整读的成本模型建立其上。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f528b2297e..9a93b89d99 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -567,6 +567,15 @@ function subagentPromptError( return err(request, { code: 'internal', message: 'subagent prompt failed', details: {} }) } +/** Stable RPC face of the missing projections capability, shared by every catalog read path. */ +function projectionsUnavailableError(): RpcError { + return { + code: 'internal', + message: 'subagent listing is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', + details: {}, + } +} + /** Verify one address and mode against the complete direct-child catalog. */ async function catalogChild( ctx: Context, @@ -605,6 +614,9 @@ async function catalogChild( || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) { return { error: { code: 'cancelled', message: 'subagent catalog read was cancelled', details: {} } } } + if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') { + return { error: projectionsUnavailableError() } + } if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { return { error: { @@ -925,28 +937,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } - /** Whether the session's own suffix carries the durable subagent discriminator. */ - function hasSubagentDescriptor(session: Pick): boolean { - const events = session.events - // Indexed scan from the own-suffix start: slicing copies the whole suffix - // on every Agent-bound RPC, including each `session.prompt` on long - // transcripts. - for (let index = session.header.seedLength ?? 0; index < events.length; index += 1) { - if (events[index]?.type === 'subagent/descriptor') return true - } - return false - } - /** - * Generic Host interaction cannot claim a durably classified subagent or an - * Agent created through its live parent. The runtime-owner arm also covers - * descriptor-less child publication windows and older stored headers. + * Generic Host interaction cannot claim a durably classified subagent + * (`origin: 'subagent'` in the header) or an Agent runtime-owned by its + * live parent. */ function hasSubagentOwner( - session: Pick, + session: Pick, agent: Agent | undefined, ): boolean { - if (session.header.origin === 'subagent' || hasSubagentDescriptor(session)) return true + if (session.header.origin === 'subagent') return true const parentId = session.header.parentSession if (parentId === undefined || agent === undefined) return false const parent = ctx.agents.get(parentId) @@ -1002,7 +1002,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro resume = (async () => { try { const inspected = await inspectServable(sessionId) - if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) { + if (hasSubagentOwner({ header: inspected.meta }, undefined)) { throw new SubagentSessionOwnership(sessionId) } const publishedSession = ctx.sessions.get(sessionId) @@ -1121,7 +1121,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // Ownership first: explicit-id adoption of a session-backed // subagent must answer `agent-busy` regardless of the requested // cwd (the api/commands.ts contract), not a cwd conflict. - if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) { + if (hasSubagentOwner({ header: inspected.meta }, undefined)) { throw new SubagentSessionOwnership(sessionId) } if (inspected.meta.cwd !== cwd) { @@ -1912,6 +1912,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: {}, }) } + if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') { + return err(request, projectionsUnavailableError()) + } return err(request, { code: 'internal', message: 'subagent catalog read failed', diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index bc73d345e5..72833b7ddb 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/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: 08b6175e018db072b99490a25b8df887bb89eb47 -README.zh.md: 435be7660b3f004a1f0bcb59a8d9a74ac8e8aae3 +README.md: bfed362d5a70bf946295c04d02ed1c6d031041e3 +README.zh.md: 11121735bd4acdfccf2ef950d30e5913646430a4 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 08b6175e01..bfed362d5a 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -21,7 +21,7 @@ The [subagent family overview](../README.md) maps implementations and model-faci | `reportFrom(child, content, { delivery, signal })` | Deliver one selected message from the exact live continuable child to its exact live direct parent and return the accepted stable `MessageId`. Quiet delivery injects context; waking delivery submits one later parent turn. | | `registerContinuableSetup(contribution)` | Compose an optional deployment capability into each continuable child's unpublished scope, with immediate revocation from resident children. | | `drainContinuableDescendants(parents)` | Close admission below exact live host-owned parent Agents, stop only their visible continuable descendants, await materializations admitted below those roots through publication or rollback, then release the selected forests child-first. The cutoff lasts until each exact parent leaves the registry; unrelated parent forests and manager-wide admission remain live. | -| `listChildren(parentSessionId, signal?)` | List direct session-backed subagents with their `one-shot`/`continuable` mode, `running`/`inactive` activity, origin-classified one-level `hasChildren` hint, and per-child diagnostics, in stable trace order without loading or resuming them. Requires session query; it does not require `ctx.agents` or the continuation manager. | +| `listChildren(parentSessionId, signal?)` | List direct session-backed subagents with their `one-shot`/`continuable` mode, `running`/`inactive` activity, origin-classified one-level `hasChildren` hint, and per-child diagnostics, ordered by `createdAt` then id, without loading or resuming them. Reads the live session store and optional session persistence directly (live-only enumeration when persistence is absent) and requires the mounted `sessionProjections` registry; it does not require `ctx.agents`, the continuation manager, or any query service. | `SubagentStartRequest.label` is an optional short durable display label for a session-backed one-shot child. Model-facing delegation supplies its existing `description`; lower-level callers need not invent presentation metadata. Continuable starts always carry their own required label. `signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the returned run's remaining turn work without hiding its id. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child. @@ -78,13 +78,13 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen Continuable children do not create `SubagentRun` or Tasks. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` remains provenance rather than authority. -When `ctx.sessionProjections` is available, the service registers `subagentTiming`. The projection resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn. While that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata. +When `ctx.sessionProjections` is available, the service registers two projection units. `subagentTiming` resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn; while that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata. `subagent` folds the durable identity — mode plus creation label — from `subagent/descriptor` events with the same last-wins reset discipline, so a fork seed's ancestor descriptor stands only until the child's own overrides it; a malformed or unrecognized-version payload folds to no value, indistinguishable from a log with no descriptor, and never throws. `registerContinuableSetup()` lets optional packages add child-scoped capabilities without teaching the continuation manager their names. Contributions install synchronously before Activation publication, roll back with failed setup, and are released with the child scope. New grants wait for the next Activation, while contribution removal revokes every resident installation immediately. ## Collection model -The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` resolves session query and dynamically imports its optional runtime only when called, then interprets a read-only live-preferred scan of all descriptor-bearing direct children without consulting the continuation manager, Agent registrations, Activations, or providers. Each healthy row derives its read-time `hasChildren` hint from traced direct-descendant headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The scan forwards the caller's signal to cancellable trace and exact-read operations, checks cancellation around the remaining event-list read, and reports every observed abort as `SubagentError` code `CANCELLED`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. +The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `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. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. Continuable Activations await a best-effort final session flush without treating listener participation as durability confirmation. One-shot runs retain best-effort session checkpointing, so a completed one-shot child is discoverable after disposal only when its session actually reached persistence; the service does not invent a catalog entry from Task history when that checkpoint is absent. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 435be7660b..11121735bd 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -21,7 +21,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `reportFrom(child, content, { delivery, signal })` | 从确切在线可继续 child 向其确切在线直接 parent 投递一条选中消息,并返回已接受的稳定 `MessageId`。静默投递会注入上下文;唤醒投递会提交一个后续 parent 轮次。 | | `registerContinuableSetup(contribution)` | 把一项可选部署能力组合到每个可继续 child 尚未发布的作用域中,并支持从驻留 child 立即撤销。 | | `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 | -| `listChildren(parentSessionId, signal?)` | 按稳定的追踪顺序列出由会话支撑的直接 subagent,包括其 `one-shot`/`continuable` 模式、`running`/`inactive` 活动状态、基于 origin 分类的一层 `hasChildren` 提示与逐 child diagnostic,且不会加载或恢复它们。要求会话查询;不要求 `ctx.agents` 或继续执行管理器。 | +| `listChildren(parentSessionId, signal?)` | 按 `createdAt` 再按 id 的顺序列出由会话支撑的直接 subagent,包括其 `one-shot`/`continuable` 模式、`running`/`inactive` 活动状态、基于 origin 分类的一层 `hasChildren` 提示与逐 child diagnostic,且不会加载或恢复它们。直接读取在线会话存储与可选的会话持久化(持久化缺席时仅枚举在线 child),并要求已挂载 `sessionProjections` 注册表;不要求 `ctx.agents`、继续执行管理器或任何查询服务。 | `SubagentStartRequest.label` 是由会话支撑的一次性 child 所使用的可选简短持久化显示标签。面向模型的委派会提供其已有的 `description`;底层调用方无需凭空构造展示元数据。可继续启动始终携带自身的必填标签。`signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消已返回 run 的剩余轮次工作,但不会隐藏其 id。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose(资源释放)子 agent。 @@ -78,13 +78,13 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 可继续子级不会创建 `SubagentRun` 或 Task。继续执行管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由确切在线的直接父级身份授权。上报则由确切在线的子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。 -当 `ctx.sessionProjections` 可用时,服务会注册 `subagentTiming`。该投影会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界。在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。 +当 `ctx.sessionProjections` 可用时,服务会注册两个投影单元。`subagentTiming` 会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界;在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。`subagent` 以同样的 last-wins 重置纪律从 `subagent/descriptor` 事件折叠持久化身份——模式与创建标签——因此 fork 种子中的祖先描述符只在 child 自身的描述符覆盖之前有效;畸形或版本不识别的载荷折叠为无值,与没有描述符的日志不可区分,且绝不抛错。 `registerContinuableSetup()` 允许可选包添加子级作用域能力,而无需让继续执行管理器知道这些能力的名称。贡献会在 Activation 发布前同步安装,在设置失败时一并回滚,并随子级作用域释放。新授权须等到下一个 Activation,移除贡献则会立即撤销每个驻留安装项。 ## 收集模型 -面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 只在被调用时解析会话查询并动态导入其可选运行时,然后解释对所有带描述符的直接 child 所作的只读、实时优先扫描,且不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个健康条目都会根据追踪结果中携带持久化 `origin: 'subagent'` 的直接后代 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。扫描会把调用方的取消信号转发到可取消的追踪与精确读取操作,在其余事件列表读取的前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 +面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照,冷 child 经一次有界并发的持久化 inspect 再经注册表折叠。投影折叠是唯一的分类权威;列表自身不解析任何描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 可继续 Activation 会等待 best-effort 的最终会话 flush,但不会把 listener 参与视为持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。 diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index c00caf7fb9..04ae941633 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -40,7 +40,6 @@ "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", - "@deepseek-ai/dsh-session-query": "^0.0.1", "@deepseek-ai/dsh-session-projection": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -50,9 +49,6 @@ "@deepseek-ai/dsh-session-persistence": { "optional": true }, - "@deepseek-ai/dsh-session-query": { - "optional": true - }, "@deepseek-ai/dsh-session-projection": { "optional": true }, @@ -68,7 +64,6 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/subagent/subagent/src/client.ts b/packages/subagent/subagent/src/client.ts index 928637dc7a..602dcd8793 100644 --- a/packages/subagent/subagent/src/client.ts +++ b/packages/subagent/subagent/src/client.ts @@ -4,4 +4,4 @@ * @module @deepseek-ai/dsh-subagent/client */ -export type { SubagentTimingProjection } from './projection-types.ts' +export type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts' diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 975d07ce4c..57634ecfac 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -20,8 +20,8 @@ * continuation manager holds their `AgentHandle` directly and orders every turn * through the child's own inbox, so providers contribute only the detached * creation spec and see no handle, turn, or teardown. Direct-child discovery - * independently interprets the optional session-query corpus and does not - * require that continuation runtime. + * reads the live session store and optional session persistence directly and + * does not require that continuation runtime. * * Same-process providers are trusted typed collaborators. Requests, provider * descriptors, results, and lifecycle payloads are borrowed immutable values; @@ -65,7 +65,7 @@ import type { ContinuableSetupContribution } from './activation-setup-registry.t import { listChildren as listSubagentChildren } from './list-children.ts' import type { SubagentListEntry } from './list-children.ts' import { snapshotSubagentDescriptor } from './descriptor.ts' -import { subagentTimingProjectionDefinition } from './projection.ts' +import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts' export * from './out-of-process.ts' export { SubagentRunId } from './types.ts' @@ -118,7 +118,7 @@ export type { export type { ContinuableSetupContribution } from './activation-setup-registry.ts' export type { SubagentListEntry } from './list-children.ts' export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts' -export type { SubagentTimingProjection } from './projection-types.ts' +export type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts' declare module 'cordis' { interface Context { @@ -190,6 +190,7 @@ export class SubagentService extends Service { }) ctx.inject(['sessionProjections'], (projectionCtx) => { projectionCtx.sessionProjections.register(subagentTimingProjectionDefinition) + projectionCtx.sessionProjections.register(subagentIdentityProjectionDefinition) }) } @@ -283,22 +284,28 @@ export class SubagentService extends Service { } /** - * Enumerate the parent's direct session-backed subagents from the - * live-preferred session corpus without loading or resuming an Agent. Session - * query supplies lineage, candidate order, event reads, and live state; this - * service interprets descriptor mode, activity, and per-child diagnostics - * without consulting Agent registrations, Activations, or providers. + * Enumerate the parent's direct session-backed subagents without loading or + * 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 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 + * live-only (a cold child cannot be resumed then either, so its absence is + * capability absence, not an error). This service consults no Agent + * registrations, Activations, or providers. * - * The trace and exact descriptor read receive `signal`; the full event-list - * read has no signal parameter, so the scan rechecks cancellation around - * every await and between candidates. Query rejections that settle after an - * abort become a stable `SubagentError` with code `CANCELLED`. + * Every persistence read receives `signal`, and the listing rechecks + * cancellation around each of those awaits. Read rejections that settle + * after an abort become a stable `SubagentError` with code `CANCELLED`. * @param parentSessionId - parent session whose direct children are listed. - * @param signal - caller-owned cancellation forwarded where supported and - * observed around every query await. - * @returns children and per-child diagnostics in stable trace order. - * @throws {@link SubagentError} when session query is unavailable or the - * caller cancels the scan. + * @param signal - caller-owned cancellation forwarded to persistence reads + * and observed around every read await. + * @returns children and per-child diagnostics ordered by `createdAt`, then id. + * @throws {@link SubagentError} when the projection registry is not mounted + * or the caller cancels the listing. */ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise { return listSubagentChildren(this.ctx, parentSessionId, signal) diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index cabbec121d..6b055be4a6 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -1,33 +1,39 @@ /** - * Read-only interpretation of session-query lineage as durable subagent - * children. Only descendants with durable `origin: 'subagent'` enter per-child - * inspection. The module owns no catalog state and does not consult Activation, - * Agent-registry, continuation-manager, or provider state. A child's descriptor - * distinguishes one-shot work from a continuable conversation. + * Read-only enumeration of one parent's durable subagent children straight + * 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 + * 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 + * module owns no catalog state and does not consult Activation, + * Agent-registry, continuation-manager, or provider state. * * @module @deepseek-ai/dsh-subagent */ import type { Context } from 'cordis' -import type { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionQueryService, SessionRecord } from '@deepseek-ai/dsh-session-query' -import type SubagentService from './index.ts' +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 { SubagentError } from './error.ts' -import { foldSubagentDescriptor } from './descriptor.ts' +import type { SubagentIdentityProjection } from './projection-types.ts' -type SessionQueryRuntime = Pick< - typeof import('@deepseek-ai/dsh-session-query'), - 'assertSessionHeadersCompatible' | 'SessionQueryError' -> +/** Concurrent cold inspections per listing; a constant because it bounds one read-only scan, not deployment behavior. */ +const COLD_READ_CONCURRENCY = 4 /** - * One entry of a {@link listChildren} result in trace candidate order. Only a - * candidate whose durable header has `origin: 'subagent'` is inspected. A - * valid descriptor produces a `child`, a per-child inspection failure produces - * a `diagnostic`, and a candidate without its own descriptor is omitted. - * Healthy rows include a one-level, origin-classified descendant hint. - * Diagnostics are transient query results, never session events or catalog - * state, and never expose model-hidden descriptor content. + * One entry of a {@link listChildren} result, ordered by header `createdAt` + * with ties broken on id. Only a candidate whose durable header has + * `origin: 'subagent'` is interpreted. A served `subagent` projection value + * produces a `child`; a settled candidate whose fold served no identity + * produces a `diagnostic`; a running candidate without one is omitted — its + * descriptor may not be appended yet (the creation window). Diagnostics + * relay the projection fold's outcome or a failed read, never a per-child + * event scan, and never expose model-hidden descriptor content. */ export type SubagentListEntry = | { @@ -35,7 +41,7 @@ export type SubagentListEntry = /** The durable child session id, stable across Activations. */ readonly id: SessionId /** - * Corpus snapshot activity: `running` means the logical record is live in + * Store snapshot activity: `running` means the logical record is live in * `ctx.sessions`; `inactive` means it exists only in persistence. Neither * encodes a durable outcome, and a continuable child may still reject * delivery as an ownership conflict. @@ -59,179 +65,185 @@ export type SubagentListEntry = ) | { readonly kind: 'diagnostic' - /** The traced candidate's session id. */ + /** The candidate's session id. */ readonly id: SessionId /** - * Why the candidate was omitted: `corrupt` for invalid surfaces, header - * conflicts, or malformed/duplicated descriptors; `unsupported` for an - * unknown descriptor version; `unavailable` when the child disappeared or - * its per-child read hit a persistence failure. + * Why the candidate has no `child` row: `corrupt` for a settled candidate + * whose projection fold served no identity (a missing, malformed, or + * unrecognized-version descriptor — deliberately undistinguished); + * `unavailable` when the candidate's persistence inspection failed + * (retried on the next listing). `unsupported` is kept for consumers + * already routing on it but is no longer produced. */ readonly reason: 'corrupt' | 'unsupported' | 'unavailable' } /** - * Interpret one parent's origin-classified direct descendants as session-backed - * subagents without loading or resuming an Agent. Ordinary forks are skipped - * before per-child event inspection. - * @see {@link SubagentService.listChildren} for the public cancellation and - * failure contract. - * @param ctx - context carrying the optional session-query service. + * 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. + * @see SubagentService.listChildren for the public cancellation and failure contract. + * @param ctx - context carrying the session store, the projection registry, + * and optional persistence. * @param parentSessionId - parent session whose direct children are listed. - * @param signal - caller-owned cancellation. - * @returns children and per-child diagnostics in stable trace order. - * @throws {@link SubagentError} when session query is unavailable or - * the caller cancels the scan. + * @param signal - caller-owned cancellation observed around every persistence read. + * @returns children and per-child diagnostics ordered by `createdAt`, then id. + * @throws {@link SubagentError} when the projection registry is not mounted + * or the caller cancels the listing. */ export async function listChildren( ctx: Context, parentSessionId: SessionId, signal?: AbortSignal, -): ReturnType { - const query = ctx.get('sessionQuery') - if (query === undefined) { +): Promise { + const projections = ctx.get('sessionProjections') + const sessions = ctx.get('sessions') + // Checked before any read, even with zero candidates: mode/label are the + // row's strong contract, so a missing fold capability is a deterministic + // deployment configuration error, never an empty success. + if (projections === undefined) { throw new SubagentError( - 'listing subagents requires session query (load a dsh-session-query backend)', - 'SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE', + 'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', + 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE', + ) + } + if (sessions === undefined) { + throw new SubagentError( + 'listing subagents requires the sessions registry (load @deepseek-ai/dsh-session)', + 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE', ) } assertListingNotCancelled(signal) - // Keep runtime values behind the listing-only boundary so ordinary - // subagent imports and control operations do not evaluate the optional peer. - const queryRuntime: SessionQueryRuntime = await import('@deepseek-ai/dsh-session-query') - assertListingNotCancelled(signal) - const trace = await runListingQuery( - () => query.traceSession(parentSessionId, signal), - signal, - ) - const entries: SubagentListEntry[] = [] - for (const node of trace.descendants) { - if (node.session.header.origin !== 'subagent') continue - const hasChildren = node.descendants.some( - descendant => descendant.session.header.origin === 'subagent', - ) - const entry = await inspectChild( - query, queryRuntime, parentSessionId, node.session, hasChildren, signal, - ) - // Cancellation can race the inspection's last checkpoint or diagnostic - // mapping; do not return success or begin another candidate afterward. - assertListingNotCancelled(signal) - if (entry !== undefined) entries.push(entry) - } - return entries -} - -/** Interpret one traced direct-child record as a child, diagnostic, or exclusion. */ -async function inspectChild( - query: SessionQueryService, - queryRuntime: SessionQueryRuntime, - parentSessionId: SessionId, - candidate: SessionRecord, - hasChildren: boolean, - signal?: AbortSignal, -): Promise { - const childId = candidate.header.id - try { - const records = await runListingQuery(() => query.listEvents(childId), signal) - // Only the child's own suffix: a fork seed may replay an ancestor's - // descriptor without making the fork itself a subagent. - const seedLength = candidate.header.seedLength ?? 0 - const descriptorSeqs = records - .filter(record => record.seq >= seedLength && record.type === 'subagent/descriptor') - .map(record => record.seq) - if (descriptorSeqs.length === 0) return undefined - if (descriptorSeqs.length > 1) { - return { kind: 'diagnostic', id: childId, reason: 'corrupt' } - } - // The length-one branch proves this exact-read sequence exists. - // oxlint-disable-next-line typescript/no-non-null-assertion - const seq = descriptorSeqs[0]! - const window = await runListingQuery( - () => query.readEvent({ sessionId: childId, seq }, signal), - signal, - ) - queryRuntime.assertSessionHeadersCompatible(window.session, candidate.header) - if (window.session.parentSession !== parentSessionId || window.target.type !== 'subagent/descriptor') { - return { kind: 'diagnostic', id: childId, reason: 'corrupt' } - } - let descriptor: ReturnType + const persistence = ctx.get('sessionPersistence') + let persistedHeaders: readonly SessionHeader[] = [] + if (persistence !== undefined) { try { - descriptor = foldSubagentDescriptor([window.target]) - } catch { - return { kind: 'diagnostic', id: childId, reason: 'corrupt' } + persistedHeaders = await persistence.list(signal) + } catch (error: unknown) { + // The backend may reject with its own abort failure after observing the + // forwarded signal; cancellation stays a stable subagent failure. + assertListingNotCancelled(signal) + throw error } - if (descriptor === undefined) { - return { kind: 'diagnostic', id: childId, reason: 'unsupported' } - } - const activity = candidate.live ? 'running' : 'inactive' - if (descriptor.mode === 'one-shot') { - return { - kind: 'child', - id: childId, - mode: descriptor.mode, - ...descriptor.label !== undefined ? { label: descriptor.label } : {}, - activity, - hasChildren, - } - } - return { - kind: 'child', id: childId, mode: descriptor.mode, label: descriptor.label, - activity, hasChildren, - } - } catch (error: unknown) { - const reason = perChildDiagnosticReason(error, queryRuntime.SessionQueryError) - if (reason === undefined) throw error - return { kind: 'diagnostic', id: childId, reason } + assertListingNotCancelled(signal) } + // Live-preferred merge without header reconciliation: a live record wins + // its id wholesale, exactly as a live-preferred corpus would serve it. + const corpus = new Map() + for (const header of persistedHeaders) corpus.set(header.id, { header, live: undefined }) + for (const session of sessions.list()) { + corpus.set(session.header.id, { header: session.header, live: session }) + } + const subagentParents = new Set() + for (const record of corpus.values()) { + if (record.header.origin === 'subagent' && record.header.parentSession !== undefined) { + subagentParents.add(record.header.parentSession) + } + } + const candidates = [...corpus.values()] + .filter(record => record.header.parentSession === parentSessionId + && record.header.origin === 'subagent') + .sort((a, b) => a.header.createdAt - b.header.createdAt + || (a.header.id < b.header.id ? -1 : a.header.id > b.header.id ? 1 : 0)) + + const rows: (SubagentListEntry | undefined)[] = Array.from({ length: candidates.length }) + const coldReads: { index: number; id: SessionId }[] = [] + candidates.forEach((candidate, index) => { + const childId = candidate.header.id + if (candidate.live === undefined) { + coldReads.push({ index, id: childId }) + return + } + // The registry's watermark cache serves the live value with zero log + // reads; a live child without an identity yet is the creation window + // before the establishing provider appends its descriptor. + const identity = projections.snapshot(candidate.live).values.subagent + if (identity === undefined) return + rows[index] = childRow(childId, identity, 'running', subagentParents.has(childId)) + }) + + // Cold candidates exist only when persistence listed them, so the narrow + // re-check is about types, not reachability. + if (persistence !== undefined && coldReads.length > 0) { + const queue = [...coldReads] + await Promise.all(Array.from( + { 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, + ) + } + }, + )) + } + assertListingNotCancelled(signal) + return rows.filter((row): row is SubagentListEntry => row !== undefined) } -/** Stop a listing scan at its next cancellation checkpoint. */ +/** + * 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 is final, so it reports `corrupt`. + */ +async function inspectColdIdentity( + persistence: SessionPersistence, + projections: SessionProjectionRegistry, + childId: SessionId, + hasChildren: boolean, + signal: AbortSignal | undefined, +): Promise { + assertListingNotCancelled(signal) + let events: readonly SessionEvent[] + try { + events = (await persistence.inspect(childId, signal)).events + } catch { + // Per-child isolation: the child vanished or its backend read failed — + // one diagnostic row, and the listing itself still succeeds. + assertListingNotCancelled(signal) + return { kind: 'diagnostic', id: childId, reason: 'unavailable' } + } + assertListingNotCancelled(signal) + const identity = projections.restore({}, events, 0).snapshot.values.subagent + if (identity === undefined) { + return { kind: 'diagnostic', id: childId, reason: 'corrupt' } + } + return childRow(childId, identity, 'inactive', hasChildren) +} + +/** Materialize one served identity as its child row. */ +function childRow( + id: SessionId, + identity: SubagentIdentityProjection, + activity: 'running' | 'inactive', + hasChildren: boolean, +): SubagentListEntry { + return identity.mode === 'one-shot' + ? { + kind: 'child', + id, + mode: 'one-shot', + ...identity.label !== undefined ? { label: identity.label } : {}, + activity, + hasChildren, + } + : { + kind: 'child', + id, + mode: 'continuable', + label: identity.label, + activity, + hasChildren, + } +} + +/** Stop a listing at its next cancellation checkpoint. */ function assertListingNotCancelled(signal: AbortSignal | undefined): void { if (signal?.aborted) { throw new SubagentError('subagent listing was cancelled', 'CANCELLED') } } - -/** - * Run one session-query operation between cancellation checkpoints. Query - * implementations may reject with their own abort error after observing the - * forwarded signal; cancellation remains a stable subagent failure. - */ -async function runListingQuery( - operation: () => Promise, - signal: AbortSignal | undefined, -): Promise { - assertListingNotCancelled(signal) - try { - const result = await operation() - assertListingNotCancelled(signal) - return result - } catch (error: unknown) { - assertListingNotCancelled(signal) - throw error - } -} - -/** - * Map a per-child query failure to a fixed diagnostic. Configuration errors - * and unrecognized failures remain operation failures. - */ -function perChildDiagnosticReason( - error: unknown, - SessionQueryError: SessionQueryRuntime['SessionQueryError'], -): 'corrupt' | 'unavailable' | undefined { - if (!(error instanceof SessionQueryError)) return undefined - switch (error.code) { - case 'SESSION_QUERY_CORRUPT_SESSION': - return 'corrupt' - case 'SESSION_QUERY_SESSION_NOT_FOUND': - case 'SESSION_QUERY_EVENT_NOT_FOUND': - case 'SESSION_QUERY_PERSISTENCE_FAILED': - return 'unavailable' - case 'SESSION_QUERY_INVALID_SURFACE': - case 'SESSION_QUERY_SOURCE_CONFLICT': - return 'corrupt' - default: - return undefined - } -} diff --git a/packages/subagent/subagent/src/projection-types.ts b/packages/subagent/subagent/src/projection-types.ts index c5a23b03b8..a92ed3a882 100644 --- a/packages/subagent/subagent/src/projection-types.ts +++ b/packages/subagent/subagent/src/projection-types.ts @@ -17,9 +17,36 @@ export interface SubagentTimingProjection { } } +/** + * Durable identity of one descriptor-backed subagent session: lifecycle mode + * plus creation label, folded last-wins from `subagent/descriptor` events. + * Label strength follows the descriptor schema: a continuable child always + * carries one, a one-shot child may omit it. + */ +export type SubagentIdentityProjection = + | { + /** A terminal one-shot child. */ + mode: 'one-shot' + /** Optional durable creation label from the child's descriptor. */ + label?: string + } + | { + /** A resumable conversation. */ + mode: 'continuable' + /** Durable creation label from the child's descriptor. */ + label: string + } + declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionMap { /** Active-turn duration for a descriptor-backed subagent session. */ subagentTiming: SubagentTimingProjection + /** + * Identity of a descriptor-backed subagent session. No value ⟺ no valid + * descriptor: a missing, malformed, or unrecognized-version descriptor is + * served identically as `undefined` in a live snapshot, and as an absent + * key after any JSON boundary (query-index rows, wire frames) drops it. + */ + subagent: SubagentIdentityProjection } } diff --git a/packages/subagent/subagent/src/projection.ts b/packages/subagent/subagent/src/projection.ts index ffdcb4fd09..41b0d093ad 100644 --- a/packages/subagent/subagent/src/projection.ts +++ b/packages/subagent/subagent/src/projection.ts @@ -1,12 +1,16 @@ /** - * Pure session projection for subagent active-turn duration. + * Pure session projections for subagent identity (mode/label) and active-turn + * duration. * * @module @deepseek-ai/dsh-subagent/projection */ import { z } from 'zod' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' -import type { SubagentTimingProjection } from './projection-types.ts' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { foldSubagentDescriptor } from './descriptor.ts' +import type { SubagentDescriptorData } from './descriptor.ts' +import type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts' interface TimingState { /** Milliseconds accumulated across completed post-descriptor turns. */ @@ -80,3 +84,64 @@ ProjectionDefinition<'subagentTiming', TimingState> = { }), stateVersion: 2, } + +interface IdentityState { + /** Identity from the last valid descriptor; absent before one, and after an invalid one. */ + identity?: SubagentIdentityProjection +} + +// Zod's optional output includes explicit `undefined`; with +// exactOptionalPropertyTypes the public map entry permits omission only, and +// JSON boundaries drop the undefined-valued key entirely. +const identitySchema = z.discriminatedUnion('mode', [ + z.object({ + mode: z.literal('one-shot'), + label: z.string().optional(), + }).strict(), + z.object({ + mode: z.literal('continuable'), + label: z.string(), + }).strict(), +]).optional() as unknown as z.ZodType + +/** Interpret one `subagent/descriptor` event's identity; no value when the payload cannot be trusted. */ +function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | undefined { + let descriptor: SubagentDescriptorData | undefined + try { + descriptor = foldSubagentDescriptor([event]) + } catch { + // Only a malformed current-version payload throws in descriptor parsing; + // a projection fold must never throw, so damage folds to no value. + descriptor = undefined + } + if (descriptor === undefined) return undefined + return descriptor.mode === 'one-shot' + ? { mode: 'one-shot', ...descriptor.label !== undefined ? { label: descriptor.label } : {} } + : { mode: 'continuable', label: descriptor.label } +} + +/** + * Fold the durable mode/label identity from `subagent/descriptor` events, + * last-wins: a fork seed may replay an ancestor's descriptor, and the child's + * own descriptor must override it — the same reset discipline as + * {@link subagentTimingProjectionDefinition}. A malformed or unknown-version + * payload resets to no value instead of throwing, so a fork of a healthy + * ancestor never inherits an identity its own descriptor failed to establish; + * no value ⟺ no valid descriptor, with the causes deliberately undistinguished. + */ +export const subagentIdentityProjectionDefinition: +ProjectionDefinition<'subagent', IdentityState> = { + key: 'subagent', + schema: identitySchema, + init: () => ({}), + apply: (state, event) => { + if (event.type !== 'subagent/descriptor') return state + const identity = descriptorIdentity(event) + return identity === undefined ? {} : { identity } + }, + // A no-value log serves `undefined` (the schema's optional side); the map + // entry stays non-optional because every consumer reads through `Partial` + // snapshot values, where absence is already the type. + view: state => state.identity as SubagentIdentityProjection, + stateVersion: 1, +} diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index f8ceab50a8..18aab0a2ca 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -9,7 +9,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import { SessionQueryError } from '@deepseek-ai/dsh-session-query' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION, SubagentError, @@ -17,7 +17,6 @@ import SubagentService, { import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' -import { TestSessionQueryService } from '../../../session-query/session-query/tests/test-service.ts' type Script = ConstructorParameters[0] @@ -26,18 +25,18 @@ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) -/** Boot the continuable stack plus a concrete session-query service. */ -async function setup(script: Script, options: { sessionQuery?: boolean } = {}) { +/** Boot the continuable stack with real JSONL session persistence. */ +async function setup(script: Script, options: { sessionProjections?: boolean } = {}) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-list-')) roots.push(root) await ctx.plugin(JsonlSessionPersistence, { root }) await ctx.plugin(AgentLoop, { agents: [] }) + if (options.sessionProjections !== false) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentService) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(SubagentFork, { providerName: 'fork' }) - if (options.sessionQuery !== false) await ctx.plugin(TestSessionQueryService) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent } @@ -102,37 +101,38 @@ function descriptorPayload(label: string, version = SUBAGENT_DESCRIPTOR_VERSION) } describe('SubagentService.listChildren', () => { - it('lists through session query without the Activation continuation runtime', async () => { + it('lists live children without persistence, query services, or the continuation runtime', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentService) - await ctx.plugin(TestSessionQueryService) expect(ctx.get('tasks')).toBeUndefined() expect(ctx.get('agents')).toBeUndefined() + expect(ctx.get('sessionPersistence')).toBeUndefined() - const parentId = SessionId('query-only-parent') + const parentId = SessionId('live-only-parent') ctx.sessions.create(parentId) - const childId = SessionId('query-only-child') + const childId = SessionId('live-only-child') const child = ctx.sessions.create(childId, { meta: { parentSession: parentId, origin: 'subagent' }, }) child.append('turn/start', { turn: 1, }) - child.append('subagent/descriptor', descriptorPayload('query-only child')) + child.append('subagent/descriptor', descriptorPayload('live-only child')) await expect(ctx.subagents.listChildren(parentId)).resolves.toEqual([ { - kind: 'child', id: childId, label: 'query-only child', mode: 'continuable', + kind: 'child', id: childId, label: 'live-only child', mode: 'continuable', activity: 'running', hasChildren: false, }, ]) }) - it('fails loud before any work when session query is not loaded', async () => { - const { ctx, parent } = await setup([], { sessionQuery: false }) + it('fails loud when the projection registry is not mounted, even with no children', async () => { + const { ctx, parent } = await setup([], { sessionProjections: false }) await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow( - expect.objectContaining({ code: 'SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE' }) as Error, + expect.objectContaining({ code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE' }) as Error, ) }) @@ -148,7 +148,7 @@ describe('SubagentService.listChildren', () => { ]) }) - it('lists one-shot and continuable children from the same trace', async () => { + it('lists one-shot and continuable children under the same parent', async () => { const { ctx, parent } = await setup([textResponse('once'), textResponse('again')]) const oneShot = await ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'finish once' }], @@ -205,7 +205,7 @@ describe('SubagentService.listChildren', () => { ]) }) - it('orders children by createdAt then id without inspecting ordinary forks', async () => { + it('orders children by createdAt then id without listing ordinary forks', async () => { const { ctx, parent } = await setup([]) // Authored headers pin the ordering key deterministically: same createdAt // ties break on id, different createdAt orders ascending. @@ -227,11 +227,11 @@ describe('SubagentService.listChildren', () => { // An ordinary session fork shares parentSession but has no subagent origin. const fork = ctx.sessions.fork(parent.session, undefined, SessionId('plain-fork')) await ctx.sessions.flush(fork) - const listEvents = vi.spyOn(ctx.sessionQuery, 'listEvents') + const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect') const entries = await ctx.subagents.listChildren(parent.id) expect(entries.map(entry => entry.id)).toEqual([tieA, tieB, late]) expect(entries.every(entry => entry.kind === 'child')).toBe(true) - expect(listEvents).not.toHaveBeenCalledWith(fork.id) + expect(inspect).not.toHaveBeenCalledWith(fork.id, expect.anything()) }) it('reports a live child as running while keeping settled siblings complete', async () => { @@ -256,7 +256,7 @@ describe('SubagentService.listChildren', () => { }) }) - it('diagnoses duplicate descriptors as corrupt without hiding healthy siblings', async () => { + it('lists the last descriptor when a log carries more than one', async () => { const { ctx, parent } = await setup([textResponse('done')]) const healthy = await startChild(ctx, parent, 'healthy sibling') const events = childEvents(descriptorPayload('twice')) @@ -267,22 +267,27 @@ describe('SubagentService.listChildren', () => { data: descriptorPayload('twice again'), } as SessionEvent) events[4] = { ...events[4]!, seq: 4 } - const corrupt = await authorChild(ctx, '00000000-0000-4000-8000-00000000dupe', { + const doubled = await authorChild(ctx, '00000000-0000-4000-8000-00000000dupe', { parentSession: parent.id, origin: 'subagent', }, events) + // The last-wins projection fold serves the final descriptor's identity; a + // repeated descriptor is not a per-child corruption diagnostic. const entries = await ctx.subagents.listChildren(parent.id) - expect(entries).toContainEqual({ kind: 'diagnostic', id: corrupt, reason: 'corrupt' }) + expect(entries).toContainEqual({ + kind: 'child', id: doubled, label: 'twice again', mode: 'continuable', + activity: 'inactive', hasChildren: false, + }) expect(entries).toContainEqual({ kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable', activity: 'inactive', hasChildren: false, }) }) - it('diagnoses a child rejected by persisted Session preparation as corrupt', async () => { + it('maps a child rejected by persistence inspection to unavailable', async () => { const { ctx, parent } = await setup([]) - // The surface-eligible user/message lacks its required surfaceOp. The - // first-party persistence inspection rejects before session-query can fold it. + // The surface-eligible user/message lacks its required surfaceOp, so the + // first-party inspection rejects before any projection fold can run. const invalid = await authorChild(ctx, '00000000-0000-4000-8000-0000000000ee', { parentSession: parent.id, origin: 'subagent', @@ -297,7 +302,7 @@ describe('SubagentService.listChildren', () => { { type: 'subagent/descriptor', seq: 2, time: 3, data: descriptorPayload('broken surface') }, ] as SessionEvent[]) const entries = await ctx.subagents.listChildren(parent.id) - expect(entries).toEqual([{ kind: 'diagnostic', id: invalid, reason: 'corrupt' }]) + expect(entries).toEqual([{ kind: 'diagnostic', id: invalid, reason: 'unavailable' }]) }) it('diagnoses a malformed descriptor payload as corrupt', async () => { @@ -310,28 +315,36 @@ describe('SubagentService.listChildren', () => { expect(entries).toEqual([{ kind: 'diagnostic', id: malformed, reason: 'corrupt' }]) }) - it('diagnoses an unknown descriptor version as unsupported', async () => { + it('diagnoses an unknown descriptor version as corrupt', async () => { const { ctx, parent } = await setup([]) const future = await authorChild(ctx, '00000000-0000-4000-8000-0000000000aa', { parentSession: parent.id, origin: 'subagent', }, childEvents(descriptorPayload('from the future', SUBAGENT_DESCRIPTOR_VERSION + 1))) + // The projection fold does not distinguish an unrecognized version from + // other invalid descriptors: both serve no identity, and a settled + // no-value candidate is corrupt. const entries = await ctx.subagents.listChildren(parent.id) - expect(entries).toEqual([{ kind: 'diagnostic', id: future, reason: 'unsupported' }]) + expect(entries).toEqual([{ kind: 'diagnostic', id: future, reason: 'corrupt' }]) }) - it('ignores an ancestor descriptor replayed inside a fork seed', async () => { + it('lists a fork whose seed replays an ancestor descriptor under that identity', async () => { const { ctx, parent } = await setup([]) - // A fork child whose seed replays a parent log containing a descriptor: - // the seed's descriptor is the ANCESTOR's, not this child's. + // The last-wins fold serves a seed-replayed ancestor descriptor until the + // child's own descriptor overrides it (known deviation #1 in the design). const seed = childEvents(descriptorPayload('ancestor label')) - await authorChild(ctx, '00000000-0000-4000-8000-0000000000f0', { + const forkChild = await authorChild(ctx, '00000000-0000-4000-8000-0000000000f0', { parentSession: parent.id, seedLength: seed.length, origin: 'subagent', }, seed) const entries = await ctx.subagents.listChildren(parent.id) - expect(entries).toEqual([]) + expect(entries).toEqual([ + { + kind: 'child', id: forkChild, label: 'ancestor label', mode: 'continuable', + activity: 'inactive', hasChildren: false, + }, + ]) }) it('does not filter by provider availability: children of unmounted providers stay listed', async () => { @@ -354,103 +367,35 @@ describe('SubagentService.listChildren', () => { ]) }) - it('maps a per-child read failure to one unavailable diagnostic after a successful trace', async () => { + it('maps a failed cold inspection to one unavailable diagnostic and retries it next listing', async () => { const { ctx, parent } = await setup([textResponse('done')]) - const childId = await startChild(ctx, parent, 'flaky storage') - const query = ctx.get('sessionQuery')! - const originalListEvents = query.listEvents.bind(query) - query.listEvents = (sessionId) => { - if (sessionId === childId) { - return Promise.reject(new SessionQueryError('backend read failed', 'SESSION_QUERY_PERSISTENCE_FAILED')) + const healthy = await startChild(ctx, parent, 'healthy sibling') + const flaky = await authorChild(ctx, '00000000-0000-4000-8000-00000000f1a7', { + parentSession: parent.id, + origin: 'subagent', + }, childEvents(descriptorPayload('flaky storage'))) + const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence) + ctx.sessionPersistence.inspect = (sessionId, signal) => { + if (sessionId === flaky) { + return Promise.reject(new Error('backend read failed')) } - return originalListEvents(sessionId) + return original(sessionId, signal) } - const entries = await ctx.subagents.listChildren(parent.id) - expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }]) - }) - - it.each([ - ['session', 'SESSION_QUERY_SESSION_NOT_FOUND'], - ['descriptor event', 'SESSION_QUERY_EVENT_NOT_FOUND'], - ] as const)('maps a missing child %s to unavailable', async (_target, code) => { - const { ctx, parent } = await setup([textResponse('done')]) - const childId = await startChild(ctx, parent, 'vanishing child') - const query = ctx.get('sessionQuery')! - query.listEvents = () => - Promise.reject(new SessionQueryError('gone', code)) - const entries = await ctx.subagents.listChildren(parent.id) - expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }]) - }) - - it('maps an invalid child surface to corrupt', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - const childId = await startChild(ctx, parent, 'invalid surface') - const query = ctx.get('sessionQuery')! - query.listEvents = () => - Promise.reject(new SessionQueryError('invalid surface', 'SESSION_QUERY_INVALID_SURFACE')) - - const entries = await ctx.subagents.listChildren(parent.id) - expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }]) - }) - - it('diagnoses a read whose header no longer names this parent as corrupt', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - const childId = await startChild(ctx, parent, 'reparented child') - const query = ctx.get('sessionQuery')! - const originalReadEvent = query.readEvent.bind(query) - query.readEvent = async (request) => { - const window = await originalReadEvent(request) - return { - ...window, - session: { ...window.session, parentSession: SessionId('someone-else') }, - } - } - const entries = await ctx.subagents.listChildren(parent.id) - // The exact read's conflicting immutable header is per-child corruption. - expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }]) - }) - - it('diagnoses a read whose target is no longer the descriptor event as corrupt', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - const childId = await startChild(ctx, parent, 'shifted log') - const query = ctx.get('sessionQuery')! - const originalReadEvent = query.readEvent.bind(query) - query.readEvent = async (request) => { - const window = await originalReadEvent(request) - return { ...window, target: { ...window.target, type: 'turn/start' } as typeof window.target } - } - const entries = await ctx.subagents.listChildren(parent.id) - expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }]) - }) - - it('fails the whole call when the initial trace fails', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - await startChild(ctx, parent, 'never listed') - const query = ctx.get('sessionQuery')! - query.traceSession = () => - Promise.reject(new SessionQueryError('listing failed', 'SESSION_QUERY_PERSISTENCE_FAILED')) - await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow( - expect.objectContaining({ code: 'SESSION_QUERY_PERSISTENCE_FAILED' }) as Error, - ) - }) - - it('propagates an unrecognized per-child failure as an operation failure', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - await startChild(ctx, parent, 'strange failure') - const query = ctx.get('sessionQuery')! - query.listEvents = () => Promise.reject(new Error('not a query failure')) - await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow('not a query failure') - }) - - it('propagates a configuration/window query failure instead of diagnosing the child', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - await startChild(ctx, parent, 'misconfigured query') - const query = ctx.get('sessionQuery')! - query.listEvents = () => - Promise.reject(new SessionQueryError('bad window', 'SESSION_QUERY_INVALID_WINDOW')) - await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow( - expect.objectContaining({ code: 'SESSION_QUERY_INVALID_WINDOW' }) as Error, - ) + // Per-child isolation: the failed child degrades to one diagnostic while + // the healthy sibling stays complete. + const degraded = await ctx.subagents.listChildren(parent.id) + expect(degraded).toContainEqual({ kind: 'diagnostic', id: flaky, reason: 'unavailable' }) + expect(degraded).toContainEqual({ + kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable', + activity: 'inactive', hasChildren: false, + }) + // Nothing is memoized: with the backend healthy again, the next listing + // folds the same child to its identity. + ctx.sessionPersistence.inspect = original + await expect(ctx.subagents.listChildren(parent.id)).resolves.toContainEqual({ + kind: 'child', id: flaky, label: 'flaky storage', mode: 'continuable', + activity: 'inactive', hasChildren: false, + }) }) it('lists compacted and uncompacted children identically', async () => { @@ -492,19 +437,18 @@ describe('SubagentService.listChildren', () => { ]) }) - it('reports an origin-classified grandchild without reading its events', async () => { + it('reports an origin-classified grandchild without inspecting it', async () => { const { ctx, parent } = await setup([textResponse('done')]) const childId = await startChild(ctx, parent, 'direct child') const grandchildId = await authorChild(ctx, '00000000-0000-4000-8000-0000000000cc', { parentSession: childId, origin: 'subagent', }, childEvents(descriptorPayload('grandchild'))) - const query = ctx.get('sessionQuery')! - const originalListEvents = query.listEvents.bind(query) const inspected: SessionId[] = [] - query.listEvents = (sessionId) => { + const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence) + ctx.sessionPersistence.inspect = (sessionId, signal) => { inspected.push(sessionId) - return originalListEvents(sessionId) + return original(sessionId, signal) } const entries = await ctx.subagents.listChildren(parent.id) expect(entries).toEqual([ @@ -513,6 +457,7 @@ describe('SubagentService.listChildren', () => { activity: 'inactive', hasChildren: true, }, ]) + // The grandchild contributes only its header to the hasChildren hint. expect(inspected).toContain(childId) expect(inspected).not.toContain(grandchildId) }) @@ -550,115 +495,92 @@ describe('SubagentService.listChildren', () => { }]) }) - it('stops the scan at the between-candidates checkpoint when the signal aborts', async () => { - const { ctx, parent } = await setup([textResponse('one'), textResponse('two')]) - await startChild(ctx, parent, 'first child') - await startChild(ctx, parent, 'second child') - const controller = new AbortController() - const query = ctx.get('sessionQuery')! - const originalListEvents = query.listEvents.bind(query) - let inspected = 0 - query.listEvents = (sessionId) => { - inspected += 1 - // Cancel while the first candidate's read is in flight: the loop's next - // between-candidates checkpoint must stop before the second read. - controller.abort() - return originalListEvents(sessionId) - } - await expect(ctx.subagents.listChildren(parent.id, controller.signal)).rejects.toThrow( - expect.objectContaining({ code: 'CANCELLED' }) as Error, - ) - expect(inspected).toBe(1) - }) - - it('forwards cancellation to the initial trace and reports the stable subagent error', async () => { + it('a pre-aborted signal stops before any persistence read', async () => { const { ctx, parent } = await setup([]) const controller = new AbortController() - const query = ctx.get('sessionQuery')! - const entered = Promise.withResolvers() - query.traceSession = (_sessionId, signal) => { - entered.resolve(undefined) - return new Promise((_resolve, reject) => { - signal?.addEventListener('abort', () => { - reject(new Error('query trace aborted')) - }, { once: true }) - }) - } - const listing = ctx.subagents.listChildren(parent.id, controller.signal) - await entered.promise controller.abort() - await expect(listing).rejects.toThrow( - expect.objectContaining({ code: 'CANCELLED' }) as Error, - ) - }) - - it('forwards cancellation to the exact descriptor read and reports the stable subagent error', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - await startChild(ctx, parent, 'cancelled exact read') - const controller = new AbortController() - const query = ctx.get('sessionQuery')! - const entered = Promise.withResolvers() - query.readEvent = (_request, signal) => { - entered.resolve(undefined) - return new Promise((_resolve, reject) => { - signal?.addEventListener('abort', () => { - reject(new Error('query read aborted')) - }, { once: true }) - }) - } - const listing = ctx.subagents.listChildren(parent.id, controller.signal) - await entered.promise - controller.abort() - await expect(listing).rejects.toThrow( - expect.objectContaining({ code: 'CANCELLED' }) as Error, - ) - }) - - it('stops after a per-child read when the signal aborts mid-inspection', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - await startChild(ctx, parent, 'cancelled mid-read') - const controller = new AbortController() - const query = ctx.get('sessionQuery')! - const originalReadEvent = query.readEvent.bind(query) - let exactReads = 0 - query.readEvent = async (request) => { - exactReads += 1 - const window = await originalReadEvent(request) - controller.abort() - return window - } - // The post-read checkpoint throws a subagent error, which is not a - // session-query failure and therefore propagates instead of becoming a - // per-child diagnostic. - await expect(ctx.subagents.listChildren(parent.id, controller.signal)) - .rejects.toThrow(expect.objectContaining({ code: 'CANCELLED' }) as Error) - expect(exactReads).toBe(1) - }) - - it('a mapped per-child failure during an abort cannot become a successful result', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - await startChild(ctx, parent, 'aborted behind a diagnostic') - const controller = new AbortController() - const query = ctx.get('sessionQuery')! - query.listEvents = () => { - // The read fails with a diagnostic-mapped code while the caller aborts: - // cancellation normalization must fail the scan rather than return a - // one-diagnostic success. - controller.abort() - return Promise.reject(new SessionQueryError('backend read failed', 'SESSION_QUERY_PERSISTENCE_FAILED')) - } + ctx.sessionPersistence.list = () => Promise.reject(new Error('must not be called')) await expect(ctx.subagents.listChildren(parent.id, controller.signal)).rejects.toThrow( expect.objectContaining({ code: 'CANCELLED' }) as Error, ) }) - it('a pre-aborted signal stops before any candidate read', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - await startChild(ctx, parent, 'never read') + it('forwards cancellation to the persisted listing and reports the stable subagent error', async () => { + const { ctx, parent } = await setup([]) const controller = new AbortController() + const entered = Promise.withResolvers() + ctx.sessionPersistence.list = (signal) => { + entered.resolve(undefined) + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { + reject(new Error('backend listing aborted')) + }, { once: true }) + }) + } + const listing = ctx.subagents.listChildren(parent.id, controller.signal) + await entered.promise controller.abort() - const query = ctx.get('sessionQuery')! - query.listEvents = () => Promise.reject(new Error('must not be called')) + await expect(listing).rejects.toThrow( + expect.objectContaining({ code: 'CANCELLED' }) as Error, + ) + }) + + it('forwards cancellation to a cold inspection and reports the stable subagent error', async () => { + const { ctx, parent } = await setup([]) + await authorChild(ctx, '00000000-0000-4000-8000-00000000ce11', { + parentSession: parent.id, + origin: 'subagent', + }, childEvents(descriptorPayload('cancelled cold read'))) + const controller = new AbortController() + const entered = Promise.withResolvers() + ctx.sessionPersistence.inspect = (_sessionId, signal) => { + entered.resolve(undefined) + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { + reject(new Error('backend read aborted')) + }, { once: true }) + }) + } + const listing = ctx.subagents.listChildren(parent.id, controller.signal) + await entered.promise + controller.abort() + await expect(listing).rejects.toThrow( + expect.objectContaining({ code: 'CANCELLED' }) as Error, + ) + }) + + it('an abort observed after a cold inspection resolves cannot become a successful result', async () => { + const { ctx, parent } = await setup([]) + await authorChild(ctx, '00000000-0000-4000-8000-00000000ce12', { + parentSession: parent.id, + origin: 'subagent', + }, childEvents(descriptorPayload('cancelled mid-listing'))) + const controller = new AbortController() + const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence) + ctx.sessionPersistence.inspect = async (sessionId, signal) => { + const result = await original(sessionId, signal) + controller.abort() + return result + } + // The post-read checkpoint throws the stable subagent error instead of + // interpreting the fully-read log as a successful listing. + await expect(ctx.subagents.listChildren(parent.id, controller.signal)) + .rejects.toThrow(expect.objectContaining({ code: 'CANCELLED' }) as Error) + }) + + it('a cold inspection failure during an abort cannot become an unavailable diagnostic', async () => { + const { ctx, parent } = await setup([]) + await authorChild(ctx, '00000000-0000-4000-8000-00000000ce13', { + parentSession: parent.id, + origin: 'subagent', + }, childEvents(descriptorPayload('aborted behind a failure'))) + const controller = new AbortController() + ctx.sessionPersistence.inspect = () => { + // The read fails while the caller aborts: cancellation normalization + // must fail the listing rather than return a one-diagnostic success. + controller.abort() + return Promise.reject(new Error('backend read failed')) + } await expect(ctx.subagents.listChildren(parent.id, controller.signal)).rejects.toThrow( expect.objectContaining({ code: 'CANCELLED' }) as Error, ) @@ -671,9 +593,9 @@ describe('SubagentService.listChildren', () => { }) it('SubagentError from listChildren is typed with its stable code', async () => { - const { ctx, parent } = await setup([], { sessionQuery: false }) + const { ctx, parent } = await setup([], { sessionProjections: false }) const caught: unknown = await ctx.subagents.listChildren(parent.id).catch((error: unknown) => error) expect(caught).toBeInstanceOf(SubagentError) - expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE') + expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') }) }) diff --git a/packages/subagent/subagent/tests/optional-session-query.spec.ts b/packages/subagent/subagent/tests/optional-session-query.spec.ts deleted file mode 100644 index 469087e576..0000000000 --- a/packages/subagent/subagent/tests/optional-session-query.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -describe('@deepseek-ai/dsh-subagent optional session-query peer', () => { - it('loads ordinary subagent operations without evaluating the optional query package', async () => { - vi.doMock('@deepseek-ai/dsh-session-query', () => { - throw new Error('optional session-query runtime was loaded eagerly') - }) - - const subagent = await import('../src/index.ts') - - expect(subagent.SubagentService).toBeTypeOf('function') - }) -}) diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index 612330c646..5bb065571f 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -29,9 +29,6 @@ { "path": "../../session-persistence/session-persistence" }, - { - "path": "../../session-query/session-query" - }, { "path": "../../session-projection/session-projection" }, diff --git a/packages/subagent/tool-subagent-control/README.i18n.yaml b/packages/subagent/tool-subagent-control/README.i18n.yaml index d6bd2d78b9..f26a290f5e 100644 --- a/packages/subagent/tool-subagent-control/README.i18n.yaml +++ b/packages/subagent/tool-subagent-control/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/tool-subagent-control/README.md -README.md: 5d775a524c38750953c6389b9ebdea67a33df7ca -README.zh.md: 3b989fca8b79cea3e3b10bb2e65805e0cee79c69 +README.md: ea95a45b85e01d1f5f1c478a35c80c65151724ac +README.zh.md: 2cc876c8b39caa19fdf30eae7c8def0ba81fe7b1 diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md index 5d775a524c..ea95a45b85 100644 --- a/packages/subagent/tool-subagent-control/README.md +++ b/packages/subagent/tool-subagent-control/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The optional, globally named `send_message` and `list_agents` tools are thin adapters over `ctx.subagents`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers shared control tools once, so multiple delegation tools never register duplicate global controls. The root plugin registers `send_message` and requires only `subagents`; the separately loadable `./list-agents` plugin registers `list_agents`, declares `sessionQuery` as a load-time dependency, and remains inactive until that service is available. A deployment without session query keeps `send_message` and omits the list tool. Neither tool's presence determines whether a delegation tool starts continuable work. These tools own only the parent-to-child direction; the independently installed [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) owns the child-to-parent direction. +The optional, globally named `send_message` and `list_agents` tools are thin adapters over `ctx.subagents`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers shared control tools once, so multiple delegation tools never register duplicate global controls. The root plugin registers `send_message` and the separately loadable `./list-agents` plugin registers `list_agents`; both require only `subagents`, so a deployment can keep `send_message` while omitting the list tool. Neither tool's presence determines whether a delegation tool starts continuable work. These tools own only the parent-to-child direction; the independently installed [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) owns the child-to-parent direction. The tool performs no lifecycle routing — residency and cold resume belong to the subagent service. It passes `exec.agent` as the exact live parent that authorizes delivery and attributes every message as durable provenance `{ kind: 'coordinator', senderSessionId: parent.id }`, which the service retains but never treats as authority. Every message becomes the subagent's next FIFO turn through `Agent.followup()`: if the child is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The tool forwards its execution signal, which owns admission only until inbox acceptance; once the child accepts the message the accepted turn cannot be cancelled through this tool. This call returns no child reply — its transcript by that id is the source of what it did — and a child with `report` sends content on its own initiative as a separate parent message. A delivery failure becomes an errored tool result stating the message was not delivered. diff --git a/packages/subagent/tool-subagent-control/README.zh.md b/packages/subagent/tool-subagent-control/README.zh.md index 3b989fca8b..2cc876c8b3 100644 --- a/packages/subagent/tool-subagent-control/README.zh.md +++ b/packages/subagent/tool-subagent-control/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -可选的全局具名 `send_message` 与 `list_agents` 工具是 `ctx.subagents` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包只注册一次共享控制工具,因此多个委派工具绝不会重复注册全局控制工具。根插件注册 `send_message`,且只要求 `subagents`;可单独加载的 `./list-agents` 插件注册 `list_agents`,将 `sessionQuery` 声明为加载时依赖,并在该服务可用前保持未激活状态。没有会话查询服务的部署可保留 `send_message` 并省略列表工具。是否加载这些工具不会决定委派工具是否启动可继续工作。这些工具只负责父到子的方向;单独安装的 [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) 负责子到父的方向。 +可选的全局具名 `send_message` 与 `list_agents` 工具是 `ctx.subagents` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包只注册一次共享控制工具,因此多个委派工具绝不会重复注册全局控制工具。根插件注册 `send_message`,可单独加载的 `./list-agents` 插件注册 `list_agents`;两者都只要求 `subagents`,部署可保留 `send_message` 而省略列表工具。是否加载这些工具不会决定委派工具是否启动可继续工作。这些工具只负责父到子的方向;单独安装的 [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) 负责子到父的方向。 本工具不执行生命周期路由:驻留与冷恢复归 subagent 服务所有。它将 `exec.agent` 作为授权投递的确切在线父级传入,并把每条消息的来源标记为持久化来源 `{ kind: 'coordinator', senderSessionId: parent.id }`;服务会保留该来源,但绝不将其视为权限。每条消息都会通过 `Agent.followup()` 成为子 agent(智能体)的下一个 FIFO 轮次:如果子 agent 仍在工作,该消息会等待其当前轮次结束,因此无法重定向已经在进行的工作。本工具会转发其执行信号,该信号只在 inbox 接受之前掌管准入;一旦子 agent 接受消息,已接受的轮次便无法再通过本工具取消。本次调用不会返回子 agent 的回复;通过该 id 查看其 transcript(文本记录),才是了解它完成了哪些工作的真源。拥有 `report` 的子 agent 会自行把内容作为一条单独的父级消息发回。投递失败会变为出错的工具结果,并明确说明消息未送达。 diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index f0d57c52aa..3a650db8fa 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -33,16 +33,10 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-query": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, - "peerDependenciesMeta": { - "@deepseek-ai/dsh-session-query": { - "optional": true - } - }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", @@ -52,7 +46,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", - "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/subagent/tool-subagent-control/src/list-agents.ts b/packages/subagent/tool-subagent-control/src/list-agents.ts index 75f9cbe450..bab3fb6f40 100644 --- a/packages/subagent/tool-subagent-control/src/list-agents.ts +++ b/packages/subagent/tool-subagent-control/src/list-agents.ts @@ -1,20 +1,17 @@ /** * The globally named `list_agents` tool: a thin model-facing adapter over - * the continuable projection of `ctx.subagents.listChildren()`. It is - * separately loadable from the - * root `send_message` plugin because it additionally requires the session - * query service — a deployment may use `send_message` without loading session - * query, and this plugin remains inactive until that service is available. + * the continuable projection of `ctx.subagents.listChildren()`. It stays + * separately loadable from the root `send_message` plugin so a deployment + * can register `send_message` without exposing the list tool. * @module @deepseek-ai/dsh-tool-subagent-control/list-agents */ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' -import type {} from '@deepseek-ai/dsh-session-query' import type {} from '@deepseek-ai/dsh-subagent' export const name = 'tool-subagent-list-agents' -export const inject = ['tools', 'subagents', 'sessionQuery'] +export const inject = ['tools', 'subagents'] type ListAgentsEntry = | { @@ -31,7 +28,7 @@ type ListAgentsEntry = /** * Register the `list_agents` tool. - * @param ctx - context carrying the tool registry, subagent service, and session query. + * @param ctx - context carrying the tool registry and subagent service. */ export function apply(ctx: Context): void { ctx.tools.register(defineTool({ diff --git a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts index 9872f73456..217d388fb4 100644 --- a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts @@ -12,7 +12,6 @@ import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentListEntry } from '@deepseek-ai/dsh-subagent' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' -import { TestSessionQueryService } from '../../../session-query/session-query/tests/test-service.ts' import * as tool from '../src/list-agents.ts' const testToolSignal = new AbortController().signal @@ -31,7 +30,6 @@ async function setup(script: ConstructorParameters[0]) { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) - await ctx.plugin(TestSessionQueryService) await ctx.plugin(tool) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) @@ -177,17 +175,16 @@ describe('dsh-tool-subagent-control/list-agents', () => { await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) - await ctx.plugin(TestSessionQueryService) const fiber = await ctx.plugin(tool) expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(true) await fiber.dispose() expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(false) }) - it('has the namespace-plugin export shape and requires sessionQuery at load', () => { + it('has the namespace-plugin export shape', () => { expect('default' in tool).toBe(false) expect(tool.name).toBe('tool-subagent-list-agents') - expect(tool.inject).toEqual(['tools', 'subagents', 'sessionQuery']) + expect(tool.inject).toEqual(['tools', 'subagents']) expect(typeof tool.apply).toBe('function') }) }) diff --git a/packages/subagent/tool-subagent-control/tsconfig.json b/packages/subagent/tool-subagent-control/tsconfig.json index 91eeb707b0..3a57a0437e 100644 --- a/packages/subagent/tool-subagent-control/tsconfig.json +++ b/packages/subagent/tool-subagent-control/tsconfig.json @@ -26,9 +26,6 @@ { "path": "../subagent" }, - { - "path": "../../session-query/session-query" - }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b826046bea..f1703d3ef7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5190,9 +5190,6 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session-projection/session-projection - '@deepseek-ai/dsh-session-query': - specifier: workspace:^ - version: link:../../session-query/session-query '@deepseek-ai/dsh-tasks': specifier: workspace:^ version: link:../../tasks/tasks @@ -5590,9 +5587,6 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl - '@deepseek-ai/dsh-session-query': - specifier: workspace:^ - version: link:../../session-query/session-query '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent From 0b0b9e47070936767a6251b09118d168d3f8cab3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:41:53 +0800 Subject: [PATCH 02/12] fix: address PR #1802 review round - listChildren reads the session store via strict ctx.get (property proxy is caller-scoped), orders candidates branchlessly, narrows the cold-read return type, and pins the cost model and store/registry composition gaps with tests; per-file coverage restored - acp-agent and headless-agent compositions mount session-projection; a keyless snapshot pins the descriptor-less diagnostic row - api-proxy cold spec pins header-origin ownership and the legacy descriptor-only opt-out - design note ships as implemented with its English pairing; companion notes and core-data-structures pages synced --- ...ubagent-list-identity-projection.i18n.yaml | 6 + ...08-06-subagent-list-identity-projection.md | 177 ++++++++++++++++++ ...06-subagent-list-identity-projection.zh.md | 96 ++++------ ...subagent-catalog-and-list-agents.i18n.yaml | 4 +- ...urable-subagent-catalog-and-list-agents.md | 2 + ...ble-subagent-catalog-and-list-agents.zh.md | 2 + ...ssion-projection-and-command-log.i18n.yaml | 4 +- ...7-27-session-projection-and-command-log.md | 4 + ...7-session-projection-and-command-log.zh.md | 4 + docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 8 +- docs/core-data-structures/subagent.zh.md | 8 +- examples/acp-agent/cordis.yml | 6 + examples/headless-agent/cordis.yml | 5 + .../subagent-diagnostic.cordis.snapshot.yml | 44 +++++ .../fixtures/subagent-diagnostic-agent.ts | 26 +++ .../parent.expected.jsonl | 31 +++ .../descriptorless-child/replay.override.json | 1 + .../tests/subagent-diagnostic.snapshot.ts | 119 ++++++++++++ examples/package.json | 1 + .../apiproxy/tests/api-proxy-cold.spec.ts | 42 +++++ packages/subagent/subagent/src/index.ts | 4 +- .../subagent/subagent/src/list-children.ts | 23 ++- packages/subagent/subagent/src/projection.ts | 8 +- .../subagent/tests/list-children.spec.ts | 102 ++++++++-- .../tool-subagent-control/package.json | 1 + .../tests/list-agents.spec.ts | 2 + .../tests/tool-subagent-control.spec.ts | 2 + pnpm-lock.yaml | 6 + 29 files changed, 637 insertions(+), 105 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md rename .agents/notes/{proposed => implemented}/architecture/2026-08-06-subagent-list-identity-projection.zh.md (50%) create mode 100644 examples/headless-agent/subagent-diagnostic.cordis.snapshot.yml create mode 100644 examples/headless-agent/tests/fixtures/subagent-diagnostic-agent.ts create mode 100644 examples/headless-agent/tests/subagent-diagnostic-snapshots/descriptorless-child/parent.expected.jsonl create mode 100644 examples/headless-agent/tests/subagent-diagnostic-snapshots/descriptorless-child/replay.override.json create mode 100644 examples/headless-agent/tests/subagent-diagnostic.snapshot.ts diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml new file mode 100644 index 0000000000..4620ec99c9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.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 .agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md +2026-08-06-subagent-list-identity-projection.md: 6b6ee863bf385e27f4c431f7a1039ea75110565e +2026-08-06-subagent-list-identity-projection.zh.md: 42a578147026ae8d09669d13ada468a4491dcb37 diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md new file mode 100644 index 0000000000..6b6ee863bf --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md @@ -0,0 +1,177 @@ +# Agent Note: Subagent list identity via the projection unit + +Status: implemented + +English | [中文](2026-08-06-subagent-list-identity-projection.zh.md) + +## Problem + +Before the rewrite, `SubagentService.listChildren` ran two full-log materializations — `listEvents` plus `readEvent` — on every listing for each direct child with `header.origin === 'subagent'`, each materialization accompanied by a full-log structuredClone, all to fold two fields, mode and label, out of the descriptor event. The descriptor's position in the log is not fixed — the fork prefix is arbitrarily long, and zstd-compressed frames carry no seq index — so there is no shortcut to locating it; this path had no cache whatsoever, and its cost amplifies with transcript length × child count × listing frequency. It also dragged session-query in as a hard dependency of listing: in a deployment without a query backend, `list_agents` rejects wholesale with `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`, even though enumeration needs nothing but header facts. + +The same root cause has a second symptom: on every Agent-bound RPC's owner check, the host-side `hasSubagentDescriptor()` scans the target session's own suffix, even though `SessionHeader.origin` already answers the vast majority of the same question. + +The root cause is that the [durable-subagent-catalog decision](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) made the descriptor event (`subagent/descriptor`) the catalog's sole durable authority yet paired descriptor reads with no cache layer, and explicitly accepted the per-child double read as the "no-index correctness baseline". [Web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md) (#1569) already put "is this a subagent" into the header (`SessionHeader.origin`), so identity determination no longer reads the log; mode and label still had to be scanned. + +## 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. + +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. +- **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. + +Relationship to existing notes: + +- This note supersedes two designs on the list read path in [durable-subagent-catalog](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md): enumeration through `sessionQuery.traceSession`, and per-child descriptor-event reads (the `listEvents`-plus-exact-`readEvent` double read with in-place diagnostic classification). The diagnostic row semantics is retained, with classification now derived by the list from projection-value absence and activity; the descriptor event remains the sole durable authority for mode/label and the fold input, and the resume authorization and Activation contracts are untouched. This is partial supersession; the two notes stay cross-linked. +- The [session-projection RFC](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md)'s registry contract (`ProjectionDefinition`, `snapshot`, `restore`) is untouched; this note only adds one registration to it — the `subagent` identity unit — and becomes another consumer instance of the two existing reads, snapshot (live) and restore (cold) — GUI history's cold read is already the same shape. The fold rules are registered with the registry exactly once; every consuming surface computes through the registry, and no second copy of the fold logic exists. + +### `subagent` projection unit + +It hangs beside the existing `subagentTiming` ([projection.ts](../../../../packages/subagent/subagent/src/projection.ts), [projection-types.ts](../../../../packages/subagent/subagent/src/projection-types.ts)), under key `subagent`: + +```ts ignore-check +export type SubagentIdentityProjection = + | { mode: 'one-shot'; label?: string } + | { mode: 'continuable'; label: string } + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + subagent: SubagentIdentityProjection + } +} +``` + +- The projection is pure identity, and **the projection system has no failure channel**: a unit never throws; a corrupt payload or an unrecognized version folds exactly like a log with no descriptor at all — the result is "no value", and the key is absent on that session. How "computed to nothing" is presented is the consumer's own business (see the `listChildren` four-state mapping below). +- Label strength is decided by the descriptor schema: a continuable's label is mandatory at parse, a one-shot's was always optional; this discriminant matches the child row's strong mode/label contract below exactly. +- Fold rule: `subagent/descriptor` is last-wins, under the same descriptor-reset discipline as `subagentTiming` — ancestor descriptors in the fork prefix are overridden by the session's own descriptor. A corrupt or unrecognized-version payload is last-wins all the same: it resets to no value rather than keeping the prior identity, so a fork of a healthy ancestor does not inherit an identity its own descriptor cannot stand up. + +### Enumeration: subagent-owned live-preferred merge + +`listChildren`'s ([list-children.ts](../../../../packages/subagent/subagent/src/list-children.ts)) enumeration goes through no query service: the two sources `ctx.sessions.list()` and `ctx.get('sessionPersistence')?.list()` merge by id, with a live record overriding the same-id persisted record wholesale and no header consistency check. Everything enumeration needs is header facts: + +- Filtering: `header.origin === 'subagent' && header.parentSession === parentSessionId`. +- `hasChildren`: the same merged material, looked at one level down — a direct descendant exists with `origin === 'subagent'` whose `parentSession` is that child. +- `activity`: a live record is `running`; one present only in persistence is `inactive`. +- Ordering: `createdAt` ascending, then child id ascending (matching the old contract). +- **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 + +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: + +| Tier | 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 | + +- 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. +- 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. +- 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 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. + +### `listChildren` row shape and consuming surfaces + +The `SubagentListEntry` **data structure is identical to before the rewrite** — the child and diagnostic arms, the `kind` discriminant, the three-valued `reason`, and the child arm's strong mode/label contract are all retained; the only change is the diagnostics' information source: the projection system has no failure channel, so diagnostics are derived by the list from projection-value absence and activity, and the list itself parses zero events. The "no value means await the hard read" rule guarantees the ladder always computes mode/label for healthy data. + +```ts ignore-check +export type SubagentListEntry = + | ({ + readonly kind: 'child' + readonly id: SessionId + readonly activity: 'running' | 'inactive' + readonly hasChildren: boolean + } & ( + | { readonly mode: 'one-shot'; readonly label?: string } + | { readonly mode: 'continuable'; readonly label: string } + )) + | { + readonly kind: 'diagnostic' + readonly id: SessionId + readonly reason: 'corrupt' | 'unsupported' | 'unavailable' + } +``` + +For each enumerated child, the ladder's result maps to a row through four states: + +| Ladder result | Row | +| --- | --- | +| Snapshot carries a `subagent` value | child row | +| Snapshot present, value absent, and the child is **inactive** | diagnostic row, reason `corrupt` (settled debris: a missing, corrupt, or unrecognized-version descriptor, no longer subdivided) | +| Snapshot present, value absent, and the child is **running** | no row (creation window: the descriptor is not yet appended — the same window the old implementation omitted) | +| The cold full read fails | diagnostic row, reason `unavailable` | + +- `unsupported` is no longer produced: the type and the wire enum retain the member under "data structures stay as they are", and this note records it as no longer produced. +- Descriptor-less settled debris moves from the old implementation's omit into the `corrupt` diagnostic — damaged, dead child sessions in the corpus are visible rather than silently vanishing, which is exactly the original motivation for keeping diagnostics. + +Known boundary deviations (deliberately accepted, recorded with this note): + +- A fork child that died in its publication window, with an ancestor descriptor in its seed, gets the ancestor identity from last-wins and wrongly surfaces as a child row; resume still fails against the own-suffix fold authority (`NOT_RESUMABLE`). The old implementation omitted it via `seedLength` filtering; the projection unit cannot see the header, and this debris-grade deviation is accepted (`subagentTiming` has the same kind of pre-existing exposure). +- Multiple descriptors in the own suffix: the old implementation judged corrupt; last-wins now takes the final one (the provider contract guarantees exactly one anyway). +- A live/persisted header conflict: the old implementation made it per-child corrupt; enumeration now prefers live with no consistency check, the conflict goes unnoticed, and the live record forms the row. +- A source-read failure on damaged storage (e.g. a bad surface rejected by the cold full read): the old implementation mapped it to per-child `corrupt`; it is now uniformly an `unavailable` row (the read side cannot tell the causes apart). + +Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entirely as it was, zero changes** (the `list_agents` description and output schema are untouched; the plugin only narrows its load requirement — `sessionQuery` dropped from inject). The only behavioral change is the apiproxy route segment: the `hasSubagentDescriptor()` scan is deleted and `hasSubagentOwner` looks only at `header.origin` — pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and the pre-release stance accepts this. + +### Change footprint + +| 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` | +| 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 | +| core/session, session-persistence, session-projection(-cache), session-query(-sqlite) | — | **Zero changes** | + +## Alternatives considered + +**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. + +**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. + +**Optional mode/label on list rows (one v4 draft).** Healthy data is always computable; optionality merely spills garbage-data handling complexity onto every consumer — each consuming surface has to grow filter branches and an unknown display state. The strong contract plus omit-when-uncomputable is cleaner. + +**Deleting diagnostic rows outright (one v5 draft).** Deletion turns corpus-corruption visibility into rows silently vanishing, and wire/tool/GUI would each have to absorb contract and snapshot changes; retention only asks the list side to derive the classification from projection-value absence and activity, at zero cost. That damaged, dead child sessions in the corpus must be visible is the original motivation for diagnostics' existence, and with retention the consuming surfaces stay wholly unchanged. + +**A registry computation failure channel (per-unit fault tolerance plus a supplementary `failures` field).** To report corruption and unrecognized versions to consumers, we once considered having the registry catch unit exceptions and attach a per-key failure state beside the snapshot. Rejected: a failure is not a value and needs no channel — a unit never throws, absence is itself the signal, worst case the computation comes back empty, and how that is presented is the consumer's problem. The discussion of this route left one independent observation behind: the vendored Cordis `emit` ([vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts)) catches nothing a listener throws, so with the projection driver hanging off `session/event`, a unit exception would escape along emit — which adds weight to the "a unit never throws" discipline, but fixing emit fault tolerance is outside this note's scope. + +**Values landed with query index preparation (the v4/v5 settled design, built for a time).** Projection values folded into session index rows during the sqlite backend's reconciliation rebuild, for zero log reads in the steady read state; the `projectionsFor` bulk read face, the invalidation reconciliation of row values stored against the `(key → stateVersion)` registration set, and the SCHEMA bump were all actually built. Retired wholesale: the direction was backwards — query infrastructure was forced to learn domain vocabulary (projection columns, registration-set reconciliation) while the sole consumer, the subagent list, is satisfied by read-time computation; with consumers down to zero, this derived persistence has no reason to exist. `SESSION_QUERY_PROJECTIONS_UNAVAILABLE` was deleted along with the read face. + +**Subagent hand-rolled parsing plus an in-process memo plus creation seeding (v6 draft).** To excise the session-query dependency, we once considered the subagent package parsing descriptor events itself, avoiding repeated full reads with an in-process memo, and seeding initial values at creation. Superseded by the v7 ladder: live goes through the `sessionProjections` watermark cache and cold through `registry.restore`, reusing the registry's single fold authority — no second copy of descriptor-interpretation logic appears, and no process-state cache or seeding ordering is introduced. + +**DeepReadonly on the session-query output surface (a read-path overhaul experiment).** Make the public query outputs deeply readonly to pin immutable borrowing at the type level. Rejected on evidence: 3 TS2589 occurrences (excessively deep type instantiation) plus 17 sites of array-position contagion (consumers' array methods and spread sites forced to follow); deep immutability is guaranteed by core/session's runtime deep freeze, and that read-path overhaul is not part of this note. + +## 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. 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. +- 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. +- 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 semantics leaves four boundary deviations (a stillborn fork surfacing under its ancestor's identity, multiple descriptors resolving to the last, header conflicts going unnoticed, and damaged-source read failures shifting from `corrupt` to `unavailable`); the full semantics is in the known-boundary-deviations list; all are display or classification deviations on debris-grade data, and resume authorization is unaffected. +- 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. + +## Related + +- [Durable subagent catalog and list_agents](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) — partially superseded by this note: the descriptor remains the durable authority for mode/label and the fold input, while the list's enumeration and value retrieval move to the subagent-owned merge plus the projection ladder. +- [Session projections and command lifecycle logging](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) — the authority for the registry contract; this note adds the `subagent` identity unit to it and becomes a consumer instance of the two existing reads, snapshot and restore. +- [Web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md) — the origin of `SessionHeader.origin` (#1569), the first half of taking identity determination off the log; its history cold read (inspect prefix plus registry fold) is the same-shape precedent for this note's value ladder. +- [Reusable Session preparation before publication](2026-08-05-session-preparation.md) — the `inspect()` cold read and LRU reuse; the cold child's full-read cost model builds on it. diff --git a/.agents/notes/proposed/architecture/2026-08-06-subagent-list-identity-projection.zh.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md similarity index 50% rename from .agents/notes/proposed/architecture/2026-08-06-subagent-list-identity-projection.zh.md rename to .agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md index c4239ca903..42a5781470 100644 --- a/.agents/notes/proposed/architecture/2026-08-06-subagent-list-identity-projection.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md @@ -1,35 +1,34 @@ # Agent Note: subagent 列表经投影单元读取身份 -Status: proposed +Status: implemented [English](2026-08-06-subagent-list-identity-projection.md) | 中文 ## 问题 -`SubagentService.listChildren`([list-children.ts](../../../../packages/subagent/subagent/src/list-children.ts))对每个 `header.origin === 'subagent'` 的直接 child,每次列表都执行 `listEvents` 加 `readEvent` 两次整日志物化,且每次物化都伴随整日志 structuredClone,只为从描述符事件里折出 mode 与 label 两个字段。描述符在日志中的位置不固定——fork 前缀任意长,zstd 压缩帧没有 seq 索引——因此定位没有捷径;这条路径没有任何缓存,代价随 transcript 长度 × child 数量 × 列表频率放大。它还把 session-query 拉成列表的硬依赖:没有 query backend 的部署,`list_agents` 以 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 整体拒绝,尽管枚举所需只是 header 事实。 +重写前的 `SubagentService.listChildren` 对每个 `header.origin === 'subagent'` 的直接 child,每次列表都执行 `listEvents` 加 `readEvent` 两次整日志物化,且每次物化都伴随整日志 structuredClone,只为从描述符事件里折出 mode 与 label 两个字段。描述符在日志中的位置不固定——fork 前缀任意长,zstd 压缩帧没有 seq 索引——因此定位没有捷径;这条路径没有任何缓存,代价随 transcript 长度 × child 数量 × 列表频率放大。它还把 session-query 拉成列表的硬依赖:没有 query backend 的部署,`list_agents` 以 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 整体拒绝,尽管枚举所需只是 header 事实。 -同一根因还有第二个症状:host 侧的 `hasSubagentDescriptor()`([api-proxy.ts](../../../../packages/host/apiproxy/src/api-proxy.ts))在每次 Agent 绑定 RPC 的属主判定上扫描目标会话的 own suffix,即便 `SessionHeader.origin` 已经回答了同一个问题的绝大部分。 +同一根因还有第二个症状:host 侧的 `hasSubagentDescriptor()` 在每次 Agent 绑定 RPC 的属主判定上扫描目标会话的 own suffix,即便 `SessionHeader.origin` 已经回答了同一个问题的绝大部分。 -根因在于 [durable-subagent-catalog 决策](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)把描述符事件(`subagent/descriptor`)定为目录的唯一持久权威,却没有为描述符读取配任何缓存层,并把逐 child 双读明确接受为"无索引的正确性基线"。[web subagent conversations](../../implemented/feature/2026-07-27-web-subagent-conversations.md)(#1569)已把"是不是 subagent"放进了 header(`SessionHeader.origin`),身份判定不再读日志;mode 与 label 仍然要扫。 +根因在于 [durable-subagent-catalog 决策](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)把描述符事件(`subagent/descriptor`)定为目录的唯一持久权威,却没有为描述符读取配任何缓存层,并把逐 child 双读明确接受为"无索引的正确性基线"。[web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md)(#1569)已把"是不是 subagent"放进了 header(`SessionHeader.origin`),身份判定不再读日志;mode 与 label 仍然要扫。 -## 提案 +## 决策 -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/cold 两级"算完即止"阶梯:live child 同步读注册表的既有水位缓存(零日志读),cold child 一次 `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 的部署照常列表。 +- **subagent 列表不依赖 session-query**:枚举由 subagent 自管的 live-preferred 合并完成,mode/label 经 `ctx.sessionProjections` 取值;没有 query backend 的部署照常列表。 - **取值两级"算完即止"阶梯**:live child 读 `sessionProjections.snapshot()`(注册表既有水位缓存,零日志读);cold child 一次 `persistence.inspect` 整读加 `registry.restore({}, events, 0)` 折叠;再没有就没有——无缓存、无回写、无索引。 - **`subagent` projection unit 是折叠规则唯一权威**:live snapshot、cold restore、GUI history 的 detached 折叠全部经 registry 计算,不存在第二份描述符解释逻辑。 -- **session-query 的净变化只剩读路径去 clone 加浅 readonly 借用视图**(附带工作项;DeepReadonly 被实证否决,见替代方案)。 -- **header、描述符(v2)、session-persistence、session-projection(-cache)、session-query-sqlite 全部零改动**;存量数据第一次被列表时一次 `inspect` 现算获得精确值,无 unknown 降级态、无迁移。 +- **header、描述符(v2)、session-persistence、session-projection(-cache)、session-query(-sqlite) 全部零改动**;存量数据第一次被列表时一次 `inspect` 现算获得精确值,无 unknown 降级态、无迁移。 与既有记录的关系: -- 本记录取代 [durable-subagent-catalog](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) 中列表读路径的两项设计:经 `sessionQuery.traceSession` 枚举,与逐 child 读取描述符事件(`listEvents` 加精确 `readEvent` 双读、就地诊断分类)。diagnostic 行语义保留,分类改由列表按投影值缺席与 activity 派生;描述符事件仍是 mode/label 的唯一持久权威与折叠输入,恢复鉴权与激活契约不动。属部分取代,两记录保持交叉链接。 -- [session-projection RFC](2026-07-27-session-projection-and-command-log.md) 的 registry 契约(`ProjectionDefinition`、`snapshot`、`restore`)零改动,本记录只为其新增 `subagent` 身份 unit 一个注册项,并成为 snapshot(live)与 restore(cold)两处既有读法的又一消费实例——GUI history 的冷读已是同款。折叠规则只在 registry 注册一份;任何消费面都经 registry 计算,不存在第二份折叠逻辑。 +- 本记录取代 [durable-subagent-catalog](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) 中列表读路径的两项设计:经 `sessionQuery.traceSession` 枚举,与逐 child 读取描述符事件(`listEvents` 加精确 `readEvent` 双读、就地诊断分类)。diagnostic 行语义保留,分类改由列表按投影值缺席与 activity 派生;描述符事件仍是 mode/label 的唯一持久权威与折叠输入,恢复鉴权与激活契约不动。属部分取代,两记录保持交叉链接。 +- [session-projection RFC](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) 的 registry 契约(`ProjectionDefinition`、`snapshot`、`restore`)零改动,本记录只为其新增 `subagent` 身份 unit 一个注册项,并成为 snapshot(live)与 restore(cold)两处既有读法的又一消费实例——GUI history 的冷读已是同款。折叠规则只在 registry 注册一份;任何消费面都经 registry 计算,不存在第二份折叠逻辑。 ### `subagent` projection unit @@ -49,17 +48,18 @@ declare module '@deepseek-ai/dsh-session-projection/types' { - 投影是纯身份,**projection 体系不做失败通道**:unit 永不抛错;载荷损坏、版本不认识与整日志没有描述符一样,折叠结果就是"无值",该 key 在这个 session 上缺席。"算出来没有"如何呈现是消费方自己的事(见下文 `listChildren` 四态映射)。 - label 强度由描述符 schema 决定:continuable 的 label 解析强制必有,one-shot 的本就可选;该判别式与下文 child 行的 mode/label 强契约完全一致。 -- 折叠规则:`subagent/descriptor` last-wins,与 `subagentTiming` 同一条 descriptor-reset 纪律——fork 前缀里的祖先描述符被自身描述符覆盖。 +- 折叠规则:`subagent/descriptor` last-wins,与 `subagentTiming` 同一条 descriptor-reset 纪律——fork 前缀里的祖先描述符被自身描述符覆盖。损坏或版本不认识的载荷同样 last-wins:重置为无值而非保留先前身份,健康祖先的 fork 不会继承自身描述符立不住的身份。 ### 枚举:subagent 自管 live-preferred 合并 -`listChildren` 的枚举不再经任何查询服务:`ctx.sessions.list()` 与 `ctx.get('sessionPersistence')?.list()` 两个来源按 id 合并,live 优先、不做一致性校验。枚举所需全部是 header 事实: +`listChildren`([list-children.ts](../../../../packages/subagent/subagent/src/list-children.ts))的枚举不经任何查询服务:`ctx.sessions.list()` 与 `ctx.get('sessionPersistence')?.list()` 两个来源按 id 合并,live 记录整条覆盖同 id 持久化记录、不做 header 一致性校验。枚举所需全部是 header 事实: - 过滤:`header.origin === 'subagent' && header.parentSession === parentSessionId`。 - `hasChildren`:同一份合并材料向下看一层——存在 `origin === 'subagent'` 且 `parentSession` 为该 child 的直接后代。 - `activity`:live 记录为 `running`,仅存在于持久化的为 `inactive`。 - 排序:`createdAt` 升序、再按 child id 升序(与旧契约一致)。 - **persistence 缺席退为 live-only 枚举,不报错**:没有 persistence 的部署,cold child 本就无法 resume,列出 live child 仍然有意义。(对照:旧实现在 sessionQuery 缺失时整体拒绝。) +- persistence 列表失败使整次枚举失败;per-child 隔离只作用于逐 child 的冷读。 ### 取值:两级"算完即止"阶梯 @@ -70,9 +70,11 @@ declare module '@deepseek-ai/dsh-session-projection/types' { | live child | `ctx.sessionProjections.snapshot(session).values.subagent` | 零日志读——注册表既有水位缓存,同步取值 | | 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 依赖一并删除。 -- per-child 隔离:单 child 的 cold 整读失败只使该行成为 `unavailable` diagnostic,不影响 sibling(见四态映射)。 -- 冷读成本如实记录:cold child 每次列表一次整读,成本与其 transcript 大小成正比;定案"算完即止",不为它建缓存。整读经 `inspect()` 走 [Session 准备阶段](../../implemented/architecture/2026-08-05-session-preparation.md)的冷读,同 id 短期重复读取可命中其 LRU 复用,但列表不依赖此。live child 全程零日志读。 +- 错误契约:`ctx.sessionProjections` 未挂载是配置错误,`listChildren` 在枚举前无条件检查并以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败——零 children 的部署同样确定失败,不因列表恰好为空而掩盖配置问题。`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 已随 session-query 依赖删除。 +- 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 全程零日志读。 +- 取消:每次 persistence 读前后检查调用方 signal,abort 之后才结算的读拒绝归一化为稳定错误码 `CANCELLED`。 ### 权威模型 @@ -82,7 +84,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { ### `listChildren` 行形状与消费面 -`SubagentListEntry` **数据结构与今天完全一致**——child 与 diagnostic 两臂、`kind` 判别、reason 三值、child 臂的 mode/label 强契约全部保留;变化只在诊断的信息来源:投影体系没有失败通道,diagnostic 由列表按投影值缺席与 activity 派生,列表本身仍零事件读取。"没有就等待硬读取"继续保证阶梯对健康数据必然算得出 mode/label。 +`SubagentListEntry` **数据结构与重写前完全一致**——child 与 diagnostic 两臂、`kind` 判别、reason 三值、child 臂的 mode/label 强契约全部保留;变化只在诊断的信息来源:投影体系没有失败通道,diagnostic 由列表按投影值缺席与 activity 派生,列表本身零事件解析。"没有就等待硬读取"保证阶梯对健康数据必然算得出 mode/label。 ```ts ignore-check export type SubagentListEntry = @@ -102,8 +104,6 @@ export type SubagentListEntry = } ``` -实现形态:`listChildren` = 自管枚举(id、activity、hasChildren、`origin` 过滤,全部来自 header 事实)+ 投影阶梯(mode/label)。逐 child 的 `listEvents`、精确 `readEvent`、描述符定位与就地分类机器整体删除。 - 对每个枚举出的 child,阶梯取值结果按四态映射成行: | 阶梯取值结果 | 行 | @@ -123,35 +123,18 @@ export type SubagentListEntry = - live/persisted header 冲突,旧实现是 per-child corrupt;现枚举 live 优先、不做一致性校验,冲突不再被察觉,以 live 记录成行。 - 损坏存储的源读失败(如坏 surface 被冷读整读拒收),旧实现映射 per-child `corrupt`,现统一成 `unavailable` 行(读侧无从区分成因)。 -消费面:wire、tool、GUI 的 diagnostic 处理**全部保持现状零改动**(`list_agents` 的 description 与 output schema 亦不动;该插件仅加载要求收窄——inject 去掉 `sessionQuery`)。唯一动行为的是 apiproxy 路由段:删 `hasSubagentDescriptor()` 扫描,`hasSubagentOwner` 只看 `header.origin`——pre-#1569 的无 `origin` 存量不再被认作 subagent 属主,其本就不进目录,pre-release 立场接受。 +消费面:wire、tool、GUI 的 diagnostic 处理**全部保持原状零改动**(`list_agents` 的 description 与 output schema 未动;该插件仅加载要求收窄——inject 去掉 `sessionQuery`)。行为上唯一动的是 apiproxy 路由段:`hasSubagentDescriptor()` 扫描已删除,`hasSubagentOwner` 只看 `header.origin`——pre-#1569 的无 `origin` 存量不再被认作 subagent 属主,其本就不进目录,pre-release 立场接受。 -### 附带工作项:session-query 读路去 clone 与浅 readonly - -- `SessionCorpus.load()`、`snapshotLive`、`listSessions` 等移除 structuredClone:live Session 的事件快照数组与事件载荷已深冻结(core/session 的 `deepFreeze` 加 `Object.freeze`),持久化读出的对象图为独占新建,克隆纯属浪费。 -- 公开查询输出标注**浅 readonly**(顶层属性与数组位);深只读化被实证否决(见替代方案),深层不可变由 core/session 的运行时深冻结事实保证,类型层面不再表达,`DeepReadonly` 不进任何公共包。 -- 契约措辞与 `projectMany` 的借用契约("borrowed only for that call")对齐:整个 corpus 面向消费方统一为"只读视图,不得留存可变引用"的不可变借用视图;需要留存的自行克隆。 - -### 改动面清单 +### 改动落点 | 区域 | 文件 | 改动 | | --- | --- | --- | | 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` | -| session-query | index.ts、corpus.ts | 读路径去 clone,公开输出浅 readonly 借用视图(净变化仅此) | | 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 处理不变 | -| core/session、session-persistence、session-projection(-cache)、session-query-sqlite | — | **零改动** | -| 测试/快照 | 相关 spec 与 snapshot | 随行为更新,提 PR 前统一处理 | - -### 推进节奏 - -1. `subagent` projection unit 与注册(纯增量)。 -2. session-query:corpus 去 clone 与浅 readonly 借用视图。 -3. `listChildren` 重写(自管枚举 + 投影阶梯);tool 加载要求收窄;apiproxy 路由段 `hasSubagentDescriptor` 删除。 -4. 测试与快照统一更新,整体 diff 评审后再拆 commit。 - -配套文档随实现 PR 处理:[session-projection RFC](2026-07-27-session-projection-and-command-log.md) 增补一节,记录 `subagent` 身份 unit 与 snapshot/restore 两处既有读法的消费实例(registry 契约零改动);[durable-subagent-catalog 记录](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)的列表读路径段落随实现更新并与本记录交叉链接。 +| core/session、session-persistence、session-projection(-cache)、session-query(-sqlite) | — | **零改动** | ## 考虑过的替代方案 @@ -171,29 +154,24 @@ export type SubagentListEntry = **subagent 手工 parse 加进程 memo 加创建播种(v6 稿)。** 为摘除 session-query 依赖,曾考虑 subagent 自己解析描述符事件、以进程内 memo 避免重复整读、创建时播种初值。被 v7 阶梯取代:live 走 `sessionProjections` 水位缓存、cold 走 `registry.restore`,复用 registry 这一份折叠权威,不再出现第二份描述符解释逻辑,也不引入进程态缓存与播种时序。 -**session-query 输出面 DeepReadonly(去 clone 一稿)。** 公开查询输出深只读化,以在类型层面钉死不可变借用。实证否决:3 处 TS2589(类型实例化过深)加 17 处数组位传染(消费方数组方法与展开处被迫跟改);退回浅 readonly,深层不可变由 core/session 的运行时深冻结保证。 +**session-query 输出面 DeepReadonly(读路径改造实验)。** 公开查询输出深只读化,以在类型层面钉死不可变借用。实证否决:3 处 TS2589(类型实例化过深)加 17 处数组位传染(消费方数组方法与展开处被迫跟改);深层不可变由 core/session 的运行时深冻结保证,该读路径改造未纳入本记录。 -## 验收标准 +## 验证 -- 稳态列表读代价:live child 全程零 events 读取(仅注册表水位缓存);cold child 每次 `listChildren` 恰一次 `persistence.inspect` 整读;由 subagent 测试断言。 -- 行为等价:同一语料下,新实现产出与旧实现相同的行集合(child 行的 id、mode、label、activity、hasChildren 与 diagnostic 行的 id、reason),例外仅限本记录留档的语义变化——descriptor-less 定局残骸由 omit 改为 `corrupt` 行、`unsupported` 归并入 `corrupt`、四条边界偏差(stillborn fork 祖先身份、多描述符 last-wins、header 冲突不再察觉、损坏源读失败由 `corrupt` 转 `unavailable`)——且每处变化有测试钉住新行为。 -- 四态映射成立:快照有值成 child 行;inactive 缺值产生 `corrupt` 行(含 descriptor-less 定局残骸);running 缺值缺席(创建窗口);cold 整读失败映射 `unavailable`;`unsupported` 不再产出。 -- 错误契约:`ctx.sessionProjections` 未挂载时 `listChildren` 于枚举前以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 失败(零 children 部署同样确定失败);`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 从代码与文档中消失。 -- persistence 缺席退为 live-only 枚举,不报错,live child 照常成行。 -- per-child 隔离:单 child 整读失败只产生该行 `unavailable`,sibling 不受影响。 -- `hasSubagentDescriptor` 删除后属主判定只认 `header.origin`;`list_agents` 的 description、output schema 与既有无密钥快照零变化,钉住 wire/tool/GUI 零改动。 -- corpus 去 clone 后公开输出为浅 readonly 借用视图,既有 session-query 行为测试全数通过。 +`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`;空列表与稳定错误码。`tool-subagent-control` 的 list-agents 测试随加载要求收窄更新;`optional-session-query.spec.ts` 随依赖消失删除;无密钥 ACP 快照(`subagent-list-agents` 等)未重录——wire 与 model-visible 面零改动由既有快照钉住。 -## 风险 +## 后果 -- **折叠规则分叉。** "折叠只在 registry 一份"是本设计的承诺;若未来某消费面绕开 registry 手写折叠,各读面的值可能漂移。缓解:列表两级阶梯与 GUI history 冷读走的都是 registry 的同两处读法(snapshot/restore),不存在旁路折叠。 -- **cold child 的每次列表整读成本。** cold child 每次 `listChildren` 都做一次 `inspect` 整读现算,成本与其 transcript 大小成正比、随列表频率重复;定案"算完即止",不建缓存、不回写。同 id 短期重复整读可命中持久化协调器准备阶段的 LRU 复用,但列表不依赖它;live child 全程零读。显式接受。 -- **诊断语义的四处边界偏差。** stillborn fork 的祖先身份误现为 child 行、多描述符改取末者、header 冲突不再被察觉、损坏源读失败由 `corrupt` 转 `unavailable`——完整语义与接受理由见提案的已知边界偏差清单。均为残骸级数据的展示或分类偏差,恢复鉴权不受影响。 -- **pre-#1569 存量属主判定收窄。** 无 `origin` 的旧 child 不再被认作 subagent 属主。其本就不进目录,pre-release 无兼容承诺,接受。 +- live child 的列表全程零日志读;cold child 每次列表一次 `inspect` 整读,成本与其 transcript 大小成正比、随列表频率重复——定案"算完即止",不建缓存、不回写,同 id 短期重复整读可命中准备阶段 LRU 但列表不依赖它。 +- subagent 列表不再要求 query backend:纯 live 与无 persistence 的部署都能列表;`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 消失,`list_agents` 插件加载不再要求 `sessionQuery`。 +- 身份解释只存在于 registry 注册的一份 unit:列表两级阶梯与 GUI history 冷读走同两处读法(snapshot/restore),不存在旁路折叠;若未来某消费面绕开 registry 手写折叠,各读面的值将漂移——这是本设计要求维持的纪律,不是机制保证。 +- per-child 隔离回归:单 child 冷读失败只损失该行,healthy sibling 不受影响;persistence 列表失败仍使整次枚举失败。 +- 诊断语义留下四处边界偏差(stillborn fork 祖先身份误现、多描述符取末者、header 冲突不再被察觉、损坏源读失败由 `corrupt` 转 `unavailable`),完整语义见已知边界偏差清单;均为残骸级数据的展示或分类偏差,恢复鉴权不受影响。 +- pre-#1569 的无 `origin` 存量不再被认作 subagent 属主;其本就不进目录,pre-release 无兼容承诺。 ## 相关 -- [durable-subagent-catalog 与 list_agents](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)——被本记录部分取代:描述符仍是 mode/label 的持久权威与折叠输入,列表的枚举与取值改为自管合并加投影阶梯。 -- [session projections 与命令生命周期日志](2026-07-27-session-projection-and-command-log.md)——registry 契约的权威;本记录为其新增 `subagent` 身份 unit,并成为 snapshot/restore 两处既有读法的消费实例。 -- [web subagent conversations](../../implemented/feature/2026-07-27-web-subagent-conversations.md)——`SessionHeader.origin` 的出处(#1569),身份判定去日志化的前半步;其 history 冷读(inspect 前缀加 registry 折叠)是本记录取值阶梯的同款先例。 -- [发布前可复用的 Session 准备阶段](../../implemented/architecture/2026-08-05-session-preparation.md)——`inspect()` 冷读与 LRU 复用;cold child 整读的成本模型建立其上。 +- [durable-subagent-catalog 与 list_agents](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)——被本记录部分取代:描述符仍是 mode/label 的持久权威与折叠输入,列表的枚举与取值改为自管合并加投影阶梯。 +- [session projections 与命令生命周期日志](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md)——registry 契约的权威;本记录为其新增 `subagent` 身份 unit,并成为 snapshot/restore 两处既有读法的消费实例。 +- [web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md)——`SessionHeader.origin` 的出处(#1569),身份判定去日志化的前半步;其 history 冷读(inspect 前缀加 registry 折叠)是本记录取值阶梯的同款先例。 +- [发布前可复用的 Session 准备阶段](2026-08-05-session-preparation.md)——`inspect()` 冷读与 LRU 复用;cold child 整读的成本模型建立其上。 diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml index aefda46d41..74932324dd 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml +++ b/.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: 0dd7eebac74689004014248c7178dba540ef4662 -2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 33b0296cf9914d1975fb1dc84564b498a09bd511 +2026-07-22-durable-subagent-catalog-and-list-agents.md: 1de93cc1374e8e86bace6af94b51efe94b38f89a +2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: fe5422c497b87bb39d43ac97cb5d1a9bed9fcbfb diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md index 0dd7eebac7..1de93cc137 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md +++ b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md @@ -12,6 +12,8 @@ Enumeration must cross-check immutable session lineage, descriptor validity, and ## Decision +**Superseded read path.** [Subagent list identity via the projection unit](../architecture/2026-08-06-subagent-list-identity-projection.md) replaces this note's enumeration and per-child read design: `listChildren` now merges the live session store with optional session persistence directly and serves each child's mode/label from the registered `subagent` projection unit — no session-query dependency, no list-time descriptor scan — and that note owns the current listing semantics, including the diagnostic mapping. This note remains the authority for descriptor persistence, the mode-discriminated descriptor as durable identity, direct-parent authorization, and the model-facing `list_agents` projection; the trace-based read mechanics below are decision context, not current behavior. + Parent-to-child enumeration is a service capability with consumer-specific projections. `SubagentService.listChildren(parentSessionId: SessionId)` ([subagent/src/index.ts](../../../../packages/subagent/subagent/src/index.ts)) does the following: - use `ctx.sessionQuery.traceSession(parentSessionId)` to obtain the parent's direct live-preferred child sessions; diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md index 33b0296cf9..fe5422c497 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md @@ -12,6 +12,8 @@ Status: implemented ## 决策 +**列表读路径已被取代。**[subagent 列表经投影单元读取身份](../architecture/2026-08-06-subagent-list-identity-projection.md)取代了本记录的枚举与逐 child 读取设计:`listChildren` 现在直接合并存活会话存储与可选的会话持久化,并从注册的 `subagent` projection unit 读取每个 child 的 mode/label——不依赖会话查询,也不在列表时扫描描述符;当前的列表语义(含 diagnostic 映射)以该记录为准。本记录仍是描述符持久化、以 mode 判别的描述符作为持久身份、直接 parent 鉴权与面向模型的 `list_agents` 投影的权威;下文基于追踪的读取机制是决策背景,不再是当前行为。 + parent 到 child 的枚举是一项带消费方专用投影的服务功能。`SubagentService.listChildren(parentSessionId: SessionId)`([subagent/src/index.ts](../../../../packages/subagent/subagent/src/index.ts))执行以下操作: - 使用 `ctx.sessionQuery.traceSession(parentSessionId)` 获取 parent 的直接且实时优先的 child 会话; diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml index 5fabe8a942..1c070a03a2 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.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/proposed/architecture/2026-07-27-session-projection-and-command-log.md -2026-07-27-session-projection-and-command-log.md: 6a073c956c27bbfc65cff2d4f44ca12023df0cd5 -2026-07-27-session-projection-and-command-log.zh.md: a22ebe57811339a0e583ae00909e60482ddb57b1 +2026-07-27-session-projection-and-command-log.md: 789e79f2ecab1a9f3ac717df86059150ed2d4da9 +2026-07-27-session-projection-and-command-log.zh.md: 4d680b37f5d49a243447542706c8b7ced8d80e2a diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md index 6a073c956c..789e79f2ec 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md @@ -56,6 +56,10 @@ declare module 'cordis' { - Registration is an effect (disposer with the fiber): an unloaded plugin's key disappears from subsequent responses and the client reads it as capability absence — HMR semantics for free. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. - The package owns `./invariant` (every served key has a live registration). +### Shipped consumer: the subagent identity unit + +The registry's two read faces already serve a shipped consumer beyond this RFC's wire plan: [subagent list identity via the projection unit](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.md) registers a `subagent` unit — the durable mode/label identity folded last-wins from `subagent/descriptor` — and `SubagentService.listChildren` reads it through `snapshot()` for a live child (the watermark cache, zero log reads) and `restore({}, events, 0)` over one persistence inspection for a cold one. The registry contract is unchanged: no failure channel and no new read face — a unit never throws, an absent value is the signal, and how absence renders is that consumer's decision. + ### Wire: projections block on the history tail page ```ts ignore-check diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md index a22ebe5781..4d680b37f5 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md @@ -56,6 +56,10 @@ declare module 'cordis' { - 注册是 effect(disposer 随 fiber 走):插件卸载后其 key 从后续响应中消失,客户端将其读作能力缺失——HMR(热模块替换)语义随之自动成立。key 重复直接 throw。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。 - 该包拥有 `./invariant`(每个被服务的 key 都有一条存活的注册)。 +### 已交付的消费方:subagent 身份单元 + +注册表的两处读面已经服务于本 RFC 协议计划之外的一个已交付消费方:[subagent 列表经投影单元读取身份](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.md)注册了 `subagent` 单元——从 `subagent/descriptor` 以 last-wins 折叠出的持久 mode/label 身份——`SubagentService.listChildren` 对 live child 经 `snapshot()` 读取(水位缓存,零日志读),对 cold child 经一次持久化检查上的 `restore({}, events, 0)` 读取。注册表契约不变:没有失败通道、没有新读面——单元永不抛错,值缺席本身就是信号,缺席如何呈现是该消费方自己的决定。 + ### 协议层:历史尾页上的 projections 块 ```ts ignore-check diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index a2d1e76928..a18deee8eb 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/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: 315051fafaa0bb291a0f7525d2de142d8570961b -subagent.zh.md: 5e147b85b1b9a57fb604145bef66e560c443c1de +subagent.md: e364572f9ac6a52acb118de0906cb5ec442536cc +subagent.zh.md: 4440c4f6a4212d0cf8a4d6367389f6973dfc5d17 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 315051fafa..e364572f9a 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -4,7 +4,7 @@ English | [中文](subagent.zh.md) The subagent seam — an agent delegating work to a child agent. Like [bash](bash.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). But it differs from every other seam on one axis: **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), where bash allows only one executor. The registry shape mirrors the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. -Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`, `-codex`, `-claude-code`, `-dsh-sdk`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message` and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only direct-child discovery through optional session query. Product-provider rationale lives in [the Codex and Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md); common-seam rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). +Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`, `-codex`, `-claude-code`, `-dsh-sdk`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message` and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only direct-child discovery straight from the session store and optional session persistence. Product-provider rationale lives in [the Codex and Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md); common-seam rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) @@ -248,11 +248,11 @@ interface ContinuableCreateSpec { The descriptor (`SubagentDescriptorData` in [descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts)) is a mode-discriminated durable identity for every session-backed subagent. Both modes carry the provider name. A `one-shot` descriptor optionally carries a caller-owned display `label`; a `continuable` descriptor requires the delegation `description` as its durable creation label and additionally snapshots resolved child `agentOptions.provider`/`model` and optional `persona`/`toolFilter` for cold resume. It never snapshots the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation and a later composition input is a deliberate version change. It omits `subagentDepth` (cold resume trusts the persisted header's `delegationDepth` as the monotone floor) and `outputSchema` (one run or Activation's result contract, not durable identity). -A local one-shot provider appends the descriptor inside the child's initial turn before its first request. The continuation manager appends the descriptor after any provider-supplied lineage and before the initial prompt is admitted; `header.seedLength` remains the fork-lineage boundary, so descriptor lookup reads the child's own suffix. The event is log-only: no `surfaceOp`, never in model history, and retained across compaction by the append-only log. Malformed current-version descriptors are corrupt; unsupported versions cannot be classified by this runtime. +A local one-shot provider appends the descriptor inside the child's initial turn before its first request. The continuation manager appends the descriptor after any provider-supplied lineage and before the initial prompt is admitted; `header.seedLength` remains the fork-lineage boundary: resume-time descriptor authority reads the child's own suffix, while the list-serving identity projection folds `subagent/descriptor` last-wins so the child's own descriptor overrides a fork-seeded ancestor's. The event is log-only: no `surfaceOp`, never in model history, and retained across compaction by the append-only log. Malformed current-version descriptors are corrupt; unsupported versions cannot be classified by this runtime. ## Durable enumeration: `listChildren()` and `SubagentListEntry` -`SubagentService.listChildren(parentSessionId)` enumerates the parent's direct session-backed subagents from one `ctx.sessionQuery.traceSession()` observation, without loading or resuming any Agent. Session lineage is broader than subagent identity — ordinary forks share `parentSession` — so exactly one supported `subagent/descriptor` event in the child's own suffix (after `seedLength`, so a fork seed cannot leak an ancestor's descriptor) is the sole subagent discriminator. `SessionHeader.origin: 'subagent'` is only a coarse product-navigation classifier stamped before publication; it can suppress duplicate sidebar rows but cannot establish a valid descriptor, resumability, or authorization. The result is one `SubagentListEntry[]` in the trace's `createdAt`-then-id candidate order: a valid descriptor yields a `child` entry with `mode: 'one-shot' | 'continuable'` and `activity: 'running' | 'inactive'`; continuable entries always carry `label`, while one-shot entries carry it only when the start caller supplied presentation metadata. A per-child inspection failure yields a `diagnostic` entry (`corrupt`, `unsupported`, or `unavailable`) so one damaged sibling cannot hide healthy children; a missing descriptor yields no entry. Activity snapshots only whether the logical record is live in `ctx.sessions`, not outcome or resumability. A service consumer such as a UI can display both modes and choose an unlabeled one-shot fallback, while the model-facing `list_agents` adapter (the separately loadable `/list-agents` plugin of [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)) keeps only continuable entries and maps activity to its existing `running`/`complete` vocabulary. A failure while building the initial trace fails the whole call — per-child isolation begins only after a trustworthy candidate set exists. The service keeps `sessionQuery` optional for by-id continuation: `listChildren()` throws `SubagentError` with code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` when it is absent, while the list tool requires `ctx.subagents` and `ctx.sessionQuery` at plugin load. Listing does not consult the continuation manager's Activation map, Agent registry, or provider availability; `send_message` remains the authoritative delivery-time operation, and a listed running continuable child may still reject delivery as an ownership conflict. +`SubagentService.listChildren(parentSessionId)` enumerates the parent's direct session-backed subagents from the live-preferred merge of `ctx.sessions.list()` and optional `ctx.sessionPersistence.list()` — no query seam, and no Agent is loaded or resumed. Candidates are the direct children whose durable header carries `origin: 'subagent'`; the marker classifies enumeration and coarse generic-route denial but cannot establish a valid descriptor, resumability, or authorization — the projection fold owns identity, and the Activation contract owns resume. Each row's `mode`/`label` is the registered `subagent` projection unit's value, served from the registry's watermark cache for a live child (zero log reads) and folded once over one `persistence.inspect()` reading for a cold one (bounded concurrency, recomputed per listing — no cache). The fold is `subagent/descriptor` last-wins with no failure channel: the child's own descriptor overrides a fork-seeded ancestor's, and a malformed or unknown-version payload folds to no value. The result is one `SubagentListEntry[]` in `createdAt`-then-id order: a served identity yields a `child` entry with `mode: 'one-shot' | 'continuable'` and `activity: 'running' | 'inactive'`; continuable entries always carry `label`, while one-shot entries carry it only when the start caller supplied presentation metadata. A settled candidate whose fold served no identity yields a `corrupt` diagnostic — missing, malformed, and unknown-version descriptors deliberately undistinguished, with `unsupported` kept in the type for consumers already routing on it but no longer produced; a running candidate without an identity is omitted (the creation window before its descriptor lands); a failed cold inspection yields one `unavailable` diagnostic retried on the next listing, so one damaged sibling cannot hide healthy children. `hasChildren` marks a direct descendant with durable subagent origin, read from the same merged material. Activity snapshots only whether the logical record is live in `ctx.sessions`, not outcome or resumability. Absent persistence, enumeration is live-only rather than an error — a cold child cannot be resumed then either. `listChildren()` throws `SubagentError` with code `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` when the `ctx.sessionProjections` registry is absent, checked before any read so a deployment with zero children still fails deterministically; the list tool requires `ctx.subagents` at plugin load. A service consumer such as a UI can display both modes and choose an unlabeled one-shot fallback, while the model-facing `list_agents` adapter (the separately loadable `/list-agents` plugin of [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)) keeps only continuable entries and maps activity to its existing `running`/`complete` vocabulary. Listing does not consult the continuation manager's Activation map, Agent registry, or provider availability; `send_message` remains the authoritative delivery-time operation, and a listed running continuable child may still reject delivery as an ownership conflict. The read-path rationale lives in [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md). ## The terminal result: `SubagentResult` @@ -344,7 +344,7 @@ interface SubagentRun { } ``` -A local one-shot run MUST publish an ordinary child agent/session before `start()` fulfills, return that child session id as `SubagentRun.id`, expose the exact child as `localAgent`, record `request.parent.session.id` in the child's `parentSession` header, and append the resolved descriptor inside the child's initial turn before its first request. Runtime ownership may place the child under the parent, provider, or root scope. A remote provider instead returns a parent-scoped lifecycle id and `localAgent: undefined`; without a local child Session, it is absent from trace-backed enumeration. +A local one-shot run MUST publish an ordinary child agent/session before `start()` fulfills, return that child session id as `SubagentRun.id`, expose the exact child as `localAgent`, record `request.parent.session.id` in the child's `parentSession` header, and append the resolved descriptor inside the child's initial turn before its first request. Runtime ownership may place the child under the parent, provider, or root scope. A remote provider instead returns a parent-scoped lifecycle id and `localAgent: undefined`; without a local child Session, it is absent from durable enumeration. ## The provider seam: `SubagentProvider` diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 5e147b85b1..4440c4f6a4 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -4,7 +4,7 @@ subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。但它在一个维度上与其他所有 seam 不同:**同一上下文中可共存多个提供方实现**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 -接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为六个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`、`-codex`、`-claude-code`、`-dsh-sdk`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并通过可选的会话查询负责只读的直接 child 发现。产品提供方设计理由见 [Codex 与 Claude Code Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md);通用 seam 的设计理由见 [subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 +接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为六个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`、`-codex`、`-claude-code`、`-dsh-sdk`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并直接从会话存储与可选的会话持久化负责只读的直接 child 发现。产品提供方设计理由见 [Codex 与 Claude Code Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md);通用 seam 的设计理由见 [subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[列表身份投影 Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) @@ -248,11 +248,11 @@ interface ContinuableCreateSpec { 描述符([descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts) 中的 `SubagentDescriptorData`)是每个由会话支撑的 subagent 所使用、按模式判别的持久化身份。两种模式都携带提供方名称。`one-shot` 描述符可以携带调用方拥有的可选显示 `label`;`continuable` 描述符要求以委派 `description` 作为持久化创建标签,并另外对已解析的子 agent `agentOptions.provider`/`model` 与可选的 `persona`/`toolFilter` 建立快照,用于冷恢复。它绝不会对可合并扩展的 `AgentOptions` 对象建立快照,因此无关的扩展值不会破坏继续执行,后续新增组合配置输入则是一次有意的版本更改。描述符省略 `subagentDepth`(冷恢复以持久化 header 中的 `delegationDepth` 作为单调下界)和 `outputSchema`(单次运行或 Activation 的结果契约,而非持久化身份)。 -本地一次性提供方会在子 agent 的初始轮次内、首次请求前追加描述符。继续执行管理器会在任何提供方提供的谱系之后、初始 prompt 获准之前追加描述符;`header.seedLength` 仍是 fork 谱系边界,因此描述符查找会读取子 agent 自身的后缀。该事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。格式错误的当前版本描述符属于损坏;本运行时无法对不受支持的版本进行分类。 +本地一次性提供方会在子 agent 的初始轮次内、首次请求前追加描述符。继续执行管理器会在任何提供方提供的谱系之后、初始 prompt 获准之前追加描述符;`header.seedLength` 仍是 fork 谱系边界:恢复时的描述符权威读取子 agent 自身的后缀,而供列表使用的身份投影以 last-wins 折叠 `subagent/descriptor`,子 agent 自己的描述符会覆盖 fork seed 中祖先的描述符。该事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。格式错误的当前版本描述符属于损坏;本运行时无法对不受支持的版本进行分类。 ## 持久化枚举:`listChildren()` 与 `SubagentListEntry` -`SubagentService.listChildren(parentSessionId)` 从一次 `ctx.sessionQuery.traceSession()` 观测中枚举 parent 直接且由会话支撑的 subagent,而不会加载或恢复任何 Agent。会话谱系的范围比 subagent 身份更广——普通 fork 也会共享 `parentSession`——因此,child 自身后缀中恰好一个受支持的 `subagent/descriptor` 事件(位于 `seedLength` 之后,避免 fork seed 泄漏祖先描述符)是唯一的 subagent 判别信息。`SessionHeader.origin: 'subagent'` 只是在发布前写入的粗粒度产品导航分类器;它可以隐藏重复的侧边栏行,却不能证明描述符有效、child 可恢复或操作已获授权。结果是一个按追踪结果中 `createdAt`、再按 id 排列候选顺序的 `SubagentListEntry[]`:有效描述符生成带有 `mode: 'one-shot' | 'continuable'` 和 `activity: 'running' | 'inactive'` 的 `child` 条目;可继续条目始终携带 `label`,一次性条目则只在启动调用方提供展示元数据时携带该字段。逐 child 检查失败生成 `diagnostic` 条目(`corrupt`、`unsupported` 或 `unavailable`),因此一个损坏的 sibling 不会隐藏健康 child;缺少描述符则不生成条目。活动状态只表示逻辑记录是否在 `ctx.sessions` 中存活,而不表示结果或可恢复性。UI 等服务消费方可以展示两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)则只保留可继续条目,并将活动状态映射到现有的 `running`/`complete` 词汇。构建初始追踪时的失败会让整个调用失败——只有得到可信候选集后才开始逐 child 隔离。服务将 `sessionQuery` 保持为按 id 继续执行时的可选依赖:缺少该服务时,`listChildren()` 抛出 `SubagentError`,并携带错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`;列表工具则在插件加载时要求 `ctx.subagents` 与 `ctx.sessionQuery`。枚举不会查询继续执行管理器的 Activation map、Agent 注册表或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的运行中可继续 child 仍可能因所有权冲突而拒绝投递。 +`SubagentService.listChildren(parentSessionId)` 从 `ctx.sessions.list()` 与可选 `ctx.sessionPersistence.list()` 的实时优先合并中枚举 parent 直接且由会话支撑的 subagent——不经查询 seam,也不会加载或恢复任何 Agent。候选是持久 header 携带 `origin: 'subagent'` 的直接 child;该标记只负责枚举分类与粗粒度的通用路由拒绝,不能证明描述符有效、child 可恢复或操作已获授权——身份由投影折叠负责,恢复由 Activation 契约负责。每行的 `mode`/`label` 是已注册 `subagent` projection unit 的值:存活 child 由注册表水位缓存同步供值(零日志读取),冷 child 在一次 `persistence.inspect()` 读取上折叠一次(有界并发,每次列表重新计算——无缓存)。折叠规则是 `subagent/descriptor` last-wins 且没有失败通道:子 agent 自己的描述符覆盖 fork seed 中祖先的描述符,格式错误或版本不认识的载荷折叠为无值。结果是按 `createdAt`、再按 id 排序的 `SubagentListEntry[]`:取到身份即生成带有 `mode: 'one-shot' | 'continuable'` 和 `activity: 'running' | 'inactive'` 的 `child` 条目;可继续条目始终携带 `label`,一次性条目则只在启动调用方提供展示元数据时携带该字段。已定局而折叠无身份的候选生成 `corrupt` diagnostic——缺失、格式错误与版本不认识的描述符有意不再细分,`unsupported` 为已按其路由的消费方保留在类型中但不再产出;运行中而无身份的候选被省略(描述符落盘前的创建窗口);冷检查失败生成一条 `unavailable` diagnostic 并在下次列表自然重试,因此一个损坏的 sibling 不会隐藏健康 child。`hasChildren` 标记存在持久 subagent origin 的直接后代,读取自同一份合并材料。活动状态只表示逻辑记录是否在 `ctx.sessions` 中存活,而不表示结果或可恢复性。缺少持久化时,枚举退化为仅存活枚举而不是报错——此时冷 child 本就无法恢复。缺少 `ctx.sessionProjections` 注册表时,`listChildren()` 抛出携带错误码 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 的 `SubagentError`,并且在任何读取之前检查,因此零 child 的部署同样确定失败;列表工具在插件加载时只要求 `ctx.subagents`。UI 等服务消费方可以展示两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)则只保留可继续条目,并将活动状态映射到现有的 `running`/`complete` 词汇。枚举不会查询继续执行管理器的 Activation map、Agent 注册表或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的运行中可继续 child 仍可能因所有权冲突而拒绝投递。读路径的设计理由见[列表身份投影 Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md)。 ## 终态结果:`SubagentResult` @@ -344,7 +344,7 @@ interface SubagentRun { } ``` -本地单次 run 必须在 `start()` fulfill 之前发布一个普通子 agent/会话,将该子会话 id 作为 `SubagentRun.id` 返回,以 `localAgent` 暴露确切的子 agent,在子 agent 的 `parentSession` header 中记录 `request.parent.session.id`,并在子 agent 的初始轮次内、首次请求前追加已解析的描述符。运行时所有权可以把子 agent 放在 parent、提供方或 root 作用域下。远程提供方则返回 parent 作用域的生命周期 id 与 `localAgent: undefined`;由于没有本地 child Session,它不会出现在基于追踪的枚举结果中。 +本地单次 run 必须在 `start()` fulfill 之前发布一个普通子 agent/会话,将该子会话 id 作为 `SubagentRun.id` 返回,以 `localAgent` 暴露确切的子 agent,在子 agent 的 `parentSession` header 中记录 `request.parent.session.id`,并在子 agent 的初始轮次内、首次请求前追加已解析的描述符。运行时所有权可以把子 agent 放在 parent、提供方或 root 作用域下。远程提供方则返回 parent 作用域的生命周期 id 与 `localAgent: undefined`;由于没有本地 child Session,它不会出现在持久化枚举结果中。 diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 6edcee5cd8..72984de195 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -80,6 +80,12 @@ maxTokens: 8192 compactionRetries: 1 +# Projection registry: subagent catalog identity (mode/label) folds through +# its registered units; the catalog surfaces (`list_agents`, subagent listing) +# fail loud without the capability. +- id: session-projection + name: '@deepseek-ai/dsh-session-projection' + # Expose fresh-child `spawn` and completed-prefix `fork` through separate tool # names so multi-child scenarios exercise both transports. These leaves follow # the app because it provides `ctx.agents` and `ctx.tools`. diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 937c976c67..6c05dfccff 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -71,6 +71,11 @@ maxTokens: 8192 compactionRetries: 1 +# Projection registry: durable subagent identity (mode/label) folds through +# its registered units; subagent catalog reads fail loud without the capability. +- id: session-projection + name: '@deepseek-ai/dsh-session-projection' + # Expose fresh-child `spawn` and completed-prefix `fork` through independent # in-process backends. - id: subagent diff --git a/examples/headless-agent/subagent-diagnostic.cordis.snapshot.yml b/examples/headless-agent/subagent-diagnostic.cordis.snapshot.yml new file mode 100644 index 0000000000..2e89c3753d --- /dev/null +++ b/examples/headless-agent/subagent-diagnostic.cordis.snapshot.yml @@ -0,0 +1,44 @@ +# Keyless real-Loader composition for the descriptor-less cold-child +# diagnostic snapshot. The seeded parent owns one session-backed child whose +# log carries `origin: 'subagent'` but no descriptor event, so the projection +# fold produces no identity and `list_agents` must surface the child as a +# `[diagnostic: corrupt]` row instead of silently dropping it. + +- id: persistence + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './.sessions' + compression: none + +# file/override both default to their DSH_SNAPSHOT_* env vars. +- id: replay + name: '@deepseek-ai/dsh-llm-replay' + +# This scenario probes the subagent catalog only, so the bash/filesystem +# stacks are absent; the bundle must opt out of the tools that would wait +# forever for executors this tree never mounts. +- id: agent + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + agents: [] + workspaceContext: false + skills: + enabled: false + toolBash: false + toolTasks: false + goals: false + +# Projection registry: the cold child's identity fold runs through it; the +# catalog read fails loud when the capability is absent. +- id: session-projection + name: '@deepseek-ai/dsh-session-projection' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + +# Await the persisted resume before the headless driver inspects root agents. +- id: resumed-agent + name: './tests/fixtures/subagent-diagnostic-agent.ts' diff --git a/examples/headless-agent/tests/fixtures/subagent-diagnostic-agent.ts b/examples/headless-agent/tests/fixtures/subagent-diagnostic-agent.ts new file mode 100644 index 0000000000..f77afe7e0a --- /dev/null +++ b/examples/headless-agent/tests/fixtures/subagent-diagnostic-agent.ts @@ -0,0 +1,26 @@ +/** + * Loader fixture that resumes the seeded diagnostic-scenario parent before + * CLI dispatch, so `list_agents` runs against its pre-seeded cold child. + * @module subagent-diagnostic-agent + */ + +import type { Context } from 'cordis' +import type { SessionId } from '@deepseek-ai/dsh-session' + +/** Fixture plugin name. */ +export const name = 'subagent-diagnostic-agent' +/** Services that must exist before the fixture resumes its agent. */ +export const inject = ['agents', 'agentLoop', 'sessionPersistence'] + +/** + * Resume the seeded session and bind its exact handle to this fixture's lifetime. + * @param ctx - settled agent and persistence services from the Loader tree. + * @returns after the resumed agent is published. + */ +export async function apply(ctx: Context): Promise { + const handle = await ctx.agents.resume({ + resumeSessionId: 'subagent-diagnostic-parent' as SessionId, + agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }) + ctx.effect(() => () => handle.dispose(), 'subagent-diagnostic-agent.handle') +} diff --git a/examples/headless-agent/tests/subagent-diagnostic-snapshots/descriptorless-child/parent.expected.jsonl b/examples/headless-agent/tests/subagent-diagnostic-snapshots/descriptorless-child/parent.expected.jsonl new file mode 100644 index 0000000000..edf331d8e3 --- /dev/null +++ b/examples/headless-agent/tests/subagent-diagnostic-snapshots/descriptorless-child/parent.expected.jsonl @@ -0,0 +1,31 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Start a background task."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"turn/end","seq":2,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/end-seed","seq":3,"time":0,"data":{}} +{"type":"agent/inbox/spliced","seq":4,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call list_agents once and report what it shows."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"turn/start","seq":5,"time":0,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":6,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":7,"time":0,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Call list_agents once and report what it shows."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":9,"time":0,"data":{"title":"Start a background task.","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"list-once","name":"list_agents","argumentsDelta":"{}"}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}}}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":17,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} +{"type":"tool/call","seq":18,"time":0,"data":{"turn":2,"step":1,"callId":"list-once","name":"list_agents","arguments":"{}"}} +{"type":"tool/result","seq":19,"time":0,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"list-once"},"content":[{"type":"tool-result","toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [diagnostic: corrupt]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"} +{"type":"step/end","seq":20,"time":0,"data":{"turn":2,"step":1}} +{"type":"step/start","seq":21,"time":0,"data":{"turn":2,"step":2}} +{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"The stored subagent is unreadable. PARENT_DONE"}}} +{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The stored subagent is unreadable. PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":27,"time":0,"data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The stored subagent is unreadable. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"} +{"type":"step/end","seq":28,"time":0,"data":{"turn":2,"step":2}} +{"type":"turn/end","seq":29,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/subagent-diagnostic-snapshots/descriptorless-child/replay.override.json b/examples/headless-agent/tests/subagent-diagnostic-snapshots/descriptorless-child/replay.override.json new file mode 100644 index 0000000000..2b9facf71f --- /dev/null +++ b/examples/headless-agent/tests/subagent-diagnostic-snapshots/descriptorless-child/replay.override.json @@ -0,0 +1 @@ +[{"kind": "chunks", "chunks": [{"type": "block-start", "index": 0, "blockType": "tool-call"}, {"type": "tool-call-delta", "index": 0, "id": "list-once", "name": "list_agents", "argumentsDelta": "{}"}, {"type": "block-end", "index": 0, "block": {"type": "tool-call", "id": "list-once", "name": "list_agents", "arguments": "{}"}}, {"type": "usage", "usage": {"inputTokens": 10, "outputTokens": 5}}, {"type": "finish", "reason": {"kind": "tool-calls"}}]}, {"kind": "chunks", "chunks": [{"type": "block-start", "index": 0, "blockType": "text"}, {"type": "text-delta", "index": 0, "text": "The stored subagent is unreadable. PARENT_DONE"}, {"type": "block-end", "index": 0, "block": {"type": "text", "text": "The stored subagent is unreadable. PARENT_DONE"}}, {"type": "usage", "usage": {"inputTokens": 10, "outputTokens": 5}}, {"type": "finish", "reason": {"kind": "stop"}}]}] diff --git a/examples/headless-agent/tests/subagent-diagnostic.snapshot.ts b/examples/headless-agent/tests/subagent-diagnostic.snapshot.ts new file mode 100644 index 0000000000..dc978b37f6 --- /dev/null +++ b/examples/headless-agent/tests/subagent-diagnostic.snapshot.ts @@ -0,0 +1,119 @@ +/** + * Assembled-app regression: a persisted `origin: 'subagent'` child whose log + * carries no descriptor event is surfaced by `list_agents` as a + * `[diagnostic: corrupt]` row instead of being silently dropped. + */ + +import { readFile, readdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Context } from 'cordis' +import { normalizeSessionLog, scrubRequestHeaders, type NormalizeContext } from '@deepseek-ai/dsh-acp-snapshot' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import { describe, expect, it } from 'vitest' + +const fixtureDir = fileURLToPath(new URL('./subagent-diagnostic-snapshots/descriptorless-child', import.meta.url)) +const replayOverride = join(fixtureDir, 'replay.override.json') +const parentExpected = join(fixtureDir, 'parent.expected.jsonl') +const configPath = fileURLToPath(new URL('../subagent-diagnostic.cordis.snapshot.yml', import.meta.url)) +const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const parentId = SessionId('subagent-diagnostic-parent') +const childId = SessionId('subagent-diagnostic-child') +const refreshing = process.env.DSH_SNAPSHOT === 'refresh' +const task = 'Call list_agents once and report what it shows.' + +/** + * Seed a completed parent turn plus one cold child that durably classifies + * as a subagent (`origin`) but never appended its descriptor event — the + * publication-window death the diagnostic row exists for. + */ +async function seedDescriptorlessChild(root: string, cwd: string): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) + const parentMeta: SessionHeader = { + version: SESSION_FORMAT_VERSION, + id: parentId, + createdAt: 1, + cwd, + delegationDepth: 0, + } + const parentEvents: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 10, data: { turn: 1 } }, + { type: 'user/message', seq: 1, time: 11, data: createUserMessage({ content: [{ type: 'text', text: 'Start a background task.' }], source: { kind: 'user' } }), surfaceOp: 'append' }, + { type: 'turn/end', seq: 2, time: 12, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + const childMeta: SessionHeader = { + version: SESSION_FORMAT_VERSION, + id: childId, + createdAt: 2, + cwd, + parentSession: parentId, + origin: 'subagent', + delegationDepth: 1, + } + const childEvents: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 20, data: { turn: 1 } }, + { type: 'turn/end', seq: 1, time: 21, data: { turn: 1, reason: { kind: 'interrupted' } } }, + ] + try { + await ctx.sessionPersistence.create(parentMeta) + await ctx.sessionPersistence.append(parentId, parentEvents) + await ctx.sessionPersistence.create(childMeta) + await ctx.sessionPersistence.append(childId, childEvents) + } finally { + await ctx.fiber.dispose() + } +} + +describe('descriptor-less cold child diagnostic snapshot', () => { + it('surfaces the unreadable child as a corrupt diagnostic through the assembled headless app', async () => { + let cwd = '' + const result = await runLoaderSmoke({ + label: 'subagent diagnostic headless stream-json snapshot', + tempDirPrefix: 'dsh-subagent-diag-', + binScript, + configPath, + binArgs: ['--config', configPath, '--output-format', 'stream-json', task], + tsconfigPath, + env: { + DSH_SNAPSHOT_FILE: replayOverride, + DSH_SNAPSHOT_OVERRIDE: replayOverride, + }, + prepare: async (runCwd) => { + cwd = runCwd + await seedDescriptorlessChild(join(runCwd, '.sessions'), runCwd) + }, + inspect: async (runCwd) => { + const sessionsDir = join(runCwd, '.sessions') + const files = (await readdir(sessionsDir, { recursive: true })).filter(file => file.endsWith('.jsonl')) + const logs = await Promise.all(files.map(async file => readFile(join(sessionsDir, file), 'utf8'))) + const parent = logs.find(content => content.includes('"subagent-diagnostic-parent"')) + if (parent === undefined) throw new Error('missing persisted parent log') + + // THE model-visible fact: the descriptor-less child is reported, not + // silently dropped, and its reason is the corrupt classification. + expect(parent).toContain(`${childId} [diagnostic: corrupt]`) + + const context: NormalizeContext = { sessionIds: [parentId, childId], cwd } + const normalizedParent = scrubRequestHeaders(normalizeSessionLog(parent, context)) + if (refreshing) { + await writeFile(parentExpected, normalizedParent) + } + expect(normalizedParent).toBe(await readFile(parentExpected, 'utf8')) + }, + }) + + expect(result.stderr).toBe('') + const records = result.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record) + expect(records.at(-1)).toMatchObject({ + type: 'result', + sessionId: parentId, + output: 'The stored subagent is unreadable. PARENT_DONE', + }) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/examples/package.json b/examples/package.json index ba947fccdb..5379595f23 100644 --- a/examples/package.json +++ b/examples/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-session-query": "workspace:*", "@deepseek-ai/dsh-session-query-sqlite": "workspace:*", "@deepseek-ai/dsh-session-reference": "workspace:*", diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 8b01e9005a..78a67ef642 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -190,6 +190,7 @@ describe('subagent ownership fence', () => { const meta = header('session-child', 1000, { parentSession: sid('session-parent'), seedLength: 0, + origin: 'subagent', }) const events = [ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, @@ -245,6 +246,47 @@ describe('subagent ownership fence', () => { expect(inspect).toHaveBeenCalledTimes(3) }) + it('no longer treats a descriptor-only cold child without origin as subagent-owned', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + const sessionId = sid('session-legacy-child') + const meta = header('session-legacy-child', 1000, { + parentSession: sid('session-parent'), + seedLength: 0, + }) + const events = [ + { + type: 'subagent/descriptor', + seq: 0, + time: 1, + data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' }, + }, + ] as SessionEvent[] + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events }), + locate: () => undefined, + } as never) + // Pre-#1569 stores classify a child only through the descriptor event and + // carry no header `origin`; the pre-release decision stops recognizing + // them, so the ownership fence lets generic resume reach the registry + // instead of answering `agent-busy`. + const resume = vi.spyOn(ctx.agents, 'resume') + .mockRejectedValue(new Error('registry unavailable in this bench')) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + + const prompt = await api.sessions.prompt(request({ + sessionId, + mode: 'queue', + content: [{ type: 'text', text: 'follow up' }], + })) + expect(resume).toHaveBeenCalledTimes(1) + expect(prompt.result.ok).toBe(false) + if (!prompt.result.ok) expect(prompt.result.error.code).toBe('internal') + }) + it('rejects origin-marked and runtime-owned live children from generic controls', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 57634ecfac..03e0bb3367 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -304,8 +304,8 @@ export class SubagentService extends Service { * @param signal - caller-owned cancellation forwarded to persistence reads * and observed around every read await. * @returns children and per-child diagnostics ordered by `createdAt`, then id. - * @throws {@link SubagentError} when the projection registry is not mounted - * or the caller cancels the listing. + * @throws {@link SubagentError} when the projection registry or the session + * store is not mounted, or the caller cancels the listing. */ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise { return listSubagentChildren(this.ctx, parentSessionId, signal) diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index 6b055be4a6..dbea888816 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -22,7 +22,11 @@ import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-project import { SubagentError } from './error.ts' import type { SubagentIdentityProjection } from './projection-types.ts' -/** Concurrent cold inspections per listing; a constant because it bounds one read-only scan, not deployment behavior. */ +/** + * Concurrent cold inspections per listing; a constant because it bounds one + * read-only scan of local media, not deployment behavior. Should a networked + * persistence backend appear, promote it to a validated `Config` field. + */ const COLD_READ_CONCURRENCY = 4 /** @@ -90,8 +94,8 @@ export type SubagentListEntry = * @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. - * @throws {@link SubagentError} when the projection registry is not mounted - * or the caller cancels the listing. + * @throws {@link SubagentError} when the projection registry or the session + * store is not mounted, or the caller cancels the listing. */ export async function listChildren( ctx: Context, @@ -99,7 +103,6 @@ export async function listChildren( signal?: AbortSignal, ): Promise { const projections = ctx.get('sessionProjections') - const sessions = ctx.get('sessions') // Checked before any read, even with zero candidates: mode/label are the // row's strong contract, so a missing fold capability is a deterministic // deployment configuration error, never an empty success. @@ -109,10 +112,14 @@ export async function listChildren( 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE', ) } + // Strict global read, never the `ctx.sessions` property proxy: the proxy is + // caller-scope bound, so a consumer plugin without its own `sessions` + // injection (the model-facing tool, the API proxy) would throw on access. + const sessions = ctx.get('sessions') if (sessions === undefined) { throw new SubagentError( - 'listing subagents requires the sessions registry (load @deepseek-ai/dsh-session)', - 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE', + 'listing subagents requires the session store (load @deepseek-ai/dsh-session)', + 'SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE', ) } assertListingNotCancelled(signal) @@ -146,7 +153,7 @@ export async function listChildren( .filter(record => record.header.parentSession === parentSessionId && record.header.origin === 'subagent') .sort((a, b) => a.header.createdAt - b.header.createdAt - || (a.header.id < b.header.id ? -1 : a.header.id > b.header.id ? 1 : 0)) + || a.header.id.localeCompare(b.header.id)) const rows: (SubagentListEntry | undefined)[] = Array.from({ length: candidates.length }) const coldReads: { index: number; id: SessionId }[] = [] @@ -196,7 +203,7 @@ async function inspectColdIdentity( childId: SessionId, hasChildren: boolean, signal: AbortSignal | undefined, -): Promise { +): Promise { assertListingNotCancelled(signal) let events: readonly SessionEvent[] try { diff --git a/packages/subagent/subagent/src/projection.ts b/packages/subagent/subagent/src/projection.ts index 41b0d093ad..5fa5d70ab8 100644 --- a/packages/subagent/subagent/src/projection.ts +++ b/packages/subagent/subagent/src/projection.ts @@ -139,9 +139,11 @@ ProjectionDefinition<'subagent', IdentityState> = { const identity = descriptorIdentity(event) return identity === undefined ? {} : { identity } }, - // A no-value log serves `undefined` (the schema's optional side); the map - // entry stays non-optional because every consumer reads through `Partial` - // snapshot values, where absence is already the type. + // The assertion deliberately widens: a log without a descriptor serves + // `undefined` at runtime, which the schema's `.optional()` accepts, and + // every registry read face already returns `Partial` snapshot values where + // absence is the type. The map entry stays non-optional so a child row's + // served identity remains a strong contract for consumers. view: state => state.identity as SubagentIdentityProjection, stateVersion: 1, } diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 18aab0a2ca..1d05f0467d 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -136,6 +136,15 @@ describe('SubagentService.listChildren', () => { ) }) + it('fails loud when the session store is not mounted', async () => { + const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SubagentService) + await expect(ctx.subagents.listChildren(SessionId('no-store-parent'))).rejects.toThrow( + expect.objectContaining({ code: 'SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE' }) as Error, + ) + }) + it('lists a persisted continuable child as inactive with its durable label', async () => { const { ctx, parent } = await setup([textResponse('done')]) const childId = await startChild(ctx, parent, 'summarize the doc') @@ -207,31 +216,57 @@ describe('SubagentService.listChildren', () => { it('orders children by createdAt then id without listing ordinary forks', async () => { const { ctx, parent } = await setup([]) - // Authored headers pin the ordering key deterministically: same createdAt - // ties break on id, different createdAt orders ascending. - const late = await authorChild(ctx, '00000000-0000-4000-8000-000000000003', { - parentSession: parent.id, - createdAt: 9, - origin: 'subagent', - }, childEvents(descriptorPayload('late child'))) - const tieB = await authorChild(ctx, '00000000-0000-4000-8000-000000000002', { - parentSession: parent.id, - createdAt: 5, - origin: 'subagent', - }, childEvents(descriptorPayload('tie b'))) - const tieA = await authorChild(ctx, '00000000-0000-4000-8000-000000000001', { - parentSession: parent.id, - createdAt: 5, - origin: 'subagent', - }, childEvents(descriptorPayload('tie a'))) + /** Publish one live child with a pinned header ordering key. */ + const liveChild = (parentId: SessionId, id: string, createdAt: number, label: string): SessionId => { + const session = ctx.sessions.create(SessionId(id), { + meta: { parentSession: parentId, origin: 'subagent', createdAt }, + }) + session.append('turn/start', { turn: 1 }) + session.append('subagent/descriptor', descriptorPayload(label)) + return session.header.id + } + // Live creation order is deliberately shuffled against the expected + // result: same-createdAt ties break on id, different createdAt orders + // ascending. + const late = liveChild(parent.id, '00000000-0000-4000-8000-000000000009', 9, 'late child') + const tieB = liveChild(parent.id, '00000000-0000-4000-8000-000000000002', 5, 'tie b') + const tieA = liveChild(parent.id, '00000000-0000-4000-8000-000000000001', 5, 'tie a') // An ordinary session fork shares parentSession but has no subagent origin. const fork = ctx.sessions.fork(parent.session, undefined, SessionId('plain-fork')) await ctx.sessions.flush(fork) - const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect') const entries = await ctx.subagents.listChildren(parent.id) expect(entries.map(entry => entry.id)).toEqual([tieA, tieB, late]) expect(entries.every(entry => entry.kind === 'child')).toBe(true) - expect(inspect).not.toHaveBeenCalledWith(fork.id, expect.anything()) + }) + + it('omits a live child that has not appended its descriptor yet', async () => { + const { ctx, parent } = await setup([]) + const pending = ctx.sessions.create(SessionId('creation-window-child'), { + meta: { parentSession: parent.id, origin: 'subagent' }, + }) + pending.append('turn/start', { turn: 1 }) + // The creation window: the establishing provider has not appended the + // descriptor yet, so the row is omitted rather than diagnosed. + await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([]) + }) + + it('lists a one-shot child with its durable creation label', async () => { + const { ctx, parent } = await setup([]) + const labeled = await authorChild(ctx, '00000000-0000-4000-8000-00000000ab02', { + parentSession: parent.id, + origin: 'subagent', + }, childEvents({ + version: SUBAGENT_DESCRIPTOR_VERSION, + mode: 'one-shot', + provider: 'spawn', + label: 'labeled one-shot', + })) + await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([ + { + kind: 'child', id: labeled, mode: 'one-shot', label: 'labeled one-shot', + activity: 'inactive', hasChildren: false, + }, + ]) }) it('reports a live child as running while keeping settled siblings complete', async () => { @@ -462,6 +497,35 @@ describe('SubagentService.listChildren', () => { expect(inspected).not.toContain(grandchildId) }) + it('inspects each cold child exactly once and a live child never', async () => { + const { ctx, parent } = await setup([textResponse('done')]) + const coldStarted = await startChild(ctx, parent, 'cold started child') + const coldAuthored = await authorChild(ctx, '00000000-0000-4000-8000-00000000ab01', { + parentSession: parent.id, + origin: 'subagent', + }, childEvents(descriptorPayload('cold authored child'))) + const liveId = SessionId('live-mixed-child') + const live = ctx.sessions.create(liveId, { + meta: { parentSession: parent.id, origin: 'subagent' }, + }) + live.append('turn/start', { turn: 1 }) + live.append('subagent/descriptor', descriptorPayload('live mixed child')) + + const inspected: SessionId[] = [] + const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence) + ctx.sessionPersistence.inspect = (sessionId, signal) => { + inspected.push(sessionId) + return original(sessionId, signal) + } + const entries = await ctx.subagents.listChildren(parent.id) + expect(entries).toHaveLength(3) + // The cost model: one inspection per cold child, none for a live child, + // whose identity is served from the registry's watermark cache. + expect(inspected.filter(id => id === coldStarted)).toHaveLength(1) + expect(inspected.filter(id => id === coldAuthored)).toHaveLength(1) + expect(inspected).not.toContain(liveId) + }) + 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') diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index 3a650db8fa..8c7841ef63 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts index 217d388fb4..e734ae8222 100644 --- a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts @@ -8,6 +8,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentListEntry } from '@deepseek-ai/dsh-subagent' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' @@ -28,6 +29,7 @@ async function setup(script: ConstructorParameters[0]) { roots.push(root) await ctx.plugin(JsonlSessionPersistence, { root }) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentService) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(tool) diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index 302e053abe..db674b0599 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -8,6 +8,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentService from '@deepseek-ai/dsh-subagent' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -27,6 +28,7 @@ async function setup(script: ConstructorParameters[0]) { roots.push(root) await ctx.plugin(JsonlSessionPersistence, { root }) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentService) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(tool) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1703d3ef7..fb1f5ae494 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -418,6 +418,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:* version: link:../packages/session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:* + version: link:../packages/session-projection/session-projection '@deepseek-ai/dsh-session-query': specifier: workspace:* version: link:../packages/session-query/session-query @@ -5587,6 +5590,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session-projection/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent From 96e7c0496af842e9e9816d0d91b0f8f823893081 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:03:55 +0800 Subject: [PATCH 03/12] fix: regenerate cordis catalog and allowlist the diagnostic fixture The subagent API surface change staled the committed catalog artifacts; the snapshot fixture agent is referenced only from its cordis.snapshot.yml, so knip learns it as an entry like its siblings. --- docs/cordis-catalog/services.md | 34 +++++++++++-------- knip.json | 1 + .../cordis/tool-cordis/src/api-catalog.ts | 2 +- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 44a44a139e..d4c944ef9d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2099,22 +2099,28 @@ registerContinuableSetup(contribution: ContinuableSetupContribution): () => void async drainContinuableDescendants(parents: readonly Agent[]): Promise /** - * Enumerate the parent's direct session-backed subagents from the - * live-preferred session corpus without loading or resuming an Agent. Session - * query supplies lineage, candidate order, event reads, and live state; this - * service interprets descriptor mode, activity, and per-child diagnostics - * without consulting Agent registrations, Activations, or providers. + * Enumerate the parent's direct session-backed subagents without loading or + * 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 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 + * live-only (a cold child cannot be resumed then either, so its absence is + * capability absence, not an error). This service consults no Agent + * registrations, Activations, or providers. * - * The trace and exact descriptor read receive `signal`; the full event-list - * read has no signal parameter, so the scan rechecks cancellation around - * every await and between candidates. Query rejections that settle after an - * abort become a stable `SubagentError` with code `CANCELLED`. + * Every persistence read receives `signal`, and the listing rechecks + * cancellation around each of those awaits. Read rejections that settle + * after an abort become a stable `SubagentError` with code `CANCELLED`. * @param parentSessionId - parent session whose direct children are listed. - * @param signal - caller-owned cancellation forwarded where supported and - * observed around every query await. - * @returns children and per-child diagnostics in stable trace order. - * @throws {@link SubagentError} when session query is unavailable or the - * caller cancels the scan. + * @param signal - caller-owned cancellation forwarded to persistence reads + * and observed around every read await. + * @returns children and per-child diagnostics ordered by `createdAt`, then id. + * @throws {@link SubagentError} when the projection registry or the session + * store is not mounted, or the caller cancels the listing. */ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise diff --git a/knip.json b/knip.json index 9b72396f1a..6dc4b56dcd 100644 --- a/knip.json +++ b/knip.json @@ -36,6 +36,7 @@ "entry": [ "headless-agent/tests/fixtures/cli-mock-llm.ts", "headless-agent/tests/fixtures/semantic-checkpoint-agent.ts", + "headless-agent/tests/fixtures/subagent-diagnostic-agent.ts", "headless-agent/tests/fixtures/subagent-inheritance-agent.ts", "headless-agent/tests/fixtures/workspace-context-resume-agent.ts", "headless-agent/tests/fixtures/goal-domain/seed-goal.ts", diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 9f520a012d..7c21ac00d1 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/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', - jsDoc: '/**\n * Enumerate the parent\'s direct session-backed subagents from the\n * live-preferred session corpus without loading or resuming an Agent. Session\n * query supplies lineage, candidate order, event reads, and live state; this\n * service interprets descriptor mode, activity, and per-child diagnostics\n * without consulting Agent registrations, Activations, or providers.\n *\n * The trace and exact descriptor read receive `signal`; the full event-list\n * read has no signal parameter, so the scan rechecks cancellation around\n * every await and between candidates. Query rejections that settle after an\n * 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 where supported and\n * observed around every query await.\n * @returns children and per-child diagnostics in stable trace order.\n * @throws {@link SubagentError} when session query is unavailable or the\n * caller cancels the scan.\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 — the registry\'s watermark snapshot for a live child, one\n * persistence inspection folded through the registry for a cold one. 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', From ac1dffb8094d1d8866cdbe00ffb4fe9cbc9f098d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:06:44 +0800 Subject: [PATCH 04/12] test(session-query): pin the persisted-corruption wrapping branch The retired subagent list path was the only caller exercising inspectPersisted's corruption arm; cover it directly. --- .../session-query/tests/session-query.spec.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index ebcad51bd7..acc993d2b3 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context, type Fiber } from 'cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' -import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' +import SessionPersistence, { SessionPersistenceCorruptionError, SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' import SessionQueryService, { SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY, type SessionEventSurface, @@ -1114,6 +1114,24 @@ describe('session-query exact reads', () => { await expect(ctx.sessionQuery.listEvents(SessionId('durable'))).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) }) + it('wraps persisted corruption as SESSION_QUERY_CORRUPT_SESSION with its cause preserved', async () => { + const durable = header('durable-corrupt') + TestPersistence.reset([{ meta: durable, events: eventLog() }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const corruption = new SessionPersistenceCorruptionError( + 'stored prefix failed validation', + { cause: new Error('torn final record') }, + ) + TestPersistence.inspectFailure = corruption + + await expect(ctx.sessionQuery.readSession(durable.id)).rejects.toMatchObject({ + code: 'SESSION_QUERY_CORRUPT_SESSION', + message: `stored session "${durable.id}" is corrupt: stored prefix failed validation`, + cause: corruption, + }) + }) + it('reports absent sessions, persisted load failures, and persisted header conflicts', async () => { const durable = header('durable') TestPersistence.reset([{ meta: durable, events: eventLog() }]) From efd78f44f4bb2090ad6b60b0df814c39d4ad9c81 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:17:23 +0800 Subject: [PATCH 05/12] fix: refresh graph docs and retire a dead spec reference Mounting session-projection in the example compositions staled the generated composition and module graphs; the 2026-07-22 note now describes the retired optional-session-query spec without a live path. --- ...07-22-durable-subagent-catalog-and-list-agents.i18n.yaml | 4 ++-- .../2026-07-22-durable-subagent-catalog-and-list-agents.md | 2 +- ...026-07-22-durable-subagent-catalog-and-list-agents.zh.md | 2 +- docs/module-graph.md | 6 ++---- examples/acp-agent/composition.md | 3 +++ examples/headless-agent/composition.md | 3 +++ 6 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml index 74932324dd..2b316aba2e 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml +++ b/.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: 1de93cc1374e8e86bace6af94b51efe94b38f89a -2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: fe5422c497b87bb39d43ac97cb5d1a9bed9fcbfb +2026-07-22-durable-subagent-catalog-and-list-agents.md: 12be9152edc1972337f96c098c8f7d93b723530c +2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 7dee4ca59ff6dd1ace32e6779dec240038d2c483 diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md index 1de93cc137..12be9152ed 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md +++ b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md @@ -93,7 +93,7 @@ The first version has no child deletion operation. If later product behavior del ## Testing - `packages/subagent/subagent/tests/service.spec.ts` pins descriptor v2 parsing for both modes and proves an unlabeled raw start resolves a one-shot descriptor before provider dispatch. `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` proves the local driver appends that descriptor inside the initial turn, returns the published id when cancellation lands in the factory-to-run handoff, and keeps result and handle-disposal failures on separate channels. Delegation-tool tests pin propagation of their existing display description and preserve independent result and disposal diagnostics. -- `packages/subagent/subagent/tests/list-children.spec.ts` pins a query-only composition with sessions, `subagents`, and `sessionQuery` but no `agents`, then drives the full real stack (agent loop, JSONL persistence, spawn/fork providers, the subagent service, and a concrete session-query service) keylessly: one-shot and continuable children from one real trace; a persisted (restart-shaped) parent target; `createdAt`-then-id ordering with authored ties; ordinary-fork and fork-seed ancestor-descriptor exclusion without diagnostics; live `running` vs persisted `inactive`; duplicate-descriptor, malformed-payload, invalid-surface, mismatched-header, and changed-read-target corruption diagnostics that leave healthy siblings visible; unsupported-version and per-child unavailable diagnostics; provider absence without child omission; compacted/uncompacted twins listing identically; grandchild exclusion; trace-phase failure failing the whole call while candidate-phase failures isolate to one child; configuration/window and unrecognized failures propagating as operation failures; forwarded trace/exact-read cancellation with stable `CANCELLED` normalization; and the `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` no-service contract. `packages/subagent/subagent/tests/optional-session-query.spec.ts` rejects eager evaluation of the optional runtime while importing the ordinary subagent surface. +- `packages/subagent/subagent/tests/list-children.spec.ts` pins a query-only composition with sessions, `subagents`, and `sessionQuery` but no `agents`, then drives the full real stack (agent loop, JSONL persistence, spawn/fork providers, the subagent service, and a concrete session-query service) keylessly: one-shot and continuable children from one real trace; a persisted (restart-shaped) parent target; `createdAt`-then-id ordering with authored ties; ordinary-fork and fork-seed ancestor-descriptor exclusion without diagnostics; live `running` vs persisted `inactive`; duplicate-descriptor, malformed-payload, invalid-surface, mismatched-header, and changed-read-target corruption diagnostics that leave healthy siblings visible; unsupported-version and per-child unavailable diagnostics; provider absence without child omission; compacted/uncompacted twins listing identically; grandchild exclusion; trace-phase failure failing the whole call while candidate-phase failures isolate to one child; configuration/window and unrecognized failures propagating as operation failures; forwarded trace/exact-read cancellation with stable `CANCELLED` normalization; and the `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` no-service contract. A companion spec (retired together with the query-backed read path) rejected eager evaluation of the optional session-query runtime while importing the ordinary subagent surface. - `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` pins the `list_agents` schema (no parameters), the continuable-only projection that omits a healthy one-shot sibling while preserving diagnostics, the fixed child/diagnostic/empty text forms, an end-to-end settled-child listing with its durable label, forwarding of the tool cancellation signal, the no-agent rejection, load-time `sessionQuery` injection, and HMR disposal. - The keyless ACP snapshot scenario `subagent-list-agents` (examples/acp-agent) fences its second parent turn on a snapshot-only `subagent/end` marker, then executes `list_agents` for real against the subagent service, session query, and JSONL persistence, rendering ` [complete] —