refactor(subagent-subprocess): shared provider skeleton for out-of-process backends
The duplication gate flagged three ACP/SDK clones; the shared halves move into dsh-subagent-subprocess as provider.ts: NO_START_CAPABILITIES (frozen all-false advertisement), assertPositiveFinite (prefix-parameterized timing validation), settleRunResult (never-reject result settlement with contained onError sink and listener hygiene), and subprocessRunHandle (idempotent dispose publication). Both backends now compose these; the previously unreachable cancelled-rejection branch is directly unit-tested at the library level instead of v8-ignored in each backend. knip learns the subagent-sdk workspace (e2e entry outside vitest unit includes).
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import { resolveChildCwd, validateConfiguredCwd } from '@deepseek-ai/dsh-subagent-subprocess'
|
||||
import { assertPositiveFinite, NO_START_CAPABILITIES, resolveChildCwd, validateConfiguredCwd } from '@deepseek-ai/dsh-subagent-subprocess'
|
||||
import {
|
||||
DEFAULT_DISPOSE_EOF_GRACE_MS,
|
||||
DEFAULT_DISPOSE_GRACE_MS,
|
||||
@@ -79,13 +79,6 @@ export const Config: z<Config> = z.object({
|
||||
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
|
||||
})
|
||||
|
||||
/** A timing bound must be a positive finite number (it bounds a teardown wait). */
|
||||
function assertPositiveFinite(name: string, value: number): void {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`subagent-sdk: ${name} must be a positive finite number`)
|
||||
}
|
||||
}
|
||||
|
||||
/** The shape after schemastery applied the defaults (cwd has none). */
|
||||
type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
|
||||
|
||||
@@ -95,7 +88,7 @@ type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
|
||||
* service rejects a request needing any of them before `start` runs).
|
||||
*/
|
||||
class SdkProvider implements SubagentProvider {
|
||||
readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }
|
||||
readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES
|
||||
// Context contract: an out-of-process SDK child starts fresh — no parent conversation crosses the process boundary.
|
||||
readonly inheritsParentContext = false
|
||||
|
||||
@@ -125,9 +118,9 @@ class SdkProvider implements SubagentProvider {
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// schemastery (Config) has already filled every defaulted field.
|
||||
const resolved = config as ResolvedConfig
|
||||
assertPositiveFinite('shutdownTimeoutMs', resolved.shutdownTimeoutMs)
|
||||
assertPositiveFinite('disposeEofGraceMs', resolved.disposeEofGraceMs)
|
||||
assertPositiveFinite('disposeGraceMs', resolved.disposeGraceMs)
|
||||
assertPositiveFinite('subagent-sdk', 'shutdownTimeoutMs', resolved.shutdownTimeoutMs)
|
||||
assertPositiveFinite('subagent-sdk', 'disposeEofGraceMs', resolved.disposeEofGraceMs)
|
||||
assertPositiveFinite('subagent-sdk', 'disposeGraceMs', resolved.disposeGraceMs)
|
||||
// Interpret a relative configured cwd against the harness launch directory
|
||||
// ONCE, at load, and fail a misconfigured directory here — not per start.
|
||||
const configuredCwd = validateConfiguredCwd('subagent-sdk', resolved.cwd)
|
||||
|
||||
@@ -14,7 +14,7 @@ import { DeepSeekHarness, type HarnessNotification } from '@deepseek-ai/dsh-sdk-
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
|
||||
import { buildChildEnv } from '@deepseek-ai/dsh-subagent-subprocess'
|
||||
import { buildChildEnv, settleRunResult, subprocessRunHandle } from '@deepseek-ai/dsh-subagent-subprocess'
|
||||
|
||||
/** Resolved spawn spec for an SDK runtime child process (no defaults — see Config). */
|
||||
export interface SdkRunSpec {
|
||||
@@ -175,43 +175,32 @@ export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpe
|
||||
return text.length > 0 ? [{ type: 'text', text }] : []
|
||||
}
|
||||
|
||||
const result: Promise<SubagentResult> = (async (): Promise<SubagentResult> => {
|
||||
try {
|
||||
// Race the child turn against local cancellation; the shared settlement
|
||||
// flattens failures under the seam's never-reject contract.
|
||||
const result: Promise<SubagentResult> = settleRunResult({
|
||||
attempt: async () => {
|
||||
const turn = await Promise.race([
|
||||
harness.session(childSessionId).run(request.prompt, { onNotification: observe }),
|
||||
cancelSettled.then(() => 'cancelled' as const),
|
||||
])
|
||||
if (turn === 'cancelled') return { output: collectOutput(), stopReason: 'aborted' }
|
||||
return { output: collectOutput(), stopReason: sdkStopReason(turn.reason) }
|
||||
} catch (error: unknown) {
|
||||
// Cover a transport rejection already queued when cancellation arrives.
|
||||
/* v8 ignore next */
|
||||
if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' }
|
||||
// Flatten post-publication transport failures while preserving diagnostics.
|
||||
try {
|
||||
spec.onError?.(toError(error), 'error')
|
||||
} catch {
|
||||
// The diagnostic sink cannot reject the run result.
|
||||
}
|
||||
return { output: collectOutput(), stopReason: 'error' }
|
||||
} finally {
|
||||
request.signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
})()
|
||||
|
||||
let disposal: Promise<void> | undefined
|
||||
return {
|
||||
id,
|
||||
localAgent: undefined,
|
||||
result,
|
||||
dispose(): Promise<void> {
|
||||
if (disposal !== undefined) return disposal
|
||||
request.signal.removeEventListener('abort', onAbort)
|
||||
// There is no wire-level prompt cancel: settle the result locally, then
|
||||
// the bounded shutdown request + dispose ladder tears the child down.
|
||||
requestCancel()
|
||||
disposal = harness.close()
|
||||
return disposal
|
||||
},
|
||||
}
|
||||
collectOutput,
|
||||
cancelled: () => flags.cancelled,
|
||||
onError: spec.onError,
|
||||
signal: request.signal,
|
||||
onAbort,
|
||||
})
|
||||
|
||||
// There is no wire-level prompt cancel: dispose settles the result locally,
|
||||
// then the bounded shutdown request + dispose ladder tears the child down.
|
||||
return subprocessRunHandle({
|
||||
id,
|
||||
result,
|
||||
signal: request.signal,
|
||||
onAbort,
|
||||
requestCancel,
|
||||
teardown: () => harness.close(),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user