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

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