fix: address review round two

- listChildren contains per-child projection faults on both ladder rungs
  (any registered unit's fold/schema rejection maps to that child's corrupt
  diagnostic) and pins the whole-enumeration listing-failure rethrow
- the base bundle mounts session-projection (web-app's own insert retired
  to avoid the double mount); stale composition comment updated
- the shared projections-unavailable wire face is pinned across
  list/history/prompt; retired session-query arms removed from the catalog
  paths
- the design note records the unknown-parent semantics shift and the
  fold-fault isolation rule
This commit is contained in:
imccyu
2026-08-06 21:41:50 +08:00
parent efd78f44f4
commit 6ff4fc0ed0
17 changed files with 166 additions and 41 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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 无兼容承诺。
## 相关

View File

@@ -40,6 +40,8 @@ flowchart LR
cfg --> plugin_dsh_base_session_persistence_jsonl
plugin_dsh_base_session_query_sqlite["session-query-sqlite<br/>@deepseek-ai/dsh-session-query-sqlite"]
cfg --> plugin_dsh_base_session_query_sqlite
plugin_dsh_base_session_projection["session-projection<br/>@deepseek-ai/dsh-session-projection"]
cfg --> plugin_dsh_base_session_projection
plugin_dsh_base_telemetry_otel["telemetry-otel<br/>@deepseek-ai/dsh-session-telemetry-otel"]
cfg --> plugin_dsh_base_telemetry_otel
plugin_dsh_base_subprocess["subprocess<br/>@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` |

View File

@@ -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'

View File

@@ -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

View File

@@ -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:^",

View File

@@ -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

View File

@@ -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

View File

@@ -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 行并禁用表层上下文。
## 模型体验

View File

@@ -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'

View File

@@ -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:^",

View File

@@ -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',

View File

@@ -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: '继续' }]

View File

@@ -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' }
}

View File

@@ -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')

6
pnpm-lock.yaml generated
View File

@@ -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