Merge origin/master: port the SDK stack onto the subprocess seam
Master's #660 replaced dsh-subagent-subprocess with the dsh-subprocess capability seam (ctx.subprocess + scrubbedParentEnv, tree-scoped teardown) and moved subagent-acp onto it. Convergence for this branch's packages: - The shared out-of-process provider vocabulary this branch had grown in the deleted library (NO_START_CAPABILITIES, assertPositiveFinite, cwd resolution, settleRunResult, subprocessRunHandle) moves into the subagent seam package as out-of-process.ts — it enforces subagent-seam contracts, not process mechanics, and both out-of-process backends now import it from there (subagent-acp keeps master's shape otherwise). - subagent-sdk spawns THROUGH the SDK client (the subprocess README's documented exception for SDK-managed transports) and now applies the seam's scrubbedParentEnv() + explicit-env merge in place of the deleted buildChildEnv. - sdk-client inlines the EOF→SIGTERM→SIGKILL ladder as private helpers (it runs outside any harness context, so it cannot ride ctx.subprocess). - The child harness fixture gains the now-required dsh-subprocess-local entry for bash-local; the fixture cordis.yml keeps exercising the shipped provider default.
This commit is contained in:
@@ -90,6 +90,8 @@ describe.skipIf(!existsSync(builtScripts))('live-linked generated projects', ()
|
||||
XDG_DATA_HOME: join(cacheRoot, 'data'),
|
||||
npm_config_cache: join(cacheRoot, 'npm'),
|
||||
...pnpmStore === undefined ? {} : { pnpm_config_store_dir: pnpmStore },
|
||||
// A generated project has no lockfile yet; ambient CI must not make its first Yarn install immutable.
|
||||
...name === 'yarn' ? { YARN_ENABLE_IMMUTABLE_INSTALLS: 'false' } : {},
|
||||
}
|
||||
await execFileAsync(name, manager.installCommand(), {
|
||||
cwd: root,
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subprocess": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -44,6 +45,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-web": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
|
||||
@@ -32,7 +32,10 @@ export function createBuiltinRegistry(profile: ProjectProfile): FeatureRegistry
|
||||
summary: 'Command execution',
|
||||
mode: 'exclusive',
|
||||
required: true,
|
||||
baseResources: [{ kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' }],
|
||||
baseResources: [
|
||||
{ kind: 'npm-cordis-config-entry', id: 'subprocess', package: '@deepseek-ai/dsh-subprocess-local' },
|
||||
{ kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' },
|
||||
],
|
||||
options: [
|
||||
{
|
||||
id: 'local',
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { execFile, spawn } from 'node:child_process'
|
||||
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
|
||||
import { promisify } from 'node:util'
|
||||
import type { PackageJsonFile } from '../documents/package-json-file.ts'
|
||||
import { PnpmWorkspaceFile } from '../documents/pnpm-workspace-file.ts'
|
||||
@@ -51,8 +52,14 @@ export async function probePackageManagerVersion(name: PackageManagerName, cwd:
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove credential-shaped environment variables from spawned commands. */
|
||||
export function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
/**
|
||||
* Remove credential-shaped environment variables from spawned commands.
|
||||
* @param environment - source environment (injectable for tests); the default
|
||||
* path shares the subprocess seam's scrub so every harness spawner drops the
|
||||
* same names.
|
||||
*/
|
||||
export function scrubEnvironment(environment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
if (environment === undefined) return scrubbedParentEnv()
|
||||
return Object.fromEntries(Object.entries(environment).filter(([name]) => !/(?:KEY|SECRET|TOKEN)/i.test(name)))
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sdk-protocol": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent-subprocess": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -39,7 +38,6 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-sdk-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-subprocess": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
* {@link HarnessClient} owns the child process: it spawns the runtime, speaks
|
||||
* the `@deepseek-ai/dsh-sdk-protocol` wire over the child's stdio, fans
|
||||
* server notifications out to subscriptions, and tears the child down to
|
||||
* quiescence through the shared subprocess dispose ladder. The design twin is
|
||||
* the Python SDK's `HarnessClient` (`python/sdk`); both drive the same
|
||||
* runtime protocol.
|
||||
* quiescence through a private EOF → SIGTERM → SIGKILL ladder. The design
|
||||
* twin is the Python SDK's `HarnessClient` (`python/sdk`); both drive the
|
||||
* same runtime protocol. This client runs OUTSIDE any harness context, so it
|
||||
* spawns directly rather than through the `dsh-subprocess` service — the
|
||||
* seam's documented exception for SDK-managed transports.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-sdk-client/client
|
||||
*/
|
||||
@@ -18,7 +20,6 @@ import {
|
||||
type InitializeResult,
|
||||
type SessionPromptParams,
|
||||
} from '@deepseek-ai/dsh-sdk-protocol'
|
||||
import { disposeChildProcess } from '@deepseek-ai/dsh-subagent-subprocess'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { HarnessClientOptions, HarnessNotification, NotificationFilter } from './types.ts'
|
||||
|
||||
@@ -372,7 +373,7 @@ export class HarnessClient {
|
||||
// for a runtime that cannot answer shutdown anymore.
|
||||
this.appendStderr([`shutdown request failed: ${errorMessage(error)}`])
|
||||
}
|
||||
await disposeChildProcess(child, {
|
||||
await disposeRuntimeProcess(child, {
|
||||
disposeEofGraceMs: this.options.disposeEofGraceMs ?? 6_000,
|
||||
disposeGraceMs: this.options.disposeGraceMs ?? 3_000,
|
||||
})
|
||||
@@ -446,6 +447,91 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Race the child's exit against a timer. Neither outcome leaves anything
|
||||
* behind on the child: the exit listener is removed on timeout and the timer
|
||||
* is cleared on exit, so the ladder's tiers never accumulate listeners.
|
||||
*/
|
||||
function exitsWithin(child: ChildProcess, ms: number): Promise<boolean> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true)
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const onExit = (): void => {
|
||||
clearTimeout(timer)
|
||||
resolve(true)
|
||||
}
|
||||
// `.unref()` so a pending grace timer never keeps the parent's loop alive.
|
||||
const timer = setTimeout(() => {
|
||||
child.removeListener('exit', onExit)
|
||||
resolve(false)
|
||||
}, ms).unref()
|
||||
child.once('exit', onExit)
|
||||
})
|
||||
}
|
||||
|
||||
/** Force-terminate the runtime and reject if no exit edge arrives within the grace. */
|
||||
function forceTerminateWithin(child: ChildProcess, ms: number): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let accepted = false
|
||||
let settled = false
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timer)
|
||||
child.off('exit', onExit)
|
||||
child.off('error', onError)
|
||||
}
|
||||
const settle = (complete: () => void): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
complete()
|
||||
}
|
||||
const onExit = (): void => { settle(resolve) }
|
||||
const onError = (error: Error): void => { settle(() => { reject(error) }) }
|
||||
child.once('exit', onExit)
|
||||
child.once('error', onError)
|
||||
const timer = setTimeout(() => {
|
||||
const disposition = accepted ? 'accepted' : 'refused'
|
||||
settle(() => {
|
||||
reject(new Error(`runtime process did not exit within ${ms}ms after SIGKILL was ${disposition}`))
|
||||
})
|
||||
}, ms).unref()
|
||||
try {
|
||||
accepted = child.kill('SIGKILL')
|
||||
if (child.exitCode !== null || child.signalCode !== null) settle(resolve)
|
||||
} catch (error: unknown) {
|
||||
settle(() => { reject(new Error('SIGKILL failed', { cause: error })) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear the runtime down to quiescence, resolving only after exit: close stdin
|
||||
* and allow cooperative flush, then use the host's graceful and forced
|
||||
* termination semantics. POSIX sends `SIGTERM` before `SIGKILL`; Windows
|
||||
* skips directly to forced termination because Node maps both signals to
|
||||
* `TerminateProcess`.
|
||||
* @throws When forced termination errors or the child does not report exit
|
||||
* within `disposeGraceMs`.
|
||||
*/
|
||||
async function disposeRuntimeProcess(
|
||||
child: ChildProcess,
|
||||
graces: { disposeEofGraceMs: number; disposeGraceMs: number },
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<void> {
|
||||
// Already gone: nothing to reap.
|
||||
if (child.exitCode !== null || child.signalCode !== null) return
|
||||
// 1. Close stdin and allow cooperative teardown and durable-state flush.
|
||||
child.stdin?.end()
|
||||
if (await exitsWithin(child, graces.disposeEofGraceMs)) return
|
||||
// 2. POSIX gets a catchable graceful signal; Windows signals all force-terminate.
|
||||
if (platform !== 'win32') {
|
||||
child.kill('SIGTERM')
|
||||
if (await exitsWithin(child, graces.disposeGraceMs)) return
|
||||
}
|
||||
// 3. Force-kill and await a bounded exit edge.
|
||||
await forceTerminateWithin(child, graces.disposeGraceMs)
|
||||
}
|
||||
|
||||
/** The message of a thrown value (the transport only throws `Error`s; `String` covers the rest). */
|
||||
function errorMessage(error: unknown): string {
|
||||
/* v8 ignore next -- the transport and dispose ladder reject only with Errors */
|
||||
|
||||
@@ -31,8 +31,8 @@ export interface HarnessClientOptions {
|
||||
/**
|
||||
* The complete child environment. `undefined` inherits the parent env
|
||||
* verbatim; passing an object replaces it entirely, so callers own
|
||||
* credential policy (see `buildChildEnv` in
|
||||
* `@deepseek-ai/dsh-subagent-subprocess` for the scrub-then-inject helper).
|
||||
* credential policy (see `scrubbedParentEnv` in `@deepseek-ai/dsh-subprocess`
|
||||
* for the shared scrub-then-merge base).
|
||||
*/
|
||||
env?: NodeJS.ProcessEnv
|
||||
/** Per-request timeout (ms); `undefined` waits indefinitely (a turn can legitimately run long). */
|
||||
|
||||
@@ -23,9 +23,6 @@
|
||||
{
|
||||
"path": "../sdk-protocol"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent-subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user