fix: address review round three
- the subagent projection's empty state becomes a serializable null sentinel (undefined fields vanish in JSON push frames, leaving stale identities in clients); consumers treat null and a missing key alike, and cached sentinels defer to the authoritative refold - the tool catalog generator mounts the projection registry for list_agents; the shared unavailable-error copy goes catalog-neutral - the design note's verification section records the new subagent-diagnostic snapshot honestly, the 2026-07-22 note's test inventory is rewritten to the current mechanism, and the session-store error code is documented
This commit is contained in:
@@ -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/subagent/subagent/README.md
|
||||
README.md: b89fcb4b4d318c872117078b6c49822d0633fd31
|
||||
README.zh.md: 6309a9b8ad4ac9edf5ccf994594638cc9f175684
|
||||
README.md: 92c8222381338f71c7d444da80c23098aba247b3
|
||||
README.zh.md: 8f61379bb789de6c1b97b22db034698009d0619c
|
||||
|
||||
@@ -78,7 +78,7 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen
|
||||
|
||||
Continuable children do not create `SubagentRun` or Tasks. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` remains provenance rather than authority.
|
||||
|
||||
When `ctx.sessionProjections` is available, the service registers two projection units. `subagentTiming` resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn; while that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata. `subagent` folds the durable identity — mode plus creation label — from `subagent/descriptor` events with the same last-wins reset discipline, so a fork seed's ancestor descriptor stands only until the child's own overrides it; a malformed or unrecognized-version payload folds to no value, indistinguishable from a log with no descriptor, and never throws.
|
||||
When `ctx.sessionProjections` is available, the service registers two projection units. `subagentTiming` resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn; while that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata. `subagent` folds the durable identity — mode plus creation label — from `subagent/descriptor` events with the same last-wins reset discipline, so a fork seed's ancestor descriptor stands only until the child's own overrides it; a malformed or unrecognized-version payload folds to the serializable `null` sentinel — indistinguishable from a log with no descriptor, and surviving every JSON push frame so a consumer replaces a stale identity instead of keeping it — and never throws.
|
||||
|
||||
`registerContinuableSetup()` lets optional packages add child-scoped capabilities without teaching the continuation manager their names. Contributions install synchronously before Activation publication, roll back with failed setup, and are released with the child scope. New grants wait for the next Activation, while contribution removal revokes every resident installation immediately.
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
可继续子级不会创建 `SubagentRun` 或 Task。继续执行管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由确切在线的直接父级身份授权。上报则由确切在线的子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。
|
||||
|
||||
当 `ctx.sessionProjections` 可用时,服务会注册两个投影单元。`subagentTiming` 会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界;在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。`subagent` 以同样的 last-wins 重置纪律从 `subagent/descriptor` 事件折叠持久化身份——模式与创建标签——因此 fork 种子中的祖先描述符只在 child 自身的描述符覆盖之前有效;畸形或版本不识别的载荷折叠为无值,与没有描述符的日志不可区分,且绝不抛错。
|
||||
当 `ctx.sessionProjections` 可用时,服务会注册两个投影单元。`subagentTiming` 会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界;在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。`subagent` 以同样的 last-wins 重置纪律从 `subagent/descriptor` 事件折叠持久化身份——模式与创建标签——因此 fork 种子中的祖先描述符只在 child 自身的描述符覆盖之前有效;畸形或版本不识别的载荷折叠为可序列化的 `null` 哨兵——与没有描述符的日志不可区分,且能完好通过每个 JSON 推送帧,让消费方以之替换掉手中过时的身份而非永久滞留——绝不抛错。
|
||||
|
||||
`registerContinuableSetup()` 允许可选包添加子级作用域能力,而无需让继续执行管理器知道这些能力的名称。贡献会在 Activation 发布前同步安装,在设置失败时一并回滚,并随子级作用域释放。新授权须等到下一个 Activation,移除贡献则会立即撤销每个驻留安装项。
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ 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.
|
||||
let identity: SubagentIdentityProjection | undefined
|
||||
let identity: SubagentIdentityProjection | null | undefined
|
||||
try {
|
||||
identity = projections.snapshot(candidate.live).values.subagent
|
||||
} catch {
|
||||
@@ -188,7 +188,9 @@ export async function listChildren(
|
||||
rows[index] = { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
return
|
||||
}
|
||||
if (identity === undefined) return
|
||||
// The unit's serializable no-value sentinel is `null`; `undefined` can
|
||||
// only mean the key was dropped at a JSON boundary. Both are no value.
|
||||
if (identity === undefined || identity === null) return
|
||||
rows[index] = childRow(childId, identity, 'running', subagentParents.has(childId))
|
||||
})
|
||||
|
||||
@@ -231,7 +233,7 @@ async function resolveColdIdentity(
|
||||
): Promise<SubagentListEntry> {
|
||||
const childId = header.id
|
||||
if (cache !== undefined) {
|
||||
let cached: SubagentIdentityProjection | undefined
|
||||
let cached: SubagentIdentityProjection | null | undefined
|
||||
try {
|
||||
cached = cache.cachedSnapshot(header)?.values.subagent
|
||||
} catch {
|
||||
@@ -240,10 +242,14 @@ async function resolveColdIdentity(
|
||||
// row of ANY unit) silently falls through to the authoritative re-fold.
|
||||
cached = undefined
|
||||
}
|
||||
// The identity is immutable once appended, so a cached value is final
|
||||
// regardless of the row's watermark; an absent key (a checkpoint cut
|
||||
// before the descriptor was appended) falls through to preparation.
|
||||
if (cached !== undefined) return childRow(childId, cached, 'inactive', hasChildren)
|
||||
// A served identity is immutable once appended, so a cached one is final
|
||||
// regardless of the row's watermark. Both no-value forms fall through to
|
||||
// preparation: an absent key (a checkpoint cut before the descriptor was
|
||||
// appended) and the `null` sentinel, whose verdict belongs to the
|
||||
// authoritative re-fold, not to a derived row.
|
||||
if (cached !== undefined && cached !== null) {
|
||||
return childRow(childId, cached, 'inactive', hasChildren)
|
||||
}
|
||||
}
|
||||
assertListingNotCancelled(signal)
|
||||
let events: readonly SessionEvent[]
|
||||
@@ -256,7 +262,7 @@ async function resolveColdIdentity(
|
||||
return { kind: 'diagnostic', id: childId, reason: 'unavailable' }
|
||||
}
|
||||
assertListingNotCancelled(signal)
|
||||
let identity: SubagentIdentityProjection | undefined
|
||||
let identity: SubagentIdentityProjection | null | undefined
|
||||
try {
|
||||
identity = projections.restore({}, events, 0).snapshot.values.subagent
|
||||
} catch {
|
||||
@@ -265,7 +271,7 @@ async function resolveColdIdentity(
|
||||
// damage in this one child, contained as its own corrupt diagnostic.
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
}
|
||||
if (identity === undefined) {
|
||||
if (identity === undefined || identity === null) {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
}
|
||||
return childRow(childId, identity, 'inactive', hasChildren)
|
||||
|
||||
@@ -42,11 +42,14 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
/** Active-turn duration for a descriptor-backed subagent session. */
|
||||
subagentTiming: SubagentTimingProjection
|
||||
/**
|
||||
* Identity of a descriptor-backed subagent session. No value ⟺ no valid
|
||||
* descriptor: a missing, malformed, or unrecognized-version descriptor is
|
||||
* served identically as `undefined` in a live snapshot, and as an absent
|
||||
* key after any JSON boundary (query-index rows, wire frames) drops it.
|
||||
* Identity of a descriptor-backed subagent session. `null` ⟺ no valid
|
||||
* descriptor (missing, malformed, or unrecognized-version — deliberately
|
||||
* undistinguished). The sentinel is deliberately serializable: a
|
||||
* value pushed over JSON transports must survive `JSON.stringify`
|
||||
* losslessly, where an `undefined` field would be dropped and a stale
|
||||
* identity would survive on the receiving side. The entry itself stays
|
||||
* non-optional.
|
||||
*/
|
||||
subagent: SubagentIdentityProjection
|
||||
subagent: SubagentIdentityProjection | null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,9 +90,11 @@ interface IdentityState {
|
||||
identity?: SubagentIdentityProjection
|
||||
}
|
||||
|
||||
// Zod's optional output includes explicit `undefined`; with
|
||||
// exactOptionalPropertyTypes the public map entry permits omission only, and
|
||||
// JSON boundaries drop the undefined-valued key entirely.
|
||||
// The cast bridges only the optional-label arm: Zod's optional output
|
||||
// includes explicit `undefined`, which exactOptionalPropertyTypes excludes
|
||||
// from the public interface. The no-value state itself is the serializable
|
||||
// `null` arm — never `undefined` — so every registry read and push frame
|
||||
// survives JSON.stringify losslessly.
|
||||
const identitySchema = z.discriminatedUnion('mode', [
|
||||
z.object({
|
||||
mode: z.literal('one-shot'),
|
||||
@@ -102,7 +104,7 @@ const identitySchema = z.discriminatedUnion('mode', [
|
||||
mode: z.literal('continuable'),
|
||||
label: z.string(),
|
||||
}).strict(),
|
||||
]).optional() as unknown as z.ZodType<SubagentIdentityProjection>
|
||||
]).nullable() as unknown as z.ZodType<SubagentIdentityProjection | null>
|
||||
|
||||
/** Interpret one `subagent/descriptor` event's identity; no value when the payload cannot be trusted. */
|
||||
function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | undefined {
|
||||
@@ -125,9 +127,11 @@ function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | u
|
||||
* last-wins: a fork seed may replay an ancestor's descriptor, and the child's
|
||||
* own descriptor must override it — the same reset discipline as
|
||||
* {@link subagentTimingProjectionDefinition}. A malformed or unknown-version
|
||||
* payload resets to no value instead of throwing, so a fork of a healthy
|
||||
* ancestor never inherits an identity its own descriptor failed to establish;
|
||||
* no value ⟺ no valid descriptor, with the causes deliberately undistinguished.
|
||||
* payload resets to the `null` sentinel instead of throwing, so a fork of a
|
||||
* healthy ancestor never inherits an identity its own descriptor failed to
|
||||
* establish — and the reset survives every JSON push frame, so a consumer
|
||||
* holding the earlier identity replaces it instead of keeping it stale;
|
||||
* `null` ⟺ no valid descriptor, with the causes deliberately undistinguished.
|
||||
*/
|
||||
export const subagentIdentityProjectionDefinition:
|
||||
ProjectionDefinition<'subagent', IdentityState> = {
|
||||
@@ -139,11 +143,6 @@ ProjectionDefinition<'subagent', IdentityState> = {
|
||||
const identity = descriptorIdentity(event)
|
||||
return identity === undefined ? {} : { identity }
|
||||
},
|
||||
// The assertion deliberately widens: a log without a descriptor serves
|
||||
// `undefined` at runtime, which the schema's `.optional()` accepts, and
|
||||
// every registry read face already returns `Partial` snapshot values where
|
||||
// absence is the type. The map entry stays non-optional so a child row's
|
||||
// served identity remains a strong contract for consumers.
|
||||
view: state => state.identity as SubagentIdentityProjection,
|
||||
view: state => state.identity ?? null,
|
||||
stateVersion: 1,
|
||||
}
|
||||
|
||||
@@ -364,6 +364,67 @@ describe('SubagentService.listChildren', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('serves the serializable null sentinel when a later descriptor invalidates the identity', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const liveId = SessionId('invalidated-live-child')
|
||||
const live = ctx.sessions.create(liveId, {
|
||||
meta: { parentSession: parent.id, origin: 'subagent' },
|
||||
})
|
||||
live.append('turn/start', { turn: 1 })
|
||||
live.append('subagent/descriptor', descriptorPayload('was valid'))
|
||||
expect(ctx.sessionProjections.snapshot(live).values.subagent)
|
||||
.toEqual({ mode: 'continuable', label: 'was valid' })
|
||||
// Last-wins: the malformed follow-up resets the identity to the sentinel.
|
||||
live.append(
|
||||
'subagent/descriptor',
|
||||
{ version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 7 } as never,
|
||||
)
|
||||
const values = ctx.sessionProjections.snapshot(live).values
|
||||
expect(values.subagent).toBeNull()
|
||||
// The sentinel survives a JSON push frame; an undefined field would be
|
||||
// dropped there and a consumer would keep the stale identity forever.
|
||||
const wired = JSON.parse(JSON.stringify(values)) as Record<string, unknown>
|
||||
expect('subagent' in wired).toBe(true)
|
||||
expect(wired['subagent']).toBeNull()
|
||||
// The listing reads the same null as no value: running → omitted.
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('diagnoses a settled child whose later descriptor invalidated the identity as corrupt', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const events = childEvents(descriptorPayload('was valid'))
|
||||
events.splice(3, 0, {
|
||||
type: 'subagent/descriptor',
|
||||
seq: 3,
|
||||
time: 3,
|
||||
data: { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 7 },
|
||||
} as SessionEvent)
|
||||
events[4] = { ...events[4]!, seq: 4 }
|
||||
const invalidated = await authorChild(ctx, '00000000-0000-4000-8000-00000000ad01', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, events)
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([
|
||||
{ kind: 'diagnostic', id: invalidated, reason: 'corrupt' },
|
||||
])
|
||||
})
|
||||
|
||||
it('lets preparation rule when the cache serves the null sentinel', async () => {
|
||||
const { ctx, parent } = await setup([], { projectionCache: true })
|
||||
const healthy = await authorChild(ctx, '00000000-0000-4000-8000-00000000ad02', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('actually valid')))
|
||||
// A stale cached sentinel must not out-rank the authoritative re-fold.
|
||||
ctx.sessionProjectionCache.cachedSnapshot = () => ({ asOfSeq: 0, values: { subagent: null } })
|
||||
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
|
||||
kind: 'child', id: healthy, label: 'actually valid', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}])
|
||||
expect(inspect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('maps a child rejected by persistence inspection to unavailable', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
// The surface-eligible user/message lacks its required surfaceOp, so the
|
||||
|
||||
Reference in New Issue
Block a user