Fix review findings: lifecycle containment, configurable tool name, coverage, type catalog
Address four findings from the first Codex review round: - Contain subagent/start|end listener throws (emitContainedStart/End): a thrown lifecycle listener could escape SubagentService.start() before the caller received the live run to dispose it (a leaked child), and a thrown subagent/end listener could surface as an unhandled rejection on the detached result-settle hook. Both emits now log-and-contain, mirroring the agent registry's agent/created|disposed containment. - Make the model-facing tool name configurable (Config.toolName, default subagent). The docs say to load dsh-tool-subagent once per provider to expose multiple transports, but the hardcoded name made the second load throw a duplicate-tool-name error; a distinct toolName per load is now required and documented. - Reach the per-file 100% coverage gate: tests for the subagent/end error branch, lifecycle-listener containment, every stopReasonError arm + the merge-extensible default, the multi-provider toolName path, agentOptions forwarding, and the direct-apply schema-bypass fallbacks. - Document the seam vocabulary in docs/core-data-structures/subagent.md with verbatim type-equiv blocks + manifest entries, and link it from core.md (a brand-new core/seam type the doc-sync gate cannot detect on its own).
This commit is contained in:
@@ -153,19 +153,51 @@ export class SubagentService extends Service {
|
||||
this.assertCapabilities(provider, request)
|
||||
|
||||
const run = provider.start(request)
|
||||
this.ctx.emit('subagent/start', { provider: name, id: run.id })
|
||||
// CONTAIN lifecycle-listener throws: the run is already live, so a throwing
|
||||
// `subagent/start` listener must NOT escape `start()` (the caller would
|
||||
// never receive the run to dispose it — a leaked child). Emit defensively
|
||||
// and log a thrown listener, mirroring the agent registry's `agent/created`
|
||||
// /`agent/disposed` containment.
|
||||
this.emitContainedStart({ provider: name, id: run.id })
|
||||
// Emit `subagent/end` when the run settles. The result promise does not
|
||||
// reject on a child-level failure (it resolves with stopReason 'error'),
|
||||
// so a rejection here is an infrastructure fault — surface its stop reason
|
||||
// as 'error' for the telemetry event without swallowing the rejection
|
||||
// (the consumer still observes it via `run.result`).
|
||||
// (the consumer still observes it via `run.result`). Containment also keeps
|
||||
// a thrown `subagent/end` listener from becoming an unhandled rejection on
|
||||
// this detached `.then`.
|
||||
void run.result.then(
|
||||
(result) => { this.ctx.emit('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason }) },
|
||||
() => { this.ctx.emit('subagent/end', { provider: name, id: run.id, stopReason: 'error' }) },
|
||||
(result) => { this.emitContainedEnd({ provider: name, id: run.id, stopReason: result.stopReason }) },
|
||||
() => { this.emitContainedEnd({ provider: name, id: run.id, stopReason: 'error' }) },
|
||||
)
|
||||
return run
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit `subagent/start`, containing a thrown listener (log, never propagate)
|
||||
* so one bad subscriber cannot strand the already-live run before the caller
|
||||
* receives it to dispose.
|
||||
*/
|
||||
private emitContainedStart(info: SubagentRunInfo): void {
|
||||
try {
|
||||
this.ctx.emit('subagent/start', info)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`subagent: subagent/start listener threw: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit `subagent/end`, containing a thrown listener so it cannot surface as an
|
||||
* unhandled rejection on the detached result-settle hook.
|
||||
*/
|
||||
private emitContainedEnd(info: SubagentRunEndInfo): void {
|
||||
try {
|
||||
this.ctx.emit('subagent/end', info)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`subagent: subagent/end listener threw: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a request that needs a start-time capability the provider lacks.
|
||||
* Each optional request field maps to one {@link SubagentCapabilities} flag;
|
||||
|
||||
@@ -173,6 +173,60 @@ describe('SubagentService', () => {
|
||||
expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' }))
|
||||
})
|
||||
|
||||
it('emits subagent/end with stopReason "error" when the run result promise rejects', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
// A provider whose run.result REJECTS (an infrastructure fault — the seam
|
||||
// contract says child-level failures resolve with stopReason 'error', but a
|
||||
// rejection is still surfaced as an 'error' telemetry event).
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'rejecter',
|
||||
capabilities: NO_CAPS,
|
||||
start: () => ({
|
||||
id: AgentId('rej-child'),
|
||||
result: Promise.reject(new Error('infra fault')),
|
||||
cancel() {},
|
||||
dispose: async () => {},
|
||||
}),
|
||||
})
|
||||
|
||||
const ended = vi.fn()
|
||||
ctx.on('subagent/end', ended)
|
||||
const run = ctx.subagents.start('rejecter', baseRequest())
|
||||
// Observe (and swallow) the rejection the consumer would see, then let the
|
||||
// detached `.then` settle the telemetry emit.
|
||||
await run.result.catch(() => {})
|
||||
await Promise.resolve()
|
||||
expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'rejecter', id: run.id, stopReason: 'error' }))
|
||||
})
|
||||
|
||||
it('contains a throwing subagent/start listener so start() still returns the run', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider(new StubProvider('contain'))
|
||||
// A bad subscriber must not strand the live run: start() returns it anyway.
|
||||
ctx.on('subagent/start', () => { throw new Error('bad start listener') })
|
||||
|
||||
const run = ctx.subagents.start('contain', baseRequest())
|
||||
expect(run.id).toBeDefined()
|
||||
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
|
||||
})
|
||||
|
||||
it('contains a throwing subagent/end listener (no unhandled rejection on the settle hook)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider(new StubProvider('contain-end'))
|
||||
ctx.on('subagent/end', () => { throw new Error('bad end listener') })
|
||||
|
||||
const run = ctx.subagents.start('contain-end', baseRequest())
|
||||
await run.result
|
||||
// Let the detached `.then` + the contained emit run; a thrown listener here
|
||||
// must be swallowed (logged), not escape as an unhandled rejection.
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(run.id).toBeDefined()
|
||||
})
|
||||
|
||||
it('SubagentError extends the shared HarnessError base', () => {
|
||||
const err = new SubagentError('boom', 'NO_PROVIDER')
|
||||
expect(err).toBeInstanceOf(HarnessError)
|
||||
|
||||
Reference in New Issue
Block a user