Review direction (tianyicui, PR #660): in a stacked PR, change all other process-running places to use the new service. - lsp-local: LspConnection spawns through ctx.subprocess (piped protocol streams + a no-spill collected stderr tail); its private process-tree helpers (POSIX group signalling, Windows taskkill, liveness polling) are deleted in favor of the seam's handle verbs, and its buildChildEnv now rides scrubbedParentEnv (LSP children also stop inheriting stale DSH_*). The plugin injects 'subprocess'; compositions/tests mount dsh-subprocess-local. - subagent-acp: the ACP child spawns through the seam (piped ndjson streams, inherited stderr); spawn failure surfaces through done-rejection into the same startup race; disposal is handle.dispose with the plugin's configured graces. dsh-subagent-subprocess is DELETED — its dispose ladder and scrub are the seam's, and the isolated-config-dir helper had no consumer. - mcp-client, pty-local, sdk-helper: adopt scrubbedParentEnv as the one scrub definition (their spawns stay put by ownership: the MCP SDK and node-pty own those calls; the SDK wizard runs outside any composition). - Coverage: per-file 100% over every touched src file, with each v8 ignore carrying a platform or contract reason; new suites cover stdio dispositions, the dispose ladder tiers, injected-win32 tree semantics, waitForExit, settled-kill/terminate no-ops, and spawn-failure disposal. - Docs: consumer-migration Agent Note (en; zh follows in this PR), seam note updated in place, subprocess.md rewritten for the reshaped vocabulary (type-equiv re-registered), READMEs and SERVICE_ROLES updated, taskkill added to knip ignoreBinaries.
7.8 KiB
Agent Note: The subprocess service is its own seam under the bash executors (dsh-subprocess / dsh-subprocess-local)
Status: implemented
English | 中文
Problem
dsh-bash-local bundled two capabilities that change for different reasons: running a bash command (command defaulting, timeout classification, model-friendly terminal environment, the stdout/stderr merge the bash tool renders) and running and managing a child process (detached process groups, bounded tail-keep output with spill files, the credential scrub and DSH_* merge order, SIGTERM→grace→SIGKILL escalation, kill-and-join disposal). The process half — run.ts, roughly half the package — had no seam of its own: a future non-shell runner (a direct-argv executor, a worker supervisor) would have to re-implement or reach into bash internals, and the shared DSH_*/CollectedOutput vocabulary lived in a package whose name promises shell semantics. The bundling also tied background-process lifetime to the executor's fiber: reloading the bash executor killed every live background process, unlike the sibling task registry, whose registrations deliberately outlive producer fibers.
Decision
A new subprocess/ capability family owns "run and manage a process"; the bash family keeps "run a bash command" and consumes it:
@deepseek-ai/dsh-subprocess(interface) — the abstractSubprocessServiceowningctx.subprocesswith one method,spawn(spec): SubprocessHandle, and the shared vocabulary: the fully-explicitSubprocessSpawnSpec(argv, cwd, per-stream stdio dispositions, grace — no defaults; deployment-varying knobs stay with the calling seam's config, per thedsh-bashrequest/spec template and the no-hidden-defaults rule),SubprocessHandlewith non-consuming offset-based readers,SubprocessOutcomewith deliberately no timeout/cancel classification, and the shared scrub plusDSH_ENV_PREFIX/DshEnvironment/CollectedOutputtypes.argvis never shell-interpreted. (The consumer-migration Agent Note later widened the stdio and termination vocabulary Node-ward.)@deepseek-ai/dsh-subprocess-local(implementation) —LocalSubprocessServiceover the formerrun.tsplumbing (spawn.ts): detached groups, tail-keep truncation with private bounded spill files, credential scrub with the two-channelDSH_*merge, group kill escalation, and disposal that kills and joins every still-running managed process. It has no config; every limit arrives on the spec. The terminalENV_OVERRIDES(TERM=dumbetc.) did NOT move — that is bash-tool presentation policy and stays indsh-bash-local, merged through the ordinary env channel.dsh-bash-local(consumer) —inject: ['subprocess']; maps each resolvedBashExecSpeconto aSubprocessSpawnSpec(['bash', '-c', command]), keeps its config,resolve()defaulting, fused-deadlinetimedOut/abortedclassification, the[stderr]-marked background read merge with its consuming cursor, and theonProcessDonesubclass hook.dsh-bash-sandboxis unchanged apart from redeclaring the inherited inject; it still wraps at the command-string level and re-enters the inherited spawn path.dsh-bash(seam) — re-exports the moved vocabulary fromdsh-subprocess, so no bash consumer changes an import;BashExecRequest/BashExecSpec/BashProcessand the sandbox facts remain bash-owned.
Every composition that loads a bash executor now also loads @deepseek-ai/dsh-subprocess-local (CLI, examples, python bundled runtime, create-sdk's bash feature resources, inline test configs).
Background-process lifetime moved from the executor to the subprocess service: the executor no longer retains a live-process set, so an executor reload leaves background work running and readable, and composition teardown (the service's disposal) remains the kill-and-join boundary. One behavioral seam shifted with it: a background spawn failure can no longer be buffered as fake stderr inside the plumbing (the service rejects done and buffers nothing for a process that never ran), so the executor injects the spawn failed: … note into exactly one readOutput() delta.
Alternatives considered
Leave the process plumbing inside dsh-bash-local (status quo). Rejected for the same reason the task registry split landed: the boundary is stable and already documented in-code (run.ts's module doc said "this layer reacts to an abort signal; the executor owns deadlines and classifies causes"), and keeping it private makes every future non-shell runner either fork the mechanics or depend on a bash-named package for non-bash work. The user-visible driver for this stack was exactly this split.
Migrate the repo's other spawn sites (lsp-local, pty-local, subagent-subprocess, sdk package-manager, test-support launchers) onto ctx.subprocess in the same change. Rejected as scope creep with real design risk at this PR's scale: those sites have materially different stream and lifecycle needs — node-pty ownership (pty), LSP framing over long-lived stdio with tree-kill fallbacks (lsp), stdin-EOF-first disposal ladders and no output buffering (subagent transports) — and forcing them under a handle shaped for bounded batch output would either bloat the seam or misfit the consumers. The seam shipped proven against its one real consumer family, per the shape-interfaces-around-current-consumers rule. Review then asked for exactly that follow-up as a stacked PR; the consumer-migration Agent Note records the Node-ward reshape and which sites moved (and which stayed, by ownership).
Put run_in_background/task semantics into the process seam instead. Rejected: that boundary already exists — ctx.tasks owns ids, ownership, and notices, and the bash tool adapts a BashProcess into task hooks. The process seam sits below the bash executor, not beside the task registry.
Move ENV_OVERRIDES (TERM=dumb, PAGER=cat …) into the subprocess service. Rejected: a generic subprocess service must not impose terminal presentation policy on non-terminal consumers; the scrub and DSH_* channel rules are security/identity invariants and stay, but terminal friendliness is the bash tool's choice, expressed through the ordinary env channel where an explicit caller entry still wins.
Consequences
Bought: "run and manage a process" is a swappable capability with the standard three-package shape (consumer count starts at two: bash-local, bash-sandbox); a containerized or remote process backend slots in without touching bash semantics; the shared DSH_*/output vocabulary has a non-shell home; and background processes survive executor reloads, matching the task registry's lifetime model. The spawn plumbing suite moved wholesale to dsh-subprocess-local (argv-based, plus argv-validation and service lifecycle/disposal suites); the executor suite now pins the bash-owned layers (classification, merge, spawn-failure note, service-owned lifetime) against the real service.
Cost: one more package pair and one more composition row everywhere a bash executor loads — a boot that loads an executor without the subprocess service leaves ctx.bash pending on ctx.subprocess (standard missing-service behavior). The moved-vocabulary re-exports keep dsh-bash imports working but mean two packages now name the same types; the subprocess seam is the owner and the bash seam documents the re-export. The spawn-failure note became single-delivery through the read path where the old plumbing retained it in the stderr buffer for repeated readFrom(0) reads — acceptable because the bash background read path was already a consuming cursor, and the note reaches the one reader that exists.