111 lines
4.9 KiB
TypeScript
111 lines
4.9 KiB
TypeScript
import type { Context } from 'cordis'
|
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
|
|
export const name = 'subagent-durability-failure'
|
|
export const inject = ['agents', 'sessionPersistence', 'subagents']
|
|
|
|
/**
|
|
* The authored parent transcript names the background child by a stable
|
|
* placeholder id, but the live continuable child is minted with a fresh random
|
|
* session id at run time. This snapshot-only overlay bridges that gap and forces
|
|
* deterministic ordering plus authored child durability failures:
|
|
*
|
|
* - `PLACEHOLDER_CHILD_ID` in a scripted `send_message` is remapped to the real
|
|
* child so both follow-ups queue onto the same live inbox in FIFO order.
|
|
* - The unknown-id `send_message` (`UNKNOWN_CHILD_ID`) resolves through a
|
|
* persistence load fenced behind both accepted follow-ups, so the transcript
|
|
* records the same order on every runner.
|
|
* - The child's final continuation turn fails its durability checkpoint with a
|
|
* fixed message, so the scenario proves child-first disposal survives a failed
|
|
* last flush.
|
|
* - Under `DSH_SUBAGENT_PUBLISHED_FAILURE`, a one-shot child's first
|
|
* follow-up fails after publication, so its model prompt never runs; its
|
|
* published handle then also fails disposal, so the parent observes both
|
|
* independent failures.
|
|
*/
|
|
const PLACEHOLDER_CHILD_ID = '33333333-3333-4333-8333-333333333333'
|
|
const UNKNOWN_CHILD_ID = '22222222-2222-4222-8222-222222222222'
|
|
/** The child continuation turn whose durability checkpoint is forced to fail. */
|
|
const FAILED_CHECKPOINT_TURN = 3
|
|
|
|
/** Fail the child checkpoint and stabilize the authored follow-up failure ordering. */
|
|
export function apply(ctx: Context): void {
|
|
const followupsAccepted = Promise.withResolvers<undefined>()
|
|
const publishedFailure = process.env.DSH_SUBAGENT_PUBLISHED_FAILURE === '1'
|
|
const persistence = ctx.sessionPersistence
|
|
const load = persistence.load.bind(persistence)
|
|
const agents = ctx.agents
|
|
const create = agents.create.bind(agents)
|
|
|
|
agents.create = async (options) => {
|
|
const handle = await create(options)
|
|
if (!publishedFailure || options.meta?.parentSession === undefined) return handle
|
|
handle.agent.followup = () => {
|
|
throw new Error('snapshot published run failed')
|
|
}
|
|
return {
|
|
...handle,
|
|
async dispose() {
|
|
await handle.dispose()
|
|
throw new Error('snapshot published handle disposal failed')
|
|
},
|
|
}
|
|
}
|
|
|
|
// The unavailable-child lookup is real asynchronous I/O. Fence it behind both
|
|
// authored follow-ups so runner speed cannot reorder the exact log.
|
|
persistence.load = async (id) => {
|
|
if (id === UNKNOWN_CHILD_ID) await followupsAccepted.promise
|
|
return load.call(persistence, id)
|
|
}
|
|
ctx.effect(() => () => {
|
|
agents.create = create
|
|
persistence.load = load
|
|
followupsAccepted.resolve(undefined)
|
|
}, 'subagent snapshot ordering')
|
|
|
|
// Remap the placeholder child id in a follow-up to the live child. The child
|
|
// id the model "knows" is authored into the transcript, while the running
|
|
// child is minted with a random id, so without this the follow-ups would
|
|
// never reach the live inbox.
|
|
let realChildId: string | undefined
|
|
const subagents = ctx.subagents as unknown as {
|
|
followup: (authority: unknown, childId: SessionId, content: unknown, options: unknown) => Promise<unknown>
|
|
}
|
|
const deliver = subagents.followup.bind(subagents)
|
|
subagents.followup = (authority, childId, content, options) => {
|
|
const mapped = childId === PLACEHOLDER_CHILD_ID && realChildId !== undefined
|
|
? SessionId(realChildId)
|
|
: childId
|
|
return deliver(authority, mapped, content, options)
|
|
}
|
|
|
|
// Both authored follow-ups reach the child inbox before the unknown-id lookup
|
|
// runs, so the queued FIFO order is what the transcript records. The first
|
|
// child enqueue is the initial delegation, which also pins the real child id.
|
|
let accepted = 0
|
|
ctx.on('agent/inbox/inserted', (agent) => {
|
|
if (agent.session.header.parentSession === undefined) return
|
|
if (realChildId === undefined) realChildId = agent.session.header.id
|
|
accepted += 1
|
|
if (accepted >= 3) followupsAccepted.resolve(undefined)
|
|
})
|
|
ctx.on('agent/pre-step', async (agent, _messages, _context, next) => {
|
|
if (agent.session.header.parentSession !== undefined) await followupsAccepted.promise
|
|
return next()
|
|
})
|
|
|
|
// The child's ordinary per-turn flushes succeed; only the final continuation
|
|
// turn's durability checkpoint fails, turning that turn/end into a durable
|
|
// error the parent never sees.
|
|
const childTurn = new WeakMap<object, number>()
|
|
ctx.on('session/event', (session, event) => {
|
|
if (session.header.parentSession === undefined || event.type !== 'turn/start') return
|
|
childTurn.set(session, event.data.turn)
|
|
})
|
|
ctx.on('session/flush', (session) => {
|
|
if (session.header.parentSession === undefined) return
|
|
if (childTurn.get(session) === FAILED_CHECKPOINT_TURN) throw new Error('snapshot disk full')
|
|
})
|
|
}
|