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:
@@ -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
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 行并禁用表层上下文。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '继续' }]
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user