refactor(subprocess): rename the process seam to subprocess and address review

Review feedback (tianyicui): 'process' is a poor service name. The family is
now packages/subprocess/ — @deepseek-ai/dsh-subprocess (ctx.subprocess,
abstract SubprocessService, Subprocess* vocabulary) and
@deepseek-ai/dsh-subprocess-local (LocalSubprocessService) — renamed
throughout code, compositions, docs (en+zh, pairs re-recorded), catalogs,
and gates. 'subprocess' is the precise term for managed OS children (the
Python-stdlib sense), avoids colliding with Node's global process object,
and reads as one system beside dsh-subagent-subprocess.

ds-review-bot findings addressed:
- kill() on a settled handle is now a no-op (no signal to a possibly-reused
  pgid, no referenced grace timer delaying exit); pinned by a spy test.
- The moved DshEnvironmentKey/DshEnvironment/CollectedOutput types get
  drift-checked type-equiv blocks on the new subprocess.md page, restoring
  their manifest registration.
- subprocess.md is registered in the core.md sub-page index (en+zh).
This commit is contained in:
Tianyi Cui
2026-07-26 12:43:14 +08:00
parent 8f75565f03
commit fc566119a7
108 changed files with 587 additions and 557 deletions

View File

@@ -0,0 +1,26 @@
# @deepseek-ai/dsh-subprocess
The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes one method — `spawn(spec): SubprocessHandle` — plus the vocabulary shared by every consumer: the fully-explicit `SubprocessSpawnSpec`, `SubprocessHandle` with its non-consuming offset-based output readers, `SubprocessOutcome`, `CollectedOutput`, and the managed `DSH_*` environment namespace (`DSH_ENV_PREFIX`, `DshEnvironment`). The local implementation lives in [`dsh-subprocess-local`](../subprocess-local/README.md).
## Contract
- `spawn(spec)` returns immediately with a live handle; `done` resolves at process close and rejects only for spawn-level failures.
- The spec is fully explicit — argv, cwd, per-stream byte caps, spill cap, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted here; a consumer that wants a shell passes `['bash', '-c', command]` itself.
- Output readers take whole-stream byte offsets and never consume: independent readers cannot steal one another's deltas. A read whose offset slid out of the in-memory tail is `lossy` and points at the full-stream spill file when one exists.
- `kill()` and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole detached group; the service reacts to the abort but never classifies why (callers own deadlines and cause classification).
- Disposal kills all still-running managed processes and awaits their exit.
See the [process data-structure catalog](../../../docs/core-data-structures/subprocess.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md).
## Model Experience
Indirectly, through consumer seams (today the bash executor family behind `dsh-tool-bash`), which own all model-facing rendering of process output and lifecycle.
#### KV Cache effect
No direct invalidation; the named consumers own any request-prefix changes.
## Known Limitations and Deferred Work
- **One consumer family so far** — the seam's shape is proven against the bash executors only; the other in-repo spawn sites (LSP servers, PTY backends, subagent transports) keep their own bespoke process handling until their stream/lifecycle needs are re-examined against this contract.
- **POSIX group semantics are assumed** — the handle vocabulary (`pid` as group leader, group kills, SIGTERM/SIGKILL escalation) has no Windows story.

View File

