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:
Tianyi Cui
2026-06-21 23:15:43 +08:00
parent 1a81f2cccd
commit 25eccdaedc
10 changed files with 319 additions and 11 deletions

View File

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