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
@@ -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
@@ -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))
+128
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
}