@@ -0,0 +1,37 @@
{
"name": "@deepseek-ai/dsh-subprocess",
"description": "Subprocess seam (ctx.subprocess) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,62 @@
/**
* The subprocess seam (`ctx.subprocess`): spawn fully-specified
* commands into managed process groups with bounded, spill-backed output and
* escalated kills. Command defaulting, shell semantics, deadlines, and
* presentation belong to consumers — the bash executor seam is the owning
* template. The local implementation lives in
* `@deepseek-ai/dsh-subprocess-local`.
* @module @deepseek-ai/dsh-subprocess
*/
import { Context, Service } from 'cordis'
import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts'
export { DSH_ENV_PREFIX } from './types.ts'
export type {
CollectedOutput,
DshEnvironment,
DshEnvironmentKey,
SubprocessHandle,
SubprocessOutcome,
SubprocessOutputRead,
SubprocessOutputReader,
SubprocessSpawnSpec,
} from './types.ts'
declare module 'cordis' {
interface Context {
subprocess: SubprocessService
}
}
/**
* Abstract subprocess service. Subclass, implement {@link spawn}, and load the
* subclass as a plugin — it registers as `ctx.subprocess` (one implementation
* per context; loading a second throws, which is cordis' standard
* duplicate-service behavior).
*
* Implementations must honor these semantics:
* - {@link spawn} returns immediately with a live handle; `done` resolves at
* process close and rejects only for spawn-level failures.
* - Output readers are offset-based and non-consuming, so independent readers
* never consume one another's output; lossy reads report truncation and the
* spill file holding the complete stream when one exists.
* - {@link SubprocessHandle.kill} and the spec's abort signal escalate
* SIGTERM→grace→SIGKILL across the whole process group.
* - Disposal kills all still-running managed processes and awaits their exit.
*/
export abstract class SubprocessService extends Service {
constructor(ctx: Context) {
super(ctx, 'subprocess')
}
/**
* Start one managed child process from a fully-specified spec; this seam
* applies no defaults.
* @param spec - argv, directory, limits, grace, cancellation, and environment.
* @returns the live process handle (readers, kill, outcome promise).
*/
abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle
}
export default SubprocessService

View File

@@ -0,0 +1,22 @@
/** Package-owned invariant companion for the subprocess seam. @module @deepseek-ai/dsh-subprocess/invariant */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-subprocess'
/** Cordis companion plugin name. */
export const name = 'subprocess-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** No runtime invariant: this stateless seam owns spawn-spec/handle types, while implementations own observations. */
const install: InvariantInstaller = () => {}
/**
* Register the subprocess invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

View File

@@ -0,0 +1,128 @@
/**
* Vocabulary for the subprocess seam: fully-specified spawn requests,
* bounded output with spill recovery, and live process handles. Command
* defaulting, shell semantics, and presentation belong to consumers such as
* the bash executor seam.
* @module dsh-subprocess/types
*/
/** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */
export const DSH_ENV_PREFIX = 'DSH_' as const
/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */
export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`
/** Trusted DeepSeek Harness variables for one child-process execution. */
export type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>>
/** One captured stream: the (possibly truncated) text plus recovery info. */
export interface CollectedOutput {
/** Collected text — the TAIL of the stream when truncated. */
text: string
/** True when bytes were dropped from `text`. */
truncated: boolean
/** Path to a file holding the COMPLETE stream, when truncated and available. */
spillPath?: string
}
/**
* A fully-specified spawn request. This seam applies no defaults: every limit
* and directory is explicit, so the caller's own config — not a hidden
* subprocess-service default — decides them (the `dsh-bash` request/spec split
* is the owning template).
*/
export interface SubprocessSpawnSpec {
/** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */
argv: readonly string[]
/** Working directory for the child. */
cwd: string
/** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */
stdoutMaxBytes: number
/** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */
stderrMaxBytes: number
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
maxSpillBytes: number
/** Grace period for kill escalation and for inherited pipes after process exit. */
graceMs: number
/**
* Abort signal — kills the process group when it fires. The caller owns
* deadlines and cause classification; this seam only reacts to the abort.
*/
signal?: AbortSignal | undefined
/**
* Bytes to write to the child's stdin, then close it. Absent (or empty)
* leaves stdin closed/empty.
*/
stdin?: string | undefined
/**
* Ordinary environment entries merged after the implementation's credential
* scrub. `DSH_*` names are rejected and belong in {@link dshEnv}.
*/
env?: Record<string, string> | undefined
/**
* Harness-owned `DSH_*` variables for this execution. Implementations
* discard ambient `DSH_*` entries before merging this snapshot, so an
* unavailable current fact cannot inherit a stale value from the harness
* process, and reject non-`DSH_*` names supplied through this channel.
*/
dshEnv?: DshEnvironment | undefined
}
/**
* Raw outcome of one closed process. Deliberately carries NO timeout or
* cancellation classification: the service kills on abort but does not decide
* why — the caller reads the signal it owns to classify causes.
*/
export interface SubprocessOutcome {
/** Exit code; null when the process died from a signal. */
exitCode: number | null
/** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */
signal: NodeJS.Signals | null
stdout: CollectedOutput
stderr: CollectedOutput
}
/** One incremental {@link SubprocessOutputReader.readFrom} read. */
export interface SubprocessOutputRead {
/** Stream text from the requested offset (the whole retained tail when lossy). */
text: string
/** Whole-stream offset to resume from on the next read. */
nextOffset: number
/** True when the requested offset slid out of the in-memory tail window. */
lossy: boolean
/** Path to the full-stream spill file, when one was created and remains intact. */
spillPath?: string
}
/**
* Cursor-free incremental access to one live output stream. Offsets are
* whole-stream byte coordinates owned by the caller, so independent readers
* cannot consume one another's output.
*/
export interface SubprocessOutputReader {
/**
* Read everything captured since `fromByte`. When that offset has slid out
* of the in-memory tail window the read is `lossy` — it returns the whole
* retained tail and the gap is only recoverable from the spill file.
* @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read).
* @returns the delta text, the next offset, the `lossy` flag, and the spill path when one exists.
*/
readFrom(fromByte: number): SubprocessOutputRead
}
/**
* A live child process. `kill()` starts the group SIGTERM→grace→SIGKILL
* escalation; buffered output remains readable after exit.
*/
export interface SubprocessHandle {
/** Process id (group leader); -1 when the spawn itself failed. */
readonly pid: number
/** Live stdout reader (also readable after exit). */
readonly stdout: SubprocessOutputReader
/** Live stderr reader (also readable after exit). */
readonly stderr: SubprocessOutputReader
/** Resolves when the process closes; rejects only for spawn-level failures. */
readonly done: Promise<SubprocessOutcome>
/** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */
kill(): void
}

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { SubprocessService } from '@deepseek-ai/dsh-subprocess'
import type { SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
/**
* Minimal concrete service: a hand-built handle. The seam is spawn-only —
* defaulting, shell semantics, and deadlines belong to callers — so this stub
* is all an implementation owes the abstract class.
*/
class StubSubprocessService extends SubprocessService {
spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
const read: SubprocessOutputRead = { text: '', nextOffset: 0, lossy: false }
let killed = false
return {
pid: spec.argv.length,
stdout: { readFrom: () => read },
stderr: { readFrom: () => read },
done: Promise.resolve({
exitCode: killed ? null : 0,
signal: null,
stdout: { text: 'ok', truncated: false },
stderr: { text: '', truncated: false },
}),
kill: () => { killed = true },
}
}
}
describe('SubprocessService seam', () => {
it('a concrete subclass registers as ctx.subprocess and serves the abstract API', async () => {
const ctx = new Context()
await ctx.plugin(StubSubprocessService)
const handle = ctx.subprocess.spawn({
argv: ['true'],
cwd: '/stub',
stdoutMaxBytes: 1,
stderrMaxBytes: 1,
maxSpillBytes: 1,
graceMs: 1,
})
expect(handle.pid).toBe(1)
expect(handle.stdout.readFrom(0)).toEqual({ text: '', nextOffset: 0, lossy: false })
handle.kill()
const outcome = await handle.done
expect(outcome.stdout.text).toBe('ok')
})
it('loading a second implementation throws (one processes service per context — cordis standard)', async () => {
const ctx = new Context()
await ctx.plugin(StubSubprocessService)
class SecondManager extends StubSubprocessService {}
await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "subprocess" has been registered/)
})
})

View File

@@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../support/invariants"
}
]
}