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

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