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