refactor(runtime): compose consumers over fs and subprocess
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md
|
||||
2026-06-17-filesystem-capability-seam.md: fee0161e5e8397ac1d1c0e2850efad840c65d971
|
||||
2026-06-17-filesystem-capability-seam.zh.md: 46e3ad22c530319d0d1b6ba23a8aba8ffc2fdb8c
|
||||
2026-06-17-filesystem-capability-seam.md: 2fb360e5ad3c972f4a8de50c602a79af158dfc22
|
||||
2026-06-17-filesystem-capability-seam.zh.md: 1f70113b1583d890047ee4a17161f76b6390d8e8
|
||||
|
||||
@@ -62,8 +62,9 @@ The root `tool-fs` plugin registers the full filesystem tool suite (`read`, `wri
|
||||
The interface covers these semantic operations:
|
||||
|
||||
- Resolve a model/plugin-supplied path into a backend-defined target.
|
||||
- Convert a resolved target to the canonical process path or `file:` URI for the same execution world, and test containment without parsing its opaque key.
|
||||
- Stat target metadata without reading file contents.
|
||||
- Read a bounded UTF-8 text page from a target.
|
||||
- Read complete or streamed UTF-8 text, including one stable-handle byte-bounded whole-file operation.
|
||||
- Create or replace a UTF-8 text file.
|
||||
- Edit an existing UTF-8 text file by literal replacement.
|
||||
|
||||
@@ -83,9 +84,11 @@ Resolved targets must expose at least three concepts:
|
||||
- An opaque `targetKey`, used for stale guards and file-state lookup. The local backend might use a realpath-like key; a remote backend might use a workspace URI or file id. Consumers must not parse or assume this is a local absolute path.
|
||||
- A `displayPath`, used for model/UI-facing output. It may be a local absolute path, workspace-relative path, or remote URI depending on the backend.
|
||||
|
||||
`targetKey` remains opaque even when another capability shares the provider's execution world. Such consumers ask the provider for `processPath(target)`, `fileUrl(target)`, or `contains(parent, child)`; the [portable execution-world decision](2026-07-28-portable-execution-world-consumers.md) owns why these facts sit on the filesystem seam.
|
||||
|
||||
Read and mutation results must include an opaque file `version`. The local backend derives its token from bigint stat metadata (`dev`, `ino`, `size`, `mtimeNs`, and `ctimeNs`) so same-size rewrites and inode replacement invalidate consumers reliably; a remote backend can use a revision id or hash-like token. The `dsh-fs-policy` plugin records versions for stale checks; consumers may display related metadata but must not interpret the version token.
|
||||
|
||||
The provider hands back decoded text: `readText` returns a whole regular text file, `streamText` streams the same text semantics for large files. Both own regular-file checks, bounded line/output handling is NOT theirs — line windowing, numbered-line rendering, and total-line accounting live in the executor (`dsh-tool-fs`), which reads through `ctx.fs` and renders the model-facing window. The provider owns UTF-8 decoding and binary/NUL rejection; it does not know about line windows or views.
|
||||
The provider hands back decoded text: `readText` returns a whole regular text file, `streamText` streams the same text semantics for large files, and `readTextBounded` holds one backend-owned stable handle while rejecting a complete file above its byte ceiling. Line windowing, numbered-line rendering, and total-line accounting live in the executor (`dsh-tool-fs`). The provider owns regular-file checks, UTF-8 decoding, binary/NUL rejection, and the bounded read's replacement/growth race; it does not know about line windows or views.
|
||||
|
||||
Observed-state recording is not on `ctx.fs`: after a successful read the executor emits `fs/observed`, and the `dsh-fs-policy` plugin records `{ version }` for the deriving owner. There is no `full`/`partial` view — a read at any window records the version, and freshness (not view completeness) authorizes a later write/edit.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local`
|
||||
|
||||
如果没有 `ctx.fs` 接口,将本地文件系统访问替换为沙箱或远程后端时,即使面向模型的契约应当保持稳定,工具 schema、演示和提示词引导也会被迫变动。这还使权限/沙箱边界更难推理:一个 `cwd` 选项看起来像沙箱,但除非有显式的后端或 `tools/execute` 策略强制隔离,否则它只是一个基础路径。
|
||||
|
||||
我们需要文件系统工具在成为公开包接口之前,以与 bash 相同的能力 seam 形态落地。
|
||||
我们需要文件系统工具在成为公开包(package)接口之前,以与 bash 相同的能力 seam 形态落地。
|
||||
|
||||
## 决策
|
||||
|
||||
@@ -51,7 +51,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local`
|
||||
|
||||
`@deepseek-ai/dsh-fs-local` 依赖 `@deepseek-ai/dsh-fs` 和 `cordis`。它继承 `FileSystem`,将自身注册为 `ctx.fs`,拥有本地后端配置(如基目录),并包含所有直接的 `node:fs` / `node:path` 访问。它不持有观测状态存储——新鲜度是后端铸造、策略插件记录的版本令牌。
|
||||
|
||||
`@deepseek-ai/dsh-tool-fs` 依赖 `@deepseek-ai/dsh-fs`、`@deepseek-ai/dsh-tools`、`@deepseek-ai/dsh-system-prompt` 和 `cordis`。它注册面向模型的工具和提示词段落。它禁止导入 `node:fs`、`node:path` 或 `@deepseek-ai/dsh-fs-local`;文件系统执行始终通过 `ctx.fs`。如果实现需要具体的 agent(智能体)或会话辅助类型,这些依赖属于 `tool-fs`;它们禁止回漏到 `dsh-fs` 中。
|
||||
`@deepseek-ai/dsh-tool-fs` 依赖 `@deepseek-ai/dsh-fs`、`@deepseek-ai/dsh-tools`、`@deepseek-ai/dsh-system-prompt` 和 `cordis`。它注册面向模型的工具和提示词段落。它禁止导入 `node:fs`、`node:path` 或 `@deepseek-ai/dsh-fs-local`;文件系统执行始终通过 `ctx.fs`。如果实现需要具体的 agent 或会话辅助类型,这些依赖属于 `tool-fs`;它们禁止回漏到 `dsh-fs` 中。
|
||||
|
||||
根 `tool-fs` 插件通过组合各工具的注册辅助函数来注册完整的文件系统工具套件(`read`、`write` 和 `edit`)。它注入 `fs`,从不导入实现包。
|
||||
|
||||
@@ -62,8 +62,9 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local`
|
||||
该接口涵盖以下语义操作:
|
||||
|
||||
- 将模型/插件提供的路径解析为后端定义的目标。
|
||||
- 将解析后的目标转换为同一执行环境的规范进程路径或 `file:` URI,并在不解析其不透明键的情况下检查包含关系。
|
||||
- 获取目标元数据而不读取文件内容。
|
||||
- 从目标读取有界的 UTF-8 文本页。
|
||||
- 读取完整或流式 UTF-8 文本,其中包括一项持有稳定句柄、以字节为上限的全文件读取操作。
|
||||
- 创建或替换一个 UTF-8 文本文件。
|
||||
- 通过字面替换编辑一个已有的 UTF-8 文本文件。
|
||||
|
||||
@@ -83,13 +84,15 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local`
|
||||
- 不透明的 `targetKey`,用于陈旧守护和文件状态查找。本地后端可能使用类似 realpath 的键;远程后端可能使用工作区 URI 或文件 id。消费方禁止解析或假设它是本地绝对路径。
|
||||
- `displayPath`,用于面向模型/UI 的输出。根据后端不同,它可能是本地绝对路径、工作区相对路径或远程 URI。
|
||||
|
||||
读取和变更结果必须包含不透明的文件 `version`。本地后端从 bigint stat 元数据(`dev`、`ino`、`size`、`mtimeNs` 和 `ctimeNs`)派生令牌,因此同大小重写和 inode 替换都会可靠地使消费方持有的版本失效;远程后端可以使用 revision id 或类似 hash 的令牌。`dsh-fs-policy` 插件记录版本用于陈旧检查;消费方可以展示相关元数据但禁止解释版本令牌。
|
||||
即使另一项能力共享提供方的执行环境,`targetKey` 仍保持不透明。这类消费方通过提供方的 `processPath(target)`、`fileUrl(target)` 或 `contains(parent, child)` 获取所需事实;[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.md)说明这些事实为何属于文件系统 seam。
|
||||
|
||||
提供方返回已解码的文本:`readText` 返回整个常规文本文件,`streamText` 为大文件流式输出相同的文本语义。两者负责常规文件检查;有界的行/输出处理不是它们的职责——行窗口化、带行号渲染和总行数统计位于执行器(`dsh-tool-fs`)中,执行器通过 `ctx.fs` 读取并渲染面向模型的窗口。提供方负责 UTF-8 解码和二进制/NUL 拒绝;它不知道行窗口或视图。
|
||||
读取和变更结果必须包含不透明的文件 `version`。本地后端从 bigint stat 元数据(`dev`、`ino`、`size`、`mtimeNs` 和 `ctimeNs`)派生令牌,因此同大小重写和 inode 替换都会可靠地使消费方失效;远程后端可以使用 revision id 或类似 hash 的令牌。`dsh-fs-policy` 插件记录版本用于陈旧检查;消费方可以展示相关元数据但禁止解释版本令牌。
|
||||
|
||||
提供方返回已解码的文本:`readText` 返回整个常规文本文件,`streamText` 为大文件流式传输相同的文本语义,`readTextBounded` 则持有一个归后端所有的稳定句柄,并在完整文件超过字节上限时拒绝。行窗口化、带行号渲染和总行数统计位于执行器(`dsh-tool-fs`)中。提供方负责普通文件检查、UTF-8 解码、二进制/NUL 拒绝,以及有界读取期间的路径替换/增长竞态;它不知道行窗口或视图。
|
||||
|
||||
观测状态记录不在 `ctx.fs` 上:成功读取后,执行器发出 `fs/observed`,`dsh-fs-policy` 插件为推导出的 owner 记录 `{ version }`。没有 `full`/`partial` 视图——任何窗口的读取都记录版本,新鲜度(而非视图完整性)授权后续的写入/编辑。
|
||||
|
||||
全文件写入创建或替换 UTF-8 文本文件。后端在支持且有文档说明时可以创建父目录。已有的非常规目标被拒绝。`writeText` 接受一个可选期望:`createIfAbsent` 创建缺失的目标并拒绝已存在的(报 `FS_NOT_OBSERVED`,这是策略处理未观测 owner 时采用的分支);`replaceIfVersion` 仅在目标处于观测版本时替换,否则报 `FS_STALE_VERSION`;省略期望则为无条件的裸提供方创建或覆盖。策略插件根据 owner 的观测状态选择提供哪个期望。
|
||||
全文件写入创建或替换 UTF-8 文本文件。后端在支持且有文档说明时可以创建父目录。已有的非常规目标被拒绝。`writeText` 接受一个可选期望:`createIfAbsent` 创建缺失的目标并拒绝已存在的(报 `FS_NOT_OBSERVED`,这是策略为未观测 owner 使用的路径);`replaceIfVersion` 仅在目标处于观测版本时替换,否则报 `FS_STALE_VERSION`;省略期望则为无条件的裸提供方创建或覆盖。策略插件根据 owner 的观测状态选择提供哪个期望。
|
||||
|
||||
字面编辑是提供方原语(`editText`),而非在 `tool-fs` 中由读取加写入组合而成。字面匹配、重复匹配拒绝、CRLF 保留、二进制拒绝、可选的陈旧版本检查和原子读-改-写必须一起留在后端的变更临界区内。`editText` 接受相同的可选版本期望;陈旧检查在字面匹配之前运行,因此基于旧读取的编辑会报 `FS_STALE_VERSION`。远程后端可以将编辑实现为原生的 compare-and-edit 操作;消费方不强制本地风格的组合。
|
||||
|
||||
@@ -124,11 +127,11 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local`
|
||||
|
||||
## 测试
|
||||
|
||||
测试遵循包边界,而不仅是用户可见的工具:`dsh-fs` 中的服务 seam;`dsh-fs-local` 中通过 `ctx.fs` 接口测试的真实文件系统行为(解析、符号链接、流式输出、二进制/UTF-8 拒绝、无条件和版本守护的写入、字面编辑语义、行尾保留、结构化 `FsError` 错误码);`dsh-tool-fs` 中基于真实本地提供方的消费方接口(只 mock 模型/时钟,从不 mock 协作者);以及通过 `ctx.tools.execute()` 在有和没有 `dsh-fs-policy` 的情况下进行集成测试,通过从磁盘回读文件来验证世界状态,既不信任规范值,也不信任渲染内容。观测状态/owner 推导策略在 `dsh-fs-policy` 中测试,不在此处。
|
||||
测试遵循包边界,而不仅是用户可见的工具:`dsh-fs` 中的服务 seam;`dsh-fs-local` 中通过 `ctx.fs` 接口测试的真实文件系统行为(解析、符号链接、流式传输、二进制/UTF-8 拒绝、无条件和版本守护的写入、字面编辑语义、行尾保留、结构化 `FsError` 错误码);`dsh-tool-fs` 中基于真实本地提供方的消费方接口(只 mock 模型/时钟,从不 mock 协作者);以及通过 `ctx.tools.execute()` 在有和没有 `dsh-fs-policy` 的情况下进行集成测试,通过从磁盘回读文件来验证世界状态,既不信任规范值,也不信任渲染内容。观测状态/owner 推导策略在 `dsh-fs-policy` 中测试,不在此处。
|
||||
|
||||
本仓库曾踩过的防御性模式类别被直接固定:
|
||||
|
||||
- **原子写入临时文件安全。** 写入/编辑通过目标旁边一个私有随机 `0700` 目录中独占且仅所有者可访问(`'wx'`、`0o600`)的临时文件暂存,失败时清理,最后原子 rename——与 bash spill 文件规则一致,因为可预测的全局可读临时路径会招致符号链接竞争和信息泄露。测试断言权限,并断言已存在的临时路径不会被覆盖;此原语是 seam 的常设要求。
|
||||
- **原子写入临时文件安全。** 写入/编辑通过目标旁边一个私有随机 `0700` 目录中的独占 owner-only(`'wx'`、`0o600`)临时文件暂存,失败时清理,最后原子 rename——与 bash 溢出文件规则一致,因为可预测的 world-readable 临时路径招致符号链接竞争和信息泄露。测试断言权限,并断言已存在的临时路径不会被覆盖;此原语是 seam 的常设要求。
|
||||
- **通过符号链接的 `targetKey` 同一性。** 两个输入路径解析到同一 realpath 时共享一个观测状态条目:通过路径 A 的 `read` 满足通过符号链接路径 B 的 `edit` 的读后编辑守护,通过一个路径的陈旧写入可通过另一个路径检测到。
|
||||
- **并发/陈旧竞争。** 对同一目标的两个并发写入/编辑操作确定性地收敛——一个成功,另一个被 `FS_STALE_VERSION` 拒绝——成功的编辑刷新记录状态,使同一 owner 的下一次编辑可以继续。
|
||||
- **HMR(热模块替换)安全与 dispose(资源释放)。** dispose 后端的 fiber 会撤回 `ctx.fs` 提供方;后续的提供方以无继承状态启动。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md
|
||||
2026-07-15-lsp-capability-seam.md: d96b3a9c5139c1455a51f4fff793293d7b5a11c0
|
||||
2026-07-15-lsp-capability-seam.zh.md: 256b293f213acb06588f3ef6c655242b1c8fd5b9
|
||||
2026-07-15-lsp-capability-seam.md: a9dab45ebf034f6d5218b25a0868a7e86803719b
|
||||
2026-07-15-lsp-capability-seam.zh.md: b411a2456ea2a3838e56422f58954473da51ae23
|
||||
|
||||
@@ -79,7 +79,7 @@ interface LspService {
|
||||
|
||||
Mapping keys normalize to lowercase, leading-dot extensions selected from `filePath`'s final extension; language ids only synchronize documents. Seam positions and ranges are zero-based UTF-16. `findReferences` always includes declarations: providers enforce this internally, the local mapping sets `context.includeDeclaration: true`, and callers get no flag. Closed result unions normalize navigation to locations and hover to content or `null`; navigation results carry the provider's resolved workspace root so consumers relativize file URIs in the same canonical namespace. The seam exposes no protocol types, process or document controls, or generic request escape hatch.
|
||||
|
||||
`dsh-lsp-local` owns host files, server configuration, JSON-RPC, process and transient-document state, and protocol translation; it depends on `dsh-lsp` and Node APIs, not `dsh-fs`. The server-table key is its provider id. The plugin resolves every server-local setting before registration, rolls back earlier registrations if a later mapping is invalid or conflicts, and retains an independent process pool per provider. `dsh-tool-lsp` runtime-injects only `tools`, `lsp`, and `systemPrompt`, obtains the workspace from `exec.agent?.session.header.cwd` through a package-local `sessionCwd(exec)` helper matching the filesystem tools' lookup, and imports no provider.
|
||||
`dsh-lsp-local` owns server configuration, JSON-RPC, process and transient-document state, and protocol translation. It reads through `ctx.fs` and launches through `ctx.subprocess`, depending on their interface packages rather than concrete providers; the [portable execution-world decision](2026-07-28-portable-execution-world-consumers.md) owns that pairing. The server-table key is its provider id. The plugin resolves every server-local setting before registration, rolls back earlier registrations if a later mapping is invalid or conflicts, and retains an independent process pool per provider. `dsh-tool-lsp` runtime-injects only `tools`, `lsp`, and `systemPrompt`, obtains the workspace from `exec.agent?.session.header.cwd` through a package-local `sessionCwd(exec)` helper matching the filesystem tools' lookup, and imports no provider.
|
||||
|
||||
## Model-facing contract
|
||||
|
||||
@@ -112,26 +112,26 @@ Provider disposal occurs outside tool execution, so `dsh-lsp-local` keeps `shutd
|
||||
|
||||
## Workspace, filesystem, and document synchronization
|
||||
|
||||
`dsh-lsp-local` canonicalizes and reads through Node APIs in the subprocess's host namespace. It rejects missing, non-regular, non-UTF-8, oversized, or canonical out-of-workspace sources and keeps one `O_NOFOLLOW | O_NONBLOCK` handle through validation and reading, so a FIFO with no writer cannot block before the regular-file check. It observes caller cancellation around each filesystem operation. It does not consume `ctx.fs` or emit `fs/observed`: only the LSP result is model-visible, so the query does not satisfy read-before-write policy.
|
||||
`dsh-lsp-local` canonicalizes and reads through `ctx.fs` in the language server's execution world. It requires the workspace target to be a directory, rejects out-of-workspace sources through provider-owned containment, and uses `readTextBounded` so regular-file validation, UTF-8 decoding, the byte ceiling, and path replacement/growth safety stay one filesystem operation. It observes caller cancellation around each provider operation. It does not emit `fs/observed`: only the LSP result is model-visible, so the query does not satisfy read-before-write policy.
|
||||
|
||||
The `read` tool is unsuitable source because its output is windowed, numbered, transcript-visible, and observed. Reading in `tool-lsp` would also assign provider-specific synchronization to the consumer and preclude non-local providers.
|
||||
|
||||
The local provider uses a compatibility-first transient-open sequence for every query. It accepts legacy `textDocumentSync` `Full` or `Incremental`, or options with `openClose: true`; omitted, `None`, or explicitly incompatible synchronization fails as unsupported before `didOpen`.
|
||||
|
||||
1. Canonicalize and validate the host path, then read the current source with Node filesystem APIs.
|
||||
1. Resolve and contain the source through `ctx.fs`, then read its current bounded text through the same provider.
|
||||
2. Send `textDocument/didOpen` with version `1`, full text, and the configured language id. Its write remains abortable; failure or cancellation invalidates the instance and awaits bounded process termination before the pool can reuse it.
|
||||
3. Send the requested `textDocument/definition`, `textDocument/references`, `textDocument/implementation`, or `textDocument/hover` request.
|
||||
4. If `didOpen` succeeded, attempt `textDocument/didClose` in `finally` after the request settles or aborts. A close-write failure does not replace the settled result or error, but invalidates the instance and awaits bounded process termination.
|
||||
|
||||
Documents close after each call, so the first version needs no `didChange`, `didSave`, content cache, mutation listener, or document LRU. One abortable per-workspace provider queue serializes source-read/open/query/close lifecycles, so a waiting query reads current bytes only when its turn starts; the instance also keeps protocol lifecycles serialized. Distinct workspaces may run in parallel. The server's workspace index remains responsible for closed files reached from the source.
|
||||
|
||||
The canonical workspace `realpath` must be a directory and supplies process cwd, `rootUri`, the sole `workspaceFolders` entry, and pool identity; symlink aliases therefore share an instance. Result locations may be external, but an external path cannot become a query source. Remote, virtual, or independently sandboxed filesystems require another provider.
|
||||
The canonical workspace target must be a directory. Its target key supplies pool identity, its process path supplies cwd, and its provider-owned `file:` URI supplies `rootUri` and the sole `workspaceFolders` entry; aliases share an instance when the filesystem provider resolves them to one key. Result locations may be external, but an external path cannot become a query source. A filesystem that cannot share paths with the mounted subprocess provider is a composition error, not a reason for another LSP package.
|
||||
|
||||
## Local server lifecycle and protocol behavior
|
||||
|
||||
`dsh-lsp-local` lazily single-flights one server per `(provider id, canonical workspace realpath)`. At load it resolves the executable after credential scrubbing and environment overrides, failing before registration if unavailable; server process launch stays lazy (first query spawns it) and uses no shell. `maxMessageBytes` defaults to `16_000_000`, `maxStderrBytes` to `1_000_000`, and `maxDocumentBytes` to `4_000_000`. A crash fails the active query without replay; a later query may replace the process. Each query starts at most one process, so the MVP has no cross-request restart counter.
|
||||
`dsh-lsp-local` lazily single-flights one server per `(provider id, canonical workspace target)`. At load it calls `ctx.subprocess.resolveExecutable()` with the configured environment, failing before registration if unavailable; first query launches through raw protocol pipes with no shell and a bounded collected stderr tail. `maxMessageBytes` defaults to `16_000_000`, `maxStderrBytes` to `1_000_000`, and `maxDocumentBytes` to `4_000_000`. A crash fails the active query without replay; a later query may replace the process. Each query starts at most one process, so the MVP has no cross-request restart counter.
|
||||
|
||||
Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: { workspaceFolders: true, configuration: true }`, `textDocument.hover.contentFormat: ['markdown', 'plaintext']`, and `linkSupport: true` for definition and implementation, with no dynamic registration. Returned operation and synchronization capabilities are authoritative. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. Configuration may supply initialization options and `workspace/configuration` responses, but the client rejects `workspace/applyEdit` and never executes commands or edits.
|
||||
Initialization uses `processId: null` because the client and server may inhabit different process namespaces. It advertises `general.positionEncodings: ['utf-16']`, `workspace: { workspaceFolders: true, configuration: true }`, `textDocument.hover.contentFormat: ['markdown', 'plaintext']`, and `linkSupport: true` for definition and implementation, with no dynamic registration. Returned operation and synchronization capabilities are authoritative. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. Configuration may supply initialization options and `workspace/configuration` responses, but the client rejects `workspace/applyEdit` and never executes commands or edits.
|
||||
|
||||
Navigation maps `Location` directly and `LocationLink` from `targetUri` plus `targetSelectionRange`. Positions must be nonnegative integers. Hover normalization accepts only valid `MarkupContent` and `MarkedString` shapes, preserves string values, renders language-tagged values as fenced code, and joins arrays with one blank line. The model-facing tool applies `maxResultChars` after rendering.
|
||||
|
||||
@@ -143,7 +143,7 @@ Symbols are deferred because they need different schemas and overlap read/search
|
||||
|
||||
Diagnostics need separate freshness, accumulation, and transcript rules. Mutations such as rename, code actions, and formatting require separate tools with preview, permission, and write-policy integration.
|
||||
|
||||
The local provider trusts its configured server and claims no sandbox confinement. Supporting untrusted binaries requires a later process/filesystem contract for workspace reads plus private cache and temporary writes; restricted, remote, or virtual workspaces require another provider.
|
||||
The provider trusts its configured server. Its filesystem visibility and process confinement are exactly those of the mounted execution world; LSP adds no independent sandbox policy.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -157,7 +157,7 @@ The local provider trusts its configured server and claims no sandbox confinemen
|
||||
|
||||
**Wrap the signal in a per-seam execution-context object.** Web passes a bare `AbortSignal`; wrapping this single field would add unexplained asymmetry. `query()` gains a context object only when another field requires it.
|
||||
|
||||
**Read through `ctx.fs` or the `read` tool.** This could mix the document with a server index from another filesystem namespace; tool output is also windowed, numbered, and observed. The host-local provider reads unobserved full text beside its subprocess.
|
||||
**Read through the model-facing `read` tool.** Rejected because tool output is windowed, numbered, transcript-visible, and observed. The provider reads bounded full text directly through the same `ctx.fs` execution world used by its subprocess.
|
||||
|
||||
**Keep documents open.** Mirroring edits requires version ownership, all-path `didChange`, HMR recovery, eviction, and stale-state rules. Transient opens avoid that MVP state machine.
|
||||
|
||||
@@ -180,7 +180,7 @@ The local provider trusts its configured server and claims no sandbox confinemen
|
||||
- Synchronization tests pin UTF-16 negotiation and conversion, supported and rejected `textDocumentSync` forms, blocked and failed open writes, balanced transient open/close, close-write failure, and malformed-response rejection.
|
||||
- Timeout tests pin one `TOOL_TIMEOUT` budget, unclassified upstream cancellation, no hidden seam deadline, and bounded awaited teardown.
|
||||
- Lifecycle tests pin startup single-flight, complete-lifecycle serialization with fresh queued source reads, cross-workspace parallelism, abortable queues, crash replacement without replay, failed-stdin teardown, and quiescent disposal.
|
||||
- Host-filesystem tests pin session-cwd requirements, relative and absolute source containment through symlinks, document validation, file/non-file URI rendering, unformatted source, and no `fs/observed` event.
|
||||
- Filesystem-host tests pin session-cwd requirements, provider-owned containment and URI rendering, bounded document reads, unformatted source, and no `fs/observed` event.
|
||||
- A keyless pinned TypeScript real-server e2e exercises all four operations; runnable configuration uses the same explicit provider mapping.
|
||||
- Snapshots cover model-visible schema, prompt, results, and omissions; a built-artifact smoke test covers framing and cleanup.
|
||||
- Package and architecture docs cover configuration, security boundaries, and search/read guidance; the new `packages/lsp/` group is added to the AGENTS.md repository-layout block, the packages/README.md group table, and architecture.md in the same change.
|
||||
@@ -195,4 +195,4 @@ Extension ownership is exclusive within one runtime. Two providers cannot both c
|
||||
|
||||
UTF-16 cursor columns are exact for the protocol but difficult for a model to count around non-BMP characters. Invalid or off-symbol positions may produce empty results, so error text and prompt examples must explain the coordinate convention without encouraging broad LSP use.
|
||||
|
||||
Direct Node access aligns the query snapshot with the server index but bypasses `ctx.fs` and its policy. Canonical containment rejects source files outside the workspace; a trusted server may still read the workspace and use caches. The first implementation therefore requires trusted host-local deployment and provides no sandbox guarantee.
|
||||
The paired filesystem/subprocess providers align the query snapshot with the server index but do not make a trusted language server safe. Canonical containment rejects query sources outside the workspace; the server itself receives the execution world's configured authority and may read other paths or use caches.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent Note: LSP 能力 seam 与面向模型的查询工具
|
||||
# Agent Note: LSP 能力服务边界与面向模型的查询工具
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -14,7 +14,7 @@ harness 已具备文本搜索与文件读取能力,但二者都无法识别程
|
||||
|
||||
## 决策
|
||||
|
||||
将 LSP 建成由三个包组成的能力 seam,其中包含一个只读模型工具和一个通用本地提供方实现:
|
||||
将 LSP 建成由三个包(package)组成的能力服务边界,其中包含一个只读模型工具和一个通用本地提供方实现:
|
||||
|
||||
1. `packages/lsp/lsp` 下的 `@deepseek-ai/dsh-lsp` 负责 `ctx.lsp`、提供方注册与选择、标准化请求与结果、执行控制,以及结构化 LSP 错误。
|
||||
2. `packages/lsp/lsp-local` 下的 `@deepseek-ai/dsh-lsp-local` 将配置的 stdio 语言服务器适配到该服务边界。一个插件实例接收具名服务器表,并为每组命令及扩展名到语言 id 的映射注册一个隔离的提供方。
|
||||
@@ -79,7 +79,7 @@ interface LspService {
|
||||
|
||||
映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。服务边界中的位置和范围从零开始按 UTF-16 计数。`findReferences` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`;导航结果携带提供方解析后的工作区根目录,使消费方依据同一规范化根目录相对化文件 URI。服务边界不公开协议类型、进程或文档控制,也不提供通用请求逃生口。
|
||||
|
||||
`dsh-lsp-local` 负责主机文件、服务器配置、JSON-RPC、进程与临时文档状态和协议转换;它依赖 `dsh-lsp` 与 Node API,不依赖 `dsh-fs`。服务器表的键是提供方 id。插件在注册前解析每个服务器的本地设置;如果后续映射无效或发生冲突,插件会撤销此前的注册,并为每个提供方保留独立进程池。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。
|
||||
`dsh-lsp-local` 负责服务器配置、JSON-RPC、进程与临时文档状态和协议转换。它通过 `ctx.fs` 读取,通过 `ctx.subprocess` 启动,只依赖二者的接口包而非具体提供方;[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.md)负责定义这种配对。服务器表的键是提供方 id。插件在注册前解析每个服务器的本地设置;如果后续映射无效或发生冲突,插件会撤销此前的注册,并为每个提供方保留独立进程池。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。
|
||||
|
||||
## 面向模型的契约
|
||||
|
||||
@@ -98,9 +98,9 @@ interface LspToolInput {
|
||||
|
||||
工具必须从会话 `header.cwd` 取得 `workspaceRoot`,没有后备值;缺失时在查询或启动前以 `LSP_WORKSPACE_REQUIRED` 失败。本地提供方基于根目录解析相对路径并直接接受绝对路径;两种路径都会进行规范化,如果目标位于规范工作区外,则在启动前拒绝。
|
||||
|
||||
位置按文件稳定分组并渲染为 `path:line:character`。Node `fileURLToPath()` 可接受的 `file:` URI 在工作区内转换为相对路径,在工作区外转换为绝对路径;其他 URI 保持原样。`maxLocations` 默认值为 `100`,并报告省略的条目;`maxResultChars` 默认值为 `16_000`,并将每个完整渲染结果(包括截断元数据)限制在该字符数内。空位置与 `null` hover 是成功的无结果响应;服务器载荷缺失或格式错误时,以结构化 `LSP_MALFORMED_RESPONSE` 错误失败。
|
||||
位置按文件稳定分组并渲染为 `path:line:character`。Node `fileURLToPath()` 可接受的 `file:` URI 在工作区内转换为相对路径,在工作区外转换为绝对路径;其他 URI 保持原样。`maxLocations` 默认值为 `100`,并报告省略的条目;`maxResultChars` 默认值为 `16_000`,并限制每个完整渲染结果,其中包括截断元数据。空位置与 `null` hover 是成功的无结果响应;服务器载荷缺失或格式错误时,以结构化 `LSP_MALFORMED_RESPONSE` 错误失败。
|
||||
|
||||
与传输方式无关的展示器使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }`,`title` 由参数推导并标明操作与光标。由于 `FileLocation` 没有 character,跟随位置聚焦输入行,标题保留完整光标;展示器仍为纯函数。
|
||||
与传输方式无关的展示器使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }`,`title` 由参数推导并标明操作与光标。由于 `FileLocation` 没有 character,跟随位置聚焦输入行,标题保留完整光标;展示保持纯函数。
|
||||
|
||||
## 超时归属
|
||||
|
||||
@@ -112,26 +112,26 @@ interface LspToolInput {
|
||||
|
||||
## 工作区、文件系统与文档同步
|
||||
|
||||
`dsh-lsp-local` 通过 Node API 在子进程所在的主机命名空间中规范化并读取文件。它拒绝缺失、非普通、非 UTF-8、超大或规范路径越出工作区的源文件,并在校验与读取期间保持同一个 `O_NOFOLLOW | O_NONBLOCK` 句柄,因此没有写入方的 FIFO 不会在普通文件校验前造成阻塞。它在每项文件系统操作前后检查调用方是否取消。它不使用 `ctx.fs` 或发送 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足写前读取策略。
|
||||
`dsh-lsp-local` 在语言服务器的执行环境中通过 `ctx.fs` 规范化并读取文件。它要求工作区目标是目录,使用提供方自有的 containment 拒绝工作区外的源文件,并通过 `readTextBounded` 把普通文件校验、UTF-8 解码、字节上限和路径替换/增长安全性保留在同一项文件系统操作中。它在每项提供方操作前后检查调用方是否取消。它不发送 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足写前读取策略。
|
||||
|
||||
`read` 工具的输出带窗口与行号,进入 transcript(文本记录)且已被观察,不适合作为源文件。在 `tool-lsp` 内读取还会把提供方专用同步职责交给消费方,并排除非本地提供方。
|
||||
|
||||
本地提供方对每次查询都采用兼容优先的临时打开流程。它接受旧式 `textDocumentSync` 的 `Full` 或 `Incremental`,也接受设置了 `openClose: true` 的选项;同步能力缺失、为 `None` 或明确不兼容时,在 `didOpen` 前以不支持错误失败。
|
||||
|
||||
1. 规范化并校验主机路径,再使用 Node 文件系统 API 读取当前源文件。
|
||||
1. 通过 `ctx.fs` 解析源文件并检查其位于工作区内,再通过同一提供方对当前文本进行有界读取。
|
||||
2. 发送 `textDocument/didOpen`,其中包含版本 `1`、完整文本和配置的语言 id。该写入仍可取消;写入失败或遭取消会使实例失效,并等待有界进程终止完成,池才能复用它。
|
||||
3. 发送所请求的 `textDocument/definition`、`textDocument/references`、`textDocument/implementation` 或 `textDocument/hover` 请求。
|
||||
4. 如果 `didOpen` 成功,则在请求完成或取消后于 `finally` 中尝试发送 `textDocument/didClose`。关闭写入失败不会覆盖已经确定的结果或错误,但会使实例失效,并等待有界进程终止完成。
|
||||
|
||||
每次调用后都关闭文档,因此第一版不需要 `didChange`、`didSave`、内容缓存、变更监听器或文档 LRU。每个工作区的提供方队列可取消,并串行执行源文件读取、打开、查询和关闭的完整生命周期,因此等待中的查询只在轮到它时才读取当前字节;实例也会串行执行协议生命周期。不同工作区可以并行。服务器工作区索引仍负责从源文件跳转到的已关闭文件。
|
||||
|
||||
规范工作区 `realpath` 必须是目录,并用于进程 cwd、`rootUri`、唯一的 `workspaceFolders` 条目和进程池 identity;符号链接别名因此共享实例。结果位置可以在工作区外,但外部路径不能成为查询源。远程、虚拟或独立沙箱化文件系统需要另一种提供方。
|
||||
规范工作区目标必须是目录。其目标键提供进程池 identity,进程路径提供 cwd,归提供方所有的 `file:` URI 则提供 `rootUri` 和唯一的 `workspaceFolders` 条目;文件系统提供方将别名解析为同一键时,它们共享实例。结果位置可以在工作区外,但外部路径不能成为查询源。无法与挂载的子进程提供方共享路径的文件系统属于组合错误,不是另建 LSP 包的理由。
|
||||
|
||||
## 本地服务器生命周期与协议行为
|
||||
|
||||
`dsh-lsp-local` 按 `(provider id, canonical workspace realpath)` 懒启动一个服务器,并通过 single-flight 合并启动。插件加载时,它在清除凭据并应用环境变量覆盖后解析可执行文件;命令不可用时在注册前失败。服务器进程的启动保持懒执行(首次查询时才拉起),且不经过 shell。`maxMessageBytes` 默认值为 `16_000_000`,`maxStderrBytes` 默认值为 `1_000_000`,`maxDocumentBytes` 默认值为 `4_000_000`。崩溃使当前查询失败且不重放;后续查询可以替换进程。每次查询最多启动一个进程,因此 MVP 不设置跨请求重启计数器。
|
||||
`dsh-lsp-local` 按 `(provider id, canonical workspace target)` 懒启动一个服务器,并通过 single-flight 合并启动。插件加载时,它使用已配置的环境调用 `ctx.subprocess.resolveExecutable()`;命令不可用时在注册前失败。首次查询通过原始协议管道启动服务器,不经过 shell,并收集有界的 stderr 尾部。`maxMessageBytes` 默认值为 `16_000_000`,`maxStderrBytes` 默认值为 `1_000_000`,`maxDocumentBytes` 默认值为 `4_000_000`。崩溃使当前查询失败且不重放;后续查询可以替换进程。每次查询最多启动一个进程,因此 MVP 不设置跨请求重启计数器。
|
||||
|
||||
初始化声明 `general.positionEncodings: ['utf-16']`、`workspace: { workspaceFolders: true, configuration: true }`、`textDocument.hover.contentFormat: ['markdown', 'plaintext']`,以及 definition 与 implementation 的 `linkSupport: true`,但不支持动态注册。服务器返回的操作能力与同步能力均为真源。服务器省略 `positionEncoding` 时默认为 `utf-16`;其他值均属于协议错误。配置可以提供初始化选项和 `workspace/configuration` 响应,但客户端拒绝 `workspace/applyEdit`,绝不执行命令或编辑。
|
||||
初始化使用 `processId: null`,因为客户端与服务器可能位于不同的进程命名空间。它声明 `general.positionEncodings: ['utf-16']`、`workspace: { workspaceFolders: true, configuration: true }`、`textDocument.hover.contentFormat: ['markdown', 'plaintext']`,以及 definition 与 implementation 的 `linkSupport: true`,但不支持动态注册。服务器返回的操作与同步能力均为真源。服务器省略 `positionEncoding` 时默认为 `utf-16`;其他值均属于协议错误。配置可以提供初始化选项和 `workspace/configuration` 响应,但客户端拒绝 `workspace/applyEdit`,绝不执行命令或编辑。
|
||||
|
||||
导航结果直接映射 `Location`,并将 `LocationLink` 的 `targetUri` 与 `targetSelectionRange` 映射为统一位置。位置必须是非负整数。`hover` 归一化只接受有效的 `MarkupContent` 和 `MarkedString` 结构,保留字符串值,把带语言标签的值渲染为围栏代码块,并以一个空行连接数组。面向模型的工具在渲染后应用 `maxResultChars`。
|
||||
|
||||
@@ -143,7 +143,7 @@ interface LspToolInput {
|
||||
|
||||
诊断需要独立的新鲜度、累积与 transcript 规则。重命名、代码操作和格式化等变更能力需要单独工具,并集成预览、权限和写入策略。
|
||||
|
||||
本地提供方信任配置的服务器,不声称具备沙箱隔离。支持不受信任的二进制文件需要后续补充允许读取工作区,并执行私有缓存写入与临时写入的进程/文件系统契约;受限、远程或虚拟工作区需要另一种提供方。
|
||||
提供方信任配置的服务器。其文件系统可见性与进程隔离完全取决于挂载的执行环境;LSP 不增加独立的沙箱策略。
|
||||
|
||||
## 备选方案
|
||||
|
||||
@@ -155,9 +155,9 @@ interface LspToolInput {
|
||||
|
||||
**公开 `resolve(request)` / `query(spec)`。** 没有需要填充默认值的字段时,resolve 只会暴露提供方选择,而公开 spec 可能活过提供方释放或替换。单一操作让选择与调用共用注册生命周期。
|
||||
|
||||
**将信号包装为服务边界专用的执行上下文对象。** Web 传递裸 `AbortSignal`;仅包装这一个字段会造成无谓的不对称。只有另一个字段确有需要时,`query()` 才引入上下文对象。
|
||||
**将信号包装为每服务边界的执行上下文对象。** Web 传递裸 `AbortSignal`;仅包装这一个字段会造成无谓的不对称。只有另一个字段确有需要时,`query()` 才引入上下文对象。
|
||||
|
||||
**通过 `ctx.fs` 或 `read` 工具读取。** 这可能把文档与另一文件系统命名空间中的服务器索引混合;工具输出还带窗口、行号且已被观察。host-local 提供方在子进程旁读取未观察的完整文本。
|
||||
**通过面向模型的 `read` 工具读取。**拒绝,因为工具输出带窗口与行号,会进入 transcript 且已被观察。提供方直接通过子进程所用的同一 `ctx.fs` 执行环境读取有界的完整文本。
|
||||
|
||||
**保持文档打开。** 镜像编辑需要版本归属、覆盖所有路径的 `didChange`、HMR 恢复、淘汰和陈旧状态规则。临时打开避免在 MVP 引入这套状态机。
|
||||
|
||||
@@ -180,14 +180,14 @@ interface LspToolInput {
|
||||
- 同步测试固定 UTF-16 协商与转换、受支持和被拒绝的 `textDocumentSync` 形式、打开写入阻塞与失败、配对的临时打开/关闭、关闭写入失败和错误响应拒绝。
|
||||
- 超时测试固定一个 `TOOL_TIMEOUT` 预算、不对上游取消错误分类、服务边界无隐藏截止时间,以及受限且等待完成的清理。
|
||||
- 生命周期测试固定启动 single-flight、完整生命周期串行化及排队查询读取最新源文件、跨工作区并行、可取消队列、崩溃后不重放的替换、stdin 失败后的进程拆除,以及释放后完全停稳。
|
||||
- 主机文件系统测试固定 session cwd 要求、符号链接下相对与绝对源路径的规范 containment、文档校验、file/non-file URI 渲染、无格式源文本和不发送 `fs/observed`。
|
||||
- 文件系统宿主测试固定 session cwd 要求、提供方自有的 containment 与 URI 渲染、有界文档读取、无格式源文本和不发送 `fs/observed`。
|
||||
- 无密钥且固定版本的 TypeScript 真实服务器 e2e 覆盖四种操作;可运行配置使用同一项显式提供方映射。
|
||||
- 快照覆盖模型可见 schema、提示词、结果和省略提示;构建产物冒烟测试覆盖分帧与清理。
|
||||
- 包与架构文档覆盖配置、安全边界和搜索/读取指导;同一改动中,新的 `packages/lsp/` 包组要加入 AGENTS.md 的仓库布局块、packages/README.md 的分组表和 architecture.md。
|
||||
|
||||
## 影响
|
||||
|
||||
各语言服务器对方法支持、能力解释和索引就绪时机的处理不同;LSP 没有统一的「索引完成」信号。不具备兼容临时打开同步能力的服务器不受支持,即使它能查询已关闭文档。受支持的服务器仍可能返回空结果或不完整结果,因此工具不承诺跨服务器完整性。固定的 TypeScript e2e 只建立一条兼容性基线,不代表跨语言承诺。
|
||||
各语言服务器对方法支持、能力解释和索引就绪时机的处理不同;LSP 没有统一的“索引完成”信号。无法声明兼容临时打开同步能力的服务器不受支持,即使它能查询已关闭文档。受支持的服务器仍可能返回空结果或不完整结果,因此工具不承诺跨服务器完整性。固定的 TypeScript e2e 只建立一条兼容性基线,不代表跨语言承诺。
|
||||
|
||||
临时打开会重复解析并产生通知。实例内串行会增加并发 agent 的延迟,长期运行的工作区进程则持续占用内存直到释放。
|
||||
|
||||
@@ -195,4 +195,4 @@ interface LspToolInput {
|
||||
|
||||
UTF-16 光标列与协议完全一致,但模型难以在包含非 BMP 字符的文本中准确计数。无效位置或不在符号上的位置可能返回空结果,因此错误文本和提示词示例必须说明坐标约定,同时避免鼓励模型广泛使用 LSP。
|
||||
|
||||
直接访问 Node 文件系统会对齐查询快照与服务器索引,但绕过 `ctx.fs` 及其策略。规范路径 containment 会拒绝工作区外的源文件;受信任的服务器仍可读取工作区并使用缓存。因此,第一版要求受信任的 host-local 部署,不提供沙箱保证。
|
||||
配对的文件系统/子进程提供方会对齐查询快照与服务器索引,但不会因此使受信任的语言服务器变得安全。规范 containment 会拒绝工作区外的查询源;服务器本身获得执行环境所配置的权限,仍可读取其他路径或使用缓存。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md
|
||||
2026-07-26-subprocess-consumer-migration.md: 477dffc2271db08b8986b34645067b247ad7ace7
|
||||
2026-07-26-subprocess-consumer-migration.zh.md: 8e0e377fc3fe00ff452803fe7fe5f4115003f937
|
||||
2026-07-26-subprocess-consumer-migration.md: abc7c8504595641b2b821df5ddbd4645c3a15336
|
||||
2026-07-26-subprocess-consumer-migration.zh.md: 500dcfe36f63315156b1a3cc48670eaf1c204b20
|
||||
|
||||
@@ -6,7 +6,7 @@ English | [中文](2026-07-26-subprocess-consumer-migration.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [subprocess seam](2026-07-26-subprocess-seam.md) shipped shaped for exactly one consumer family: batch-collected stdout/stderr, batch stdin, a single escalating `kill()`. That was deliberate scope control, and its own note records "migrate the other spawn sites" as rejected-for-now. Review on the introducing PR reversed that deferral: the stacked follow-up should reshape the interface toward Node's API and move the remaining process-running places onto the service. The remaining spawners each carried a private copy of some slice of the same mechanics — lsp-local had its own detached-tree signalling (POSIX group + Windows taskkill + liveness polling), subagent-subprocess had the dispose ladder and its own scrub, and mcp-client, pty-local, the SDK helper, and the TUI Git probe each carried another credential scrub — and none of it was swappable or centrally testable.
|
||||
The [subprocess seam](2026-07-26-subprocess-seam.md) shipped shaped for exactly one consumer family: batch-collected stdout/stderr, batch stdin, a single escalating `kill()`. That was deliberate scope control, and its own note records "migrate the other spawn sites" as rejected-for-now. Review on the introducing PR reversed that deferral: the stacked follow-up should reshape the interface toward Node's API and move the remaining process-running places onto the service. The remaining spawners each carried a private copy of some slice of the same mechanics — lsp-local had its own detached-tree signalling (POSIX group + Windows taskkill + liveness polling), subagent-subprocess had the dispose ladder and its own scrub, mcp-client and pty-local and the SDK helper each had a third/fourth/fifth copy of the credential scrub — and none of it was swappable or centrally testable.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -15,24 +15,24 @@ The seam's vocabulary is now Node-shaped, and every spawner that can ride the se
|
||||
- **Per-stream stdio dispositions** on `SubprocessSpawnSpec`: `'pipe'` (the raw `Readable`/`Writable`, for consumer-owned protocol framing), `'inherit'` (diagnostics to the parent's stream), and collect mode `{ maxBytes, spill? }` — the original bounded tail-keep shape, with the spill file now optional so a diagnostic tail (a language server's stderr) buffers without touching disk. stdin is `'ignore'`, `'pipe'`, or `{ data }` (write-and-close batch).
|
||||
- **`SubprocessOutcome` carries exit facts only** (Node's close-event vocabulary); collected output stays readable through `handle.collected` after settlement (spill fds seal at the settle boundary), so batch and streaming callers share one access path and nothing is copied into the outcome.
|
||||
- **Tree-scoped termination behind one verb**: `terminate()` owns the SIGTERM→grace→SIGKILL escalation (serves the spec's abort signal too, and is a no-op once the tree is gone) — the handle exposes no single-signal `kill(signal?)`, so a consumer cannot skip the grace window; `waitForExit()` polls tree liveness (POSIX group probe; direct-child boundary on Windows); Windows tree termination (`taskkill /T`, injectable) moved in from lsp-local, so tree semantics are platform-correct for every consumer. (The stdin-EOF-first dispose ladder initially absorbed from `subagent-subprocess` later moved back out to its one consumer — see the [ladder-ownership Agent Note](2026-07-27-dispose-ladder-to-consumer.md).)
|
||||
- **One scrub definition**: `scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` live on the seam, and credential-shaped names contain `KEY`, `PASSWORD`, `SECRET`, or `TOKEN`, case-insensitively. Spawners that cannot route the spawn itself through the service — pty-local (node-pty owns the fork), mcp-client (the MCP SDK owns the transport spawn), and the TUI's synchronous Git branch probe — import the function, so environment policy is single-sourced even where process ownership is not. The SDK helper's `scrubEnvironment()` defaults through the function and applies the exported pattern when its caller supplies an explicit environment; the pattern remains public for this production consumer.
|
||||
- **One scrub definition**: `scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` live on the seam. Ordinary and terminal local spawns apply it inside `dsh-subprocess-local`; mcp-client still imports it because the MCP SDK owns that transport spawn, and the SDK helper's `scrubEnvironment()` defaults through it as well.
|
||||
|
||||
Migrations landed with the reshape: **bash-local/bash-sandbox** (collect modes + batch stdin; the bash `kill()` maps to `terminate()` so `task_kill` keeps escalation semantics), **lsp-local** (piped protocol streams + a no-spill collected stderr tail; `LspConnection` takes the seam's spawn function; its private tree-op helpers deleted), **subagent-acp** (piped ndjson streams + inherited stderr; spawn failure surfaces through `done` rejection into the same startup race; disposal is the backend-owned `disposeAcpChild` ladder over the seam's verbs, with the plugin's configured graces). **`dsh-subagent-subprocess` is deleted** — the dispose ladder and scrub are the seam's; the unused isolated-config-dir helper died with it (no consumer existed).
|
||||
Migrations landed with the reshape: **bash-local/bash-sandbox** (collect modes + batch stdin; the bash `kill()` maps to `terminate()` so `task_kill` keeps escalation semantics), **lsp-local** (piped protocol streams + a no-spill collected stderr tail; `LspConnection` takes the seam's spawn function; its private tree-op helpers deleted), **subagent-acp** (piped ndjson streams + inherited stderr; spawn failure surfaces through `done` rejection into the same startup race; disposal is the backend-owned `disposeAcpChild` ladder over the seam's verbs, with the plugin's configured graces), and **pty-local** through the later [portable execution-world decision](2026-07-28-portable-execution-world-consumers.md) (`node-pty` allocation and process inspection sit behind `spawnTerminal()`, while readiness and terminal policy remain in the consumer). **`dsh-subagent-subprocess` is deleted** — the dispose ladder and scrub are the seam's; the unused isolated-config-dir helper died with it (no consumer existed).
|
||||
|
||||
Compositions mounting lsp-local or subagent-acp now load `dsh-subprocess-local` (the plugins inject `'subprocess'`); the acp/lsp test fixtures gained the row.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the batch-only seam and let stream consumers stay bespoke.** The introducing note's position, rejected by review: it leaves three private copies of tree signalling and six of the scrub, and any future runner (containerized executor, remote process host) would have to pick which private copy to fork. The Node-shaped dispositions cover all three observed stream shapes without widening the outcome type or buffering piped streams.
|
||||
**Keep the batch-only seam and let stream consumers stay bespoke.** The introducing note's position, rejected by review: it leaves three private copies of tree signalling and five of the scrub, and any future runner (containerized executor, remote process host) would have to pick which private copy to fork. The Node-shaped dispositions cover all three observed stream shapes without widening the outcome type or buffering piped streams.
|
||||
|
||||
**A single `stdio: 'pipe' | 'inherit' | 'collect'` mode for all three streams at once.** Rejected: real consumers mix modes per stream (lsp: pipe/pipe/collect; acp: pipe/pipe/inherit; bash: data/collect/collect). Per-stream dispositions are exactly Node's shape and avoid a second spawn call for the mixed cases.
|
||||
|
||||
**Migrate pty-local and mcp-client spawns too.** Rejected on ownership grounds, not scope: node-pty's `fork()` allocates the terminal itself, and the MCP SDK's `StdioClientTransport` spawns internally — neither call site is ours to route. They adopt the shared scrub (the part that is policy), and their READMEs say why the spawn stays put.
|
||||
**Keep pty-local and mcp-client spawns outside the service.** The MCP SDK still owns its transport spawn. PTY allocation is different: the [portable execution-world decision](2026-07-28-portable-execution-world-consumers.md) moves `node-pty` behind one deep terminal primitive, resolving the ownership objection without pretending an ordinary piped spawn can provide terminal semantics.
|
||||
|
||||
**Migrate the test-support launchers (acp-snapshot, loader-smoke), the SDK package-manager runner, and the TUI Git probe.** Rejected: the support packages are deliberately dependency-light test infrastructure that must not depend on product seams; the SDK wizard's `stdio: 'inherit'`-with-redirect semantics plus its out-of-composition lifecycle (no cordis context at all) make the service a poor fit; and the TUI probe is synchronous. The production callers share the scrub instead.
|
||||
**Migrate the test-support launchers (acp-snapshot, loader-smoke) and the SDK package-manager runner.** Rejected: the support packages are deliberately dependency-light test infrastructure that must not depend on product seams, and the SDK wizard's `stdio: 'inherit'`-with-redirect semantics plus its out-of-composition lifecycle (no cordis context at all) make the service a poor fit; it shares the scrub instead.
|
||||
|
||||
## Consequences
|
||||
|
||||
Bought: one implementation of tree signalling, escalation, bounded collection, and the scrub, tested once in `dsh-subprocess-local`'s suites (including injected-platform Windows coverage that lsp-local's private copy never had); lsp-local and subagent-acp shed their process plumbing and their children now survive plugin reloads and die with composition teardown like bash's; a whole package (`dsh-subagent-subprocess`) is gone. The seam README's "one consumer family" limitation is retired.
|
||||
Bought: one implementation of tree signalling, escalation, bounded collection, terminal process mechanics, and the scrub, tested once in `dsh-subprocess-local`'s suites (including injected-platform Windows coverage that lsp-local's private copy never had); lsp-local, pty-local, and subagent-acp shed provider-specific process plumbing and their children die with composition teardown like bash's; a whole package (`dsh-subagent-subprocess`) is gone. The seam README's "one consumer family" limitation is retired.
|
||||
|
||||
Cost: the seam is wider — three stdio modes and the terminate/waitForExit/dispose lifecycle surface instead of one mode and one verb — so a future backend implements more surface; the compositions for lsp-local/subagent-acp each carry the subprocess row now; and `SubprocessOutcome` no longer carries output, a breaking shape change inside the still-unreleased stack (the PR2 layer was updated in place rather than shimmed, per the pre-release stance). pty-local/mcp-client/SDK/TUI/test-support spawns remain outside the service by ownership or execution shape, with the scrub as the shared floor and its pattern intentionally exported for explicit-environment consumers.
|
||||
Cost: the seam is wider — execution-world coordinates, executable lookup, three stdio modes, process-tree lifecycle, and one terminal primitive — so a future backend implements more surface; the compositions for lsp-local, pty-local, and subagent-acp each carry the subprocess row; and `SubprocessOutcome` no longer carries output, a breaking shape change inside the still-unreleased stack. MCP/SDK/test-support spawns remain outside the service by ownership, with the scrub as the shared floor.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent Note: 子进程 seam 转向 Node 形状,所有具备条件的 spawn 调用点一并迁入
|
||||
# Agent Note: 进程 seam 转向 Node 形状,所有具备条件的 spawn 调用点一并迁入
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,7 +6,7 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
[子进程 seam](2026-07-26-subprocess-seam.md) 交付时恰好只为一个消费方家族塑形:批量收集的 stdout/stderr、批量 stdin、单一的升级式 `kill()`。那是有意的范围控制,其自身的 Agent Note 也把「迁移其余 spawn 调用点」记为暂缓否决项。引入该 seam 的 PR(Pull Request)上的评审推翻了这一暂缓决定:堆叠其上的后续变更应当把接口向 Node 的 API 方向重塑,并把其余运行进程之处迁到该服务上。其余各 spawn 调用点此前各自持有同一套机制中某个切片的私有副本——lsp-local 自带 detached 进程树信号发送(POSIX 进程组 + Windows taskkill + 存活轮询),subagent-subprocess 自带 dispose(资源释放)阶梯和自己的凭据清除,mcp-client、pty-local、SDK helper 与 TUI Git 探测则各自持有另一份凭据清除——而这一切既不可替换,也无法集中测试。
|
||||
[进程 seam](2026-07-26-subprocess-seam.md) 交付时恰好只为一个消费方家族塑形:批量收集的 stdout/stderr、批量 stdin、单一的升级式 `kill()`。那是有意的范围控制,其自身的 Agent Note 也把「迁移其余 spawn 调用点」记为暂缓否决项。引入该 seam 的 PR(Pull Request)上的评审推翻了这一暂缓决定:堆叠其上的后续变更应当把接口向 Node 的 API 方向重塑,并把其余运行进程之处迁到该服务上。其余各 spawn 调用点此前各自持有同一套机制中某个切片的私有副本——lsp-local 自带 detached 进程树信号发送(POSIX 进程组 + Windows taskkill + 存活轮询),subagent-subprocess 自带 dispose(资源释放)阶梯和自己的凭据清除,mcp-client、pty-local 与 SDK helper 则各自持有凭据清除的第三、第四、第五份副本——而这一切既不可替换,也无法集中测试。
|
||||
|
||||
## 决策
|
||||
|
||||
@@ -15,24 +15,24 @@ Status: implemented
|
||||
- **按流划分的 stdio 处置方式(disposition)**,位于 `SubprocessSpawnSpec` 上:`'pipe'`(原始的 `Readable`/`Writable`,供消费方自有的协议分帧使用)、`'inherit'`(诊断输出直通父进程的流),以及收集模式(collect)`{ maxBytes, spill? }`——即最初的有界尾部保留形状,只是 spill 文件改为可选,使诊断尾部(例如语言服务器的 stderr)无需落盘即可缓冲。stdin 则为 `'ignore'`、`'pipe'` 或 `{ data }`(写完即关闭的批量形式)。
|
||||
- **`SubprocessOutcome` 只承载退出事实**(Node close 事件的词汇);收集到的输出在结算后仍可经 `handle.collected` 读取(spill 文件描述符在结算边界封存),因此批量与流式调用方共用一条访问路径,也没有任何内容被复制进这份结果。
|
||||
- **以进程树为范围的终止,集中在一个动词后面**:`terminate()` 拥有 SIGTERM→宽限期→SIGKILL 升级(也承接 spec 的 abort 信号,进程树消亡后为空操作)——句柄不暴露单信号的 `kill(signal?)`,因此消费方无法跳过宽限窗口;`waitForExit()` 轮询进程树存活状态(POSIX 进程组探测;Windows 上以直接子进程为界)。Windows 进程树终止(`taskkill /T`,可注入)自 lsp-local 迁入,因此每个消费方拿到的进程树语义在各平台上都正确。(最初从 `subagent-subprocess` 吸收的以 stdin EOF 打头的 dispose 阶梯,后来又移回其唯一消费方——见[阶梯归属 Agent Note](2026-07-27-dispose-ladder-to-consumer.md)。)
|
||||
- **凭据清除只有一份定义**:`scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` 定义在 seam 上,且凭据形状的名称不区分大小写地包含 `KEY`、`PASSWORD`、`SECRET` 或 `TOKEN`。无法把 spawn 本身路由到该服务的调用点——pty-local(node-pty 拥有 fork)、mcp-client(MCP SDK 拥有传输层的 spawn)与 TUI 的同步 Git 分支探测——改为导入该函数,因此即便进程所有权无法统一,环境策略仍是单一来源。SDK helper 的 `scrubEnvironment()` 默认委托给该函数,并在调用方显式传入环境时应用导出的正则;该正则因此生产消费方而保持公开。
|
||||
- **凭据清除只有一份定义**:`scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` 定义在 seam 上。普通本地 spawn 与终端本地 spawn 都在 `dsh-subprocess-local` 内部应用该定义;mcp-client 仍需导入它,因为 MCP SDK 拥有该传输层 spawn,SDK helper 的 `scrubEnvironment()` 默认同样委托给它。
|
||||
|
||||
各项迁移随这次重塑一并落地:**bash-local/bash-sandbox**(收集模式 + 批量 stdin;bash 的 `kill()` 映射到 `terminate()`,因此 `task_kill` 保有升级语义),**lsp-local**(管道化的协议流 + 无 spill 的 stderr 收集尾部;`LspConnection` 改为接收 seam 的 spawn 函数;其私有的进程树操作辅助函数已删除),**subagent-acp**(管道化的 ndjson 流 + inherit 的 stderr;spawn 失败经 `done` 的 reject 汇入同一个启动竞态;dispose 是后端自有的 `disposeAcpChild` 阶梯,经由 seam 的动词运行,携带插件所配置的宽限期)。**`dsh-subagent-subprocess` 已删除**——dispose 阶梯与凭据清除归 seam 所有;无人使用的隔离配置目录辅助函数随之消亡(其消费方本就不存在)。
|
||||
各项迁移随这次重塑一并落地:**bash-local/bash-sandbox**(收集模式 + 批量 stdin;bash 的 `kill()` 映射到 `terminate()`,因此 `task_kill` 保有升级语义),**lsp-local**(管道化的协议流 + 无 spill 的 stderr 收集尾部;`LspConnection` 改为接收 seam 的 spawn 函数;其私有的进程树操作辅助函数已删除),**subagent-acp**(管道化的 ndjson 流 + inherit 的 stderr;spawn 失败经 `done` 的 reject 汇入同一个启动竞态;dispose 是后端自有的 `disposeAcpChild` 阶梯,经由 seam 的动词运行,携带插件所配置的宽限期),以及后来通过[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.md)迁移的 **pty-local**(`node-pty` 分配与进程检查位于 `spawnTerminal()` 之后,就绪状态和终端策略仍归消费方所有)。**`dsh-subagent-subprocess` 已删除**——dispose 阶梯与凭据清除归 seam 所有;无人使用的隔离配置目录辅助函数随之消亡(其消费方本就不存在)。
|
||||
|
||||
挂载 lsp-local 或 subagent-acp 的组合如今都加载 `dsh-subprocess-local`(这两个插件注入 `'subprocess'`);acp/lsp 测试 fixture(测试前置数据)补上了这一行组合配置。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**保持只支持批量的 seam,让流式消费方继续各自为政。**这正是引入该 seam 的 Agent Note 当初的立场,评审将其否决:这样会留下三份进程树信号发送的私有副本和六份凭据清除的私有副本,而未来任何运行器(容器化执行器、远程进程宿主)都得挑选去 fork 哪一份私有副本。Node 形状的处置方式覆盖已观察到的全部三种流形状,既不拓宽结果类型,也不缓冲管道化的流。
|
||||
**保持只支持批量的 seam,让流式消费方继续各自为政。**这正是引入该 seam 的 Agent Note 当初的立场,评审将其否决:这样会留下三份进程树信号发送的私有副本和五份凭据清除的私有副本,而未来任何运行器(容器化执行器、远程进程宿主)都得挑选去 fork 哪一份私有副本。Node 形状的处置方式覆盖已观察到的全部三种流形状,既不拓宽结果类型,也不缓冲管道化的流。
|
||||
|
||||
**用单个 `stdio: 'pipe' | 'inherit' | 'collect'` 模式一次性统辖全部三条流。**否决:真实消费方按流混用模式(lsp:pipe/pipe/collect;acp:pipe/pipe/inherit;bash:data/collect/collect)。按流划分的处置方式恰好就是 Node 的形状,也免去了混用场景的第二个 spawn 调用。
|
||||
|
||||
**把 pty-local 与 mcp-client 的 spawn 也一并迁移。**基于所有权而非范围否决:node-pty 的 `fork()` 自行分配终端,MCP SDK 的 `StdioClientTransport` 在内部完成 spawn——这两处调用点都不归我们路由。它们采纳共享的凭据清除(那正是属于策略的部分),并在各自的 README 中说明 spawn 为何留在原地。
|
||||
**让 pty-local 与 mcp-client 的 spawn 留在服务之外。**MCP SDK 仍拥有其传输层 spawn。PTY 分配则不同:[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.md)把 `node-pty` 放到一个深层终端原语之后,既解决所有权异议,也不假装普通管道化 spawn 能够提供终端语义。
|
||||
|
||||
**迁移 test-support 启动器(acp-snapshot、loader-smoke)、SDK package-manager 运行器与 TUI Git 探测。**否决:support 各包是刻意保持轻依赖的测试基础设施,不得依赖产品 seam;SDK 向导那套附带重定向的 `stdio: 'inherit'` 语义,加上其完全脱离组合的生命周期(根本没有 cordis 上下文),使该服务并不合用;TUI 探测则是同步调用。这些生产调用点改为共享凭据清除。
|
||||
**迁移 test-support 启动器(acp-snapshot、loader-smoke)与 SDK package-manager 运行器。**否决:support 各包(package)是刻意保持轻依赖的测试基础设施,不得依赖产品 seam;而 SDK 向导那套附带重定向的 `stdio: 'inherit'` 语义,加上其完全脱离组合的生命周期(根本没有 cordis 上下文),使该服务并不合用;它改为共享凭据清除。
|
||||
|
||||
## 后果
|
||||
|
||||
换来的是:进程树信号发送、升级、有界收集与凭据清除各自只剩一份实现,且只在 `dsh-subprocess-local` 的测试套件中测试一次(其中包括 lsp-local 的私有副本从未有过的、以注入平台方式实现的 Windows 覆盖);lsp-local 与 subagent-acp 卸下了自己的进程管道,其子进程如今像 bash 的一样,在插件重载后存活、随组合拆除而终止;一个完整的包(`dsh-subagent-subprocess`)就此消失。seam README 中「只有一个消费方家族」的限制说明也随之退役。
|
||||
换来的是:进程树信号发送、升级、有界收集、终端进程机制与凭据清除各自只剩一份实现,且只在 `dsh-subprocess-local` 的测试套件中测试一次(其中包括 lsp-local 的私有副本从未有过的、以注入平台方式实现的 Windows 覆盖);lsp-local、pty-local 与 subagent-acp 卸下了提供方专用的进程管道,其子进程如今像 bash 的一样随组合拆除而终止;一个完整的包(`dsh-subagent-subprocess`)就此消失。seam README 中「只有一个消费方家族」的限制说明也随之退役。
|
||||
|
||||
代价是:这道 seam 变宽了(stdio 模式从一种变为三种、生命周期接口面从一个动词扩展为 terminate/waitForExit/dispose 这一组),未来的后端因此要实现更宽的接口面;lsp-local/subagent-acp 的各组合如今都多出 subprocess 这一行组合配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/TUI/test-support 的 spawn 因所有权归属或执行形状留在该服务之外,以凭据清除作为共底线,且为显式环境的生产消费方有意保持该正则导出。
|
||||
代价是:这道 seam 变宽了,涵盖执行环境坐标、可执行文件查找、三种 stdio 模式、进程树生命周期和一个终端原语,未来的后端因此要实现更宽的表面;lsp-local、pty-local 与 subagent-acp 的各组合如今都多出 subprocess 这一行组合配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更。MCP/SDK/test-support 的 spawn 因所有权归属留在该服务之外,以凭据清除作为共享底线。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md
|
||||
2026-07-26-subprocess-seam.md: ad2f8522be51ba16b0df155aeb334a88f493890f
|
||||
2026-07-26-subprocess-seam.zh.md: 575c02e346531824ef409ddc6155aa2b59ce4e63
|
||||
2026-07-26-subprocess-seam.md: d5dee7361e48f18f3147444b2db74da3dd1c1161
|
||||
2026-07-26-subprocess-seam.zh.md: 884c572dc65c0fbf240f7e4e3b0387038e21f5ad
|
||||
|
||||
@@ -12,8 +12,8 @@ English | [中文](2026-07-26-subprocess-seam.zh.md)
|
||||
|
||||
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 abstract `SubprocessService` owning `ctx.subprocess` with one method, `spawn(spec): SubprocessHandle`, and the shared vocabulary: the fully-explicit `SubprocessSpawnSpec` (argv, cwd, per-stream stdio dispositions, grace — no defaults; deployment-varying knobs stay with the calling seam's config, per the `dsh-bash` request/spec template and the no-hidden-defaults rule), `SubprocessHandle` with non-consuming offset-based readers, `SubprocessOutcome` with deliberately no timeout/cancel classification, and the shared scrub plus `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` types. `argv` is never shell-interpreted. (The [consumer-migration Agent Note](2026-07-26-subprocess-consumer-migration.md) later widened the stdio and termination vocabulary Node-ward.)
|
||||
- **`@deepseek-ai/dsh-subprocess-local` (implementation)** — `LocalSubprocessService` over the former `run.ts` plumbing (`spawn.ts`): detached groups, tail-keep truncation with private bounded spill files, credential scrub with the explicit-env merge after it, 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 terminal `ENV_OVERRIDES` (`TERM=dumb` etc.) did NOT move — that is bash-tool presentation policy and stays in `dsh-bash-local`, merged through the ordinary env channel.
|
||||
- **`@deepseek-ai/dsh-subprocess` (interface)** — the abstract `SubprocessService` owning `ctx.subprocess`: execution-world cwd and runtime storage, executable lookup, fully explicit ordinary spawns, and the terminal primitive added by the [portable execution-world decision](2026-07-28-portable-execution-world-consumers.md). Its vocabulary includes per-stream stdio dispositions, process and terminal handles, exit facts with deliberately no timeout/cancel classification, and the shared scrub plus `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` types. `argv` is never shell-interpreted.
|
||||
- **`@deepseek-ai/dsh-subprocess-local` (implementation)** — `LocalSubprocessService` over the former `run.ts` plumbing (`spawn.ts`) plus `node-pty`: detached groups, bounded collection and private spill files, executable lookup, private runtime storage, foreground/session inspection, credential scrub with explicit env merged after it, tree cleanup, and disposal that terminates and joins every managed process. It has no config; every limit arrives on the spec. Bash and PTY presentation environment overrides stay in their consumers.
|
||||
- **`dsh-bash-local` (consumer)** — `inject: ['subprocess']`; maps each resolved `BashExecSpec` onto a `SubprocessSpawnSpec` (`['bash', '-c', command]`), keeps its config, `resolve()` defaulting, fused-deadline `timedOut`/`aborted` classification, the `[stderr]`-marked background read merge with its consuming cursor, and the `onProcessDone` subclass hook. `dsh-bash-sandbox` is 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 from `dsh-subprocess`, so no bash consumer changes an import; `BashExecRequest`/`BashExecSpec`/`BashProcess` and the sandbox facts remain bash-owned.
|
||||
|
||||
@@ -25,7 +25,7 @@ Background-process lifetime moved from the executor to the subprocess service: t
|
||||
|
||||
**Leave the process plumbing inside `dsh-bash-local` (status quo).** Rejected for the same reason the [task registry split](2026-07-26-task-registry-seam.md) 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](2026-07-26-subprocess-consumer-migration.md) records the Node-ward reshape and which sites moved (and which stayed, by ownership).
|
||||
**Migrate the repo's other spawn sites in the introducing change.** Rejected as scope creep with real design risk at that scale. The later [consumer-migration](2026-07-26-subprocess-consumer-migration.md) and [portable execution-world](2026-07-28-portable-execution-world-consumers.md) decisions reshape the interface around the observed LSP, subagent, PTY, and Code Runtime consumers; SDK and test-support launchers remain outside 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.
|
||||
|
||||
@@ -33,6 +33,6 @@ Background-process lifetime moved from the executor to the subprocess service: t
|
||||
|
||||
## 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.
|
||||
Bought: "run and manage a process" is a swappable capability used by Bash, LSP, PTY, Code Runtime, and ACP consumers; a containerized or remote process backend slots in without changing their domain semantics; the shared `DSH_*`/output vocabulary has a non-shell home; and background processes survive executor reloads, matching the task registry's lifetime model. Process and terminal plumbing is tested through `dsh-subprocess-local`; consumer suites pin only their owned behavior 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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent Note: 子进程服务是 bash 执行器之下的独立 seam(`dsh-subprocess` / `dsh-subprocess-local`)
|
||||
# Agent Note: 进程管理器是 bash 执行器之下的独立 seam(`dsh-subprocess` / `dsh-subprocess-local`)
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,33 +6,33 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
`dsh-bash-local` 原先把两项因不同原因而变化的能力捆绑在一起:*运行一条 bash 命令*(命令默认值补全、超时分类、对模型友好的终端环境、bash 工具所渲染的 stdout/stderr 合并)与*运行并管理一个子进程*(detached 进程组、附带 spill 文件的有界尾部保留输出、凭据清除与 `DSH_*` 合并次序、SIGTERM→宽限期→SIGKILL 升级、先终止再等待退出的 dispose(资源释放))。进程这一半(`run.ts`)约占整个包的一半,却没有属于自己的 seam:未来的非 shell 运行器(直接执行 argv 的执行器、worker supervisor)将不得不重新实现这套机制,或者探入 bash 内部;而共享的 `DSH_*`/`CollectedOutput` 词汇则存放在一个名字承诺 shell 语义的包里。这种捆绑还把后台进程的存续期系在执行器的 fiber 上:重载 bash 执行器会杀死每一个存活的后台进程。这一点不同于同级的[任务注册表](2026-07-26-task-registry-seam.md):后者的注册存续期刻意长于生产方 fiber。
|
||||
`dsh-bash-local` 原先把两项因不同原因而变化的能力捆绑在一起:*运行一条 bash 命令*(命令默认值补全、超时分类、对模型友好的终端环境、bash 工具所渲染的 stdout/stderr 合并)与*运行并管理一个子进程*(detached 进程组、附带 spill 文件的有界尾部保留输出、凭据清除与 `DSH_*` 合并次序、SIGTERM→宽限期→SIGKILL 升级、先终止再等待退出的 dispose(资源释放))。进程这一半(`run.ts`)约占整个包(package)的一半,却没有属于自己的 seam:未来的非 shell 运行器(直接执行 argv 的执行器、worker supervisor)将不得不重新实现这套机制,或者探入 bash 内部;而共享的 `DSH_*`/`CollectedOutput` 词汇则存放在一个名字承诺 shell 语义的包里。这种捆绑还把后台进程的存续期系在执行器的 fiber 上:重载 bash 执行器会杀死每一个存活的后台进程。这一点不同于兄弟的[任务注册表](2026-07-26-task-registry-seam.md):后者的注册存续期刻意长于生产方 fiber。
|
||||
|
||||
## 决策
|
||||
|
||||
新的 `subprocess/` 能力家族拥有「运行并管理一个进程」;bash 家族保留「运行一条 bash 命令」,并成为前者的消费方:
|
||||
|
||||
- **`@deepseek-ai/dsh-subprocess`(接口)**——拥有 `ctx.subprocess` 的抽象 `SubprocessService`(仅一个方法:`spawn(spec): SubprocessHandle`),以及共享词汇:完全显式的 `SubprocessSpawnSpec`(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期,一律不设默认值;随部署变化的旋钮依照 `dsh-bash` 的 request/spec 模板与无隐藏默认值规则,留在调用方 seam 的配置里)、携带基于偏移量的非消费式读取器的 `SubprocessHandle`、刻意不含超时/取消分类的 `SubprocessOutcome`,以及共享的凭据清除与 `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` 类型。`argv` 绝不经过 shell 解释。([消费方迁移 Agent Note](2026-07-26-subprocess-consumer-migration.md) 其后将 stdio 与终止词汇拓宽为 Node 形状。)
|
||||
- **`@deepseek-ai/dsh-subprocess-local`(实现)**——`LocalSubprocessService`,构建在原 `run.ts` 管道(现为 `spawn.ts`)之上:detached 进程组、带私有有界 spill 文件的尾部保留截断、清除之后合并显式 env 的凭据清除、进程组 kill 升级,以及会终止每个仍在运行的受管进程并等待其退出的 dispose。该实现没有任何配置;每项限制都随 spec 到达。终端相关的 `ENV_OVERRIDES`(`TERM=dumb` 等)并未迁移:那是 bash 工具的呈现策略,留在 `dsh-bash-local` 里,经普通 env 通道合并。
|
||||
- **`@deepseek-ai/dsh-subprocess`(接口)**——拥有 `ctx.subprocess` 的抽象 `SubprocessService`:执行环境 cwd 与运行时存储、可执行文件查找、完全显式的普通 spawn,以及[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.md)新增的终端原语。其词汇包括按流划分的 stdio 处置方式(disposition)、进程与终端句柄、刻意不含超时/取消分类的退出事实,以及共享的凭据清除与 `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` 类型。`argv` 绝不经过 shell 解释。
|
||||
- **`@deepseek-ai/dsh-subprocess-local`(实现)**——`LocalSubprocessService` 构建在原 `run.ts` 管道(现为 `spawn.ts`)与 `node-pty` 之上:detached 进程组、有界收集与私有 spill 文件、可执行文件查找、私有运行时存储、前台/会话检查、清除之后合并显式 env 的凭据清除、进程树清理,以及终止每个受管进程并等待其退出的 dispose。该实现没有任何配置;每项限制都随 spec 到达。Bash 与 PTY 的呈现环境覆盖仍归各自消费方所有。
|
||||
- **`dsh-bash-local`(消费方)**——`inject: ['subprocess']`;把每个解析后的 `BashExecSpec` 映射为一个 `SubprocessSpawnSpec`(`['bash', '-c', command]`),并保留自身配置、`resolve()` 默认值补全、基于融合 deadline 的 `timedOut`/`aborted` 分类、带 `[stderr]` 标记的后台读取合并及其消费游标,以及 `onProcessDone` 子类钩子。`dsh-bash-sandbox` 除了重新声明继承来的 inject 之外没有变化;它仍在命令字符串层面做包装,并重新进入继承的 spawn 路径。
|
||||
- **`dsh-bash`(seam)**——把迁走的词汇从 `dsh-subprocess` 重导出,因此没有任何 bash 消费方需要改动导入;`BashExecRequest`/`BashExecSpec`/`BashProcess` 与沙箱事实仍归 bash 所有。
|
||||
|
||||
如今,每个加载 bash 执行器的组合都同时加载 `@deepseek-ai/dsh-subprocess-local`:CLI(命令行界面)、各示例、Python 捆绑运行时、create-sdk 的 bash 功能资源,以及各内联测试配置。
|
||||
|
||||
后台进程的存续期从执行器移到了子进程服务:执行器不再保有存活进程集合,于是重载执行器后,后台工作会继续运行且仍可读取,而组合拆除(服务的 dispose)仍是先终止再等待退出的边界。一条行为 seam 随之挪动:后台 spawn 失败不再能在管道内部被缓冲成伪造的 stderr(对一个从未真正运行的进程,服务会 reject `done`,且不缓冲任何内容),因此执行器把 `spawn failed: …` 提示注入恰好一个 `readOutput()` 增量。
|
||||
后台进程的存续期从执行器移到了管理器:执行器不再保有存活进程集合,于是重载执行器后,后台工作会继续运行且仍可读取,而组合拆除(管理器的 dispose)仍是先终止再等待退出的边界。一条行为 seam 随之挪动:后台 spawn 失败不再能在管道内部被缓冲成伪造的 stderr(对一个从未真正运行的进程,管理器会 reject `done`,且不缓冲任何内容),因此执行器把 `spawn failed: …` 提示注入恰好一个 `readOutput()` 增量。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**把进程管道留在 `dsh-bash-local` 里(维持现状)。**否决的理由与[任务注册表拆分](2026-07-26-task-registry-seam.md)得以落地的理由相同:这条边界既稳定,也早已记录在代码里(`run.ts` 的模块文档曾写明「this layer reacts to an abort signal; the executor owns deadlines and classifies causes」),而若继续将它保持私有,未来每个非 shell 运行器就只能要么 fork 这套机制,要么为非 bash 工作去依赖一个以 bash 命名的包。这组堆叠变更对用户可见的动因正是这一拆分。
|
||||
|
||||
**在同一变更中把仓库其余 spawn 调用点(lsp-local、pty-local、subagent-subprocess、sdk package-manager、test-support 各启动器)迁到 `ctx.subprocess` 上。**在本 PR(Pull Request)的规模下,作为带有真实设计风险的范围蔓延否决。这些调用点在流与生命周期上的需求存在实质差异:node-pty 所有权(pty)、长生命周期 stdio 上的 LSP 分帧加进程树终止回退(lsp)、以 stdin EOF 打头的 dispose 阶梯和完全不缓冲输出(subagent 传输层)。把它们强行纳入一个按有界批量输出塑形的句柄之下,要么会让这道 seam 膨胀,要么会让句柄与消费方错配。依照「接口围绕当前消费方塑形」的规则,该 seam 当时在其唯一真实的消费方家族上得到验证后交付。评审随后恰恰要求以堆叠 PR 的形式完成这项后续工作;[消费方迁移 Agent Note](2026-07-26-subprocess-consumer-migration.md) 记录了向 Node 形状的重塑,以及哪些调用点迁入(哪些因所有权归属而留在原地)。
|
||||
**在引入 seam 的同一变更中迁移仓库其余 spawn 调用点。**否决,因为在当时的规模下属于带有真实设计风险的范围蔓延。后续的[消费方迁移](2026-07-26-subprocess-consumer-migration.md)与[可移植执行环境](2026-07-28-portable-execution-world-consumers.md)决策围绕已观察到的 LSP、subagent、PTY 与 Code Runtime 消费方重塑接口;SDK 与 test-support 启动器因所有权归属仍留在服务之外。
|
||||
|
||||
**改把 `run_in_background`/任务语义放进进程 seam。**否决:那条边界已经存在。`ctx.tasks` 拥有 id、所有权与通知,bash 工具则把 `BashProcess` 适配成任务钩子。进程 seam 位于 bash 执行器*之下*,而不是与任务注册表并列。
|
||||
|
||||
**把 `ENV_OVERRIDES`(TERM=dumb、PAGER=cat 等)移入子进程服务。**否决:通用子进程服务不得把终端呈现策略强加给非终端消费方;对环境中凭据形态名称与 `DSH_*` 名称的清除是安全与身份不变式,予以保留,但终端友好性是 bash 工具自己的选择,经 spec 的显式 env 表达,而调用方自己的条目依旧优先。
|
||||
**把 `ENV_OVERRIDES`(TERM=dumb、PAGER=cat 等)移入管理器。**否决:通用进程管理器不得把终端呈现策略强加给非终端消费方;对环境中凭据形态名称与 `DSH_*` 名称的清除是安全与身份不变式,予以保留,但终端友好性是 bash 工具自己的选择,经 spec 的显式 env 表达,而调用方自己的条目依旧优先。
|
||||
|
||||
## 后果
|
||||
|
||||
换来的是:「运行并管理一个进程」成为一项具备标准三包形态的可替换能力(消费方起步就有两个:`bash-local`、`bash-sandbox`);容器化或远程进程后端可以直接接入,而不触碰 bash 语义;共享的 `DSH_*`/输出词汇有了一个不带 shell 含义的归属;后台进程也能在执行器重载后存活,与任务注册表的存续期模型一致。spawn 管道测试套件整体迁至 `dsh-subprocess-local`(现以 argv 为基础,外加 argv 校验与服务生命周期/dispose 套件);执行器测试套件如今以真实服务为基准,固定 bash 自有的各层行为(分类、合并、spawn 失败提示、归服务所有的存续期)。
|
||||
换来的是:「运行并管理一个进程」成为 Bash、LSP、PTY、Code Runtime 与 ACP(Agent Client Protocol)消费方共用的可替换能力;容器化或远程进程后端可以直接接入,而无需改变各领域语义;共享的 `DSH_*`/输出词汇有了一个不带 shell 含义的归属;后台进程也能在执行器重载后存活,与任务注册表的存续期模型一致。进程与终端管道通过 `dsh-subprocess-local` 测试;消费方测试套件只需针对真实服务固定各自拥有的行为。
|
||||
|
||||
代价是:多出一对包,而且凡加载 bash 执行器之处都多一行组合配置。若某次启动加载了执行器却没有加载子进程服务,`ctx.bash` 会因等待 `ctx.subprocess` 而保持挂起(标准的服务缺失行为)。迁移词汇的重导出让 `dsh-bash` 的导入继续可用,但也意味着两个包如今命名同一批类型;进程 seam 是所有者,bash seam 则记录这层重导出。spawn 失败提示经由读取路径变为单次交付,而旧管道曾把它保留在 stderr 缓冲区里,供重复的 `readFrom(0)` 读取;这一点可以接受,因为 bash 的后台读取路径本就是消费游标,该提示能到达唯一存在的那个读取方。
|
||||
代价是:多出一对包,而且凡加载 bash 执行器之处都多一行组合配置。若某次启动加载了执行器却没有加载管理器,`ctx.bash` 会因等待 `ctx.subprocess` 而保持挂起(标准的服务缺失行为)。迁移词汇的重导出让 `dsh-bash` 的导入继续可用,但也意味着两个包如今命名同一批类型;进程 seam 是所有者,bash seam 则记录这层重导出。spawn 失败提示经由读取路径变为单次交付,而旧管道曾把它保留在 stderr 缓冲区里,供重复的 `readFrom(0)` 读取;这一点可以接受,因为 bash 的后台读取路径本就是消费游标,该提示能到达唯一存在的那个读取方。
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md
|
||||
2026-07-28-portable-execution-world-consumers.md: 000fa1b98964eb85550d0c607281937f8d7932c6
|
||||
2026-07-28-portable-execution-world-consumers.zh.md: 94e6bfcf94e450fafea2b33e152384da400e4b63
|
||||
@@ -0,0 +1,48 @@
|
||||
# Agent Note: Portable consumers over filesystem and subprocess execution worlds
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-portable-execution-world-consumers.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The filesystem and subprocess seams made file and ordinary process access replaceable, but several higher capabilities still reached host Node APIs directly. A remote execution provider therefore appeared to need separate PTY, LSP, and Code Runtime packages even though their domain behavior did not change. Those packages would be shallow adapters: each would duplicate an existing consumer merely to replace its file and process operations.
|
||||
|
||||
Ordinary pipes do not cover one requirement. A persistent terminal needs PTY allocation, foreground-process-group inspection and signalling, and cleanup of the complete terminal session. Pretending those operations can be rebuilt in `dsh-pty-local` from an ordinary `spawn()` handle would either leak provider internals or weaken its lifecycle contract.
|
||||
|
||||
## Decision
|
||||
|
||||
`ctx.fs` and `ctx.subprocess` together define one execution world. Providers mounted together must describe the same path namespace, executables, processes, and terminal sessions; higher capabilities consume those two interfaces rather than name the provider.
|
||||
|
||||
The filesystem interface owns the path facts that another capability needs without exposing its opaque target identity: a canonical process path, canonical `file:` URI, containment, and a bounded stable-handle text read. The existing text and mutation operations remain filesystem-owned.
|
||||
|
||||
The subprocess interface owns the process coordinates and primitives: canonical cwd, private runtime storage, executable lookup, ordinary raw or collected process spawning, and `spawnTerminal()`. The terminal operation is one deep primitive whose handle owns byte I/O, foreground groups, signalling, TERM-to-KILL session cleanup, and a quiescence wait. Prompt detection, idle inference, scrollback, sandbox policy, and owner lifecycle remain in the PTY consumer.
|
||||
|
||||
Generic consumers use that execution world:
|
||||
|
||||
- `dsh-bash-local` continues to map Bash semantics onto ordinary `ctx.subprocess.spawn()`.
|
||||
- `dsh-lsp-local` reads and contains source through `ctx.fs`, resolves and launches language servers through `ctx.subprocess`, and sends provider-owned file URIs. Its JSON-RPC, pooling, synchronization, cancellation, and normalization stay unchanged.
|
||||
- `dsh-pty-local` maps persistent-shell semantics onto `ctx.subprocess.spawnTerminal()`. The local `node-pty` and process-inspection implementation moves into `dsh-subprocess-local`; another subprocess provider supplies the same primitive.
|
||||
- `dsh-code-runtime-subprocess` materializes a dependency-free runner through `ctx.fs` and launches it through `ctx.subprocess`, preserving the Code Runtime binding and output contract across local or remote worlds. It shares host-side worker mechanics through the non-plugin `dsh-code-runtime-worker/runtime-host` subpath instead of copying them.
|
||||
|
||||
`dsh-code-runtime-worker` remains a separate implementation. It is the smaller in-process backend and works in single-file distributions that cannot assume an installed Node executable. Remote filesystem/process compositions select `dsh-code-runtime-subprocess`; they do not need a provider-specific Code Runtime package.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep one PTY, LSP, and Code Runtime package per remote provider.** Rejected because provider mechanics would be repeated above the existing seams. The deletion test exposes the problem: deleting those adapters should not scatter domain behavior into the remote provider; the generic consumers already own it.
|
||||
|
||||
**Model a terminal as an ordinary piped subprocess.** Rejected because pipes cannot allocate a controlling terminal, resolve the current foreground process group, or prove complete terminal-session cleanup. One terminal primitive is smaller and more honest than exposing substrate-specific escape hatches.
|
||||
|
||||
**Move PTY readiness and session policy into the subprocess service.** Rejected because those are persistent-terminal consumer semantics, not OS process mechanics. A subprocess provider owns what only its substrate can do; `dsh-pty-local` owns what a Harness terminal means.
|
||||
|
||||
**Delete the worker-thread Code Runtime.** Rejected because portability does not erase its current deployment need. The subprocess backend requires a Node executable and filesystem materialization; the worker backend has neither requirement and remains the supported single-process path.
|
||||
|
||||
**Run the whole harness inside the remote environment.** Rejected as a different deployment model. Making execution capabilities portable does not move model calls, session state, plugin state, or the agent loop.
|
||||
|
||||
## Consequences
|
||||
|
||||
A remote execution provider implements only its shared sandbox owner plus filesystem and subprocess adapters. Bash, PTY, LSP, and subprocess Code Runtime compose above them, so fixes to those capabilities remain provider-neutral.
|
||||
|
||||
The fundamental interfaces are wider, and a filesystem/subprocess pair must agree on one execution world. The added operations are limited to facts and lifecycle mechanics that current generic consumers require; model schemas, protocol framing, readiness policy, and presentation do not leak into the providers.
|
||||
|
||||
The local implementation absorbs `node-pty` and platform process inspection because it owns local terminal mechanics. This moves code without weakening terminal teardown: disposal still waits for exact PID-identity-fenced descendants and the top-level terminal process to reach quiescence.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Agent Note: 基于文件系统与进程管理执行世界的可移植消费方
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-portable-execution-world-consumers.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
文件系统与进程管理 seam 使文件访问和普通进程访问具备可替换性,但若干上层能力仍直接调用宿主 Node API。因此,即使领域行为没有变化,远程执行提供方看起来仍需要独立的 PTY、LSP 与代码运行时包(package)。这些包只会成为浅层适配器:每个包都仅为替换文件与进程操作而复制一个现有消费方。
|
||||
|
||||
普通管道无法满足其中一项要求。持久终端需要分配 PTY、检查前台进程组并发送信号,以及清理完整的终端会话。如果假设可以在 `dsh-pty-local` 中基于普通 `spawn()` 句柄重建这些操作,最终不是泄漏提供方内部细节,就是削弱其生命周期契约。
|
||||
|
||||
## 决策
|
||||
|
||||
`ctx.fs` 与 `ctx.subprocess` 共同定义一个执行世界。共同挂载的提供方必须描述相同的路径命名空间、可执行文件、进程和终端会话;上层能力消费这两个接口,而不引用具体提供方。
|
||||
|
||||
文件系统接口负责其他能力需要的路径事实,同时不公开其不透明目标身份:规范化进程路径、规范化 `file:` URI、包含关系,以及通过稳定句柄执行的有界文本读取。现有文本与变更操作仍归文件系统负责。
|
||||
|
||||
进程管理接口负责进程运行坐标与原语:规范化 cwd、私有运行时存储、可执行文件查找、以原始或收集模式 spawn 普通进程,以及 `spawnTerminal()`。终端操作是一项深层原语,其句柄负责字节 I/O、前台进程组管理、信号发送、TERM→KILL 会话清理以及等待完全停稳。提示符检测、空闲推断、scrollback、沙箱策略和所有者生命周期仍由 PTY 消费方负责。
|
||||
|
||||
通用消费方使用该执行世界:
|
||||
|
||||
- `dsh-bash-local` 继续把 Bash 语义映射到普通的 `ctx.subprocess.spawn()`。
|
||||
- `dsh-lsp-local` 通过 `ctx.fs` 读取源文件并验证包含关系,通过 `ctx.subprocess` 解析和启动语言服务器,并发送由提供方负责的文件 URI。其 JSON-RPC、池化、同步、取消和规范化保持不变。
|
||||
- `dsh-pty-local` 把持久 shell 语义映射到 `ctx.subprocess.spawnTerminal()`。本地 `node-pty` 与进程检查实现移入 `dsh-subprocess-local`;其他进程管理提供方则提供相同原语。
|
||||
- `dsh-code-runtime-subprocess` 通过 `ctx.fs` 物化无依赖 runner,并通过 `ctx.subprocess` 启动它,从而在本地或远程执行世界中保留代码运行时的绑定与输出契约。它通过非插件子路径 `dsh-code-runtime-worker/runtime-host` 共享宿主侧 worker 机制,而不是复制这些机制。
|
||||
|
||||
`dsh-code-runtime-worker` 仍是独立实现。它是较小的进程内后端,可用于无法假定已安装 Node 可执行文件的单文件分发。远程文件系统/进程组合选择 `dsh-code-runtime-subprocess`;它们不需要提供方专用的代码运行时包。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**为每个远程提供方分别保留 PTY、LSP 和代码运行时包。** 不予采纳,因为这会在现有 seam 之上重复实现提供方机制。删除检验揭示了这一问题:删除这些适配器不应使领域行为散落到远程提供方中;通用消费方本已负责这些行为。
|
||||
|
||||
**把终端建模为普通的管道子进程。** 不予采纳,因为管道无法分配控制终端、确定当前前台进程组或证明完整终端会话已清理。一项终端原语比公开特定于执行基底的逃生口更小,也更能如实表达契约。
|
||||
|
||||
**把 PTY 就绪判断与会话策略移入进程管理服务。** 不予采纳,因为这些属于持久终端消费方的语义,而非 OS 进程机制。进程管理提供方负责只有其执行基底才能完成的操作;`dsh-pty-local` 负责 Harness 终端的语义。
|
||||
|
||||
**删除 worker 线程代码运行时。** 不予采纳,因为可移植性不会消除其当前部署需求。进程管理后端需要 Node 可执行文件和文件系统物化,而 worker 后端两者都不需要,并且仍是受支持的单进程路径。
|
||||
|
||||
**在远程环境中运行整个 harness。** 不予采纳,因为这是另一种部署模型。让执行能力可移植,并不意味着移动模型调用、会话状态、插件状态或 agent loop(智能体循环)。
|
||||
|
||||
## 后果
|
||||
|
||||
远程执行提供方只需实现共享沙箱所有者,以及文件系统与进程管理适配器。Bash、PTY、LSP 和基于进程管理的代码运行时组合在这些适配器之上,因此对这些能力的修复仍与提供方无关。
|
||||
|
||||
基础接口更宽,一对文件系统/进程管理提供方必须在同一个执行世界上保持一致。新增操作仅限当前通用消费方所需的事实与生命周期机制;模型 schema、协议分帧、就绪策略和呈现不会渗入提供方。
|
||||
|
||||
本地实现承接 `node-pty` 和平台进程检查,因为它负责本地终端机制。这种代码迁移不会削弱终端拆卸:dispose(资源释放)仍会等待受精确 PID 身份校验保护的后代进程和顶层终端进程完全停稳。
|
||||
Reference in New Issue
Block a user