Files
deepseek-harness/docs/core-data-structures/process.md
T
Tianyi Cui 0d6bfd8856 refactor(process): split the process manager out of the bash executor
New process/ capability family: @deepseek-ai/dsh-process owns ctx.processes —
abstract ProcessManager.spawn(spec) over a fully-explicit ProcessSpawnSpec —
plus the shared DSH_* managed-environment and CollectedOutput vocabulary;
@deepseek-ai/dsh-process-local carries the former bash-local run.ts plumbing
(detached groups, tail-keep spill-backed output, credential scrub, kill
escalation, kill-and-join disposal) with no config of its own.

dsh-bash-local becomes a consumer: it keeps command defaulting, the fused
deadline timedOut/aborted classification, the model-friendly terminal env
(now merged through the ordinary env channel), and the [stderr]-marked
background read merge, and spawns through ctx.processes. Background-process
lifetime moves to the manager, so an executor reload no longer kills live
background work; a background spawn failure is injected once into the read
path instead of being buffered as fake stderr. dsh-bash re-exports the moved
vocabulary so bash consumers keep one import root; dsh-bash-sandbox only
redeclares the inherited inject.

Every composition loading a bash executor now loads dsh-process-local (CLI,
examples, python bundled runtime, create-sdk bash feature, inline test
configs).
2026-07-26 06:59:01 +08:00

6.1 KiB

Process Manager

The child-process manager seam is split across interface (dsh-process, ctx.processes) and implementation (dsh-process-local); its consumers are other capability seams — today the bash executor family, which passes ['bash', '-c', command] argv and owns every default. This seam owns the managed DSH_* environment namespace and the CollectedOutput shape; dsh-bash re-exports them so bash consumers keep one import root.

Source: packages/process/process/src/types.ts

The fully-explicit spawn spec

The seam applies no defaults: every limit and directory is explicit on the spec, so the caller's own config — not a hidden process-manager default — decides them. argv is never shell-interpreted.

/**
 * 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
 * process-manager default — decides them (the `dsh-bash` request/spec split
 * is the owning template).
 */
interface ProcessSpawnSpec {
  /** 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
}

Handles and offset-based reads

A spawn returns a live handle immediately. Output readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; the consuming-cursor model the bash tool presents is consumer-owned state over these readers.

/**
 * A live child process. `kill()` starts the group SIGTERM→grace→SIGKILL
 * escalation; buffered output remains readable after exit.
 */
interface ProcessHandle {
  /** Process id (group leader); -1 when the spawn itself failed. */
  readonly pid: number
  /** Live stdout reader (also readable after exit). */
  readonly stdout: ProcessOutputReader
  /** Live stderr reader (also readable after exit). */
  readonly stderr: ProcessOutputReader
  /** Resolves when the process closes; rejects only for spawn-level failures. */
  readonly done: Promise<ProcessOutcome>
  /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */
  kill(): void
}
/**
 * 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.
 */
interface ProcessOutputReader {
  /**
   * 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): ProcessOutputRead
}
/** One incremental {@link ProcessOutputReader.readFrom} read. */
interface ProcessOutputRead {
  /** 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
}

Outcomes carry no cause classification

done reports raw exit facts. The manager kills on abort but never decides why — the caller reads the deadline signal it owns to classify timeout versus cancellation (the bash executor's timedOut/aborted split).

/**
 * Raw outcome of one closed process. Deliberately carries NO timeout or
 * cancellation classification: the manager kills on abort but does not decide
 * why — the caller reads the signal it owns to classify causes.
 */
interface ProcessOutcome {
  /** 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
}

Service behavior

The abstract ProcessManager seam defines spawn only; LocalProcessManager is the local implementation (detached groups, tail-keep spill-backed collection, credential scrub, kill-and-join disposal). See dsh-process for the seam contract and dsh-process-local for the mechanics.