fix: address review round next
- subagents.history computes its projections best-effort on both arms (a hostile unit's fold rejection serves the page without the block, matching the session-list precedent) with dual-arm coverage - the lifecycle-witness spec probes every field of the seven-key witness, protecting the key list itself - list-children's own module docs catch up with the seq-gate contract, and the design note records the rung-two later-event window as an accepted, self-healing deviation of the corruption class
This commit is contained in:
@@ -527,6 +527,28 @@ function detachedProjectionsFor(
|
||||
return registry.restore({}, events, 0).snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort projections for one subagent history page, fail-soft like
|
||||
* {@link listProjectionsFor}: a registered unit throwing on a corrupt payload
|
||||
* never blocks transcript reading — the page is served without the block.
|
||||
* @param ctx - context carrying the logger for the degradation warning.
|
||||
* @param childSessionId - the child whose page is being decorated.
|
||||
* @param compute - the arm-specific fold (live watermark or detached restore).
|
||||
* @returns the projections block, or undefined when the fold failed.
|
||||
*/
|
||||
function subagentHistoryProjections(
|
||||
ctx: Context,
|
||||
childSessionId: SessionId,
|
||||
compute: () => SessionProjectionsBlock | undefined,
|
||||
): SessionProjectionsBlock | undefined {
|
||||
try {
|
||||
return compute()
|
||||
} catch (error) {
|
||||
ctx.logger.warn(`subagent.history: projections for "${childSessionId}" failed (serving the page without them): ${String(error)}`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Map continuation admission failures without exposing provider details. */
|
||||
function subagentPromptError(
|
||||
request: RpcRequest<{ childSessionId: SessionId }>,
|
||||
@@ -1928,14 +1950,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
if (attached !== undefined) {
|
||||
header = attached.header
|
||||
events = [...attached.events]
|
||||
projections = beforeSeq === undefined ? projectionsFor(ctx, attached) : undefined
|
||||
projections = beforeSeq === undefined
|
||||
? subagentHistoryProjections(ctx, childSessionId, () => projectionsFor(ctx, attached))
|
||||
: undefined
|
||||
} else {
|
||||
try {
|
||||
const inspected = await inspectServable(childSessionId)
|
||||
header = inspected.meta
|
||||
events = inspected.events
|
||||
projections = beforeSeq === undefined
|
||||
? detachedProjectionsFor(ctx, inspected.events)
|
||||
? subagentHistoryProjections(ctx, childSessionId, () => detachedProjectionsFor(ctx, inspected.events))
|
||||
: undefined
|
||||
} catch (error: unknown) {
|
||||
if (signal?.aborted) {
|
||||
|
||||
@@ -24,6 +24,8 @@ function bench(options: {
|
||||
storedChild?: false
|
||||
/** Attach the child to the live session store instead of persistence only. */
|
||||
liveChild?: true
|
||||
/** Every registered projection unit throws on this child's payloads. */
|
||||
projectionsThrow?: true
|
||||
historyParent?: SessionId
|
||||
} = {}) {
|
||||
const parent = { id: PARENT }
|
||||
@@ -60,8 +62,14 @@ function bench(options: {
|
||||
const inspect = vi.fn(() => Promise.resolve({ meta: childHeader, events: childEvents }))
|
||||
const liveBlock = { values: {}, asOfSeq: 3 }
|
||||
const coldBlock = { values: {}, asOfSeq: 0 }
|
||||
const snapshot = vi.fn(() => liveBlock)
|
||||
const restore = vi.fn(() => ({ snapshot: coldBlock }))
|
||||
const snapshot = vi.fn(() => {
|
||||
if (options.projectionsThrow === true) throw new Error('hostile unit')
|
||||
return liveBlock
|
||||
})
|
||||
const restore = vi.fn(() => {
|
||||
if (options.projectionsThrow === true) throw new Error('hostile unit')
|
||||
return { snapshot: coldBlock }
|
||||
})
|
||||
const ctx = new Context()
|
||||
ctx.provide('agents', { get: getAgent })
|
||||
ctx.provide('subagents', { listChildren, followup })
|
||||
@@ -155,6 +163,29 @@ describe('subagent gateway', () => {
|
||||
expect(inspect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('serves the page without projections when a hostile unit breaks the fold', async () => {
|
||||
const cold = bench({ projectionsThrow: true })
|
||||
const coldResponse = await cold.api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
|
||||
}))
|
||||
expect(coldResponse.result).toMatchObject({
|
||||
ok: true,
|
||||
value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
|
||||
})
|
||||
if (coldResponse.result.ok) expect('projections' in coldResponse.result.value).toBe(false)
|
||||
|
||||
const live = bench({ projectionsThrow: true, liveChild: true })
|
||||
const liveResponse = await live.api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
|
||||
}))
|
||||
expect(liveResponse.result).toMatchObject({
|
||||
ok: true,
|
||||
value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
|
||||
})
|
||||
if (liveResponse.result.ok) expect('projections' in liveResponse.result.value).toBe(false)
|
||||
expect(live.snapshot).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reads one-shot history and rejects an address with the wrong mode', async () => {
|
||||
const oneShot = {
|
||||
kind: 'child', id: CHILD, mode: 'one-shot', label: 'batch',
|
||||
|
||||
Reference in New Issue
Block a user