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 index 4620ec99c9..4d500d7519 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md -2026-08-06-subagent-list-identity-projection.md: 6b6ee863bf385e27f4c431f7a1039ea75110565e -2026-08-06-subagent-list-identity-projection.zh.md: 42a578147026ae8d09669d13ada468a4491dcb37 +2026-08-06-subagent-list-identity-projection.md: d23e068b6dbbc08d62bec18b0b00e9651a502a86 +2026-08-06-subagent-list-identity-projection.zh.md: 45bdd41ba07e48b70e8030fb6e61ada944f80645 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 index 6b6ee863bf..d23e068b6d 100644 --- 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 @@ -115,6 +115,7 @@ For each enumerated child, the ladder's result maps to a row through four states - `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. +- Any registered unit whose fold/schema throws on this child's log is likewise contained as that child's diagnostic row, reason `corrupt` — a deterministic data fault, aligned with the old implementation's `SESSION_QUERY_CORRUPT_SESSION`→`corrupt` mapping semantics; live and cold are treated alike, isolation is per-child, and siblings and the listing itself are unaffected. It is orthogonal to "value absent + running → omit": the creation window means "no data yet", a fold throw means "the data is bad" — a poisoned running child also gets a `corrupt` row rather than an omit. Known boundary deviations (deliberately accepted, recorded with this note): @@ -122,6 +123,7 @@ Known boundary deviations (deliberately accepted, recorded with this note): - 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). +- An unknown parent: the old implementation threw not-found through session-query ('parent session … was not found'); the subagent-owned merge now yields an empty subset for a nonexistent parent, enumeration returns an empty list, and later operations on the wire land as child-level subagent-not-found — a silent change of semantics and wording, recorded as explicitly accepted. 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. @@ -158,7 +160,7 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir ## Verification -`packages/subagent/subagent/tests/list-children.spec.ts` is rewritten to this contract: live-only listing without persistence, query services, or the continuation runtime; with the registry absent, even zero children loudly report `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`; a live child incurs zero `inspect` throughout while a cold child incurs exactly one per listing; multiple descriptors resolve last-wins to the final one; corrupt payloads and unknown versions fold to `corrupt`; a cold-read failure maps to `unavailable` and retries on the next listing; the ancestor descriptor in a fork seed forms a row under that identity (pinning deviation one); ordinary forks and descendants without a subagent origin neither enter the list nor count toward `hasChildren`; `createdAt`-then-id ordering; an unmounted provider does not affect listing; compacted and uncompacted twins list identically; the three cases of pre-abort, persistence listing, and cold-read cancellation all normalize to `CANCELLED`; the empty list and stable error codes. The `tool-subagent-control` list-agents tests are updated for the narrowed load requirement; `optional-session-query.spec.ts` is deleted with the dependency it guarded; the keyless ACP snapshots (`subagent-list-agents` among others) are not re-recorded — zero change to the wire and model-visible surfaces is pinned by the existing snapshots. +`packages/subagent/subagent/tests/list-children.spec.ts` is rewritten to this contract: live-only listing without persistence, query services, or the continuation runtime; with the registry absent, even zero children loudly report `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`; a live child incurs zero `inspect` throughout while a cold child incurs exactly one per listing; multiple descriptors resolve last-wins to the final one; corrupt payloads and unknown versions fold to `corrupt`; a cold-read failure maps to `unavailable` and retries on the next listing; the ancestor descriptor in a fork seed forms a row under that identity (pinning deviation one); ordinary forks and descendants without a subagent origin neither enter the list nor count toward `hasChildren`; `createdAt`-then-id ordering; an unmounted provider does not affect listing; compacted and uncompacted twins list identically; the three cases of pre-abort, persistence listing, and cold-read cancellation all normalize to `CANCELLED`; the empty list and stable error codes. A hostile-unit dual-path probe (`apply` lazily poisons, `view` detonates) proves that any registered unit's fold/schema throw on this child's log is contained as that child's `corrupt` row on both the live and the cold retrieval paths, with siblings and the listing itself unaffected. 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 @@ -166,7 +168,7 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir - 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. +- The diagnostic and enumeration semantics leaves five boundary deviations (a stillborn fork surfacing under its ancestor's identity, multiple descriptors resolving to the last, header conflicts going unnoticed, damaged-source read failures shifting from `corrupt` to `unavailable`, and an unknown parent yielding an empty list instead of not-found); the full semantics is in the known-boundary-deviations list; the first four are display or classification deviations on debris-grade data with resume authorization unaffected, and the unknown-parent one is a silent query-semantics change, explicitly accepted. - Pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and pre-release carries no compatibility promise. ## Related diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md index 42a5781470..45bdd41ba0 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md @@ -115,6 +115,7 @@ export type SubagentListEntry = - `unsupported` 不再被产出:类型与 wire 枚举按"数据结构保持现状"留存该成员,本记录留档其为不再产出。 - descriptor-less 定局残骸从旧实现的 omit 归入 `corrupt` diagnostic——库里的坏、死子会话可见,不静默消失,这正是保留 diagnostic 的原始动机。 +- 任一注册 unit 的 fold/schema 在该 child 日志上抛错,同样收纳为该 child 的 diagnostic 行,reason `corrupt`——确定性数据故障,对齐旧实现 `SESSION_QUERY_CORRUPT_SESSION`→`corrupt` 的映射语义;live 与 cold 同待遇,逐 child 隔离,sibling 与列表本身不受影响。它与「无值 + running → omit」正交:创建窗口是"尚无数据",fold 抛错是"数据坏了"——running 的中毒 child 也出 `corrupt` 行而非 omit。 已知边界偏差(有意接受,随本记录留档): @@ -122,6 +123,7 @@ export type SubagentListEntry = - own suffix 出现多个描述符,旧实现判 corrupt,现 last-wins 取末者(provider 契约本就保证恰一)。 - live/persisted header 冲突,旧实现是 per-child corrupt;现枚举 live 优先、不做一致性校验,冲突不再被察觉,以 live 记录成行。 - 损坏存储的源读失败(如坏 surface 被冷读整读拒收),旧实现映射 per-child `corrupt`,现统一成 `unavailable` 行(读侧无从区分成因)。 +- 未知 parent,旧实现经 session-query 抛 not-found('parent session … was not found');现自管合并对不存在的 parent 得到空子集,枚举返回空列表,wire 上后续操作落到 child 级 subagent-not-found——语义与文案的静默变化,显式接受。 消费面:wire、tool、GUI 的 diagnostic 处理**全部保持原状零改动**(`list_agents` 的 description 与 output schema 未动;该插件仅加载要求收窄——inject 去掉 `sessionQuery`)。行为上唯一动的是 apiproxy 路由段:`hasSubagentDescriptor()` 扫描已删除,`hasSubagentOwner` 只看 `header.origin`——pre-#1569 的无 `origin` 存量不再被认作 subagent 属主,其本就不进目录,pre-release 立场接受。 @@ -158,7 +160,7 @@ export type SubagentListEntry = ## 验证 -`packages/subagent/subagent/tests/list-children.spec.ts` 重写为本契约:无 persistence、query 服务与继续运行时的 live-only 列表;registry 缺席时零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;live child 全程零 `inspect`、cold child 每次列表恰一次;多描述符 last-wins 取末者;损坏载荷与未知版本折为 `corrupt`;冷读失败映射 `unavailable` 且下次列表重试;fork seed 里的祖先描述符按该身份成行(偏差一钉住);普通 fork 与无 subagent origin 的后代不入列也不计入 `hasChildren`;`createdAt`→id 排序;provider 未挂载不影响列表;压缩与未压缩孪生一致;预中止、持久化列表与冷读取消三例归一 `CANCELLED`;空列表与稳定错误码。`tool-subagent-control` 的 list-agents 测试随加载要求收窄更新;`optional-session-query.spec.ts` 随依赖消失删除;无密钥 ACP 快照(`subagent-list-agents` 等)未重录——wire 与 model-visible 面零改动由既有快照钉住。 +`packages/subagent/subagent/tests/list-children.spec.ts` 重写为本契约:无 persistence、query 服务与继续运行时的 live-only 列表;registry 缺席时零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;live child 全程零 `inspect`、cold child 每次列表恰一次;多描述符 last-wins 取末者;损坏载荷与未知版本折为 `corrupt`;冷读失败映射 `unavailable` 且下次列表重试;fork seed 里的祖先描述符按该身份成行(偏差一钉住);普通 fork 与无 subagent origin 的后代不入列也不计入 `hasChildren`;`createdAt`→id 排序;provider 未挂载不影响列表;压缩与未压缩孪生一致;预中止、持久化列表与冷读取消三例归一 `CANCELLED`;空列表与稳定错误码。敌意 unit 双路探针(`apply` 惰性置毒、`view` 引爆)证明任一注册 unit 在该 child 日志上的 fold/schema 抛错,在 live 与 cold 两条取值路径上都收纳为该 child 的 `corrupt` 行,sibling 与列表本身不受影响。`tool-subagent-control` 的 list-agents 测试随加载要求收窄更新;`optional-session-query.spec.ts` 随依赖消失删除;无密钥 ACP 快照(`subagent-list-agents` 等)未重录——wire 与 model-visible 面零改动由既有快照钉住。 ## 后果 @@ -166,7 +168,7 @@ export type SubagentListEntry = - 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`),完整语义见已知边界偏差清单;均为残骸级数据的展示或分类偏差,恢复鉴权不受影响。 +- 诊断与枚举语义留下五处边界偏差(stillborn fork 祖先身份误现、多描述符取末者、header 冲突不再被察觉、损坏源读失败由 `corrupt` 转 `unavailable`、未知 parent 由 not-found 改为空列表),完整语义见已知边界偏差清单;前四处为残骸级数据的展示或分类偏差,恢复鉴权不受影响,未知 parent 一处是查询语义的静默变化,显式接受。 - pre-#1569 的无 `origin` 存量不再被认作 subagent 属主;其本就不进目录,pre-release 无兼容承诺。 ## 相关 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 462b528a30..618f25c667 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -40,6 +40,8 @@ flowchart LR cfg --> plugin_dsh_base_session_persistence_jsonl plugin_dsh_base_session_query_sqlite["session-query-sqlite
@deepseek-ai/dsh-session-query-sqlite"] cfg --> plugin_dsh_base_session_query_sqlite + plugin_dsh_base_session_projection["session-projection
@deepseek-ai/dsh-session-projection"] + cfg --> plugin_dsh_base_session_projection plugin_dsh_base_telemetry_otel["telemetry-otel
@deepseek-ai/dsh-session-telemetry-otel"] cfg --> plugin_dsh_base_telemetry_otel plugin_dsh_base_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] @@ -166,6 +168,7 @@ flowchart LR | `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` | | `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` | | `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` | +| `session-projection` | `@deepseek-ai/dsh-session-projection` | | `telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` | | `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `sandbox` | `@deepseek-ai/dsh-sandbox-local` | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 72984de195..2b440e77ac 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -104,8 +104,8 @@ # Continuable background children are selected per delegation tool. The # separately loaded control package registers the global `send_message`; its -# list plugin registers `list_agents` and requires the app's session query. -# `report` is installed only in continuable child scopes. +# list plugin registers `list_agents`, served through the sessionProjections +# registry mounted above. `report` is installed only in continuable child scopes. - id: tool-subagent-control name: '@deepseek-ai/dsh-tool-subagent-control' diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index c09c7da39c..4c006198fe 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -100,6 +100,12 @@ path: ':memory:' openAt: first-search + # Shared projection registry: subagent catalog identity (mode/label) folds + # through its registered units, so the `list_agents` surface below fails + # loud without it; web layers reuse this same mount for list rows. + - id: session-projection + name: '@deepseek-ai/dsh-session-projection' + # Session telemetry, on for every dsh mode: mirrors every session-log # event (assistant/chunk projected to first-of-step) plus ops markers onto # OTLP/HTTP log records, streaming on the batch processor's cadence diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 95e169cabb..14399cd441 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -61,6 +61,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-sqlite": "workspace:^", "@deepseek-ai/dsh-session-telemetry-otel": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index bdf871b6ac..7662182df0 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/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/bundle/web-app/README.md -README.md: a835497982e41c29c5dbc287c43b0515a61f77c9 -README.zh.md: 00d8636e848a7927792dfc8ad3a460015c9e7fef +README.md: dc35cb4b596b265b70cea81aa5d6784fc1eff65b +README.zh.md: 0ffc5cdaf1a98e5df11ef042c7d15a994515c170 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index a835497982..dc35cb4b59 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection, storage) and the browser plugin roster, and mounts this package's own `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin owns what used to be launcher code: it resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports (workspace knowledge of this bundle, never user config), mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. The `dsh web` launcher alias patches `mode`/`lanAddresses` and the flag family over these rows; [`dsh-headless`](../headless/README.md) layers on top, silences the URL line, and disables the surface context. +The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's own `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin owns what used to be launcher code: it resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports (workspace knowledge of this bundle, never user config), mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. The `dsh web` launcher alias patches `mode`/`lanAddresses` and the flag family over these rows; [`dsh-headless`](../headless/README.md) layers on top, silences the URL line, and disables the surface context. ## Model Experience diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 00d8636e84..0ffc5cdaf1 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影、存储)与浏览器插件名录,并挂载本包自己的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件接管了原先属于启动器的代码:它通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist(这是本组合包的 workspace 知识,绝不是用户配置),在其上挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。`dsh web` 启动器别名把 `mode`/`lanAddresses` 与相应 flag 家族 patch 到这些行上;[`dsh-headless`](../headless/README.md) 再叠加一层,关闭 URL 行并禁用表层上下文。 +dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包自己的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件接管了原先属于启动器的代码:它通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist(这是本组合包的 workspace 知识,绝不是用户配置),在其上挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。`dsh web` 启动器别名把 `mode`/`lanAddresses` 与相应 flag 家族 patch 到这些行上;[`dsh-headless`](../headless/README.md) 再叠加一层,关闭 URL 行并禁用表层上下文。 ## 模型体验 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index a0f53b787f..624e9e37af 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -45,9 +45,6 @@ # `dshClient` rows are the browser roster the modules node half scans into # window.__DSH_BOOT__; the modules row is simultaneously a host row. - insert: - - id: session-projection - name: '@deepseek-ai/dsh-session-projection' - - id: code-runtime name: '@deepseek-ai/dsh-code-runtime-worker' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 1b812b9943..0a406a7bf8 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -63,7 +63,6 @@ "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 9a93b89d99..835ca39728 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -609,23 +609,12 @@ async function catalogChild( } return { entry } } catch (error: unknown) { - if (signal?.aborted - || (error instanceof SubagentError && error.code === 'CANCELLED') - || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) { + if (signal?.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')) { 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: { - code: 'subagent-not-found', - message: `parent session "${parentSessionId}" was not found`, - details: { parentSessionId, childSessionId }, - }, - } - } return { error: { code: 'internal', message: 'subagent catalog read failed', details: {} } } } } @@ -1903,9 +1892,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro parentAvailable: ctx.agents.get(request.payload.parentSessionId) !== undefined, }) } catch (error: unknown) { - if (signal?.aborted - || (error instanceof SubagentError && error.code === 'CANCELLED') - || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) { + if (signal?.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')) { return err(request, { code: 'cancelled', message: 'subagent catalog read was cancelled', diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index faa1c39e9f..df0a8d53a3 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -157,6 +157,33 @@ describe('subagent gateway', () => { expect(readSession).not.toHaveBeenCalled() }) + it('maps the missing projections capability to one wire face on list, history, and prompt', async () => { + const listError = () => new SubagentError( + 'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', + 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE', + ) + const expected = { + code: 'internal', + message: 'subagent listing is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', + } + + const list = bench({ listError: listError() }) + expect((await list.api.subagents.list(request({ parentSessionId: PARENT }))).result) + .toMatchObject({ ok: false, error: expected }) + + const history = bench({ listError: listError() }) + expect((await history.api.subagents.history(request({ + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', + }))).result).toMatchObject({ ok: false, error: expected }) + expect(history.readSession).not.toHaveBeenCalled() + + const prompt = bench({ listError: listError() }) + expect((await prompt.api.subagents.prompt(request({ + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [], + }), new AbortController().signal)).result).toMatchObject({ ok: false, error: expected }) + expect(prompt.followup).not.toHaveBeenCalled() + }) + it('routes human content through the exact live parent with rpc attribution', async () => { const { api, parent, followup } = bench() const content = [{ type: 'text' as const, text: '继续' }] diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index dbea888816..8116d3dd88 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -74,10 +74,12 @@ export type SubagentListEntry = /** * 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. + * unrecognized-version descriptor — deliberately undistinguished), and + * for any candidate whose log makes a registered unit's fold or schema + * throw (deterministic data damage, contained per child); `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' } @@ -166,7 +168,17 @@ export async function listChildren( // 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 + let identity: SubagentIdentityProjection | undefined + try { + identity = projections.snapshot(candidate.live).values.subagent + } catch { + // The snapshot folds EVERY registered unit over this child's log, so + // any unit's fold or schema can reject damaged payloads. That is + // deterministic data damage in this one child; it degrades to one + // corrupt diagnostic instead of failing the whole listing. + rows[index] = { kind: 'diagnostic', id: childId, reason: 'corrupt' } + return + } if (identity === undefined) return rows[index] = childRow(childId, identity, 'running', subagentParents.has(childId)) }) @@ -195,7 +207,8 @@ export async function listChildren( * 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`. + * cannot identify — or that makes any registered unit throw — is final, so + * it reports `corrupt`. */ async function inspectColdIdentity( persistence: SessionPersistence, @@ -215,7 +228,15 @@ async function inspectColdIdentity( return { kind: 'diagnostic', id: childId, reason: 'unavailable' } } assertListingNotCancelled(signal) - const identity = projections.restore({}, events, 0).snapshot.values.subagent + let identity: SubagentIdentityProjection | undefined + try { + identity = projections.restore({}, events, 0).snapshot.values.subagent + } catch { + // The restore folds EVERY registered unit over this child's log, so any + // unit's fold or schema can reject damaged payloads — deterministic data + // damage in this one child, contained as its own corrupt diagnostic. + return { kind: 'diagnostic', id: childId, reason: 'corrupt' } + } if (identity === undefined) { return { kind: 'diagnostic', id: childId, reason: 'corrupt' } } diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 1d05f0467d..8745496d16 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { z } from 'zod' import { Context } from 'cordis' import { createUserMessage } from '@deepseek-ai/dsh-llm' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -10,6 +11,7 @@ import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/ds import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION, SubagentError, @@ -100,6 +102,34 @@ function descriptorPayload(label: string, version = SUBAGENT_DESCRIPTOR_VERSION) return { version, mode: 'continuable' as const, provider: 'spawn', label } } +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + /** Test-only hostile probe proving per-child isolation of foreign unit failures. */ + subagentListHostileProbe: null + } +} + +/** + * A foreign registered unit that rejects one specific child's log at view + * time: `apply` never throws (the eager drive passes every committed event + * through it), while the poisoned state detonates only when a listing read + * folds or serves this child through the registry. + */ +const hostileProjectionDefinition: ProjectionDefinition<'subagentListHostileProbe', { poisoned?: boolean }> = { + key: 'subagentListHostileProbe', + schema: z.null(), + init: () => ({}), + apply: (state, event) => + event.type === 'subagent/descriptor' && (event.data as { label?: string }).label === 'poison me' + ? { poisoned: true } + : state, + view: (state) => { + if (state.poisoned === true) throw new Error('hostile unit rejects the poisoned log') + return null + }, + stateVersion: 1, +} + describe('SubagentService.listChildren', () => { it('lists live children without persistence, query services, or the continuation runtime', async () => { const ctx = new Context() @@ -402,6 +432,56 @@ describe('SubagentService.listChildren', () => { ]) }) + it('contains a foreign unit failure during a cold fold to that child as corrupt', async () => { + const { ctx, parent } = await setup([textResponse('done')]) + ctx.sessionProjections.register(hostileProjectionDefinition) + const healthy = await startChild(ctx, parent, 'healthy sibling') + const poisoned = await authorChild(ctx, '00000000-0000-4000-8000-00000000d00d', { + parentSession: parent.id, + origin: 'subagent', + }, childEvents(descriptorPayload('poison me'))) + // The subagent unit itself folds this child cleanly; the FOREIGN unit's + // view throws, and that damage stays contained to the one child. + const entries = await ctx.subagents.listChildren(parent.id) + expect(entries).toContainEqual({ kind: 'diagnostic', id: poisoned, reason: 'corrupt' }) + expect(entries).toContainEqual({ + kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable', + activity: 'inactive', hasChildren: false, + }) + }) + + it('contains a foreign unit failure during a live snapshot to that child as corrupt', async () => { + const { ctx, parent } = await setup([]) + ctx.sessionProjections.register(hostileProjectionDefinition) + const poisonedId = SessionId('live-poisoned-child') + const poisoned = ctx.sessions.create(poisonedId, { + meta: { parentSession: parent.id, origin: 'subagent' }, + }) + poisoned.append('turn/start', { turn: 1 }) + poisoned.append('subagent/descriptor', descriptorPayload('poison me')) + const healthyId = SessionId('live-healthy-child') + const healthy = ctx.sessions.create(healthyId, { + meta: { parentSession: parent.id, origin: 'subagent' }, + }) + healthy.append('turn/start', { turn: 1 }) + healthy.append('subagent/descriptor', descriptorPayload('live healthy')) + const entries = await ctx.subagents.listChildren(parent.id) + expect(entries).toContainEqual({ kind: 'diagnostic', id: poisonedId, reason: 'corrupt' }) + expect(entries).toContainEqual({ + kind: 'child', id: healthyId, label: 'live healthy', mode: 'continuable', + activity: 'running', hasChildren: false, + }) + }) + + it('fails the whole enumeration when the persisted listing itself fails', async () => { + const { ctx, parent } = await setup([textResponse('done')]) + await startChild(ctx, parent, 'never listed') + ctx.sessionPersistence.list = () => Promise.reject(new Error('backend listing failed')) + // Without any abort in flight, the original backend failure propagates + // as the operation failure — no cancellation mapping, no diagnostic rows. + await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow('backend listing failed') + }) + it('maps a failed cold inspection to one unavailable diagnostic and retries it next listing', async () => { const { ctx, parent } = await setup([textResponse('done')]) const healthy = await startChild(ctx, parent, 'healthy sibling') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb1f5ae494..6a2ba96a54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -946,6 +946,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-session-query-sqlite': specifier: workspace:^ version: link:../../session-query/session-query-sqlite @@ -1191,9 +1194,6 @@ importers: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver - '@deepseek-ai/dsh-session-projection': - specifier: workspace:^ - version: link:../../session-projection/session-projection '@deepseek-ai/dsh-session-projection-cache': specifier: workspace:^ version: link:../../session-projection/session-projection-cache